diff --git a/suixinkan/App/Navigation/NavigationRouter.swift b/suixinkan/App/Navigation/NavigationRouter.swift index 38b558d..9129b9d 100644 --- a/suixinkan/App/Navigation/NavigationRouter.swift +++ b/suixinkan/App/Navigation/NavigationRouter.swift @@ -12,6 +12,7 @@ import SwiftUI enum AppRoute: Hashable { case placeholder(title: String) case home(HomeRoute) + case profile(ProfileRoute) /// 返回该路由通过 NavigationStack push 后是否隐藏底部 TabBar。 var hidesTabBarWhenPushed: Bool { @@ -26,6 +27,8 @@ enum AppRoute: Hashable { PlaceholderDetailView(title: title) case .home(let route): route.destinationView + case .profile(let route): + route.destinationView } } } @@ -52,6 +55,7 @@ final class RouterPath { /// 主导航状态中心,管理当前 Tab 和每个 Tab 独立的 NavigationStack 路径。 final class AppRouter { var selectedTab: AppTab = .home + var selectedOrdersEntry: OrdersEntry = .storeOrders private var routers: [AppTab: RouterPath] = [:] /// 获取指定 Tab 对应的路由路径容器,不存在时自动创建。 @@ -80,9 +84,16 @@ final class AppRouter { selectedTab = tab } + /// 切换到订单 Tab,并指定订单内部子入口。 + func selectOrders(entry: OrdersEntry) { + selectedOrdersEntry = entry + selectedTab = .orders + } + /// 重置主 Tab 选择和所有 Tab 的导航历史。 func reset() { selectedTab = .home + selectedOrdersEntry = .storeOrders routers.values.forEach { $0.reset() } } } diff --git a/suixinkan/App/RootView.swift b/suixinkan/App/RootView.swift index bec550b..615a5a0 100644 --- a/suixinkan/App/RootView.swift +++ b/suixinkan/App/RootView.swift @@ -19,6 +19,8 @@ struct RootView: View { @State private var authAPI: AuthAPI @State private var profileAPI: ProfileAPI @State private var accountContextAPI: AccountContextAPI + @State private var ordersAPI: OrdersAPI + @State private var statisticsAPI: StatisticsAPI @State private var authSessionCoordinator: AuthSessionCoordinator @State private var sessionBootstrapper: SessionBootstrapper @@ -31,6 +33,8 @@ struct RootView: View { _authAPI = State(initialValue: AuthAPI(client: apiClient)) _profileAPI = State(initialValue: ProfileAPI(client: apiClient)) _accountContextAPI = State(initialValue: AccountContextAPI(client: apiClient)) + _ordersAPI = State(initialValue: OrdersAPI(client: apiClient)) + _statisticsAPI = State(initialValue: StatisticsAPI(client: apiClient)) _authSessionCoordinator = State( initialValue: AuthSessionCoordinator( tokenStore: tokenStore, @@ -59,6 +63,8 @@ struct RootView: View { .environment(authAPI) .environment(profileAPI) .environment(accountContextAPI) + .environment(ordersAPI) + .environment(statisticsAPI) .environment(authSessionCoordinator) .task { apiClient.bindAuthTokenProvider { appSession.token } diff --git a/suixinkan/Core/Design/AppDesign.swift b/suixinkan/Core/Design/AppDesign.swift index 3ae9221..8b3c2e0 100644 --- a/suixinkan/Core/Design/AppDesign.swift +++ b/suixinkan/Core/Design/AppDesign.swift @@ -13,6 +13,9 @@ enum AppDesign { static let primarySoft = Color(hex: 0xEFF6FF) static let textPrimary = Color(hex: 0x1F2937) static let textSecondary = Color(hex: 0x6B7280) + static let placeholder = Color(hex: 0xA8B2C1) + static let success = Color(hex: 0x14964A) + static let warning = Color(hex: 0xFF7B00) } extension Color { @@ -27,3 +30,16 @@ extension Color { ) } } + +extension View { + /// 应用通用输入框背景、圆角和描边样式。 + func appInputFieldStyle(cornerRadius: CGFloat, minHeight: CGFloat) -> some View { + padding(.horizontal, AppMetrics.Spacing.medium) + .frame(minHeight: minHeight) + .background(Color(hex: 0xF8FAFC), in: RoundedRectangle(cornerRadius: cornerRadius)) + .overlay { + RoundedRectangle(cornerRadius: cornerRadius) + .stroke(Color(hex: 0xE2E8F0), lineWidth: 1) + } + } +} diff --git a/suixinkan/Core/Networking/ListPayload.swift b/suixinkan/Core/Networking/ListPayload.swift index 65cc569..8fd2e10 100644 --- a/suixinkan/Core/Networking/ListPayload.swift +++ b/suixinkan/Core/Networking/ListPayload.swift @@ -32,6 +32,34 @@ struct ListPayload: Decodable { } } +/// 数据列表响应实体,表示后端常见的 total + data 分页结构,并兼容 list 字段。 +struct DataListPayload: Decodable { + let total: Int + let data: [T] + + /// 数据列表响应的 JSON 字段映射。 + enum CodingKeys: String, CodingKey { + case total + case data + case list + } + + /// 创建数据列表响应,主要用于测试和本地兜底数据。 + init(total: Int, data: [T]) { + self.total = total + self.data = data + } + + /// 自定义解码,兼容 data/list 字段和 total 字符串。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + total = try container.decodeLossyInt(forKey: .total) ?? 0 + data = try container.decodeIfPresent([T].self, forKey: .data) + ?? container.decodeIfPresent([T].self, forKey: .list) + ?? [] + } +} + private extension KeyedDecodingContainer { /// 将 Int、Double 或数字字符串宽松解码为整数。 func decodeLossyInt(forKey key: Key) throws -> Int? { diff --git a/suixinkan/Features/Home/Models/HomeMenuItem.swift b/suixinkan/Features/Home/Models/HomeMenuItem.swift index a806bcd..67e6e27 100644 --- a/suixinkan/Features/Home/Models/HomeMenuItem.swift +++ b/suixinkan/Features/Home/Models/HomeMenuItem.swift @@ -23,12 +23,14 @@ enum HomeRoute: Hashable { case profileSpace case scenicSelection case moreFunctions + case settings case modulePlaceholder(uri: String, title: String) } /// 首页权限 URI 解析结果,区分 Tab 跳转、本地页面、已知不支持和未知占位。 enum HomeMenuResolvedRoute: Equatable { case tab(AppTab) + case orders(OrdersEntry) case destination(HomeRoute) case unsupported(uri: String, title: String, reason: String) case placeholder(uri: String, title: String) diff --git a/suixinkan/Features/Home/Routing/HomeMenuRouter.swift b/suixinkan/Features/Home/Routing/HomeMenuRouter.swift index 8e0a4da..39aaf53 100644 --- a/suixinkan/Features/Home/Routing/HomeMenuRouter.swift +++ b/suixinkan/Features/Home/Routing/HomeMenuRouter.swift @@ -70,13 +70,15 @@ enum HomeMenuRouter { static func resolve(uri: String, title: String) -> HomeMenuResolvedRoute { switch uri { case "photographer_orders", "/scenic-order-manage": - return .tab(.orders) + return .orders(.storeOrders) case "verification_order": - return .tab(.orders) + return .orders(.verificationOrders) case "photographer_stats": return .tab(.statistics) case "space_settings", "basic_info": return .destination(.profileSpace) + case "system_settings": + return .destination(.settings) case "scenicselection": return .destination(.scenicSelection) case "store", "more_functions": @@ -127,7 +129,6 @@ enum HomeMenuRouter { "registration_invitation", "photographer_invite", "invite_record", - "system_settings", "pilot_cert": let resolvedTitle = title.isEmpty ? self.title(for: uri) : title return .destination(.modulePlaceholder(uri: uri, title: resolvedTitle)) @@ -230,7 +231,7 @@ enum HomePermissionRouteAuditor { return HomePermissionRouteAudit( routable: entries.filter { entry in switch entry.route { - case .tab, .destination: + case .tab, .orders, .destination: return true case .unsupported, .placeholder: return false diff --git a/suixinkan/Features/Home/Views/HomeMoreFunctionsView.swift b/suixinkan/Features/Home/Views/HomeMoreFunctionsView.swift index 0225ed9..33ddf07 100644 --- a/suixinkan/Features/Home/Views/HomeMoreFunctionsView.swift +++ b/suixinkan/Features/Home/Views/HomeMoreFunctionsView.swift @@ -190,6 +190,8 @@ struct HomeMoreFunctionsView: View { switch HomeMenuRouter.resolve(uri: item.uri, title: item.title) { case .tab(let tab): appRouter.select(tab) + case .orders(let entry): + appRouter.selectOrders(entry: entry) case .destination(let route): if route == .moreFunctions { return diff --git a/suixinkan/Features/Home/Views/HomeSupportViews.swift b/suixinkan/Features/Home/Views/HomeSupportViews.swift index f736378..a9a3b6c 100644 --- a/suixinkan/Features/Home/Views/HomeSupportViews.swift +++ b/suixinkan/Features/Home/Views/HomeSupportViews.swift @@ -18,6 +18,8 @@ extension HomeRoute { HomeScenicSelectionView() case .moreFunctions: HomeMoreFunctionsView() + case .settings: + SettingsCenterView() case let .modulePlaceholder(uri, title): HomeMigrationModuleView(title: title, uri: uri) } diff --git a/suixinkan/Features/Home/Views/HomeView.swift b/suixinkan/Features/Home/Views/HomeView.swift index 4e5c182..e17de54 100644 --- a/suixinkan/Features/Home/Views/HomeView.swift +++ b/suixinkan/Features/Home/Views/HomeView.swift @@ -413,6 +413,8 @@ struct HomeView: View { switch route { case .tab(let tab): appRouter.select(tab) + case .orders(let entry): + appRouter.selectOrders(entry: entry) case .destination(let route): router.navigate(to: .home(route)) case .unsupported(let uri, let title, _): diff --git a/suixinkan/Features/Main/Main.md b/suixinkan/Features/Main/Main.md index 93a66d4..e94a3e7 100644 --- a/suixinkan/Features/Main/Main.md +++ b/suixinkan/Features/Main/Main.md @@ -17,7 +17,7 @@ Main 模块负责登录后的主界面 Tab 容器,以及当前尚未迁移页 - `statistics`:数据 - `profile`:我的 -`HomeRootView` 已接入真实的 `HomeView`,`ProfileRootView` 已接入真实的 `ProfileView`。订单和数据 Tab 当前仍使用 `PlaceholderTabRootView`,用于保留入口和验证 Tab 内导航。 +`HomeRootView` 已接入真实的 `HomeView`,`OrdersRootView` 已接入真实的 `OrdersView`,`StatisticsRootView` 已接入真实的 `StatisticsView`,`ProfileRootView` 已接入真实的 `ProfileView`。 ## 导航流程 @@ -25,7 +25,7 @@ Main 模块负责登录后的主界面 Tab 容器,以及当前尚未迁移页 2. `TabView` 使用 `appRouter.selectedTab` 作为选中状态。 3. 每个 Tab 创建自己的 `NavigationStack(path:)`。 4. 路径绑定来自 `appRouter.binding(for:)`。 -5. 占位页点击“打开详情”时,通过当前 Tab 注入的 `RouterPath` push 一个 `AppRoute.placeholder`。 +5. Tab 内页面需要进入尚未迁移的子页面时,通过当前 Tab 注入的 `RouterPath` push 一个 `AppRoute.placeholder`。 6. `navigationDestination` 根据 `AppRoute` 展示详情页。 7. 子页面展示时读取 `AppRoute.hidesTabBarWhenPushed`,默认隐藏底部 TabBar。 diff --git a/suixinkan/Features/Main/Views/PlaceholderRootViews.swift b/suixinkan/Features/Main/Views/PlaceholderRootViews.swift index c43376e..69459e1 100644 --- a/suixinkan/Features/Main/Views/PlaceholderRootViews.swift +++ b/suixinkan/Features/Main/Views/PlaceholderRootViews.swift @@ -17,14 +17,14 @@ struct HomeRootView: View { /// 订单根视图,占位承载后续订单模块迁移内容。 struct OrdersRootView: View { var body: some View { - PlaceholderTabRootView(title: "订单", systemImage: "doc.text") + OrdersView() } } /// 数据根视图,占位承载后续统计模块迁移内容。 struct StatisticsRootView: View { var body: some View { - PlaceholderTabRootView(title: "数据", systemImage: "chart.bar") + StatisticsView() } } diff --git a/suixinkan/Features/Orders/API/OrdersAPI.swift b/suixinkan/Features/Orders/API/OrdersAPI.swift new file mode 100644 index 0000000..cf5d85e --- /dev/null +++ b/suixinkan/Features/Orders/API/OrdersAPI.swift @@ -0,0 +1,112 @@ +// +// OrdersAPI.swift +// suixinkan +// +// Created by Codex on 2026/6/22. +// + +import Foundation +import Observation + +@MainActor +/// 订单服务协议,抽象订单列表、核销列表和核销操作以便测试替换。 +protocol OrderServing { + /// 获取订单管理列表。 + func orderList( + scenicId: Int, + page: Int, + pageSize: Int, + orderStatus: Int?, + userPhone: String?, + startTime: String?, + endTime: String?, + isRefined: Int?, + isScenicAdmin: Bool + ) async throws -> ListPayload + + /// 获取核销订单列表。 + func writeOffList(scenicId: Int, page: Int, pageSize: Int) async throws -> ListPayload + + /// 核销指定订单号。 + func writeOff(orderNumber: String) async throws +} + +@MainActor +@Observable +/// 订单 API,封装订单管理、核销订单和订单核销接口。 +final class OrdersAPI: OrderServing { + @ObservationIgnored private let client: APIClient + + /// 初始化订单 API,并注入共享网络客户端。 + init(client: APIClient) { + self.client = client + } + + /// 获取订单管理列表。 + func orderList( + scenicId: Int, + page: Int = 1, + pageSize: Int = 20, + orderStatus: Int? = nil, + userPhone: String? = nil, + startTime: String? = nil, + endTime: String? = nil, + isRefined: Int? = nil, + isScenicAdmin: Bool = false + ) async throws -> ListPayload { + var query = [ + URLQueryItem(name: "scenic_id", value: "\(scenicId)"), + URLQueryItem(name: "page", value: "\(max(page, 1))"), + URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))") + ] + if let orderStatus, orderStatus > 0 { + query.append(URLQueryItem(name: "order_status", value: "\(orderStatus)")) + } + if let userPhone, !userPhone.isEmpty { + query.append(URLQueryItem(name: "user_phone", value: userPhone)) + } + if let startTime, !startTime.isEmpty { + query.append(URLQueryItem(name: "start_time", value: startTime)) + } + if let endTime, !endTime.isEmpty { + query.append(URLQueryItem(name: "end_time", value: endTime)) + } + if let isRefined, isRefined > 0 { + query.append(URLQueryItem(name: "is_refined", value: "\(isRefined)")) + } + + return try await client.send( + APIRequest( + method: .get, + path: isScenicAdmin ? "/api/app/scenic-admin/order/list" : "/api/yf-handset-app/photog/order/listv2", + queryItems: query + ) + ) + } + + /// 获取核销订单列表。 + func writeOffList(scenicId: Int, page: Int = 1, pageSize: Int = 20) async throws -> ListPayload { + try await client.send( + APIRequest( + method: .get, + path: "/api/yf-handset-app/photog/order/order-verification-list", + queryItems: [ + URLQueryItem(name: "scenic_id", value: "\(scenicId)"), + URLQueryItem(name: "page", value: "\(max(page, 1))"), + URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))") + ] + ) + ) + } + + /// 核销指定订单号。 + func writeOff(orderNumber: String) async throws { + let _: EmptyPayload = try await client.send( + APIRequest( + method: .post, + path: "/api/yf-handset-app/photog/order/order-verify", + body: WriteOffRequest(orderNumber: orderNumber) + ) + ) + } +} diff --git a/suixinkan/Features/Orders/Models/OrderModels.swift b/suixinkan/Features/Orders/Models/OrderModels.swift new file mode 100644 index 0000000..83e49ae --- /dev/null +++ b/suixinkan/Features/Orders/Models/OrderModels.swift @@ -0,0 +1,261 @@ +// +// OrderModels.swift +// suixinkan +// +// Created by Codex on 2026/6/22. +// + +import Foundation + +/// 订单 Tab 内的子入口,表示订单管理或核销订单。 +enum OrdersEntry: Hashable { + case storeOrders + case verificationOrders +} + +/// 订单状态筛选实体,表示订单管理列表顶部的状态过滤项。 +struct OrderStatusFilter: Identifiable, Hashable { + let id: Int + let title: String +} + +/// 订单筛选常量,集中维护旧工程同步过来的订单状态。 +enum OrderFilters { + static let statusFilters: [OrderStatusFilter] = [ + .init(id: 0, title: "全部"), + .init(id: 18, title: "已付定金"), + .init(id: 30, title: "已完成"), + .init(id: 50, title: "已退款"), + .init(id: -1, title: "需要精修"), + .init(id: -2, title: "无需精修") + ] +} + +/// 订单管理列表实体,承载订单卡片展示和筛选判断所需字段。 +struct OrderEntity: Decodable, Identifiable, Equatable { + var id: String { orderNumber } + + let photogUid: Int + let orderNumber: String + let remark: String + let scenicAreaId: Int + let payTime: String + let completeTime: String + let orderStatus: Int + let orderStatusName: String + let storeId: Int? + let userId: Int + let projectId: Int + let phone: String + let orderAmount: String + let actualPayAmount: String + let actualRefundAmount: String + let refundAmount: String + let payTypeName: String + let payType: Int + let projectName: String + let orderType: Int + let orderTypeLabel: String + let depositPayTime: String + let createdAt: String + let photoTravel: OrderPhotoTravel? + let isNeedEdit: Bool + let isRefined: Int + + enum CodingKeys: String, CodingKey { + case photogUid = "photog_uid" + case orderNumber = "order_number" + case remark + case scenicAreaId = "scenic_area_id" + case payTime = "pay_time" + case completeTime = "complete_time" + case orderStatus = "order_status" + case orderStatusName = "order_status_name" + case storeId = "store_id" + case userId = "user_id" + case projectId = "project_id" + case phone + case orderAmount = "order_amount" + case actualPayAmount = "actual_pay_amount" + case actualRefundAmount = "actual_refund_amount" + case refundAmount = "refund_amount" + case payTypeName = "pay_type_name" + case payType = "pay_type" + case projectName = "project_name" + case orderType = "order_type" + case orderTypeLabel = "order_type_label" + case depositPayTime = "deposit_pay_time" + case createdAt = "created_at" + case photoTravel = "photo_travel" + case isNeedEdit + case isRefined = "is_refined" + } + + /// 宽松解码订单字段,兼容后端数字和字符串混用。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + photogUid = try container.decodeLossyInt(forKey: .photogUid) ?? 0 + orderNumber = try container.decodeLossyString(forKey: .orderNumber) + remark = try container.decodeLossyString(forKey: .remark) + scenicAreaId = try container.decodeLossyInt(forKey: .scenicAreaId) ?? 0 + payTime = try container.decodeLossyString(forKey: .payTime) + completeTime = try container.decodeLossyString(forKey: .completeTime) + orderStatus = try container.decodeLossyInt(forKey: .orderStatus) ?? 0 + orderStatusName = try container.decodeLossyString(forKey: .orderStatusName) + storeId = try container.decodeLossyInt(forKey: .storeId) + userId = try container.decodeLossyInt(forKey: .userId) ?? 0 + projectId = try container.decodeLossyInt(forKey: .projectId) ?? 0 + phone = try container.decodeLossyString(forKey: .phone) + orderAmount = try container.decodeLossyString(forKey: .orderAmount) + actualPayAmount = try container.decodeLossyString(forKey: .actualPayAmount) + actualRefundAmount = try container.decodeLossyString(forKey: .actualRefundAmount) + refundAmount = try container.decodeLossyString(forKey: .refundAmount) + payTypeName = try container.decodeLossyString(forKey: .payTypeName) + payType = try container.decodeLossyInt(forKey: .payType) ?? 0 + projectName = try container.decodeLossyString(forKey: .projectName) + orderType = try container.decodeLossyInt(forKey: .orderType) ?? 0 + orderTypeLabel = try container.decodeLossyString(forKey: .orderTypeLabel) + depositPayTime = try container.decodeLossyString(forKey: .depositPayTime) + createdAt = try container.decodeLossyString(forKey: .createdAt) + photoTravel = try container.decodeIfPresent(OrderPhotoTravel.self, forKey: .photoTravel) + isNeedEdit = try container.decodeLossyBool(forKey: .isNeedEdit) ?? true + isRefined = try container.decodeLossyInt(forKey: .isRefined) ?? 0 + } +} + +/// 旅拍订单附加信息实体,表示修图、视频和核销相关数量。 +struct OrderPhotoTravel: Decodable, Equatable { + let orderPhotoNum: Int + let orderVideoNum: Int + let checkInTime: String + let retouchGiftPhotoNum: Int + let retouchGiftVideoNum: Int + + enum CodingKeys: String, CodingKey { + case orderPhotoNum = "order_photo_num" + case orderVideoNum = "order_video_num" + case checkInTime = "check_in_time" + case retouchGiftPhotoNum = "retouch_gift_photo_num" + case retouchGiftVideoNum = "retouch_gift_video_num" + } + + /// 宽松解码旅拍附加信息。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + orderPhotoNum = try container.decodeLossyInt(forKey: .orderPhotoNum) ?? 0 + orderVideoNum = try container.decodeLossyInt(forKey: .orderVideoNum) ?? 0 + checkInTime = try container.decodeLossyString(forKey: .checkInTime) + retouchGiftPhotoNum = try container.decodeLossyInt(forKey: .retouchGiftPhotoNum) ?? 0 + retouchGiftVideoNum = try container.decodeLossyInt(forKey: .retouchGiftVideoNum) ?? 0 + } +} + +/// 核销订单列表实体,表示可核销订单卡片的展示和操作状态。 +struct WriteOffOrderItem: Decodable, Identifiable, Equatable { + var id: String { orderNumber } + + let orderNumber: String + let orderVerificationStatus: String + let payTime: String + let projectName: String + let userPhone: String + let orderAmount: String + let orderStatusName: String + let orderVerificationTime: String + + enum CodingKeys: String, CodingKey { + case orderNumber = "order_number" + case orderVerificationStatus = "order_verification_status" + case payTime = "pay_time" + case projectName = "project_name" + case userPhone = "user_phone" + case orderAmount = "order_amount" + case orderStatusName = "order_status_name" + case orderVerificationTime = "order_verification_time" + } + + /// 宽松解码核销订单字段。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + orderNumber = try container.decodeLossyString(forKey: .orderNumber) + orderVerificationStatus = try container.decodeLossyString(forKey: .orderVerificationStatus) + payTime = try container.decodeLossyString(forKey: .payTime) + projectName = try container.decodeLossyString(forKey: .projectName) + userPhone = try container.decodeLossyString(forKey: .userPhone) + orderAmount = try container.decodeLossyString(forKey: .orderAmount) + orderStatusName = try container.decodeLossyString(forKey: .orderStatusName) + orderVerificationTime = try container.decodeLossyString(forKey: .orderVerificationTime) + } +} + +/// 核销订单请求实体,提交手动输入的订单号。 +struct WriteOffRequest: Encodable { + let orderNumber: String + + enum CodingKeys: String, CodingKey { + case orderNumber = "order_number" + } +} + +private extension KeyedDecodingContainer { + /// 将任意常见 JSON 值宽松解码成字符串。 + func decodeLossyString(forKey key: Key) throws -> String { + if let value = try? decodeIfPresent(String.self, forKey: key) { + return value + } + if let value = try? decodeIfPresent(Int.self, forKey: key) { + return String(value) + } + if let value = try? decodeIfPresent(Double.self, forKey: key) { + return String(value) + } + if let value = try? decodeIfPresent(Bool.self, forKey: key) { + return value ? "1" : "0" + } + return "" + } + + /// 将 Int、Double、Bool 或数字字符串宽松解码成整数。 + func decodeLossyInt(forKey key: Key) throws -> Int? { + if let value = try? decodeIfPresent(Int.self, forKey: key) { + return value + } + if let value = try? decodeIfPresent(String.self, forKey: key) { + let text = value.trimmingCharacters(in: .whitespacesAndNewlines) + if let intValue = Int(text) { + return intValue + } + if let doubleValue = Double(text) { + return Int(doubleValue) + } + } + if let value = try? decodeIfPresent(Double.self, forKey: key) { + return Int(value) + } + if let value = try? decodeIfPresent(Bool.self, forKey: key) { + return value ? 1 : 0 + } + return nil + } + + /// 将 Bool、Int 或字符串宽松解码成布尔值。 + func decodeLossyBool(forKey key: Key) throws -> Bool? { + if let value = try? decodeIfPresent(Bool.self, forKey: key) { + return value + } + if let value = try? decodeIfPresent(Int.self, forKey: key) { + return value != 0 + } + if let value = try? decodeIfPresent(String.self, forKey: key) { + switch value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "1", "true", "yes": + return true + case "0", "false", "no": + return false + default: + return nil + } + } + return nil + } +} diff --git a/suixinkan/Features/Orders/Orders.md b/suixinkan/Features/Orders/Orders.md new file mode 100644 index 0000000..d8fb643 --- /dev/null +++ b/suixinkan/Features/Orders/Orders.md @@ -0,0 +1,26 @@ +# Orders 模块业务逻辑 + +## 模块职责 + +Orders 模块负责登录后的订单 Tab。当前第一轮同步覆盖订单管理和核销订单两个入口,提供列表展示、筛选、分页、刷新和手动核销闭环。 + +本轮不迁移扫码核销、退款、完整订单详情、历史拍摄、任务上传、视频预告等长尾流程;这些入口统一进入安全占位页,后续按独立模块逐个迁移。 + +## 核心对象 + +- `OrdersView`:订单 Tab 根视图,读取 `AccountContext`、`PermissionContext`、`AppRouter` 和 `OrdersAPI`。 +- `OrdersViewModel`:管理订单子入口、筛选条件、分页状态、加载状态和手动核销状态。 +- `OrdersAPI`:封装订单列表、核销订单列表和订单核销接口。 +- `OrdersEntry`:表示订单 Tab 内部入口,包含订单管理和核销订单。 + +## 数据流程 + +订单页面从 `AccountContext.currentScenic` 读取当前景区 ID,从 `PermissionContext.currentRole` 读取角色 ID。景区管理员角色 `roleId == 53` 使用景区管理员订单接口,其他角色使用摄影师订单接口。 + +订单管理支持状态筛选、手机号搜索、开始/结束日期筛选。筛选变更后从第一页重新加载,滚动到列表底部时加载下一页。 + +核销订单支持手动输入订单号。核销成功后重新拉取第一页核销订单,并重置分页状态;核销失败只清理提交状态,不刷新列表。 + +## 路由边界 + +首页 `photographer_orders` 和 `/scenic-order-manage` 会进入订单管理,`verification_order` 会进入核销订单。订单 Tab 内点击详情、扫码、退款等未迁移能力时进入占位页,避免入口崩溃。 diff --git a/suixinkan/Features/Orders/ViewModels/OrdersViewModel.swift b/suixinkan/Features/Orders/ViewModels/OrdersViewModel.swift new file mode 100644 index 0000000..e7ccb8a --- /dev/null +++ b/suixinkan/Features/Orders/ViewModels/OrdersViewModel.swift @@ -0,0 +1,196 @@ +// +// OrdersViewModel.swift +// suixinkan +// +// Created by Codex on 2026/6/22. +// + +import Foundation +import Observation + +@MainActor +@Observable +/// 订单页面 ViewModel,管理订单列表、核销列表、筛选条件和手动核销流程。 +final class OrdersViewModel { + var selectedEntry: OrdersEntry = .storeOrders + var selectedStatus = 0 + var searchPhone = "" + var filterStartDate: Date? + var filterEndDate: Date? + private(set) var loading = false + private(set) var loadingMore = false + private(set) var isVerifying = false + private(set) var currentVerifyingOrderNumber: String? + + private(set) var storeOrders: [OrderEntity] = [] + private(set) var storeTotal = 0 + private(set) var storePage = 1 + private(set) var storeHasMore = false + + private(set) var writeOffOrders: [WriteOffOrderItem] = [] + private(set) var writeOffTotal = 0 + private(set) var writeOffPage = 1 + private(set) var writeOffHasMore = false + + /// 返回去除首尾空白后的手机号搜索值。 + var normalizedPhone: String? { + let text = searchPhone.trimmingCharacters(in: .whitespacesAndNewlines) + return text.isEmpty ? nil : text + } + + /// 根据当前子入口刷新对应列表。 + func reload(api: OrderServing, scenicId: Int?, roleId: Int?, showLoading: Bool = true) async throws { + switch selectedEntry { + case .storeOrders: + try await reloadStoreOrders(api: api, scenicId: scenicId, roleId: roleId, showLoading: showLoading) + case .verificationOrders: + try await reloadWriteOffOrders(api: api, scenicId: scenicId, showLoading: showLoading) + } + } + + /// 刷新订单管理列表,缺少景区时清空旧数据。 + func reloadStoreOrders(api: OrderServing, scenicId: Int?, roleId: Int?, showLoading: Bool = true) async throws { + guard let scenicId else { + storeOrders = [] + storeTotal = 0 + storePage = 1 + storeHasMore = false + return + } + + if showLoading { loading = true } + defer { if showLoading { loading = false } } + + let result = try await api.orderList( + scenicId: scenicId, + page: 1, + pageSize: pageSize, + orderStatus: orderStatusQuery, + userPhone: normalizedPhone, + startTime: startTimeQuery, + endTime: endTimeQuery, + isRefined: refinedQuery, + isScenicAdmin: roleId == 53 + ) + storeOrders = result.list + storeTotal = result.total + storePage = 1 + storeHasMore = storeOrders.count < result.total && !result.list.isEmpty + } + + /// 加载订单管理下一页。 + func loadMoreStoreOrders(api: OrderServing, scenicId: Int?, roleId: Int?) async throws { + guard !loadingMore, storeHasMore, let scenicId else { return } + loadingMore = true + defer { loadingMore = false } + + let nextPage = storePage + 1 + let result = try await api.orderList( + scenicId: scenicId, + page: nextPage, + pageSize: pageSize, + orderStatus: orderStatusQuery, + userPhone: normalizedPhone, + startTime: startTimeQuery, + endTime: endTimeQuery, + isRefined: refinedQuery, + isScenicAdmin: roleId == 53 + ) + storeOrders.append(contentsOf: result.list) + storeTotal = result.total + storePage = nextPage + storeHasMore = storeOrders.count < result.total && !result.list.isEmpty + } + + /// 刷新核销订单列表,缺少景区时清空旧数据。 + func reloadWriteOffOrders(api: OrderServing, scenicId: Int?, showLoading: Bool = true) async throws { + guard let scenicId else { + writeOffOrders = [] + writeOffTotal = 0 + writeOffPage = 1 + writeOffHasMore = false + return + } + + if showLoading { loading = true } + defer { if showLoading { loading = false } } + + let result = try await api.writeOffList(scenicId: scenicId, page: 1, pageSize: pageSize) + writeOffOrders = result.list + writeOffTotal = result.total + writeOffPage = 1 + writeOffHasMore = writeOffOrders.count < result.total && !result.list.isEmpty + } + + /// 加载核销订单下一页。 + func loadMoreWriteOffOrders(api: OrderServing, scenicId: Int?) async throws { + guard !loadingMore, writeOffHasMore, let scenicId else { return } + loadingMore = true + defer { loadingMore = false } + + let nextPage = writeOffPage + 1 + let result = try await api.writeOffList(scenicId: scenicId, page: nextPage, pageSize: pageSize) + writeOffOrders.append(contentsOf: result.list) + writeOffTotal = result.total + writeOffPage = nextPage + writeOffHasMore = writeOffOrders.count < result.total && !result.list.isEmpty + } + + /// 手动核销订单,并在成功后刷新核销列表。 + func verify(api: OrderServing, scenicId: Int, orderNumber: String) async throws { + guard !isVerifying else { return } + + isVerifying = true + currentVerifyingOrderNumber = orderNumber + defer { + isVerifying = false + currentVerifyingOrderNumber = nil + } + + try await api.writeOff(orderNumber: orderNumber) + let refreshed = try await api.writeOffList(scenicId: scenicId, page: 1, pageSize: pageSize) + writeOffOrders = refreshed.list + writeOffTotal = refreshed.total + writeOffPage = 1 + writeOffHasMore = writeOffOrders.count < refreshed.total && !refreshed.list.isEmpty + } + + /// 用于测试重复提交场景的内部状态设置。 + func markVerifyingForTests(orderNumber: String) { + isVerifying = true + currentVerifyingOrderNumber = orderNumber + } + + private let pageSize = 20 + + private var orderStatusQuery: Int? { + selectedStatus > 0 ? selectedStatus : nil + } + + private var startTimeQuery: String? { + filterStartDate.map { Self.dayFormatter.string(from: $0) } + } + + private var endTimeQuery: String? { + filterEndDate.map { Self.dayFormatter.string(from: $0) } + } + + private var refinedQuery: Int? { + switch selectedStatus { + case -1: + return 1 + case -2: + return 2 + default: + return nil + } + } + + private static let dayFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .gregorian) + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd" + return formatter + }() +} diff --git a/suixinkan/Features/Orders/Views/OrdersView.swift b/suixinkan/Features/Orders/Views/OrdersView.swift new file mode 100644 index 0000000..52ceb75 --- /dev/null +++ b/suixinkan/Features/Orders/Views/OrdersView.swift @@ -0,0 +1,622 @@ +// +// OrdersView.swift +// suixinkan +// +// Created by Codex on 2026/6/22. +// + +import SwiftUI + +/// 订单 Tab 根视图,展示订单管理和核销订单两个业务入口。 +struct OrdersView: View { + @Environment(AccountContext.self) private var accountContext + @Environment(PermissionContext.self) private var permissionContext + @Environment(AppRouter.self) private var appRouter + @Environment(RouterPath.self) private var router + @Environment(OrdersAPI.self) private var ordersAPI + @Environment(ToastCenter.self) private var toastCenter + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + + @State private var viewModel = OrdersViewModel() + @State private var manualOrderNumber = "" + @State private var showDateFilterSheet = false + @State private var showVerifyConfirmation = false + @State private var pendingVerifyOrderNumber: String? + + private var currentScenicId: Int? { + accountContext.currentScenic?.id + } + + private var currentRoleId: Int? { + permissionContext.currentRole?.id + } + + private var contentMaxWidth: CGFloat { + horizontalSizeClass == .regular ? 900 : .infinity + } + + private var currentFilterTitle: String { + OrderFilters.statusFilters.first(where: { $0.id == viewModel.selectedStatus })?.title ?? "全部" + } + + private var timeFilterTitle: String { + if let start = viewModel.filterStartDate, let end = viewModel.filterEndDate { + return "\(Self.dateFormatter.string(from: start)) 至 \(Self.dateFormatter.string(from: end))" + } + return "时间筛选" + } + + var body: some View { + ScrollView { + VStack(spacing: AppMetrics.Spacing.small) { + entrySection + + if currentScenicId == nil { + ContentUnavailableView( + "缺少经营上下文", + systemImage: "mountain.2", + description: Text("请先在首页选择景区后查看订单。") + ) + .frame(minHeight: 360) + } else if viewModel.selectedEntry == .storeOrders { + storeOrdersSection + } else { + writeOffSection + } + } + .frame(maxWidth: contentMaxWidth) + .padding(.horizontal, AppMetrics.Spacing.pageHorizontal) + .padding(.top, AppMetrics.Spacing.medium) + .padding(.bottom, AppMetrics.Spacing.pageVertical) + .frame(maxWidth: .infinity) + } + .background(Color(hex: 0xF5F5F5).ignoresSafeArea()) + .navigationTitle("订单") + .navigationBarTitleDisplayMode(.inline) + .refreshable { await reload(showLoading: false) } + .task { + viewModel.selectedEntry = appRouter.selectedOrdersEntry + await reload() + } + .onChange(of: appRouter.selectedOrdersEntry) { _, entry in + guard viewModel.selectedEntry != entry else { return } + viewModel.selectedEntry = entry + Task { await reload() } + } + .onChange(of: accountContext.currentScenic?.id) { _, _ in + Task { await reload() } + } + .confirmationDialog("确认核销该订单?", isPresented: $showVerifyConfirmation, titleVisibility: .visible) { + Button("确认核销") { + guard let orderNumber = pendingVerifyOrderNumber else { return } + Task { await verify(orderNumber: orderNumber) } + } + Button("取消", role: .cancel) { + pendingVerifyOrderNumber = nil + } + } message: { + Text(pendingVerifyOrderNumber.map { "订单号:\($0)" } ?? "") + } + .sheet(isPresented: $showDateFilterSheet) { + OrdersDateFilterSheet( + startDate: $viewModel.filterStartDate, + endDate: $viewModel.filterEndDate, + onApply: { Task { await reload() } } + ) + } + } + + private var entrySection: some View { + VStack(spacing: AppMetrics.Spacing.small) { + HStack(spacing: 0) { + entryButton("订单管理", entry: .storeOrders) + entryButton("核销订单", entry: .verificationOrders) + } + .padding(AppMetrics.Spacing.xxxSmall) + .background(Color(hex: 0xF3F4F6), in: RoundedRectangle(cornerRadius: 8)) + + HStack(spacing: AppMetrics.Spacing.small) { + contextPill( + icon: viewModel.selectedEntry == .storeOrders ? "mountain.2" : "qrcode", + title: viewModel.selectedEntry == .storeOrders ? "当前景区" : "核销订单", + value: viewModel.selectedEntry == .storeOrders ? (accountContext.currentScenic?.name ?? "--") : "\(viewModel.writeOffTotal)" + ) + contextPill( + icon: "doc.text", + title: viewModel.selectedEntry == .storeOrders ? "订单总数" : "当前景区", + value: viewModel.selectedEntry == .storeOrders ? "\(viewModel.storeTotal)" : (accountContext.currentScenic?.name ?? "--") + ) + } + } + .padding(AppMetrics.Spacing.medium) + .background(.white, in: RoundedRectangle(cornerRadius: 8)) + } + + private var storeOrdersSection: some View { + VStack(spacing: AppMetrics.Spacing.small) { + storeFilterSection + storeList + } + } + + private var writeOffSection: some View { + VStack(spacing: AppMetrics.Spacing.small) { + writeOffActionSection + writeOffList + } + } + + private var storeFilterSection: some View { + VStack(spacing: AppMetrics.Spacing.small) { + HStack(spacing: AppMetrics.Spacing.xSmall) { + Menu { + ForEach(OrderFilters.statusFilters) { filter in + Button(filter.title) { + viewModel.selectedStatus = filter.id + Task { await reload() } + } + } + } label: { + filterLabel(title: currentFilterTitle, icon: "chevron.down") + } + Button { + showDateFilterSheet = true + } label: { + filterLabel(title: timeFilterTitle, icon: "calendar") + } + .buttonStyle(.plain) + } + + HStack(spacing: AppMetrics.Spacing.xSmall) { + TextField("输入手机号搜索", text: $viewModel.searchPhone) + .keyboardType(.phonePad) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + .font(.system(size: AppMetrics.FontSize.subheadline)) + .padding(.horizontal, AppMetrics.Spacing.small) + .frame(height: 40) + .background(Color(hex: 0xF4F4F4), in: RoundedRectangle(cornerRadius: 6)) + + Button("搜索") { + Task { await reload() } + } + .font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold)) + .foregroundStyle(.white) + .padding(.horizontal, AppMetrics.Spacing.medium) + .frame(height: 40) + .background(AppDesign.primary, in: RoundedRectangle(cornerRadius: 6)) + } + } + .padding(AppMetrics.Spacing.medium) + .background(.white, in: RoundedRectangle(cornerRadius: 8)) + } + + private var storeList: some View { + LazyVStack(spacing: AppMetrics.Spacing.small) { + if viewModel.loading { + ProgressView() + .frame(maxWidth: .infinity) + .padding(.vertical, AppMetrics.Spacing.xxLarge) + } else if viewModel.storeOrders.isEmpty { + ContentUnavailableView("暂无订单", systemImage: "tray", description: Text("可切换筛选条件或下拉刷新。")) + .frame(minHeight: 260) + } else { + ForEach(viewModel.storeOrders) { item in + storeOrderCard(item) + .onAppear { + guard item.id == viewModel.storeOrders.last?.id else { return } + Task { await loadMoreStoreOrders() } + } + } + if viewModel.loadingMore { + ProgressView() + .frame(maxWidth: .infinity) + .padding(.vertical, AppMetrics.Spacing.small) + } + } + } + } + + private var writeOffActionSection: some View { + VStack(spacing: AppMetrics.Spacing.small) { + Button { + router.navigate(to: .placeholder(title: "扫码核销")) + } label: { + Label("扫码核销", systemImage: "qrcode.viewfinder") + .font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold)) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .frame(height: 42) + .background(Color(hex: 0x9CA3AF), in: RoundedRectangle(cornerRadius: 8)) + } + .buttonStyle(.plain) + .accessibilityIdentifier("orders.scan") + + HStack(spacing: AppMetrics.Spacing.xSmall) { + TextField("手动输入订单号", text: $manualOrderNumber) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + .font(.system(size: AppMetrics.FontSize.subheadline)) + .padding(.horizontal, AppMetrics.Spacing.small) + .frame(height: 42) + .background(Color(hex: 0xF4F4F4), in: RoundedRectangle(cornerRadius: 8)) + .accessibilityIdentifier("orders.manualOrderNumber") + + Button(viewModel.isVerifying ? "核销中" : "核销") { + triggerManualVerify() + } + .font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold)) + .foregroundStyle(.white) + .padding(.horizontal, AppMetrics.Spacing.medium) + .frame(height: 42) + .background(AppDesign.primary, in: RoundedRectangle(cornerRadius: 8)) + .disabled(viewModel.isVerifying) + } + } + .padding(AppMetrics.Spacing.medium) + .background(.white, in: RoundedRectangle(cornerRadius: 8)) + } + + private var writeOffList: some View { + LazyVStack(spacing: AppMetrics.Spacing.small) { + if viewModel.loading { + ProgressView() + .frame(maxWidth: .infinity) + .padding(.vertical, AppMetrics.Spacing.xxLarge) + } else if viewModel.writeOffOrders.isEmpty { + ContentUnavailableView("暂无核销订单", systemImage: "tray", description: Text("可下拉刷新或切换景区查看。")) + .frame(minHeight: 260) + } else { + ForEach(viewModel.writeOffOrders) { item in + writeOffOrderCard(item) + .onAppear { + guard item.id == viewModel.writeOffOrders.last?.id else { return } + Task { await loadMoreWriteOffOrders() } + } + } + if viewModel.loadingMore { + ProgressView() + .frame(maxWidth: .infinity) + .padding(.vertical, AppMetrics.Spacing.small) + } + } + } + } + + private func entryButton(_ title: String, entry: OrdersEntry) -> some View { + let selected = viewModel.selectedEntry == entry + return Button { + switchEntry(entry) + } label: { + Text(title) + .font(.system(size: AppMetrics.FontSize.subheadline, weight: selected ? .semibold : .medium)) + .foregroundStyle(selected ? AppDesign.primary : AppDesign.textSecondary) + .frame(maxWidth: .infinity) + .frame(height: 36) + .background(selected ? .white : .clear, in: RoundedRectangle(cornerRadius: 6)) + } + .buttonStyle(.plain) + } + + private func contextPill(icon: String, title: String, value: String) -> some View { + HStack(spacing: AppMetrics.Spacing.xSmall) { + Image(systemName: icon) + .font(.system(size: AppMetrics.FontSize.footnote, weight: .semibold)) + .foregroundStyle(AppDesign.primary) + .frame(width: 28, height: 28) + .background(AppDesign.primarySoft, in: RoundedRectangle(cornerRadius: 8)) + VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxxSmall) { + Text(title) + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(AppDesign.textSecondary) + Text(value) + .font(.system(size: AppMetrics.FontSize.footnote, weight: .semibold)) + .foregroundStyle(AppDesign.textPrimary) + .lineLimit(1) + .minimumScaleFactor(0.75) + } + Spacer(minLength: 0) + } + .padding(AppMetrics.Spacing.xSmall) + .frame(maxWidth: .infinity) + .background(Color(hex: 0xF8FAFC), in: RoundedRectangle(cornerRadius: 8)) + } + + private func filterLabel(title: String, icon: String) -> some View { + HStack(spacing: AppMetrics.Spacing.xSmall) { + Text(title) + .font(.system(size: AppMetrics.FontSize.subheadline)) + .foregroundStyle(Color(hex: 0x4B5563)) + .lineLimit(1) + .minimumScaleFactor(0.75) + Spacer(minLength: AppMetrics.Spacing.xSmall) + Image(systemName: icon) + .font(.system(size: AppMetrics.FontSize.caption, weight: .semibold)) + .foregroundStyle(Color(hex: 0x4B5563)) + } + .padding(.horizontal, AppMetrics.Spacing.small) + .frame(maxWidth: .infinity) + .frame(height: 38) + .background(Color(hex: 0xF4F4F4), in: RoundedRectangle(cornerRadius: 6)) + } + + private func storeOrderCard(_ item: OrderEntity) -> some View { + VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) { + HStack(alignment: .top, spacing: AppMetrics.Spacing.xSmall) { + Text("订单号:\(item.orderNumber)") + .font(.system(size: AppMetrics.FontSize.title3)) + .foregroundStyle(.black) + .lineLimit(1) + .minimumScaleFactor(0.72) + Spacer() + statusBadge(item.orderStatusName, color: statusColor(for: item.orderStatus)) + } + + Text(item.createdAt.isEmpty ? "--" : item.createdAt) + .font(.system(size: AppMetrics.FontSize.subheadline)) + .foregroundStyle(AppDesign.textSecondary) + + if !item.orderTypeLabel.isEmpty { + Text(item.orderTypeLabel) + .font(.system(size: AppMetrics.FontSize.caption, weight: .medium)) + .foregroundStyle(AppDesign.primary) + .padding(.horizontal, AppMetrics.Spacing.xSmall) + .padding(.vertical, AppMetrics.Spacing.xxSmall) + .background(AppDesign.primarySoft, in: RoundedRectangle(cornerRadius: 4)) + } + + orderInfoRow("付款金额", "¥\(item.actualPayAmount)", valueColor: AppDesign.primary) + orderInfoRow("付款时间", item.payTime.isEmpty ? "--" : item.payTime) + orderInfoRow("完成时间", item.completeTime.isEmpty ? "--" : item.completeTime) + orderInfoRow("付款方式", item.payTypeName.isEmpty ? "--" : item.payTypeName) + orderInfoRow("手机号", maskPhoneNumber(item.phone)) + orderInfoRow("关联项目", item.projectName.isEmpty ? "--" : item.projectName) + } + .padding(AppMetrics.Spacing.medium) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.white, in: RoundedRectangle(cornerRadius: 8)) + .contentShape(Rectangle()) + .onTapGesture { + router.navigate(to: .placeholder(title: "订单详情")) + } + .accessibilityIdentifier("orders.store.card.\(item.orderNumber)") + } + + private func writeOffOrderCard(_ item: WriteOffOrderItem) -> some View { + VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) { + HStack(alignment: .top, spacing: AppMetrics.Spacing.xSmall) { + Text("订单号:\(item.orderNumber)") + .font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold)) + .foregroundStyle(.black) + .lineLimit(1) + Spacer() + statusBadge(displayVerificationStatus(for: item), color: writeOffStatusColor(for: item)) + } + + Text(item.payTime.isEmpty ? "--" : item.payTime) + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(AppDesign.textSecondary) + orderInfoRow("项目名称", item.projectName.isEmpty ? "--" : item.projectName) + orderInfoRow("手机号", item.userPhone.isEmpty ? "--" : item.userPhone) + orderInfoRow("订单状态", item.orderStatusName.isEmpty ? "--" : item.orderStatusName) + + HStack { + Text("¥\(item.orderAmount)") + .font(.system(size: AppMetrics.FontSize.title3, weight: .bold)) + .foregroundStyle(AppDesign.primary) + Spacer() + Button("详情") { + router.navigate(to: .placeholder(title: "核销订单详情")) + } + .font(.system(size: AppMetrics.FontSize.caption, weight: .medium)) + .foregroundStyle(AppDesign.primary) + .padding(.horizontal, AppMetrics.Spacing.small) + .frame(height: 32) + .background(AppDesign.primarySoft, in: RoundedRectangle(cornerRadius: 8)) + .buttonStyle(.plain) + .accessibilityIdentifier("orders.writeoff.detail.\(item.orderNumber)") + + Button(verifyButtonTitle(for: item)) { + pendingVerifyOrderNumber = item.orderNumber + showVerifyConfirmation = true + } + .font(.system(size: AppMetrics.FontSize.footnote, weight: .semibold)) + .foregroundStyle(.white) + .padding(.horizontal, AppMetrics.Spacing.small) + .frame(height: 32) + .background(canVerify(item) ? AppDesign.primary : Color(hex: 0x9CA3AF), in: RoundedRectangle(cornerRadius: 8)) + .buttonStyle(.plain) + .disabled(!canVerify(item) || viewModel.isVerifying) + .accessibilityIdentifier("orders.writeoff.verify.\(item.orderNumber)") + } + } + .padding(AppMetrics.Spacing.medium) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.white, in: RoundedRectangle(cornerRadius: 8)) + } + + private func orderInfoRow(_ title: String, _ value: String, valueColor: Color = .black) -> some View { + HStack(alignment: .top, spacing: AppMetrics.Spacing.xSmall) { + Text(title) + .font(.system(size: AppMetrics.FontSize.subheadline)) + .foregroundStyle(Color(hex: 0x4B5563)) + .frame(width: 72, alignment: .leading) + Spacer(minLength: AppMetrics.Spacing.xSmall) + Text(value) + .font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium)) + .foregroundStyle(valueColor) + .multilineTextAlignment(.trailing) + .lineLimit(2) + .minimumScaleFactor(0.75) + } + } + + private func statusBadge(_ text: String, color: Color) -> some View { + Text(text.isEmpty ? "--" : text) + .font(.system(size: AppMetrics.FontSize.caption, weight: .medium)) + .foregroundStyle(color) + .padding(.horizontal, AppMetrics.Spacing.xSmall) + .padding(.vertical, AppMetrics.Spacing.xxSmall) + .background(color.opacity(0.12), in: RoundedRectangle(cornerRadius: 4)) + } + + private func switchEntry(_ entry: OrdersEntry) { + guard viewModel.selectedEntry != entry else { return } + viewModel.selectedEntry = entry + appRouter.selectOrders(entry: entry) + Task { await reload() } + } + + private func reload(showLoading: Bool = true) async { + do { + try await viewModel.reload( + api: ordersAPI, + scenicId: currentScenicId, + roleId: currentRoleId, + showLoading: showLoading + ) + } catch { + toastCenter.show(error.localizedDescription) + } + } + + private func loadMoreStoreOrders() async { + do { + try await viewModel.loadMoreStoreOrders(api: ordersAPI, scenicId: currentScenicId, roleId: currentRoleId) + } catch { + toastCenter.show(error.localizedDescription) + } + } + + private func loadMoreWriteOffOrders() async { + do { + try await viewModel.loadMoreWriteOffOrders(api: ordersAPI, scenicId: currentScenicId) + } catch { + toastCenter.show(error.localizedDescription) + } + } + + private func triggerManualVerify() { + let orderNumber = manualOrderNumber.trimmingCharacters(in: .whitespacesAndNewlines) + guard !orderNumber.isEmpty else { + toastCenter.show("请输入订单号") + return + } + pendingVerifyOrderNumber = orderNumber + showVerifyConfirmation = true + } + + private func verify(orderNumber: String) async { + guard let scenicId = currentScenicId else { return } + do { + try await viewModel.verify(api: ordersAPI, scenicId: scenicId, orderNumber: orderNumber) + manualOrderNumber = "" + pendingVerifyOrderNumber = nil + toastCenter.show("核销成功") + } catch { + toastCenter.show(error.localizedDescription) + } + } + + private func canVerify(_ item: WriteOffOrderItem) -> Bool { + let status = displayVerificationStatus(for: item) + return !status.contains("已核销") && !status.contains("已退款") && !status.contains("已完成") + } + + private func displayVerificationStatus(for item: WriteOffOrderItem) -> String { + let text = item.orderVerificationStatus.trimmingCharacters(in: .whitespacesAndNewlines) + return text.isEmpty ? "待核销" : text + } + + private func verifyButtonTitle(for item: WriteOffOrderItem) -> String { + canVerify(item) ? "核销" : "已处理" + } + + private func writeOffStatusColor(for item: WriteOffOrderItem) -> Color { + canVerify(item) ? AppDesign.primary : Color(hex: 0x22C55E) + } + + private func statusColor(for status: Int) -> Color { + switch status { + case 18: + return AppDesign.primary + case 30: + return Color(hex: 0x22C55E) + case 50: + return Color(hex: 0xEF4444) + default: + return Color(hex: 0x6B7280) + } + } + + private func maskPhoneNumber(_ phone: String) -> String { + let trimmed = phone.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.count >= 7 else { return trimmed.isEmpty ? "--" : trimmed } + let prefix = trimmed.prefix(3) + let suffix = trimmed.suffix(4) + return "\(prefix)****\(suffix)" + } + + private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd" + return formatter + }() +} + +/// 订单日期筛选 Sheet,负责选择开始和结束日期。 +private struct OrdersDateFilterSheet: View { + @Environment(\.dismiss) private var dismiss + @Binding var startDate: Date? + @Binding var endDate: Date? + let onApply: () -> Void + + @State private var draftStartDate = Date() + @State private var draftEndDate = Date() + + var body: some View { + NavigationStack { + Form { + DatePicker("开始日期", selection: $draftStartDate, displayedComponents: .date) + DatePicker("结束日期", selection: $draftEndDate, displayedComponents: .date) + } + .navigationTitle("时间筛选") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("清空") { + startDate = nil + endDate = nil + onApply() + dismiss() + } + } + ToolbarItem(placement: .confirmationAction) { + Button("确定") { + startDate = min(draftStartDate, draftEndDate) + endDate = max(draftStartDate, draftEndDate) + onApply() + dismiss() + } + } + } + .onAppear { + draftStartDate = startDate ?? Date() + draftEndDate = endDate ?? Date() + } + } + } +} + +#Preview { + NavigationStack { + OrdersView() + .environment(AccountContext()) + .environment(PermissionContext()) + .environment(AppRouter()) + .environment(RouterPath()) + .environment(OrdersAPI(client: APIClient())) + .environment(ToastCenter()) + } +} diff --git a/suixinkan/Features/Profile/API/ProfileAPI.swift b/suixinkan/Features/Profile/API/ProfileAPI.swift index aaa5ce7..58546f5 100644 --- a/suixinkan/Features/Profile/API/ProfileAPI.swift +++ b/suixinkan/Features/Profile/API/ProfileAPI.swift @@ -29,6 +29,16 @@ final class ProfileAPI { ) } + /// 拉取当前登录账号可切换的景区账号和门店账号。 + func switchableAccounts() async throws -> V9AuthResponse { + try await client.send( + APIRequest( + method: .get, + path: "/api/app/v9/accounts" + ) + ) + } + /// 拉取当前登录用户的实名认证状态。 func realNameInfo() async throws -> RealNameInfoResponse { try await client.send( @@ -61,6 +71,28 @@ final class ProfileAPI { ) ) } + + /// 发送实名认证短信验证码。 + func realNameSmsVerifyCode() async throws { + let _: EmptyPayload = try await client.send( + APIRequest( + method: .post, + path: "/api/yf-handset-app/photog/real-name/sms-verify-code", + body: EmptyPayload() + ) + ) + } + + /// 提交实名认证资料。 + func realNameSubmit(_ request: RealNameAuthRequest) async throws { + let _: EmptyPayload = try await client.send( + APIRequest( + method: .post, + path: "/api/yf-handset-app/photog/real-name/submit", + body: request + ) + ) + } } extension ProfileAPI: UserProfileServing {} diff --git a/suixinkan/Features/Profile/Models/ProfileModels.swift b/suixinkan/Features/Profile/Models/ProfileModels.swift index b075c68..fe785fd 100644 --- a/suixinkan/Features/Profile/Models/ProfileModels.swift +++ b/suixinkan/Features/Profile/Models/ProfileModels.swift @@ -80,25 +80,100 @@ struct RealNameInfoResponse: Decodable, Equatable { /// 实名认证信息实体,表示当前用户实名审核状态和失败原因。 struct RealNameInfo: Decodable, Equatable { let realName: String + let idCardNo: String let auditStatus: Int let auditStatusText: String? let rejectReason: String? + let startDate: String? + let endDate: String? + let isLongValid: Bool + let frontUrl: String? + let backUrl: String? + let auditorId: Int? + let auditor: RealNameAuditor? + let auditAt: String? + + /// 当前认证是否已审核通过。 + var verified: Bool { + auditStatus == 2 + } /// 实名认证信息的 JSON 字段映射。 enum CodingKeys: String, CodingKey { case realName = "real_name" + case idCardNo = "id_card_no" case auditStatus = "audit_status" case auditStatusText = "audit_status_text" case rejectReason = "reject_reason" + case startDate = "start_date" + case endDate = "end_date" + case isLongValid = "is_long_valid" + case frontUrl = "front_url" + case backUrl = "back_url" + case auditorId = "auditor_id" + case auditor + case auditAt = "audit_at" } /// 自定义解码,兼容审核状态字段类型不稳定的情况。 init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) realName = try container.decodeLossyString(forKey: .realName) + idCardNo = try container.decodeLossyString(forKey: .idCardNo) auditStatus = try container.decodeLossyInt(forKey: .auditStatus) ?? 0 auditStatusText = try container.decodeIfPresent(String.self, forKey: .auditStatusText) rejectReason = try container.decodeIfPresent(String.self, forKey: .rejectReason) + startDate = try container.decodeIfPresent(String.self, forKey: .startDate) + endDate = try container.decodeIfPresent(String.self, forKey: .endDate) + isLongValid = (try container.decodeLossyInt(forKey: .isLongValid) ?? 0) == 1 + frontUrl = try container.decodeIfPresent(String.self, forKey: .frontUrl) + backUrl = try container.decodeIfPresent(String.self, forKey: .backUrl) + auditorId = try container.decodeLossyInt(forKey: .auditorId) + auditor = try container.decodeIfPresent(RealNameAuditor.self, forKey: .auditor) + auditAt = try container.decodeIfPresent(String.self, forKey: .auditAt) + } +} + +/// 实名认证审核人实体,表示后台审核人的基础展示信息。 +struct RealNameAuditor: Decodable, Equatable { + let id: Int + let name: String + + /// 审核人 JSON 字段映射。 + enum CodingKeys: String, CodingKey { + case id + case name + } + + /// 自定义解码,兼容审核人 ID 类型不稳定。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeLossyInt(forKey: .id) ?? 0 + name = try container.decodeLossyString(forKey: .name) + } +} + +/// 实名认证提交请求实体,表示用户填写的姓名、证件号、短信和证件图片。 +struct RealNameAuthRequest: Encodable, Equatable { + let realName: String + let idCardNo: String + let smsVerifyCode: String + let startDate: String + let endDate: String + let isLongValid: Int + let frontUrl: String + let backUrl: String + + /// 实名认证提交请求 JSON 字段映射。 + enum CodingKeys: String, CodingKey { + case realName = "real_name" + case idCardNo = "id_card_no" + case smsVerifyCode = "sms_verify_code" + case startDate = "start_date" + case endDate = "end_date" + case isLongValid = "is_long_valid" + case frontUrl = "front_url" + case backUrl = "back_url" } } diff --git a/suixinkan/Features/Profile/Profile.md b/suixinkan/Features/Profile/Profile.md index 88b90bd..9a362ad 100644 --- a/suixinkan/Features/Profile/Profile.md +++ b/suixinkan/Features/Profile/Profile.md @@ -2,7 +2,7 @@ ## 模块职责 -Profile 模块负责“我的/个人信息”页面,包括用户资料展示、昵称编辑、密码修改、实名认证状态展示和退出登录。 +Profile 模块负责“我的/个人信息”页面及其二级页面,包括用户资料展示、昵称编辑、账号切换、密码修改、实名认证、系统设置、协议页和退出登录。 该模块聚焦个人资料业务: - 拉取用户基础资料。 @@ -10,6 +10,9 @@ Profile 模块负责“我的/个人信息”页面,包括用户资料展示 - 展示头像、昵称、UID、手机号、账号状态和实名认证状态。 - 修改昵称。 - 修改密码。 +- 切换当前景区/门店业务账号。 +- 提交实名认证基础资料。 +- 展示系统设置、版本号、下载链接和协议 H5 页面。 - 退出登录入口。 登录态清理和缓存清理由 App 模块的 `AuthSessionCoordinator` 统一处理。 @@ -18,10 +21,15 @@ Profile 模块负责“我的/个人信息”页面,包括用户资料展示 - `ProfileView`:个人信息页 UI,负责展示资料、编辑入口、密码弹窗和退出确认。 - `ProfileViewModel`:维护资料加载状态、编辑状态、保存状态和展示文案。 +- `ProfileRoute`:个人中心二级页面路由。 +- `AccountSwitchView` / `AccountSwitchViewModel`:账号切换页面和状态逻辑。 +- `RealNameAuthView` / `RealNameAuthViewModel`:实名认证页面和表单逻辑。 +- `SettingsCenterView` / `AgreementView`:设置中心和协议 H5 页面。 - `ProfileAPI`:封装用户资料、实名认证和资料更新接口。 - `UserInfoResponse`:用户基础资料。 - `RealNameInfoResponse` / `RealNameInfo`:实名认证信息。 - `UpdateInfoRequest`:昵称、密码或头像更新请求体。 +- `RealNameAuthRequest`:实名认证提交请求体。 ## 加载流程 @@ -54,6 +62,33 @@ Profile 模块负责“我的/个人信息”页面,包括用户资料展示 密码不会写入本地缓存。 +## 账号切换流程 + +1. 用户点击“当前账号”进入 `AccountSwitchView`。 +2. `AccountSwitchViewModel.load` 调用 `ProfileAPI.switchableAccounts`,读取 `/api/app/v9/accounts`。 +3. 页面展示景区账号和门店账号,优先选中后端标记的当前账号,否则选中第一项。 +4. 用户确认后调用 `AuthAPI.setUser`,使用 `AccountSwitchAccount.toSetUserRequest()` 生成请求体。 +5. 切换成功后调用 `AuthSessionCoordinator.completeLogin`,重新写入正式 token、账号快照、权限、景区和门店上下文。 +6. `AppRouter.reset()` 清空旧路由栈并回到首页。 + +## 实名认证流程 + +1. 用户点击“认证状态”进入 `RealNameAuthView`。 +2. `RealNameAuthViewModel.load` 调用 `ProfileAPI.realNameInfo` 并回填姓名、身份证号、证件有效期、图片 URL 和审核状态。 +3. 用户可发送短信验证码,接口为 `/api/yf-handset-app/photog/real-name/sms-verify-code`。 +4. 提交前校验姓名、身份证号、短信验证码、证件图片 URL 和有效期。 +5. 校验通过后调用 `/api/yf-handset-app/photog/real-name/submit`。 +6. 提交成功后重新加载实名认证信息。 + +本轮证件图片先保留 URL 输入和网络预览,阿里云 OSS 图片选择上传后续接入时替换该输入区。 + +## 设置和协议流程 + +1. 用户点击“系统设置”进入 `SettingsCenterView`。 +2. 设置中心展示关于我们、系统版本、App 下载链接、用户协议和隐私政策。 +3. App 下载点击后复制 `/h5/app/download` 链接到剪贴板。 +4. 协议页使用 `AgreementView` 加载 `APIEnvironment.current.baseURL` 下的 H5 页面。 + ## 退出登录流程 1. 用户点击退出登录。 @@ -72,4 +107,5 @@ Profile 模块负责“我的/个人信息”页面,包括用户资料展示 - `auditStatus == 3`:审核不通过。 - 其他状态:审核中。 - 无实名认证信息时:点击去实名认证。 - +- 账号切换失败只展示 Toast,不清空登录态。 +- 实名认证失败只影响实名认证页面,不影响登录态和账号上下文。 diff --git a/suixinkan/Features/Profile/Routing/ProfileRoute.swift b/suixinkan/Features/Profile/Routing/ProfileRoute.swift new file mode 100644 index 0000000..9f42e33 --- /dev/null +++ b/suixinkan/Features/Profile/Routing/ProfileRoute.swift @@ -0,0 +1,31 @@ +// +// ProfileRoute.swift +// suixinkan +// +// Created by Codex on 2026/6/22. +// + +import SwiftUI + +/// 个人中心二级页面路由,集中声明“我的”Tab 可以 push 的页面。 +enum ProfileRoute: Hashable { + case accountSwitch + case realNameAuth + case settings + case agreement(AgreementPage) + + /// 构建个人中心路由对应的 SwiftUI 目标页面。 + @ViewBuilder + var destinationView: some View { + switch self { + case .accountSwitch: + AccountSwitchView() + case .realNameAuth: + RealNameAuthView() + case .settings: + SettingsCenterView() + case .agreement(let page): + AgreementView(page: page) + } + } +} diff --git a/suixinkan/Features/Profile/ViewModels/AccountSwitchViewModel.swift b/suixinkan/Features/Profile/ViewModels/AccountSwitchViewModel.swift new file mode 100644 index 0000000..bf106c6 --- /dev/null +++ b/suixinkan/Features/Profile/ViewModels/AccountSwitchViewModel.swift @@ -0,0 +1,78 @@ +// +// AccountSwitchViewModel.swift +// suixinkan +// +// Created by Codex on 2026/6/22. +// + +import Foundation +import Observation + +@MainActor +@Observable +/// 账号切换 ViewModel,管理可切换账号列表、选择状态和提交状态。 +final class AccountSwitchViewModel { + private(set) var accounts: [AccountSwitchAccount] = [] + var selectedAccountId: String? + private(set) var loading = false + private(set) var switching = false + private var didLoad = false + + /// 当前选中的账号。 + var selectedAccount: AccountSwitchAccount? { + accounts.first { $0.id == selectedAccountId } + } + + /// 拉取可切换账号列表,默认只加载一次。 + func load(api: ProfileAPI, force: Bool = false, currentAccountId: String? = nil) async throws { + guard force || !didLoad else { return } + didLoad = true + loading = true + defer { loading = false } + + let response = try await api.switchableAccounts() + accounts = response.accounts.filter { $0.businessUserId > 0 } + selectedAccountId = accounts.first(where: { $0.isCurrent || isCurrent($0, currentAccountId: currentAccountId) })?.id + ?? accounts.first?.id + } + + /// 选择指定账号。 + func select(_ account: AccountSwitchAccount) { + selectedAccountId = account.id + } + + /// 提交账号切换,调用 set-user 换取正式 token。 + func switchAccount(_ account: AccountSwitchAccount, api: AuthAPI) async throws -> V9AuthResponse { + guard !switching else { + throw AccountSwitchError.submitting + } + guard account.businessUserId > 0 else { + throw AccountSwitchError.invalidAccount + } + + switching = true + defer { switching = false } + return try await api.setUser(account.toSetUserRequest()) + } + + /// 判断账号是否与当前账号标识一致。 + func isCurrent(_ account: AccountSwitchAccount, currentAccountId: String?) -> Bool { + guard let currentAccountId else { return false } + return account.id == currentAccountId || String(account.businessUserId) == currentAccountId + } +} + +/// 账号切换错误实体,表示重复提交或账号数据异常。 +enum AccountSwitchError: LocalizedError, Equatable { + case submitting + case invalidAccount + + var errorDescription: String? { + switch self { + case .submitting: + "账号正在切换,请稍候" + case .invalidAccount: + "账号信息异常,请重新选择账号" + } + } +} diff --git a/suixinkan/Features/Profile/ViewModels/RealNameAuthViewModel.swift b/suixinkan/Features/Profile/ViewModels/RealNameAuthViewModel.swift new file mode 100644 index 0000000..6802c1c --- /dev/null +++ b/suixinkan/Features/Profile/ViewModels/RealNameAuthViewModel.swift @@ -0,0 +1,170 @@ +// +// RealNameAuthViewModel.swift +// suixinkan +// +// Created by Codex on 2026/6/22. +// + +import Foundation +import Observation + +@MainActor +@Observable +/// 实名认证 ViewModel,管理认证资料表单、审核状态、短信验证码和提交状态。 +final class RealNameAuthViewModel { + private(set) var info: RealNameInfo? + var realName = "" + var idCardNo = "" + var smsCode = "" + var startDate = Date() + var endDate = Calendar.current.date(byAdding: .year, value: 10, to: Date()) ?? Date() + var isLongValid = false + var frontUrl = "" + var backUrl = "" + private(set) var loading = false + private(set) var sendingCode = false + private(set) var submitting = false + var statusMessage: String? + + /// 拉取实名认证信息,并回填表单。 + func load(api: ProfileAPI) async throws { + guard !loading else { return } + loading = true + defer { loading = false } + + let response = try await api.realNameInfo() + apply(response.realNameInfo) + } + + /// 发送短信验证码。 + func sendCode(api: ProfileAPI) async throws { + guard !sendingCode else { return } + sendingCode = true + defer { sendingCode = false } + + try await api.realNameSmsVerifyCode() + statusMessage = "验证码已发送" + } + + /// 提交实名认证资料,成功后刷新审核状态。 + func submit(api: ProfileAPI) async throws { + if info?.verified == true { + statusMessage = "已完成实名认证" + return + } + if let validationMessage { + throw RealNameValidationError.message(validationMessage) + } + + submitting = true + defer { submitting = false } + try await api.realNameSubmit(makeRequest()) + statusMessage = "已提交审核" + try await load(api: api) + } + + /// 根据审核状态生成展示文案。 + func auditStatusText(_ status: Int) -> String { + switch status { + case 1: "待审核" + case 2: "审核通过" + case 3: "审核不通过" + default: "未认证" + } + } + + /// 当前表单校验错误,nil 表示可以提交。 + var validationMessage: String? { + if realName.trimmed.isEmpty { return "请输入真实姓名" } + if idCardNo.trimmed.isEmpty { return "请输入身份证号" } + if !Self.isValidMainlandIDCardNumber(idCardNo) { return "请输入有效的身份证号" } + if info?.verified != true { + if smsCode.trimmed.isEmpty { return "请输入短信验证码" } + if frontUrl.trimmed.isEmpty { return "请填写身份证人像面图片 URL" } + if backUrl.trimmed.isEmpty { return "请填写身份证国徽面图片 URL" } + } + if !isLongValid && endDate < startDate { return "证件结束日期不能早于起始日期" } + return nil + } + + /// 应用接口返回的实名认证信息。 + private func apply(_ info: RealNameInfo?) { + self.info = info + guard let info else { return } + realName = info.realName + idCardNo = info.idCardNo + frontUrl = info.frontUrl ?? "" + backUrl = info.backUrl ?? "" + isLongValid = info.isLongValid + startDate = Self.date(from: info.startDate) ?? startDate + endDate = Self.date(from: info.endDate) ?? endDate + } + + /// 生成提交请求。 + private func makeRequest() -> RealNameAuthRequest { + RealNameAuthRequest( + realName: realName.trimmed, + idCardNo: Self.normalizedIDCardNumber(idCardNo), + smsVerifyCode: smsCode.trimmed, + startDate: Self.dateFormatter.string(from: startDate), + endDate: isLongValid ? "" : Self.dateFormatter.string(from: endDate), + isLongValid: isLongValid ? 1 : 0, + frontUrl: frontUrl.trimmed, + backUrl: backUrl.trimmed + ) + } + + /// 规范化身份证号,去除空白并大写末位 X。 + nonisolated static func normalizedIDCardNumber(_ value: String) -> String { + value.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + } + + /// 校验大陆身份证号基础格式和校验位。 + nonisolated static func isValidMainlandIDCardNumber(_ value: String) -> Bool { + let number = normalizedIDCardNumber(value) + guard number.count == 18 else { return false } + let chars = Array(number) + guard chars.prefix(17).allSatisfy(\.isNumber) else { return false } + guard chars[17].isNumber || chars[17] == "X" else { return false } + + let weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2] + let checkCodes = Array("10X98765432") + let sum = zip(chars.prefix(17), weights).reduce(0) { partial, pair in + partial + (pair.0.wholeNumberValue ?? 0) * pair.1 + } + return chars[17] == checkCodes[sum % 11] + } + + private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .gregorian) + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd" + return formatter + }() + + /// 将 yyyy-MM-dd 文本解析为日期。 + private static func date(from value: String?) -> Date? { + guard let value, !value.isEmpty else { return nil } + return dateFormatter.date(from: value) + } +} + +/// 实名认证校验错误实体,用于把表单错误统一抛给页面展示。 +enum RealNameValidationError: LocalizedError, Equatable { + case message(String) + + var errorDescription: String? { + switch self { + case .message(let message): + message + } + } +} + +private extension String { + /// 去除首尾空白后的文本。 + var trimmed: String { + trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/suixinkan/Features/Profile/Views/AccountSwitchView.swift b/suixinkan/Features/Profile/Views/AccountSwitchView.swift new file mode 100644 index 0000000..fd7d722 --- /dev/null +++ b/suixinkan/Features/Profile/Views/AccountSwitchView.swift @@ -0,0 +1,260 @@ +// +// AccountSwitchView.swift +// suixinkan +// +// Created by Codex on 2026/6/22. +// + +import SwiftUI + +/// 账号切换页面,展示当前登录主体下可进入的景区账号和门店账号。 +struct AccountSwitchView: View { + @Environment(AppSession.self) private var appSession + @Environment(AccountContext.self) private var accountContext + @Environment(PermissionContext.self) private var permissionContext + @Environment(AppRouter.self) private var appRouter + @Environment(ProfileAPI.self) private var profileAPI + @Environment(AuthAPI.self) private var authAPI + @Environment(AccountContextAPI.self) private var accountContextAPI + @Environment(AuthSessionCoordinator.self) private var authSessionCoordinator + @Environment(ToastCenter.self) private var toastCenter + @Environment(\.dismiss) private var dismiss + + @State private var viewModel = AccountSwitchViewModel() + + private var selectedAccount: AccountSwitchAccount? { + viewModel.selectedAccount + } + + var body: some View { + VStack(spacing: 0) { + accountList + bottomBar + } + .background(Color(hex: 0xF5F7FB).ignoresSafeArea()) + .navigationTitle("账号切换") + .navigationBarTitleDisplayMode(.inline) + .task { + await loadAccounts() + } + } + + @ViewBuilder + private var accountList: some View { + if viewModel.accounts.isEmpty && !viewModel.loading { + ContentUnavailableView( + "暂无可切换账号", + systemImage: "person.crop.circle.badge.exclamationmark", + description: Text("当前登录账号下没有其他可切换账号。") + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(24) + } else { + ScrollView { + LazyVStack(spacing: 12) { + ForEach(viewModel.accounts) { account in + accountCard(account) + } + } + .padding(16) + .padding(.bottom, 92) + } + .refreshable { + await loadAccounts(force: true) + } + } + } + + private var bottomBar: some View { + VStack(spacing: 0) { + Divider() + Button { + Task { await confirmSelection() } + } label: { + HStack(spacing: 8) { + if viewModel.switching { + ProgressView() + .tint(.white) + } + Text("确认切换") + .font(.system(size: 16, weight: .semibold)) + } + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .frame(height: 50) + .background(canConfirm ? AppDesign.primary : Color(hex: 0xC9CED6), in: RoundedRectangle(cornerRadius: 12)) + .padding(.horizontal, 16) + .padding(.vertical, 14) + } + .buttonStyle(.plain) + .disabled(!canConfirm) + } + .background(.white) + } + + private var canConfirm: Bool { + selectedAccount != nil && !viewModel.loading && !viewModel.switching + } + + /// 构造单个账号卡片。 + private func accountCard(_ account: AccountSwitchAccount) -> some View { + let selected = viewModel.selectedAccountId == account.id + return Button { + viewModel.select(account) + } label: { + HStack(spacing: 13) { + accountAvatar(account) + + VStack(alignment: .leading, spacing: 5) { + HStack(spacing: 8) { + Text(nonEmpty(account.title) ?? account.accountTypeLabel) + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(AppDesign.textPrimary) + .lineLimit(1) + if account.isCurrent || isCurrentAccount(account) { + tag("当前", foreground: AppDesign.primary, background: AppDesign.primarySoft) + } + } + + if !account.subtitle.isEmpty { + Text(account.subtitle) + .font(.system(size: 13)) + .foregroundStyle(AppDesign.textSecondary) + .lineLimit(1) + } + + if !account.phone.isEmpty { + Text(account.phone) + .font(.system(size: 12)) + .foregroundStyle(Color(hex: 0x9AA1AA)) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + + VStack(alignment: .trailing, spacing: 8) { + tag( + account.isStoreUser ? "门店" : "景区", + foreground: account.isStoreUser ? Color(hex: 0x0F9F6E) : Color(hex: 0x7C3AED), + background: account.isStoreUser ? Color(hex: 0xE8F8F1) : Color(hex: 0xF3ECFF) + ) + Image(systemName: selected ? "checkmark.circle.fill" : "circle") + .font(.system(size: 22, weight: .semibold)) + .foregroundStyle(selected ? AppDesign.primary : Color(hex: 0xB6BECA)) + } + } + .padding(14) + .background(.white, in: RoundedRectangle(cornerRadius: 12)) + .overlay { + RoundedRectangle(cornerRadius: 12) + .stroke(selected ? AppDesign.primary : Color(hex: 0xE6ECF4), lineWidth: selected ? 1.4 : 1) + } + .shadow(color: Color(hex: 0xC8D7EA, alpha: selected ? 0.24 : 0.12), radius: selected ? 10 : 6, x: 0, y: 5) + } + .buttonStyle(.plain) + } + + @ViewBuilder + /// 构造账号头像,网络图加载失败时显示账号类型首字。 + private func accountAvatar(_ account: AccountSwitchAccount) -> some View { + if let url = URL(string: account.avatar), !account.avatar.isEmpty { + AsyncImage(url: url) { phase in + switch phase { + case .success(let image): + image.resizable().scaledToFill() + default: + avatarFallback(account) + } + } + .frame(width: 50, height: 50) + .clipShape(Circle()) + } else { + avatarFallback(account) + .frame(width: 50, height: 50) + } + } + + /// 构造账号头像占位。 + private func avatarFallback(_ account: AccountSwitchAccount) -> some View { + ZStack { + Circle() + .fill(account.isStoreUser ? Color(hex: 0xE8F8F1) : Color(hex: 0xF3ECFF)) + Text(account.isStoreUser ? "店" : "景") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(account.isStoreUser ? Color(hex: 0x0F9F6E) : Color(hex: 0x7C3AED)) + } + } + + /// 构造账号标签。 + private func tag(_ text: String, foreground: Color, background: Color) -> some View { + Text(text) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(foreground) + .padding(.horizontal, 7) + .frame(height: 22) + .background(background, in: RoundedRectangle(cornerRadius: 4)) + } + + /// 拉取账号列表。 + private func loadAccounts(force: Bool = false) async { + do { + try await viewModel.load(api: profileAPI, force: force, currentAccountId: currentAccountId) + } catch is CancellationError { + return + } catch { + toastCenter.show(error.localizedDescription) + } + } + + /// 确认账号切换,并刷新全局登录上下文。 + private func confirmSelection() async { + guard let account = selectedAccount else { return } + if isCurrentAccount(account) { + dismiss() + return + } + + do { + let response = try await viewModel.switchAccount(account, api: authAPI) + let username = nonEmpty(accountContext.profile?.phone) ?? nonEmpty(account.phone) ?? "" + try await authSessionCoordinator.completeLogin( + with: response, + username: username, + privacyAgreementAccepted: authSessionCoordinator.loginPreferences().privacyAgreementAccepted, + appSession: appSession, + accountContext: accountContext, + permissionContext: permissionContext, + profileAPI: profileAPI, + accountContextAPI: accountContextAPI + ) + appRouter.reset() + toastCenter.show("账号已切换") + dismiss() + } catch is CancellationError { + return + } catch { + toastCenter.show(error.localizedDescription) + } + } + + private var currentAccountId: String? { + guard let current = accountContext.profile else { return nil } + if let store = accountContext.currentStore { + return "\(V9StoreUser.accountTypeValue)_\(store.id)" + } + if let scenic = accountContext.currentScenic { + return "\(V9ScenicUser.accountTypeValue)_\(scenic.id)" + } + return current.userId.isEmpty ? nil : current.userId + } + + /// 判断账号是否为当前已选账号。 + private func isCurrentAccount(_ account: AccountSwitchAccount) -> Bool { + account.isCurrent || viewModel.isCurrent(account, currentAccountId: currentAccountId) + } + + /// 返回去空白后的非空文本。 + private func nonEmpty(_ value: String?) -> String? { + let text = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return text.isEmpty ? nil : text + } +} diff --git a/suixinkan/Features/Profile/Views/ProfileView.swift b/suixinkan/Features/Profile/Views/ProfileView.swift index b11d871..6e4c695 100644 --- a/suixinkan/Features/Profile/Views/ProfileView.swift +++ b/suixinkan/Features/Profile/Views/ProfileView.swift @@ -14,6 +14,7 @@ struct ProfileView: View { @Environment(PermissionContext.self) private var permissionContext @Environment(ScenicSpotContext.self) private var scenicSpotContext @Environment(AppRouter.self) private var appRouter + @Environment(RouterPath.self) private var router @Environment(ProfileAPI.self) private var profileAPI @Environment(ToastCenter.self) private var toastCenter @Environment(AuthSessionCoordinator.self) private var authSessionCoordinator @@ -234,7 +235,7 @@ struct ProfileView: View { divider Button { - toastCenter.show("账号切换待接入") + router.navigate(to: .profile(.accountSwitch)) } label: { infoRow(title: "当前账号") { VStack(alignment: .trailing, spacing: 3) { @@ -269,7 +270,7 @@ struct ProfileView: View { divider Button { - toastCenter.show("实名认证页面待接入") + router.navigate(to: .profile(.realNameAuth)) } label: { infoRow(title: "认证状态") { realNameStatusView @@ -289,6 +290,17 @@ struct ProfileView: View { infoRow(title: "当前景区") { scenicStatusView } + + divider + + Button { + router.navigate(to: .profile(.settings)) + } label: { + infoRow(title: "系统设置") { + rowChevron + } + } + .buttonStyle(.plain) } .padding(.horizontal, 24) .padding(.vertical, 4) diff --git a/suixinkan/Features/Profile/Views/RealNameAuthView.swift b/suixinkan/Features/Profile/Views/RealNameAuthView.swift new file mode 100644 index 0000000..0a6bfaa --- /dev/null +++ b/suixinkan/Features/Profile/Views/RealNameAuthView.swift @@ -0,0 +1,383 @@ +// +// RealNameAuthView.swift +// suixinkan +// +// Created by Codex on 2026/6/22. +// + +import SwiftUI + +/// 实名认证页面,展示审核状态并允许提交基础实名资料。 +struct RealNameAuthView: View { + @Environment(ProfileAPI.self) private var profileAPI + @Environment(ToastCenter.self) private var toastCenter + @State private var viewModel = RealNameAuthViewModel() + + var body: some View { + ScrollView { + VStack(spacing: 14) { + auditStatusPanel + identityCard + privilegeCard + tipsCard + } + .padding(.horizontal, 16) + .padding(.top, 16) + .padding(.bottom, 34) + } + .background(Color(hex: 0xF5F5F5).ignoresSafeArea()) + .navigationTitle("实名认证") + .navigationBarTitleDisplayMode(.inline) + .task { + await loadInfo() + } + .overlay { + if viewModel.loading && viewModel.info == nil { + ProgressView() + .controlSize(.large) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.white.opacity(0.35)) + } + } + } + + private var auditStatusPanel: some View { + VStack(spacing: 14) { + auditRow("审核状态", value: viewModel.info?.auditStatusText?.nonEmpty ?? viewModel.auditStatusText(viewModel.info?.auditStatus ?? 0)) + auditRow("审核人:", value: viewModel.info?.auditor?.name.nonEmpty ?? "--") + auditRow("审核时间:", value: viewModel.info?.auditAt?.nonEmpty ?? "--") + auditRow("审核备注:", value: viewModel.info?.rejectReason?.nonEmpty ?? "--") + } + .padding(.horizontal, 20) + .padding(.vertical, 22) + .background(Color(hex: 0xEAF4FF), in: RoundedRectangle(cornerRadius: 14)) + } + + /// 构造审核信息行。 + private func auditRow(_ title: String, value: String) -> some View { + HStack(alignment: .top) { + Text(title) + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(Color(hex: 0x1687D9)) + Spacer(minLength: 20) + Text(value) + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(Color(hex: 0x1687D9)) + .multilineTextAlignment(.trailing) + } + } + + private var identityCard: some View { + VStack(alignment: .leading, spacing: 14) { + identityImageSection(title: "身份证国徽面", url: viewModel.backUrl) + identityImageSection(title: "身份证人像面", url: viewModel.frontUrl) + formField("姓名", text: realNameBinding, placeholder: "请输入姓名") + formField("身份证号码", text: idCardNoBinding, placeholder: "请输入身份证号码") + validitySection + if shouldShowSmsSection { + smsSection + } + urlField("身份证人像面图片 URL", text: frontURLBinding) + urlField("身份证国徽面图片 URL", text: backURLBinding) + submitButton + statusBanner + } + .padding(.horizontal, 18) + .padding(.vertical, 20) + .background(.white, in: RoundedRectangle(cornerRadius: 14)) + } + + /// 构造证件图片预览区域。 + private func identityImageSection(title: String, url: String) -> some View { + VStack(alignment: .leading, spacing: 10) { + Text(title) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(Color(hex: 0x222222)) + imagePreview(url: url) + } + } + + private var validitySection: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 10) { + Text("有效期") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(Color(hex: 0x222222)) + Spacer() + Button { + viewModel.isLongValid.toggle() + } label: { + HStack(spacing: 6) { + Image(systemName: viewModel.isLongValid ? "checkmark.circle.fill" : "circle") + .font(.system(size: 18, weight: .semibold)) + Text("长期有效") + .font(.system(size: 15)) + } + .foregroundStyle(viewModel.isLongValid ? AppDesign.primary : Color(hex: 0x8EA0AF)) + } + .buttonStyle(.plain) + } + HStack(spacing: 12) { + dateField(selection: startDateBinding) + dateField(selection: endDateBinding) + .disabled(viewModel.isLongValid) + .opacity(viewModel.isLongValid ? 0.45 : 1) + } + } + } + + private var smsSection: some View { + HStack(spacing: 10) { + TextField("", text: smsCodeBinding, prompt: Text("请输入短信验证码").foregroundColor(AppDesign.placeholder)) + .keyboardType(.numberPad) + .font(.system(size: 16)) + .appInputFieldStyle(cornerRadius: 8, minHeight: 48) + Button { + Task { await sendCode() } + } label: { + Text(viewModel.sendingCode ? "发送中" : "获取验证码") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(viewModel.sendingCode ? Color(hex: 0x9CA3AF) : .white) + .frame(width: 104, height: 48) + .background(viewModel.sendingCode ? Color(hex: 0xEEF2F7) : AppDesign.primary, in: RoundedRectangle(cornerRadius: 8)) + } + .buttonStyle(.plain) + .disabled(viewModel.sendingCode) + } + } + + private var submitButton: some View { + Button { + Task { await submit() } + } label: { + Text(viewModel.submitting ? "提交中..." : "下一步") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .frame(height: 52) + .background(canSubmit ? AppDesign.primary : Color(hex: 0xC9CED6), in: RoundedRectangle(cornerRadius: 10)) + } + .buttonStyle(.plain) + .disabled(!canSubmit || viewModel.submitting) + } + + @ViewBuilder + private var statusBanner: some View { + if let statusMessage = viewModel.statusMessage { + HStack(spacing: 6) { + Image(systemName: statusMessageIsSuccess ? "checkmark.circle.fill" : "exclamationmark.triangle.fill") + .font(.system(size: 12)) + Text(statusMessage) + .font(.system(size: 13)) + } + .foregroundStyle(statusMessageIsSuccess ? AppDesign.success : AppDesign.warning) + .padding(.horizontal, 10) + .frame(minHeight: 34, alignment: .leading) + .background((statusMessageIsSuccess ? AppDesign.success : AppDesign.warning).opacity(0.12), in: RoundedRectangle(cornerRadius: 8)) + } + } + + private var privilegeCard: some View { + VStack(alignment: .leading, spacing: 22) { + Text("实名认证特权") + .font(.system(size: 20, weight: .semibold)) + .foregroundStyle(Color(hex: 0x222222)) + privilegeRow(icon: "checkmark.circle.fill", title: "开启接单权限", message: "完成实名认证后可立即接单,实名状态对游客可见") + privilegeRow(icon: "person.3.fill", title: "加入营销群", message: "专业摄影师交流群,获取更多流量与技术支持") + privilegeRow(icon: "map.fill", title: "景区选择权限", message: "可自主选择运营景区,获得独家运营资源") + } + .padding(.horizontal, 20) + .padding(.vertical, 22) + .background(.white, in: RoundedRectangle(cornerRadius: 14)) + } + + /// 构造认证特权行。 + private func privilegeRow(icon: String, title: String, message: String) -> some View { + HStack(alignment: .top, spacing: 14) { + Image(systemName: icon) + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 30, height: 30) + .background(AppDesign.primary, in: Circle()) + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(Color(hex: 0x333333)) + Text(message) + .font(.system(size: 14)) + .foregroundStyle(Color(hex: 0x777777)) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + private var tipsCard: some View { + VStack(alignment: .leading, spacing: 14) { + Text("温馨提示:") + .font(.system(size: 20, weight: .semibold)) + .foregroundStyle(Color(hex: 0x333333)) + tipText("请确保填写的信息真实有效") + tipText("身份信息仅用于实名认证,我们将严格保护您的隐私") + tipText("如有疑问请联系客服") + } + .padding(.horizontal, 20) + .padding(.vertical, 22) + .background(.white, in: RoundedRectangle(cornerRadius: 14)) + } + + /// 构造温馨提示行。 + private func tipText(_ text: String) -> some View { + HStack(alignment: .top, spacing: 8) { + Text("-") + Text(text) + .fixedSize(horizontal: false, vertical: true) + } + .font(.system(size: 15)) + .foregroundStyle(Color(hex: 0x666666)) + } + + /// 构造证件图片预览。 + private func imagePreview(url: String) -> some View { + ZStack { + if let imageURL = URL(string: url.trimmingCharacters(in: .whitespacesAndNewlines)), !url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + AsyncImage(url: imageURL) { phase in + switch phase { + case .success(let image): + image.resizable().scaledToFit() + case .empty: + ProgressView().tint(AppDesign.primary) + default: + placeholderImageContent + } + } + } else { + placeholderImageContent + } + } + .frame(maxWidth: .infinity) + .frame(height: 166) + .background(Color(hex: 0xFAFAFA)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color(hex: 0xE0E0E0), style: StrokeStyle(lineWidth: 1, dash: [5, 4])) + ) + } + + private var placeholderImageContent: some View { + ZStack { + Color(hex: 0xF3F6FA) + Image(systemName: "photo") + .font(.system(size: 26, weight: .semibold)) + .foregroundStyle(Color(hex: 0x8A94A6)) + } + } + + /// 构造文本输入行。 + private func formField(_ title: String, text: Binding, placeholder: String) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(Color(hex: 0x222222)) + TextField("", text: text, prompt: Text(placeholder).foregroundColor(AppDesign.placeholder)) + .font(.system(size: 16)) + .foregroundStyle(Color(hex: 0x333333)) + .appInputFieldStyle(cornerRadius: 8, minHeight: 54) + } + } + + /// 构造 URL 输入行。 + private func urlField(_ title: String, text: Binding) -> some View { + formField(title, text: text, placeholder: "OSS 上传接入前可先填写图片 URL") + } + + /// 构造日期选择器。 + private func dateField(selection: Binding) -> some View { + DatePicker("", selection: selection, displayedComponents: .date) + .labelsHidden() + .datePickerStyle(.compact) + .font(.system(size: 16)) + .frame(maxWidth: .infinity) + .frame(height: 54) + .background(Color(hex: 0xF1F1F1), in: RoundedRectangle(cornerRadius: 6)) + } + + private var canSubmit: Bool { + viewModel.validationMessage == nil && !viewModel.submitting + } + + private var shouldShowSmsSection: Bool { + !viewModel.smsCode.isEmpty || viewModel.info?.verified != true + } + + private var statusMessageIsSuccess: Bool { + guard let statusMessage = viewModel.statusMessage else { return false } + return statusMessage.contains("成功") || statusMessage.contains("已发送") || statusMessage.contains("已提交") + } + + private var realNameBinding: Binding { + Binding(get: { viewModel.realName }, set: { viewModel.realName = $0 }) + } + + private var idCardNoBinding: Binding { + Binding(get: { viewModel.idCardNo }, set: { viewModel.idCardNo = $0 }) + } + + private var smsCodeBinding: Binding { + Binding(get: { viewModel.smsCode }, set: { viewModel.smsCode = $0 }) + } + + private var frontURLBinding: Binding { + Binding(get: { viewModel.frontUrl }, set: { viewModel.frontUrl = $0 }) + } + + private var backURLBinding: Binding { + Binding(get: { viewModel.backUrl }, set: { viewModel.backUrl = $0 }) + } + + private var startDateBinding: Binding { + Binding(get: { viewModel.startDate }, set: { viewModel.startDate = $0 }) + } + + private var endDateBinding: Binding { + Binding(get: { viewModel.endDate }, set: { viewModel.endDate = $0 }) + } + + /// 拉取实名认证信息。 + private func loadInfo() async { + do { + try await viewModel.load(api: profileAPI) + } catch is CancellationError { + return + } catch { + toastCenter.show(error.localizedDescription) + } + } + + /// 发送验证码。 + private func sendCode() async { + do { + try await viewModel.sendCode(api: profileAPI) + } catch { + toastCenter.show(error.localizedDescription) + } + } + + /// 提交实名资料。 + private func submit() async { + do { + try await viewModel.submit(api: profileAPI) + } catch { + viewModel.statusMessage = error.localizedDescription + toastCenter.show(error.localizedDescription) + } + } +} + +private extension String { + /// 去除空白后返回非空字符串。 + var nonEmpty: String? { + let text = trimmingCharacters(in: .whitespacesAndNewlines) + return text.isEmpty ? nil : text + } +} diff --git a/suixinkan/Features/Profile/Views/SettingsFlowViews.swift b/suixinkan/Features/Profile/Views/SettingsFlowViews.swift new file mode 100644 index 0000000..c06450b --- /dev/null +++ b/suixinkan/Features/Profile/Views/SettingsFlowViews.swift @@ -0,0 +1,246 @@ +// +// SettingsFlowViews.swift +// suixinkan +// +// Created by Codex on 2026/6/22. +// + +import SwiftUI +import UIKit +import WebKit + +/// 协议和说明页面枚举,描述设置中心可打开的 H5 页面。 +enum AgreementPage: Hashable, Identifiable { + case about + case userAgreement + case privacyPolicy + case walletUserNotice + case walletPrivacy + + var id: String { title } + + /// 页面导航标题。 + var title: String { + switch self { + case .about: "关于我们" + case .userAgreement: "用户协议" + case .privacyPolicy: "隐私政策" + case .walletUserNotice: "钱包用户须知" + case .walletPrivacy: "钱包隐私政策" + } + } + + /// 页面对应的 H5 地址。 + var url: URL { + let path = switch self { + case .about: "/h5/app/about-us" + case .userAgreement: "/h5/app/user-agreement" + case .privacyPolicy: "/h5/app/privacy-policy" + case .walletUserNotice: "/h5/app/wallet-user-notice" + case .walletPrivacy: "/h5/app/wallet-privacy" + } + return APIEnvironment.current.baseURL.appending(path: path) + } +} + +/// 设置展示策略,集中处理版本号等纯展示逻辑。 +enum SettingsDisplayPolicy { + /// 生成页面显示的版本号。 + nonisolated static func versionText(infoDictionary: [String: Any]? = Bundle.main.infoDictionary) -> String { + AppClientInfo.appVersion(infoDictionary: infoDictionary) + } +} + +/// 设置中心页面,展示关于我们、版本、下载链接和协议入口。 +struct SettingsCenterView: View { + @Environment(RouterPath.self) private var router + @State private var copiedDownloadLink = false + + private var versionText: String { + SettingsDisplayPolicy.versionText() + } + + private var downloadLink: String { + APIEnvironment.current.baseURL.appending(path: "/h5/app/download").absoluteString + } + + var body: some View { + ScrollView { + VStack(spacing: 0) { + settingsGroup { + settingsButton(title: "关于我们") { + router.navigate(to: .profile(.agreement(.about))) + } + settingsDivider + settingsRow(title: "系统版本", value: versionText) + settingsDivider + Button { + copyDownloadLink() + } label: { + settingsRow(title: "App下载", value: copiedDownloadLink ? "已复制" : "复制链接", valueColor: AppDesign.primary) + } + .buttonStyle(.plain) + settingsDivider + settingsButton(title: "用户协议") { + router.navigate(to: .profile(.agreement(.userAgreement))) + } + settingsDivider + settingsButton(title: "隐私政策") { + router.navigate(to: .profile(.agreement(.privacyPolicy))) + } + } + + footer + .padding(.top, 40) + } + .padding(.horizontal, 16) + .padding(.top, 20) + .padding(.bottom, 32) + } + .background(Color(hex: 0xF5F5F5).ignoresSafeArea()) + .navigationTitle("设置") + .navigationBarTitleDisplayMode(.inline) + } + + /// 复制 App 下载链接到系统剪贴板。 + private func copyDownloadLink() { + UIPasteboard.general.string = downloadLink + copiedDownloadLink = true + Task { + try? await Task.sleep(nanoseconds: 1_500_000_000) + copiedDownloadLink = false + } + } + + /// 构造设置分组容器。 + private func settingsGroup(@ViewBuilder content: () -> Content) -> some View { + VStack(spacing: 0) { + content() + } + .padding(.horizontal, 20) + .padding(.vertical, 10) + .background(.white, in: RoundedRectangle(cornerRadius: 12)) + } + + /// 构造可点击的设置行。 + private func settingsButton(title: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + settingsRow(title: title, showsChevron: true) + } + .buttonStyle(.plain) + } + + /// 构造设置行。 + private func settingsRow( + title: String, + value: String? = nil, + valueColor: Color = Color(hex: 0x333333), + showsChevron: Bool = false + ) -> some View { + HStack(spacing: 12) { + Text(title) + .font(.system(size: 18)) + .foregroundStyle(Color(hex: 0x4B5563)) + Spacer() + if let value, !value.isEmpty { + Text(value) + .font(.system(size: 18)) + .foregroundStyle(valueColor) + .lineLimit(1) + } + if showsChevron { + Image(systemName: "chevron.right") + .font(.system(size: 20, weight: .semibold)) + .foregroundStyle(Color(hex: 0x999999)) + } + } + .frame(height: 58) + .contentShape(Rectangle()) + } + + /// 设置行分割线。 + private var settingsDivider: some View { + Rectangle() + .fill(Color(hex: 0xEEEEEE)) + .frame(height: 0.5) + } + + /// 设置页版权页脚。 + private var footer: some View { + VStack(spacing: 12) { + Text("Copyright © 2025 All Rights Reserved") + Text("苏ICP备2025157647号") + } + .font(.system(size: 16)) + .foregroundStyle(Color(hex: 0xB6BECA)) + .multilineTextAlignment(.center) + } +} + +/// 协议 H5 页面,使用 WKWebView 加载线上内容。 +struct AgreementView: View { + let page: AgreementPage + @State private var isLoading = true + + var body: some View { + ZStack { + AgreementWebView(url: page.url, isLoading: $isLoading) + if isLoading { + ProgressView() + .controlSize(.large) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.white.opacity(0.4)) + } + } + .background(Color(hex: 0xF5F5F5).ignoresSafeArea()) + .navigationTitle(page.title) + .navigationBarTitleDisplayMode(.inline) + } +} + +/// WKWebView 的 SwiftUI 包装,用于加载协议和说明页面。 +private struct AgreementWebView: UIViewRepresentable { + let url: URL + @Binding var isLoading: Bool + + /// 创建 WebView 协调器。 + func makeCoordinator() -> Coordinator { + Coordinator(isLoading: $isLoading) + } + + /// 创建底层 WKWebView。 + func makeUIView(context: Context) -> WKWebView { + let configuration = WKWebViewConfiguration() + configuration.defaultWebpagePreferences.allowsContentJavaScript = true + let webView = WKWebView(frame: .zero, configuration: configuration) + webView.navigationDelegate = context.coordinator + return webView + } + + /// 根据 URL 更新 WebView 请求。 + func updateUIView(_ webView: WKWebView, context: Context) { + guard webView.url != url else { return } + isLoading = true + webView.load(URLRequest(url: url)) + } + + /// WebView 代理协调器,负责同步加载状态。 + final class Coordinator: NSObject, WKNavigationDelegate { + @Binding var isLoading: Bool + + /// 初始化协调器并绑定加载状态。 + init(isLoading: Binding) { + _isLoading = isLoading + } + + /// 页面加载成功后关闭 loading。 + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + isLoading = false + } + + /// 页面加载失败后关闭 loading。 + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + isLoading = false + } + } +} diff --git a/suixinkan/Features/Statistics/API/StatisticsAPI.swift b/suixinkan/Features/Statistics/API/StatisticsAPI.swift new file mode 100644 index 0000000..11efa1f --- /dev/null +++ b/suixinkan/Features/Statistics/API/StatisticsAPI.swift @@ -0,0 +1,82 @@ +// +// StatisticsAPI.swift +// suixinkan +// +// Created by Codex on 2026/6/22. +// + +import Foundation +import Observation + +@MainActor +/// 数据统计服务协议,抽象汇总和每日明细读取能力以便测试替换。 +protocol StatisticsServing { + /// 获取统计汇总。 + func summary(scenicId: Int, range: String, isScenicAdmin: Bool) async throws -> StatisticsSummaryResponse + + /// 获取每日统计分页列表。 + func dailyList( + scenicId: Int, + startTime: String, + endTime: String, + page: Int, + pageSize: Int, + isScenicAdmin: Bool + ) async throws -> DataListPayload +} + +@MainActor +@Observable +/// 数据统计 API,封装摄影师和景区管理员两套统计接口。 +final class StatisticsAPI: StatisticsServing { + @ObservationIgnored private let client: APIClient + + /// 初始化数据统计 API,并注入共享网络客户端。 + init(client: APIClient) { + self.client = client + } + + /// 获取统计汇总。 + func summary(scenicId: Int, range: String, isScenicAdmin: Bool) async throws -> StatisticsSummaryResponse { + try await client.send( + APIRequest( + method: .get, + path: isScenicAdmin ? "/api/app/scenic-admin/analyse" : "/api/yf-handset-app/photog/analyse/user", + queryItems: [ + URLQueryItem(name: "scenic_id", value: "\(scenicId)"), + URLQueryItem(name: "range", value: range) + ] + ) + ) + } + + /// 获取每日统计分页列表。 + func dailyList( + scenicId: Int, + startTime: String, + endTime: String, + page: Int = 1, + pageSize: Int = 15, + isScenicAdmin: Bool + ) async throws -> DataListPayload { + var query = [ + URLQueryItem(name: "scenic_id", value: "\(scenicId)"), + URLQueryItem(name: "page", value: "\(max(page, 1))"), + URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))") + ] + if !startTime.isEmpty { + query.append(URLQueryItem(name: "start_time", value: startTime)) + } + if !endTime.isEmpty { + query.append(URLQueryItem(name: "end_time", value: endTime)) + } + + return try await client.send( + APIRequest( + method: .get, + path: isScenicAdmin ? "/api/app/scenic-admin/analyse/daily" : "/api/yf-handset-app/photog/analyse/user/daily", + queryItems: query + ) + ) + } +} diff --git a/suixinkan/Features/Statistics/Models/StatisticsModels.swift b/suixinkan/Features/Statistics/Models/StatisticsModels.swift new file mode 100644 index 0000000..5a577b7 --- /dev/null +++ b/suixinkan/Features/Statistics/Models/StatisticsModels.swift @@ -0,0 +1,206 @@ +// +// StatisticsModels.swift +// suixinkan +// +// Created by Codex on 2026/6/22. +// + +import Foundation + +/// 数据统计时间段实体,表示看板顶部的快捷日期范围。 +enum StatisticsPeriod: String, CaseIterable, Identifiable { + case today = "今日" + case yesterday = "昨日" + case sevenDays = "7日" + case thisMonth = "本月" + + var id: String { rawValue } + + /// 后端汇总接口需要的范围标识。 + var apiRange: String { + switch self { + case .today: + return "1" + case .yesterday: + return "2" + case .sevenDays: + return "3" + case .thisMonth: + return "4" + } + } + + /// 当前时间段对应的本地日期区间。 + var interval: (start: Date, end: Date) { + let calendar = Calendar.current + let today = calendar.startOfDay(for: Date()) + switch self { + case .today: + return (today, today) + case .yesterday: + let day = calendar.date(byAdding: .day, value: -1, to: today) ?? today + return (day, day) + case .sevenDays: + return (calendar.date(byAdding: .day, value: -6, to: today) ?? today, today) + case .thisMonth: + let components = calendar.dateComponents([.year, .month], from: today) + return (calendar.date(from: components) ?? today, today) + } + } + + /// 页面展示的时间范围文案。 + var selectedTimeText: String { + let dates = interval + return "\(Self.uiDateFormatter.string(from: dates.start))至\(Self.uiDateFormatter.string(from: dates.end))" + } + + /// 后端列表接口开始日期。 + var apiStartTime: String { + Self.apiDateFormatter.string(from: interval.start) + } + + /// 后端列表接口结束日期。 + var apiEndTime: String { + Self.apiDateFormatter.string(from: interval.end) + } + + private static let uiDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "zh_CN") + formatter.dateFormat = "yyyy/MM/dd" + return formatter + }() + + private static let apiDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd" + return formatter + }() +} + +/// 数据统计汇总实体,表示订单金额、数量、客单价、实收和退款。 +struct StatisticsSummaryResponse: Decodable, Equatable { + let orderAmountSum: String + let orderCount: Int + let orderPriceAverage: String + let receivedAmountSum: String + let refundAmountSum: String + + var orderAmountValue: Double { Self.parseAmount(orderAmountSum) } + var orderPriceAverageValue: Double { Self.parseAmount(orderPriceAverage) } + var receivedAmountValue: Double { Self.parseAmount(receivedAmountSum) } + var refundAmountValue: Double { Self.parseAmount(refundAmountSum) } + + enum CodingKeys: String, CodingKey { + case orderAmountSum = "order_amount_sum" + case orderCount = "order_count" + case orderPriceAverage = "order_price_avg" + case receivedAmountSum = "received_amount_sum" + case refundAmountSum = "refund_amount_sum" + } + + /// 创建统计汇总实体,主要用于初始状态和测试。 + init( + orderAmountSum: String = "", + orderCount: Int = 0, + orderPriceAverage: String = "", + receivedAmountSum: String = "", + refundAmountSum: String = "" + ) { + self.orderAmountSum = orderAmountSum + self.orderCount = orderCount + self.orderPriceAverage = orderPriceAverage + self.receivedAmountSum = receivedAmountSum + self.refundAmountSum = refundAmountSum + } + + /// 宽松解码统计汇总字段。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + orderAmountSum = try container.decodeLossyString(forKey: .orderAmountSum) + orderCount = try container.decodeLossyInt(forKey: .orderCount) ?? 0 + orderPriceAverage = try container.decodeLossyString(forKey: .orderPriceAverage) + receivedAmountSum = try container.decodeLossyString(forKey: .receivedAmountSum) + refundAmountSum = try container.decodeLossyString(forKey: .refundAmountSum) + } + + private static func parseAmount(_ text: String) -> Double { + let normalized = text.filter { "0123456789.-".contains($0) } + return Double(normalized) ?? 0 + } +} + +/// 每日统计实体,表示某天订单数、客单价、退款和实收。 +struct StatisticsDailyItem: Decodable, Identifiable, Equatable { + var id: String { date } + + let date: String + let orderCount: Int + let orderPrice: String + let refund: String + let received: String + + var orderPriceValue: Double { Self.parseAmount(orderPrice) } + var refundValue: Double { Self.parseAmount(refund) } + var receivedValue: Double { Self.parseAmount(received) } + + enum CodingKeys: String, CodingKey { + case date + case orderCount = "order_count" + case orderPrice = "order_price" + case refund + case received + } + + /// 宽松解码每日统计字段。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + date = try container.decodeLossyString(forKey: .date) + orderCount = try container.decodeLossyInt(forKey: .orderCount) ?? 0 + orderPrice = try container.decodeLossyString(forKey: .orderPrice) + refund = try container.decodeLossyString(forKey: .refund) + received = try container.decodeLossyString(forKey: .received) + } + + private static func parseAmount(_ text: String) -> Double { + let normalized = text.filter { "0123456789.-".contains($0) } + return Double(normalized) ?? 0 + } +} + +private extension KeyedDecodingContainer { + /// 将任意常见 JSON 值宽松解码成字符串。 + func decodeLossyString(forKey key: Key) throws -> String { + if let value = try? decodeIfPresent(String.self, forKey: key) { + return value + } + if let value = try? decodeIfPresent(Int.self, forKey: key) { + return String(value) + } + if let value = try? decodeIfPresent(Double.self, forKey: key) { + return String(value) + } + return "" + } + + /// 将 Int、Double 或数字字符串宽松解码成整数。 + func decodeLossyInt(forKey key: Key) throws -> Int? { + if let value = try? decodeIfPresent(Int.self, forKey: key) { + return value + } + if let value = try? decodeIfPresent(String.self, forKey: key) { + let text = value.trimmingCharacters(in: .whitespacesAndNewlines) + if let intValue = Int(text) { + return intValue + } + if let doubleValue = Double(text) { + return Int(doubleValue) + } + } + if let value = try? decodeIfPresent(Double.self, forKey: key) { + return Int(value) + } + return nil + } +} diff --git a/suixinkan/Features/Statistics/Statistics.md b/suixinkan/Features/Statistics/Statistics.md new file mode 100644 index 0000000..d56a51e --- /dev/null +++ b/suixinkan/Features/Statistics/Statistics.md @@ -0,0 +1,26 @@ +# Statistics 模块业务逻辑 + +## 模块职责 + +Statistics 模块负责登录后的数据 Tab,展示当前景区下的订单统计看板。 + +当前同步旧工程数据页的主要能力:时间段切换、统计汇总卡、每日明细、分页加载和下拉刷新。 + +## 核心对象 + +- `StatisticsView`:数据 Tab 根视图,读取当前景区、当前角色和统计 API。 +- `StatisticsViewModel`:管理时间段、汇总数据、每日明细、分页状态和加载状态。 +- `StatisticsAPI`:封装摄影师和景区管理员两套统计接口。 +- `StatisticsPeriod`:表示今日、昨日、7日、本月四个快捷时间段。 + +## 数据流程 + +页面从 `AccountContext.currentScenic` 获取当前景区 ID。缺少景区时显示空状态,不请求接口。 + +切换时间段时同时请求汇总接口和第一页日数据接口。汇总接口使用 `range` 参数,日数据接口使用 `start_time`、`end_time`、`page` 和 `page_size`。 + +`roleId == 53` 时使用景区管理员接口,其他角色使用摄影师接口。日数据响应使用 `DataListPayload` 解码,兼容后端返回 `data` 或 `list` 字段。 + +## 分页规则 + +每日明细第一页随刷新或时间段切换加载。列表滚动到底部后,如果当前数量小于 total,则继续加载下一页。加载更多失败时保留已有数据和当前页状态。 diff --git a/suixinkan/Features/Statistics/ViewModels/StatisticsViewModel.swift b/suixinkan/Features/Statistics/ViewModels/StatisticsViewModel.swift new file mode 100644 index 0000000..4d90a9d --- /dev/null +++ b/suixinkan/Features/Statistics/ViewModels/StatisticsViewModel.swift @@ -0,0 +1,100 @@ +// +// StatisticsViewModel.swift +// suixinkan +// +// Created by Codex on 2026/6/22. +// + +import Foundation +import Observation + +@MainActor +@Observable +/// 数据统计页面 ViewModel,管理时间段、汇总数据、每日明细和分页状态。 +final class StatisticsViewModel { + var selectedPeriod: StatisticsPeriod = .today + private(set) var loading = false + private(set) var loadingMore = false + private(set) var summary = StatisticsSummaryResponse() + private(set) var dailyItems: [StatisticsDailyItem] = [] + private(set) var totalDailyCount = 0 + private(set) var lastRefreshAt: Date? + + private var page = 1 + private let pageSize = 15 + + /// 当前日数据是否还有下一页。 + var hasMore: Bool { + dailyItems.count < totalDailyCount + } + + /// 切换统计时间段,并重置分页重新加载。 + func selectPeriod(_ period: StatisticsPeriod, api: StatisticsServing, scenicId: Int?, roleId: Int?) async throws { + selectedPeriod = period + try await reload(api: api, scenicId: scenicId, roleId: roleId) + } + + /// 刷新统计汇总和第一页日数据,缺少景区时清空旧状态。 + func reload(api: StatisticsServing, scenicId: Int?, roleId: Int?, showLoading: Bool = true) async throws { + guard let scenicId else { + reset() + return + } + + if showLoading { loading = true } + defer { if showLoading { loading = false } } + + let isScenicAdmin = roleId == 53 + async let summaryResult = api.summary( + scenicId: scenicId, + range: selectedPeriod.apiRange, + isScenicAdmin: isScenicAdmin + ) + async let dailyResult = api.dailyList( + scenicId: scenicId, + startTime: selectedPeriod.apiStartTime, + endTime: selectedPeriod.apiEndTime, + page: 1, + pageSize: pageSize, + isScenicAdmin: isScenicAdmin + ) + let (summaryData, dailyData) = try await (summaryResult, dailyResult) + summary = summaryData + dailyItems = dailyData.data + totalDailyCount = dailyData.total + page = 1 + lastRefreshAt = Date() + } + + /// 加载每日统计下一页。 + func loadMore(api: StatisticsServing, scenicId: Int?, roleId: Int?) async throws { + guard !loadingMore, hasMore, let scenicId else { return } + + loadingMore = true + defer { loadingMore = false } + + let nextPage = page + 1 + let result = try await api.dailyList( + scenicId: scenicId, + startTime: selectedPeriod.apiStartTime, + endTime: selectedPeriod.apiEndTime, + page: nextPage, + pageSize: pageSize, + isScenicAdmin: roleId == 53 + ) + dailyItems.append(contentsOf: result.data) + totalDailyCount = result.total + page = nextPage + } + + /// 清空统计状态。 + private func reset() { + summary = StatisticsSummaryResponse() + dailyItems = [] + totalDailyCount = 0 + page = 1 + lastRefreshAt = nil + loading = false + loadingMore = false + } +} diff --git a/suixinkan/Features/Statistics/Views/StatisticsView.swift b/suixinkan/Features/Statistics/Views/StatisticsView.swift new file mode 100644 index 0000000..06238a3 --- /dev/null +++ b/suixinkan/Features/Statistics/Views/StatisticsView.swift @@ -0,0 +1,321 @@ +// +// StatisticsView.swift +// suixinkan +// +// Created by Codex on 2026/6/22. +// + +import SwiftUI + +/// 数据 Tab 根视图,展示订单统计汇总和每日明细。 +struct StatisticsView: View { + @Environment(AccountContext.self) private var accountContext + @Environment(PermissionContext.self) private var permissionContext + @Environment(StatisticsAPI.self) private var statisticsAPI + @Environment(ToastCenter.self) private var toastCenter + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + + @State private var viewModel = StatisticsViewModel() + + private var currentScenicId: Int? { + accountContext.currentScenic?.id + } + + private var currentRoleId: Int? { + permissionContext.currentRole?.id + } + + private var contentMaxWidth: CGFloat { + horizontalSizeClass == .regular ? 900 : .infinity + } + + var body: some View { + Group { + if currentScenicId == nil { + ContentUnavailableView( + "缺少经营上下文", + systemImage: "chart.bar.doc.horizontal", + description: Text("请先在首页选择景区后查看数据看板。") + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(hex: 0xF5F5F5).ignoresSafeArea()) + } else { + ScrollView { + VStack(spacing: AppMetrics.Spacing.medium) { + overviewCard + detailCard + } + .frame(maxWidth: contentMaxWidth) + .padding(.horizontal, AppMetrics.Spacing.pageHorizontal) + .padding(.top, AppMetrics.Spacing.medium) + .padding(.bottom, AppMetrics.Spacing.pageVertical) + .frame(maxWidth: .infinity) + } + .refreshable { await reload(showLoading: false) } + .background(Color(hex: 0xF5F5F5).ignoresSafeArea()) + } + } + .navigationTitle("数据") + .navigationBarTitleDisplayMode(.inline) + .task { await reload() } + .onChange(of: accountContext.currentScenic?.id) { _, _ in + Task { await reload() } + } + .onChange(of: permissionContext.currentRole?.id) { _, _ in + Task { await reload() } + } + } + + private var overviewCard: some View { + VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) { + HStack(spacing: 0) { + Text("已选日期:") + .font(.system(size: AppMetrics.FontSize.subheadline)) + .foregroundStyle(AppDesign.textPrimary) + Text(viewModel.selectedPeriod.selectedTimeText) + .font(.system(size: AppMetrics.FontSize.subheadline)) + .foregroundStyle(AppDesign.primary) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + + HStack(spacing: AppMetrics.Spacing.small) { + ForEach(StatisticsPeriod.allCases) { period in + periodButton(period) + } + } + + VStack(spacing: AppMetrics.Spacing.small) { + statCard( + title: "订单总金额", + value: amountText(viewModel.summary.orderAmountValue), + textColor: Color(hex: 0x22C55E), + backgroundColor: Color(hex: 0xF0FDF4), + icon: "doc.text.fill" + ) + + HStack(spacing: AppMetrics.Spacing.small) { + statCard( + title: "订单总数", + value: "\(viewModel.summary.orderCount)单", + textColor: Color(hex: 0x7F00FF), + backgroundColor: Color(hex: 0xEBD8FF), + icon: "chart.bar.fill" + ) + statCard( + title: "实收金额", + value: amountText(viewModel.summary.receivedAmountValue), + textColor: Color(hex: 0x22C55E), + backgroundColor: Color(hex: 0xFFF0E2), + icon: "yensign.circle.fill" + ) + } + + HStack(spacing: AppMetrics.Spacing.small) { + statCard( + title: "客单价", + value: amountText(viewModel.summary.orderPriceAverageValue), + textColor: AppDesign.primary, + backgroundColor: Color(hex: 0xEFF6FF), + icon: "tag.fill" + ) + statCard( + title: "退款金额", + value: amountText(viewModel.summary.refundAmountValue), + textColor: Color(hex: 0xEF4444), + backgroundColor: Color(hex: 0xFFE7E7), + icon: "arrow.down.doc.fill" + ) + } + } + } + .padding(AppMetrics.Spacing.medium) + .background(.white, in: RoundedRectangle(cornerRadius: 8)) + } + + private var detailCard: some View { + VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) { + HStack { + Text("时间范围") + .font(.system(size: AppMetrics.FontSize.subheadline, weight: .bold)) + .foregroundStyle(.black) + Spacer() + Menu { + ForEach(StatisticsPeriod.allCases) { period in + Button(period.rawValue) { + Task { await selectPeriod(period) } + } + } + } label: { + HStack(spacing: AppMetrics.Spacing.xSmall) { + Text(viewModel.selectedPeriod.rawValue) + .font(.system(size: AppMetrics.FontSize.subheadline)) + .foregroundStyle(Color(hex: 0x4B5563)) + Spacer(minLength: AppMetrics.Spacing.small) + Image(systemName: "chevron.down") + .font(.system(size: AppMetrics.FontSize.caption, weight: .semibold)) + .foregroundStyle(Color(hex: 0x4B5563)) + } + .padding(.horizontal, AppMetrics.Spacing.small) + .frame(width: 166, height: 36) + .background(Color(hex: 0xF4F4F4), in: RoundedRectangle(cornerRadius: 6)) + } + .buttonStyle(.plain) + } + + Divider() + + if viewModel.loading { + ProgressView() + .frame(maxWidth: .infinity) + .frame(minHeight: 280) + } else if viewModel.dailyItems.isEmpty { + ContentUnavailableView("暂无数据", systemImage: "tray", description: Text("可切换时间范围或下拉刷新。")) + .frame(minHeight: 280) + } else { + VStack(spacing: 0) { + ForEach(viewModel.dailyItems) { item in + dailyDataRow(item) + .onAppear { + guard item.id == viewModel.dailyItems.last?.id else { return } + Task { await loadMore() } + } + if item.id != viewModel.dailyItems.last?.id { + Divider() + } + } + if viewModel.loadingMore { + ProgressView() + .frame(maxWidth: .infinity) + .padding(.vertical, AppMetrics.Spacing.small) + } + } + } + } + .padding(AppMetrics.Spacing.medium) + .frame(minHeight: 420, alignment: .top) + .background(.white, in: RoundedRectangle(cornerRadius: 8)) + } + + private func periodButton(_ period: StatisticsPeriod) -> some View { + let isSelected = period == viewModel.selectedPeriod + return Button { + Task { await selectPeriod(period) } + } label: { + Text(period.rawValue) + .font(.system(size: AppMetrics.FontSize.subheadline, weight: isSelected ? .semibold : .medium)) + .foregroundStyle(isSelected ? .white : Color(hex: 0x4B5563)) + .frame(maxWidth: .infinity) + .frame(height: 34) + .background(isSelected ? AppDesign.primary : Color(hex: 0xF3F4F6), in: RoundedRectangle(cornerRadius: 17)) + } + .buttonStyle(.plain) + } + + private func statCard(title: String, value: String, textColor: Color, backgroundColor: Color, icon: String) -> some View { + HStack(spacing: AppMetrics.Spacing.xSmall) { + VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxxSmall) { + Text(title) + .font(.system(size: AppMetrics.FontSize.subheadline)) + .foregroundStyle(.black.opacity(0.6)) + .lineLimit(1) + Text(value) + .font(.system(size: AppMetrics.FontSize.title3, weight: .bold)) + .foregroundStyle(textColor) + .lineLimit(1) + .minimumScaleFactor(0.72) + } + Spacer(minLength: AppMetrics.Spacing.xxSmall) + statisticIcon(symbol: icon, color: textColor) + } + .padding(.horizontal, AppMetrics.Spacing.small) + .padding(.vertical, AppMetrics.Spacing.small) + .frame(maxWidth: .infinity) + .background(backgroundColor, in: RoundedRectangle(cornerRadius: 8)) + } + + private func statisticIcon(symbol: String, color: Color) -> some View { + ZStack { + RoundedRectangle(cornerRadius: 7) + .fill(color.opacity(0.22)) + .frame(width: 30, height: 30) + .offset(x: 6, y: 6) + RoundedRectangle(cornerRadius: 7) + .fill(color.opacity(0.9)) + .frame(width: 30, height: 30) + .offset(x: -6, y: -6) + Image(systemName: symbol) + .font(.system(size: AppMetrics.FontSize.title3, weight: .bold)) + .foregroundStyle(.white) + } + .frame(width: 46, height: 46) + } + + private func dailyDataRow(_ item: StatisticsDailyItem) -> some View { + VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) { + Text(item.date) + .font(.system(size: AppMetrics.FontSize.subheadline)) + .foregroundStyle(.black) + + HStack(alignment: .top) { + dataColumn("订单数", "\(item.orderCount)", .black) + dataColumn("客单价", amountText(item.orderPriceValue), .black) + dataColumn("退款", amountText(item.refundValue), Color(hex: 0xEF4444)) + dataColumn("实收", amountText(item.receivedValue), Color(hex: 0x22C55E)) + } + } + .padding(.vertical, AppMetrics.Spacing.small) + } + + private func dataColumn(_ title: String, _ value: String, _ color: Color) -> some View { + VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxSmall) { + Text(title) + .font(.system(size: AppMetrics.FontSize.subheadline)) + .foregroundStyle(Color(hex: 0x4B5563)) + Text(value) + .font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold)) + .foregroundStyle(color) + .lineLimit(1) + .minimumScaleFactor(0.65) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func amountText(_ value: Double) -> String { + "¥\(String(format: "%.2f", value))" + } + + private func selectPeriod(_ period: StatisticsPeriod) async { + do { + try await viewModel.selectPeriod(period, api: statisticsAPI, scenicId: currentScenicId, roleId: currentRoleId) + } catch { + toastCenter.show(error.localizedDescription) + } + } + + private func reload(showLoading: Bool = true) async { + do { + try await viewModel.reload(api: statisticsAPI, scenicId: currentScenicId, roleId: currentRoleId, showLoading: showLoading) + } catch { + toastCenter.show(error.localizedDescription) + } + } + + private func loadMore() async { + do { + try await viewModel.loadMore(api: statisticsAPI, scenicId: currentScenicId, roleId: currentRoleId) + } catch { + toastCenter.show(error.localizedDescription) + } + } +} + +#Preview { + NavigationStack { + StatisticsView() + .environment(AccountContext()) + .environment(PermissionContext()) + .environment(StatisticsAPI(client: APIClient())) + .environment(ToastCenter()) + } +} diff --git a/suixinkanTests/Fixtures/empty_success.json b/suixinkanTests/Fixtures/empty_success.json new file mode 100644 index 0000000..90b1942 --- /dev/null +++ b/suixinkanTests/Fixtures/empty_success.json @@ -0,0 +1,4 @@ +{ + "code": 100000, + "msg": "success" +} diff --git a/suixinkanTests/Fixtures/order_v2_success.json b/suixinkanTests/Fixtures/order_v2_success.json new file mode 100644 index 0000000..fe429ff --- /dev/null +++ b/suixinkanTests/Fixtures/order_v2_success.json @@ -0,0 +1,83 @@ +{ + "code": 100000, + "msg": "success", + "data": { + "total": "2", + "list": [ + { + "photog_uid": "42", + "order_number": "ORDV2001", + "remark": "需要精修", + "scenic_area_id": "88", + "pay_time": "2026-05-23 04:40:00", + "complete_time": "", + "order_status": "20", + "order_status_name": "已支付", + "user_id": "7001", + "project_id": "5101", + "phone": "13800000000", + "order_amount": "299.50", + "actual_pay_amount": "299.50", + "actual_refund_amount": "0", + "refund_amount": "0", + "pay_type_name": "微信支付", + "pay_type": "1", + "project_name": "亲子跟拍", + "order_type": "19", + "order_type_label": "多点旅拍", + "deposit_pay_time": "", + "created_at": "2026-05-23 04:39:00", + "photo_travel": { + "order_photo_num": "9", + "order_video_num": "1", + "can_cancel_order": "1", + "need_check_in": "0", + "check_in_time": "", + "can_gift_retouch": "1", + "retouch_gift_time": "2026-05-24 04:40:00", + "retouch_gift_photo_num": "2", + "retouch_gift_video_num": "0" + }, + "multi_travel": { + "material_list": [ + { + "file_name": "spot-901.jpg", + "file_url": "https://cdn.example.com/order/spot-901.jpg", + "file_type": "1", + "file_size": "204800", + "cover_url": "" + } + ] + }, + "isNeedEdit": "1", + "is_refined": "1" + }, + { + "photog_uid": 43, + "order_number": "ORDV2002", + "remark": "", + "scenic_area_id": 88, + "pay_time": "2026-05-23 04:42:00", + "complete_time": "2026-05-23 05:00:00", + "order_status": 30, + "order_status_name": "已完成", + "user_id": 7002, + "project_id": 5102, + "phone": "13900000000", + "order_amount": "99.00", + "actual_pay_amount": "99.00", + "actual_refund_amount": "0", + "refund_amount": "0", + "pay_type_name": "余额支付", + "pay_type": 3, + "project_name": "押金体验", + "order_type": 4, + "order_type_label": "押金", + "deposit_pay_time": "2026-05-23 04:41:00", + "created_at": "2026-05-23 04:41:00", + "isNeedEdit": false, + "is_refined": 0 + } + ] + } +} diff --git a/suixinkanTests/Fixtures/real_name_info_success.json b/suixinkanTests/Fixtures/real_name_info_success.json new file mode 100644 index 0000000..694b438 --- /dev/null +++ b/suixinkanTests/Fixtures/real_name_info_success.json @@ -0,0 +1,26 @@ +{ + "code": 100000, + "msg": "success", + "data": { + "real_name_info": { + "real_name": "测试摄影师", + "id_card_no": "320100********0012", + "verified": "1", + "start_date": "2026-01-01", + "end_date": "2046-01-01", + "is_long_valid": "0", + "front_url": "https://cdn.example.com/front.jpg", + "back_url": "https://cdn.example.com/back.jpg", + "created_at": "2026-05-23 04:00:00", + "audit_status": "2", + "audit_status_text": "审核通过", + "audit_at": "2026-05-23 04:05:00", + "auditor_id": "7", + "auditor": { + "id": 7, + "name": "审核员" + }, + "reject_reason": "" + } + } +} diff --git a/suixinkanTests/Fixtures/server_code_error.json b/suixinkanTests/Fixtures/server_code_error.json new file mode 100644 index 0000000..bb80148 --- /dev/null +++ b/suixinkanTests/Fixtures/server_code_error.json @@ -0,0 +1,4 @@ +{ + "code": 200001, + "msg": "登录已过期" +} diff --git a/suixinkanTests/Fixtures/statistics_daily_page1_total3.json b/suixinkanTests/Fixtures/statistics_daily_page1_total3.json new file mode 100644 index 0000000..f346212 --- /dev/null +++ b/suixinkanTests/Fixtures/statistics_daily_page1_total3.json @@ -0,0 +1,16 @@ +{ + "code": 100000, + "msg": "success", + "data": { + "total": 3, + "data": [ + { + "date": "2026-05-23", + "order_count": "5", + "order_price": "1299.50", + "refund": "0", + "received": "1299.50" + } + ] + } +} diff --git a/suixinkanTests/Fixtures/statistics_daily_page2_total3.json b/suixinkanTests/Fixtures/statistics_daily_page2_total3.json new file mode 100644 index 0000000..4473677 --- /dev/null +++ b/suixinkanTests/Fixtures/statistics_daily_page2_total3.json @@ -0,0 +1,23 @@ +{ + "code": 100000, + "msg": "success", + "data": { + "total": 3, + "data": [ + { + "date": "2026-05-22", + "order_count": 3, + "order_price": "650.00", + "refund": "50.00", + "received": 600 + }, + { + "date": "2026-05-21", + "order_count": 2, + "order_price": "420.00", + "refund": "0", + "received": "420.00" + } + ] + } +} diff --git a/suixinkanTests/Fixtures/statistics_summary_success.json b/suixinkanTests/Fixtures/statistics_summary_success.json new file mode 100644 index 0000000..c06bf09 --- /dev/null +++ b/suixinkanTests/Fixtures/statistics_summary_success.json @@ -0,0 +1,11 @@ +{ + "code": 100000, + "msg": "success", + "data": { + "order_amount_sum": "¥12,345.67", + "order_count": "18", + "order_price_avg": "685.87", + "received_amount_sum": "12000.00", + "refund_amount_sum": "345.67" + } +} diff --git a/suixinkanTests/Fixtures/user_info_success.json b/suixinkanTests/Fixtures/user_info_success.json new file mode 100644 index 0000000..db0b8d6 --- /dev/null +++ b/suixinkanTests/Fixtures/user_info_success.json @@ -0,0 +1,13 @@ +{ + "code": 100000, + "msg": "success", + "data": { + "avatar": "https://example.com/avatar.jpg", + "real_name": "测试摄影师", + "phone": "13800000000", + "nickname": "跟拍小苏", + "role_name": "摄影师", + "status": 1, + "status_name": "已认证" + } +} diff --git a/suixinkanTests/Fixtures/v9_login_multi_success.json b/suixinkanTests/Fixtures/v9_login_multi_success.json new file mode 100644 index 0000000..67801e8 --- /dev/null +++ b/suixinkanTests/Fixtures/v9_login_multi_success.json @@ -0,0 +1,67 @@ +{ + "code": 100000, + "msg": "success", + "data": { + "token": "person-temp-token", + "scenic_users": [ + { + "account_type": "scenic_user", + "id": 101, + "user_id": 101, + "scenic_user_id": 101, + "ss_user_id": 101, + "username": "scenic_admin", + "real_name": "张三", + "nickname": "张三", + "phone": "13800138000", + "scenic_id": 10, + "scenic_name": "示例景区", + "status": 1, + "status_label": "正常", + "app_role_id": 3, + "app_role_code": "scenic_admin", + "app_role_name": "景区管理员", + "app_role": { + "id": 3, + "legacy_role_id": 53, + "code": "scenic_admin", + "name": "景区管理员", + "notes": "", + "status": 1 + }, + "is_current": false + } + ], + "store_users": [ + { + "account_type": "store_user", + "id": 201, + "user_id": 201, + "store_user_id": 201, + "username": "store_admin", + "user_name": "store_admin", + "real_name": "张三", + "phone": "13800138000", + "avatar": "https://example.com/avatar.png", + "scenic_id": 10, + "scenic_name": "示例景区", + "store_id": 20, + "store_name": "示例门店", + "status": 1, + "status_label": "正常", + "app_role_id": 1, + "app_role_code": "store_admin", + "app_role_name": "店铺管理员", + "app_role": { + "id": 1, + "legacy_role_id": 46, + "code": "store_admin", + "name": "店铺管理员", + "notes": "", + "status": 1 + }, + "is_current": false + } + ] + } +} diff --git a/suixinkanTests/Fixtures/v9_set_store_user_success.json b/suixinkanTests/Fixtures/v9_set_store_user_success.json new file mode 100644 index 0000000..0bdb72c --- /dev/null +++ b/suixinkanTests/Fixtures/v9_set_store_user_success.json @@ -0,0 +1,39 @@ +{ + "code": 100000, + "msg": "success", + "data": { + "token": "business-token-store", + "scenic_users": [], + "store_users": [ + { + "account_type": "store_user", + "id": 201, + "user_id": 201, + "store_user_id": 201, + "username": "store_admin", + "user_name": "store_admin", + "real_name": "张三", + "phone": "13800138000", + "avatar": "https://example.com/avatar.png", + "scenic_id": 10, + "scenic_name": "示例景区", + "store_id": 20, + "store_name": "示例门店", + "status": 1, + "status_label": "正常", + "app_role_id": 1, + "app_role_code": "store_admin", + "app_role_name": "店铺管理员", + "app_role": { + "id": 1, + "legacy_role_id": 46, + "code": "store_admin", + "name": "店铺管理员", + "notes": "", + "status": 1 + }, + "is_current": true + } + ] + } +} diff --git a/suixinkanTests/Fixtures/writeoff_list_success.json b/suixinkanTests/Fixtures/writeoff_list_success.json new file mode 100644 index 0000000..38363e3 --- /dev/null +++ b/suixinkanTests/Fixtures/writeoff_list_success.json @@ -0,0 +1,19 @@ +{ + "code": 100000, + "msg": "success", + "data": { + "total": 1, + "list": [ + { + "order_number": "VERIFY001", + "order_verification_status": "待核销", + "pay_time": "2026-05-23 03:03", + "project_name": "核销套餐", + "user_phone": "13800000000", + "order_amount": "99.00", + "order_status_name": "已支付", + "order_verification_time": "" + } + ] + } +} diff --git a/suixinkanTests/HomeMenuRouterTests.swift b/suixinkanTests/HomeMenuRouterTests.swift index 41cc361..f5db8c9 100644 --- a/suixinkanTests/HomeMenuRouterTests.swift +++ b/suixinkanTests/HomeMenuRouterTests.swift @@ -41,9 +41,9 @@ final class HomeMenuRouterTests: XCTestCase { /// 测试 Tab 路由仍集中在 HomeMenuRouter。 func testTabRoutesStayCentralized() { - XCTAssertEqual(HomeMenuRouter.resolve(uri: "photographer_orders", title: ""), .tab(.orders)) - XCTAssertEqual(HomeMenuRouter.resolve(uri: "/scenic-order-manage", title: ""), .tab(.orders)) - XCTAssertEqual(HomeMenuRouter.resolve(uri: "verification_order", title: ""), .tab(.orders)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "photographer_orders", title: ""), .orders(.storeOrders)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "/scenic-order-manage", title: ""), .orders(.storeOrders)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "verification_order", title: ""), .orders(.verificationOrders)) XCTAssertEqual(HomeMenuRouter.resolve(uri: "photographer_stats", title: ""), .tab(.statistics)) } diff --git a/suixinkanTests/NavigationRouterTests.swift b/suixinkanTests/NavigationRouterTests.swift index 6729b6a..bacbc35 100644 --- a/suixinkanTests/NavigationRouterTests.swift +++ b/suixinkanTests/NavigationRouterTests.swift @@ -15,4 +15,14 @@ final class NavigationRouterTests: XCTestCase { XCTAssertTrue(AppRoute.placeholder(title: "详情").hidesTabBarWhenPushed) XCTAssertTrue(AppRoute.home(.moreFunctions).hidesTabBarWhenPushed) } + + /// 测试切换订单 Tab 时会保存订单内部子入口。 + func testSelectOrdersUpdatesTabAndEntry() { + let router = AppRouter() + + router.selectOrders(entry: .verificationOrders) + + XCTAssertEqual(router.selectedTab, .orders) + XCTAssertEqual(router.selectedOrdersEntry, .verificationOrders) + } } diff --git a/suixinkanTests/OrdersViewModelTests.swift b/suixinkanTests/OrdersViewModelTests.swift new file mode 100644 index 0000000..834f5bf --- /dev/null +++ b/suixinkanTests/OrdersViewModelTests.swift @@ -0,0 +1,306 @@ +// +// OrdersViewModelTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/22. +// + +import XCTest +@testable import suixinkan + +@MainActor +/// 订单 ViewModel 测试,覆盖列表分页、筛选条件和手动核销状态。 +final class OrdersViewModelTests: XCTestCase { + /// 测试无景区时清空订单管理旧数据且不请求接口。 + func testStoreOrdersClearStaleDataWhenScenicIsMissing() async throws { + let api = MockOrderService() + api.storeResponses = [try TestFixture.payload(ListPayload.self, named: "order_v2_success")] + let viewModel = OrdersViewModel() + + try await viewModel.reloadStoreOrders(api: api, scenicId: 88, roleId: nil) + XCTAssertFalse(viewModel.storeOrders.isEmpty) + + try await viewModel.reloadStoreOrders(api: api, scenicId: nil, roleId: nil) + + XCTAssertTrue(viewModel.storeOrders.isEmpty) + XCTAssertEqual(viewModel.storeTotal, 0) + XCTAssertEqual(viewModel.storePage, 1) + XCTAssertFalse(viewModel.storeHasMore) + XCTAssertEqual(api.orderListCalls.count, 1) + } + + /// 测试订单管理最后一页不会继续请求。 + func testStoreOrdersDoNotRequestMoreAfterLastPage() async throws { + let api = MockOrderService() + api.storeResponses = [try TestFixture.payload(ListPayload.self, named: "order_v2_success")] + let viewModel = OrdersViewModel() + + try await viewModel.reloadStoreOrders(api: api, scenicId: 88, roleId: nil) + try await viewModel.loadMoreStoreOrders(api: api, scenicId: 88, roleId: nil) + + XCTAssertEqual(viewModel.storeOrders.count, 2) + XCTAssertEqual(viewModel.storeTotal, 2) + XCTAssertEqual(viewModel.storePage, 1) + XCTAssertFalse(viewModel.storeHasMore) + XCTAssertEqual(api.orderListCalls.count, 1) + } + + /// 测试订单管理筛选参数会传入服务层并重置到第一页。 + func testStoreOrderFilterUsesExpectedQueries() async throws { + let api = MockOrderService() + api.storeResponses = [ + try TestFixture.payload(ListPayload.self, named: "order_v2_success"), + try TestFixture.payload(ListPayload.self, named: "order_v2_success") + ] + let viewModel = OrdersViewModel() + viewModel.selectedStatus = -1 + viewModel.searchPhone = " 13800000000 " + viewModel.filterStartDate = Self.date("2026-05-01") + viewModel.filterEndDate = Self.date("2026-05-31") + + try await viewModel.reloadStoreOrders(api: api, scenicId: 88, roleId: nil) + + viewModel.selectedStatus = 30 + viewModel.searchPhone = " " + viewModel.filterStartDate = nil + viewModel.filterEndDate = nil + try await viewModel.reloadStoreOrders(api: api, scenicId: 88, roleId: 53) + + XCTAssertEqual(api.orderListCalls.count, 2) + XCTAssertEqual(api.orderListCalls[0].page, 1) + XCTAssertEqual(api.orderListCalls[0].isRefined, 1) + XCTAssertEqual(api.orderListCalls[0].userPhone, "13800000000") + XCTAssertNil(api.orderListCalls[0].orderStatus) + XCTAssertEqual(api.orderListCalls[0].startTime, "2026-05-01") + XCTAssertEqual(api.orderListCalls[0].endTime, "2026-05-31") + XCTAssertFalse(api.orderListCalls[0].isScenicAdmin) + XCTAssertEqual(api.orderListCalls[1].orderStatus, 30) + XCTAssertNil(api.orderListCalls[1].isRefined) + XCTAssertNil(api.orderListCalls[1].userPhone) + XCTAssertTrue(api.orderListCalls[1].isScenicAdmin) + XCTAssertEqual(viewModel.storePage, 1) + } + + /// 测试无景区时清空核销订单旧数据且不请求接口。 + func testWriteOffOrdersClearStaleDataWhenScenicIsMissing() async throws { + let api = MockOrderService() + api.writeOffResponses = [try TestFixture.payload(ListPayload.self, named: "writeoff_list_success")] + let viewModel = OrdersViewModel() + + try await viewModel.reloadWriteOffOrders(api: api, scenicId: 88) + XCTAssertFalse(viewModel.writeOffOrders.isEmpty) + + try await viewModel.reloadWriteOffOrders(api: api, scenicId: nil) + + XCTAssertTrue(viewModel.writeOffOrders.isEmpty) + XCTAssertEqual(viewModel.writeOffTotal, 0) + XCTAssertEqual(viewModel.writeOffPage, 1) + XCTAssertFalse(viewModel.writeOffHasMore) + XCTAssertEqual(api.writeOffListCalls.count, 1) + } + + /// 测试核销订单最后一页不会继续请求。 + func testWriteOffOrdersDoNotRequestMoreAfterLastPage() async throws { + let api = MockOrderService() + api.writeOffResponses = [try TestFixture.payload(ListPayload.self, named: "writeoff_list_success")] + let viewModel = OrdersViewModel() + + try await viewModel.reloadWriteOffOrders(api: api, scenicId: 88) + try await viewModel.loadMoreWriteOffOrders(api: api, scenicId: 88) + + XCTAssertEqual(viewModel.writeOffOrders.count, 1) + XCTAssertEqual(viewModel.writeOffTotal, 1) + XCTAssertEqual(viewModel.writeOffPage, 1) + XCTAssertFalse(viewModel.writeOffHasMore) + XCTAssertEqual(api.writeOffListCalls.count, 1) + } + + /// 测试核销成功后刷新列表并重置分页状态。 + func testVerifyRefreshesWriteOffOrdersAndResetsPagingState() async throws { + let api = MockOrderService() + api.writeOffResponses = [try TestFixture.payload(ListPayload.self, named: "writeoff_list_success")] + let viewModel = OrdersViewModel() + + try await viewModel.verify(api: api, scenicId: 88, orderNumber: "VERIFY001") + + XCTAssertEqual(api.verifiedOrderNumbers, ["VERIFY001"]) + XCTAssertEqual(api.writeOffListCalls.count, 1) + XCTAssertEqual(api.writeOffListCalls[0].scenicId, 88) + XCTAssertEqual(api.writeOffListCalls[0].page, 1) + XCTAssertEqual(viewModel.writeOffOrders.count, 1) + XCTAssertEqual(viewModel.writeOffPage, 1) + XCTAssertFalse(viewModel.isVerifying) + XCTAssertNil(viewModel.currentVerifyingOrderNumber) + } + + /// 测试重复核销提交会被忽略。 + func testVerifyIgnoresDuplicateSubmitWhileAlreadyVerifying() async throws { + let api = MockOrderService() + let viewModel = OrdersViewModel() + viewModel.markVerifyingForTests(orderNumber: "VERIFY001") + + try await viewModel.verify(api: api, scenicId: 88, orderNumber: "VERIFY002") + + XCTAssertTrue(api.verifiedOrderNumbers.isEmpty) + XCTAssertTrue(api.writeOffListCalls.isEmpty) + XCTAssertTrue(viewModel.isVerifying) + XCTAssertEqual(viewModel.currentVerifyingOrderNumber, "VERIFY001") + } + + /// 测试核销失败时清理提交状态且不刷新列表。 + func testVerifyFailureClearsSubmittingStateWithoutRefreshingList() async throws { + let api = MockOrderService() + api.verifyError = APIError.httpStatus(500, "server error") + let viewModel = OrdersViewModel() + + do { + try await viewModel.verify(api: api, scenicId: 88, orderNumber: "VERIFY001") + XCTFail("Expected verify to fail") + } catch APIError.httpStatus(let statusCode, _) { + XCTAssertEqual(statusCode, 500) + } catch { + XCTFail("Unexpected error: \(error)") + } + + XCTAssertEqual(api.verifiedOrderNumbers, ["VERIFY001"]) + XCTAssertTrue(api.writeOffListCalls.isEmpty) + XCTAssertFalse(viewModel.isVerifying) + XCTAssertNil(viewModel.currentVerifyingOrderNumber) + } + + /// 测试订单 API 会按角色选择正确接口路径。 + func testOrdersAPIUsesExpectedEndpointForRole() async throws { + let session = RecordingURLSession(data: try TestFixture.data(named: "order_v2_success")) + let api = OrdersAPI(client: APIClient(session: session)) + + _ = try await api.orderList( + scenicId: 88, + page: 1, + pageSize: 20, + orderStatus: 30, + userPhone: "13800000000", + startTime: "2026-05-01", + endTime: "2026-05-31", + isRefined: nil, + isScenicAdmin: true + ) + + let request = try XCTUnwrap(session.requests.first) + XCTAssertEqual(request.url?.path, "/api/app/scenic-admin/order/list") + let query = queryItems(from: request) + XCTAssertEqual(query["scenic_id"], "88") + XCTAssertEqual(query["order_status"], "30") + XCTAssertEqual(query["user_phone"], "13800000000") + XCTAssertEqual(query["start_time"], "2026-05-01") + XCTAssertEqual(query["end_time"], "2026-05-31") + } + + private static func date(_ text: String) -> Date { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd" + return formatter.date(from: text)! + } +} + +@MainActor +/// 订单服务测试替身,记录请求参数并返回预设响应。 +private final class MockOrderService: OrderServing { + struct OrderListCall: Equatable { + let scenicId: Int + let page: Int + let pageSize: Int + let orderStatus: Int? + let userPhone: String? + let startTime: String? + let endTime: String? + let isRefined: Int? + let isScenicAdmin: Bool + } + + struct WriteOffListCall: Equatable { + let scenicId: Int + let page: Int + let pageSize: Int + } + + var storeResponses: [ListPayload] = [] + var writeOffResponses: [ListPayload] = [] + var verifyError: Error? + private(set) var orderListCalls: [OrderListCall] = [] + private(set) var writeOffListCalls: [WriteOffListCall] = [] + private(set) var verifiedOrderNumbers: [String] = [] + + /// 返回测试订单管理列表。 + func orderList( + scenicId: Int, + page: Int, + pageSize: Int, + orderStatus: Int?, + userPhone: String?, + startTime: String?, + endTime: String?, + isRefined: Int?, + isScenicAdmin: Bool + ) async throws -> ListPayload { + orderListCalls.append( + OrderListCall( + scenicId: scenicId, + page: page, + pageSize: pageSize, + orderStatus: orderStatus, + userPhone: userPhone, + startTime: startTime, + endTime: endTime, + isRefined: isRefined, + isScenicAdmin: isScenicAdmin + ) + ) + return storeResponses.isEmpty ? ListPayload(total: 0, list: []) : storeResponses.removeFirst() + } + + /// 返回测试核销订单列表。 + func writeOffList(scenicId: Int, page: Int, pageSize: Int) async throws -> ListPayload { + writeOffListCalls.append(WriteOffListCall(scenicId: scenicId, page: page, pageSize: pageSize)) + return writeOffResponses.isEmpty ? ListPayload(total: 0, list: []) : writeOffResponses.removeFirst() + } + + /// 记录测试核销订单号。 + func writeOff(orderNumber: String) async throws { + verifiedOrderNumbers.append(orderNumber) + if let verifyError { + throw verifyError + } + } +} + +/// URLSession 测试替身,记录请求并返回固定响应。 +private final class RecordingURLSession: URLSessionProtocol { + let data: Data + var statusCode: Int + private(set) var requests: [URLRequest] = [] + + init(data: Data, statusCode: Int = 200) { + self.data = data + self.statusCode = statusCode + } + + /// 返回固定数据和 HTTP 响应。 + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + requests.append(request) + return ( + data, + HTTPURLResponse(url: request.url!, statusCode: statusCode, httpVersion: nil, headerFields: nil)! + ) + } +} + +private func queryItems(from request: URLRequest) -> [String: String] { + guard let url = request.url, + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return [:] + } + return Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).compactMap { item in + item.value.map { (item.name, $0) } + }) +} diff --git a/suixinkanTests/ProfileSecondaryPagesTests.swift b/suixinkanTests/ProfileSecondaryPagesTests.swift new file mode 100644 index 0000000..cb8d06f --- /dev/null +++ b/suixinkanTests/ProfileSecondaryPagesTests.swift @@ -0,0 +1,201 @@ +// +// ProfileSecondaryPagesTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/22. +// + +import XCTest +@testable import suixinkan + +@MainActor +/// 个人中心二级页面测试,覆盖设置、账号切换和实名认证基础逻辑。 +final class ProfileSecondaryPagesTests: XCTestCase { + /// 测试设置页版本号展示规则。 + func testSettingsVersionTextUsesBuildNumberWhenNeeded() { + XCTAssertEqual( + SettingsDisplayPolicy.versionText(infoDictionary: [ + "CFBundleShortVersionString": "1.2", + "CFBundleVersion": "45" + ]), + "1.2.45" + ) + XCTAssertEqual( + SettingsDisplayPolicy.versionText(infoDictionary: [ + "CFBundleShortVersionString": "1.2.3", + "CFBundleVersion": "45" + ]), + "1.2.3" + ) + XCTAssertEqual(SettingsDisplayPolicy.versionText(infoDictionary: nil), "1.0.0") + } + + /// 测试协议页面映射到预期 H5 路径。 + func testAgreementPagesUseExpectedPaths() { + let pages: [(AgreementPage, String, String)] = [ + (.about, "关于我们", "/h5/app/about-us"), + (.userAgreement, "用户协议", "/h5/app/user-agreement"), + (.privacyPolicy, "隐私政策", "/h5/app/privacy-policy"), + (.walletUserNotice, "钱包用户须知", "/h5/app/wallet-user-notice"), + (.walletPrivacy, "钱包隐私政策", "/h5/app/wallet-privacy") + ] + + for (page, title, path) in pages { + XCTAssertEqual(page.title, title) + XCTAssertEqual(page.id, title) + XCTAssertEqual(page.url.path, path) + } + } + + /// 测试账号切换列表接口使用旧工程一致的 v9 accounts 路径。 + func testSwitchableAccountsUsesExpectedEndpoint() async throws { + let session = ProfileSecondaryURLSession(responses: [ + try TestFixture.data(named: "v9_login_multi_success") + ]) + let api = ProfileAPI(client: APIClient(session: session)) + + let response = try await api.switchableAccounts() + + XCTAssertEqual(response.accounts.count, 2) + let request = try XCTUnwrap(session.requests.first) + XCTAssertEqual(request.httpMethod, "GET") + XCTAssertEqual(request.url?.path, "/api/app/v9/accounts") + } + + /// 测试账号切换 ViewModel 能加载账号并默认选中第一项。 + func testAccountSwitchViewModelLoadsAccounts() async throws { + let session = ProfileSecondaryURLSession(responses: [ + try TestFixture.data(named: "v9_login_multi_success") + ]) + let api = ProfileAPI(client: APIClient(session: session)) + let viewModel = AccountSwitchViewModel() + + try await viewModel.load(api: api) + + XCTAssertEqual(viewModel.accounts.map(\.businessUserId), [101, 201]) + XCTAssertEqual(viewModel.selectedAccount?.businessUserId, 101) + XCTAssertFalse(viewModel.loading) + } + + /// 测试账号切换提交使用 set-user 接口和账号对应请求体。 + func testAccountSwitchViewModelSubmitsSetUser() async throws { + let session = ProfileSecondaryURLSession(responses: [ + try TestFixture.data(named: "v9_set_store_user_success") + ]) + let api = AuthAPI(client: APIClient(session: session)) + let account = AccountSwitchAccount( + accountType: V9StoreUser.accountTypeValue, + businessUserId: 201, + title: "示例门店", + subtitle: "示例景区", + phone: "13800138000", + avatar: "", + scenicName: "示例景区", + storeId: 20, + storeName: "示例门店", + scenicId: 10, + isCurrent: false + ) + let viewModel = AccountSwitchViewModel() + + let response = try await viewModel.switchAccount(account, api: api) + + XCTAssertFalse(response.token.isEmpty) + let request = try XCTUnwrap(session.requests.first) + XCTAssertEqual(request.httpMethod, "POST") + XCTAssertEqual(request.url?.path, "/api/app/v9/set-user") + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: XCTUnwrap(request.httpBody)) as? [String: Any]) + XCTAssertEqual(json["store_user_id"] as? Int, 201) + XCTAssertNil(json["ss_user_id"] as? Int) + } + + /// 测试实名认证信息解码兼容字符串数字字段。 + func testRealNameInfoDecodesLossyFields() async throws { + let session = ProfileSecondaryURLSession(responses: [ + try TestFixture.data(named: "real_name_info_success") + ]) + let api = ProfileAPI(client: APIClient(session: session)) + + let response = try await api.realNameInfo() + let info = try XCTUnwrap(response.realNameInfo) + + XCTAssertEqual(info.realName, "测试摄影师") + XCTAssertEqual(info.idCardNo, "320100********0012") + XCTAssertTrue(info.verified) + XCTAssertFalse(info.isLongValid) + XCTAssertEqual(info.auditStatus, 2) + XCTAssertEqual(info.auditorId, 7) + XCTAssertEqual(info.auditor?.name, "审核员") + XCTAssertEqual(session.requests.first?.url?.path, "/api/yf-handset-app/photog/real-name/info") + } + + /// 测试实名认证短信和提交接口路径及请求体。 + func testRealNameSmsAndSubmitUseExpectedEndpoints() async throws { + let session = ProfileSecondaryURLSession(responses: [ + try TestFixture.data(named: "empty_success"), + try TestFixture.data(named: "empty_success") + ]) + let api = ProfileAPI(client: APIClient(session: session)) + + try await api.realNameSmsVerifyCode() + try await api.realNameSubmit( + RealNameAuthRequest( + realName: "测试摄影师", + idCardNo: "11010519491231002X", + smsVerifyCode: "123456", + startDate: "2026-01-01", + endDate: "2046-01-01", + isLongValid: 0, + frontUrl: "https://cdn.example.com/front.jpg", + backUrl: "https://cdn.example.com/back.jpg" + ) + ) + + XCTAssertEqual(session.requests.map { $0.url?.path }, [ + "/api/yf-handset-app/photog/real-name/sms-verify-code", + "/api/yf-handset-app/photog/real-name/submit" + ]) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: XCTUnwrap(session.requests.last?.httpBody)) as? [String: Any]) + XCTAssertEqual(json["real_name"] as? String, "测试摄影师") + XCTAssertEqual(json["id_card_no"] as? String, "11010519491231002X") + XCTAssertEqual(json["sms_verify_code"] as? String, "123456") + XCTAssertEqual(json["is_long_valid"] as? Int, 0) + } + + /// 测试身份证号校验和格式归一化。 + func testRealNameIDCardValidation() { + XCTAssertTrue(RealNameAuthViewModel.isValidMainlandIDCardNumber("11010519491231002x")) + XCTAssertEqual(RealNameAuthViewModel.normalizedIDCardNumber(" 11010519491231002x "), "11010519491231002X") + XCTAssertFalse(RealNameAuthViewModel.isValidMainlandIDCardNumber("110105194912310021")) + XCTAssertFalse(RealNameAuthViewModel.isValidMainlandIDCardNumber("123")) + } + + /// 测试首页系统设置 URI 已接入真实设置页。 + func testHomeSystemSettingsRouteResolvesToSettingsPage() { + XCTAssertEqual( + HomeMenuRouter.resolve(uri: "system_settings", title: "设置中心"), + .destination(.settings) + ) + } +} + +/// 个人中心测试用 URLSession 替身,按顺序返回预设响应。 +private final class ProfileSecondaryURLSession: URLSessionProtocol { + private var responses: [Data] + private(set) var requests: [URLRequest] = [] + + /// 初始化顺序响应。 + init(responses: [Data]) { + self.responses = responses + } + + /// 记录请求并返回下一份响应。 + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + requests.append(request) + let data = responses.isEmpty ? Data() : responses.removeFirst() + return ( + data, + HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + ) + } +} diff --git a/suixinkanTests/StatisticsViewModelTests.swift b/suixinkanTests/StatisticsViewModelTests.swift new file mode 100644 index 0000000..ffd8742 --- /dev/null +++ b/suixinkanTests/StatisticsViewModelTests.swift @@ -0,0 +1,238 @@ +// +// StatisticsViewModelTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/22. +// + +import XCTest +@testable import suixinkan + +@MainActor +/// 数据统计 ViewModel 测试,覆盖汇总、日数据、分页和角色接口选择。 +final class StatisticsViewModelTests: XCTestCase { + /// 测试首屏会加载汇总和第一页日数据。 + func testReloadLoadsSummaryAndDailyItems() async throws { + let api = MockStatisticsService() + api.summaryResponses = [try TestFixture.payload(StatisticsSummaryResponse.self, named: "statistics_summary_success")] + api.dailyResponses = [try TestFixture.payload(DataListPayload.self, named: "statistics_daily_page1_total3")] + let viewModel = StatisticsViewModel() + + try await viewModel.reload(api: api, scenicId: 88, roleId: nil) + + XCTAssertEqual(viewModel.summary.orderCount, 18) + XCTAssertEqual(viewModel.dailyItems.map(\.date), ["2026-05-23"]) + XCTAssertEqual(viewModel.totalDailyCount, 3) + XCTAssertTrue(viewModel.hasMore) + XCTAssertFalse(viewModel.loading) + XCTAssertNotNil(viewModel.lastRefreshAt) + XCTAssertEqual(api.summaryCalls.map(\.range), ["1"]) + XCTAssertEqual(api.dailyCalls.map(\.page), [1]) + } + + /// 测试切换时间段会重置分页并使用对应 range。 + func testSelectPeriodResetsPaginationAndUsesExpectedRange() async throws { + let api = MockStatisticsService() + api.summaryResponses = [ + try TestFixture.payload(StatisticsSummaryResponse.self, named: "statistics_summary_success"), + try TestFixture.payload(StatisticsSummaryResponse.self, named: "statistics_summary_success") + ] + api.dailyResponses = [ + try TestFixture.payload(DataListPayload.self, named: "statistics_daily_page1_total3"), + try TestFixture.payload(DataListPayload.self, named: "statistics_daily_page2_total3"), + try TestFixture.payload(DataListPayload.self, named: "statistics_daily_page1_total3") + ] + let viewModel = StatisticsViewModel() + + try await viewModel.reload(api: api, scenicId: 88, roleId: nil) + try await viewModel.loadMore(api: api, scenicId: 88, roleId: nil) + XCTAssertEqual(viewModel.dailyItems.count, 3) + + try await viewModel.selectPeriod(.sevenDays, api: api, scenicId: 88, roleId: nil) + + XCTAssertEqual(viewModel.selectedPeriod, .sevenDays) + XCTAssertEqual(viewModel.dailyItems.map(\.date), ["2026-05-23"]) + XCTAssertEqual(viewModel.totalDailyCount, 3) + XCTAssertTrue(viewModel.hasMore) + XCTAssertEqual(api.summaryCalls.last?.range, "3") + XCTAssertEqual(api.dailyCalls.last?.page, 1) + XCTAssertFalse(api.dailyCalls.last?.startTime.isEmpty ?? true) + XCTAssertFalse(api.dailyCalls.last?.endTime.isEmpty ?? true) + } + + /// 测试加载更多会追加日数据。 + func testLoadMoreAppendsDailyItems() async throws { + let api = MockStatisticsService() + api.summaryResponses = [try TestFixture.payload(StatisticsSummaryResponse.self, named: "statistics_summary_success")] + api.dailyResponses = [ + try TestFixture.payload(DataListPayload.self, named: "statistics_daily_page1_total3"), + try TestFixture.payload(DataListPayload.self, named: "statistics_daily_page2_total3") + ] + let viewModel = StatisticsViewModel() + + try await viewModel.reload(api: api, scenicId: 88, roleId: nil) + try await viewModel.loadMore(api: api, scenicId: 88, roleId: nil) + + XCTAssertEqual(viewModel.dailyItems.map(\.date), ["2026-05-23", "2026-05-22", "2026-05-21"]) + XCTAssertFalse(viewModel.hasMore) + XCTAssertFalse(viewModel.loadingMore) + XCTAssertEqual(api.dailyCalls.map(\.page), [1, 2]) + } + + /// 测试加载更多失败时保留已有数据和下一页能力。 + func testLoadMoreFailureKeepsCurrentItems() async throws { + let api = MockStatisticsService() + api.summaryResponses = [try TestFixture.payload(StatisticsSummaryResponse.self, named: "statistics_summary_success")] + api.dailyResponses = [try TestFixture.payload(DataListPayload.self, named: "statistics_daily_page1_total3")] + let viewModel = StatisticsViewModel() + + try await viewModel.reload(api: api, scenicId: 88, roleId: nil) + api.nextDailyError = APIError.httpStatus(500, "server error") + + do { + try await viewModel.loadMore(api: api, scenicId: 88, roleId: nil) + XCTFail("Expected load more to fail") + } catch APIError.httpStatus(let statusCode, _) { + XCTAssertEqual(statusCode, 500) + } catch { + XCTFail("Unexpected error: \(error)") + } + + XCTAssertEqual(viewModel.dailyItems.map(\.date), ["2026-05-23"]) + XCTAssertTrue(viewModel.hasMore) + XCTAssertFalse(viewModel.loadingMore) + } + + /// 测试无景区时清空旧统计状态。 + func testReloadWithoutScenicClearsStaleStatisticsState() async throws { + let api = MockStatisticsService() + api.summaryResponses = [try TestFixture.payload(StatisticsSummaryResponse.self, named: "statistics_summary_success")] + api.dailyResponses = [try TestFixture.payload(DataListPayload.self, named: "statistics_daily_page1_total3")] + let viewModel = StatisticsViewModel() + + try await viewModel.reload(api: api, scenicId: 88, roleId: nil) + XCTAssertEqual(viewModel.summary.orderCount, 18) + + try await viewModel.reload(api: api, scenicId: nil, roleId: nil) + + XCTAssertEqual(api.summaryCalls.count, 1) + XCTAssertEqual(viewModel.summary.orderCount, 0) + XCTAssertTrue(viewModel.dailyItems.isEmpty) + XCTAssertEqual(viewModel.totalDailyCount, 0) + XCTAssertFalse(viewModel.hasMore) + XCTAssertNil(viewModel.lastRefreshAt) + } + + /// 测试景区管理员角色使用管理员统计接口。 + func testStatisticsAPIUsesAdminEndpointForScenicAdminRole() async throws { + let session = SequencedURLSession(responses: [ + try TestFixture.data(named: "statistics_summary_success"), + try TestFixture.data(named: "statistics_daily_page1_total3") + ]) + let api = StatisticsAPI(client: APIClient(session: session)) + + _ = try await api.summary(scenicId: 88, range: "1", isScenicAdmin: true) + _ = try await api.dailyList( + scenicId: 88, + startTime: "2026-05-01", + endTime: "2026-05-31", + page: 2, + pageSize: 15, + isScenicAdmin: true + ) + + XCTAssertEqual(session.requests.map { $0.url?.path }, ["/api/app/scenic-admin/analyse", "/api/app/scenic-admin/analyse/daily"]) + let dailyQuery = queryItems(from: try XCTUnwrap(session.requests.last)) + XCTAssertEqual(dailyQuery["scenic_id"], "88") + XCTAssertEqual(dailyQuery["page"], "2") + XCTAssertEqual(dailyQuery["start_time"], "2026-05-01") + XCTAssertEqual(dailyQuery["end_time"], "2026-05-31") + } +} + +@MainActor +/// 数据统计服务测试替身,记录请求参数并返回预设响应。 +private final class MockStatisticsService: StatisticsServing { + struct SummaryCall: Equatable { + let scenicId: Int + let range: String + let isScenicAdmin: Bool + } + + struct DailyCall: Equatable { + let scenicId: Int + let startTime: String + let endTime: String + let page: Int + let pageSize: Int + let isScenicAdmin: Bool + } + + var summaryResponses: [StatisticsSummaryResponse] = [] + var dailyResponses: [DataListPayload] = [] + var nextDailyError: Error? + private(set) var summaryCalls: [SummaryCall] = [] + private(set) var dailyCalls: [DailyCall] = [] + + /// 返回测试汇总数据。 + func summary(scenicId: Int, range: String, isScenicAdmin: Bool) async throws -> StatisticsSummaryResponse { + summaryCalls.append(SummaryCall(scenicId: scenicId, range: range, isScenicAdmin: isScenicAdmin)) + return summaryResponses.isEmpty ? StatisticsSummaryResponse() : summaryResponses.removeFirst() + } + + /// 返回测试每日明细数据。 + func dailyList( + scenicId: Int, + startTime: String, + endTime: String, + page: Int, + pageSize: Int, + isScenicAdmin: Bool + ) async throws -> DataListPayload { + dailyCalls.append( + DailyCall( + scenicId: scenicId, + startTime: startTime, + endTime: endTime, + page: page, + pageSize: pageSize, + isScenicAdmin: isScenicAdmin + ) + ) + if let nextDailyError { + self.nextDailyError = nil + throw nextDailyError + } + return dailyResponses.isEmpty ? DataListPayload(total: 0, data: []) : dailyResponses.removeFirst() + } +} + +/// URLSession 顺序响应测试替身。 +private final class SequencedURLSession: URLSessionProtocol { + private var responses: [Data] + private(set) var requests: [URLRequest] = [] + + init(responses: [Data]) { + self.responses = responses + } + + /// 依次返回预设响应。 + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + requests.append(request) + let data = responses.isEmpty ? Data() : responses.removeFirst() + return ( + data, + HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + ) + } +} + +private func queryItems(from request: URLRequest) -> [String: String] { + guard let url = request.url, + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return [:] + } + return Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).compactMap { item in + item.value.map { (item.name, $0) } + }) +} diff --git a/suixinkanTests/TestFixture.swift b/suixinkanTests/TestFixture.swift new file mode 100644 index 0000000..3778e5e --- /dev/null +++ b/suixinkanTests/TestFixture.swift @@ -0,0 +1,35 @@ +// +// TestFixture.swift +// suixinkanTests +// +// Created by Codex on 2026/6/22. +// + +import Foundation +@testable import suixinkan + +/// 测试 Fixture 读取工具,用于复用旧工程迁移过来的 JSON 响应。 +enum TestFixture { + /// 读取指定名称的 JSON fixture。 + static func data(named name: String, filePath: String = #filePath) throws -> Data { + let url = URL(fileURLWithPath: filePath) + .deletingLastPathComponent() + .appendingPathComponent("Fixtures") + .appendingPathComponent("\(name).json") + return try Data(contentsOf: url) + } + + /// 读取并解包后端统一 Envelope。 + static func payload(_ type: T.Type, named name: String) throws -> T { + let envelope = try JSONDecoder().decode(APIEnvelope.self, from: data(named: name)) + guard let payload = envelope.data else { + throw FixtureError.emptyPayload + } + return payload + } +} + +/// 测试 Fixture 错误实体。 +enum FixtureError: Error { + case emptyPayload +}