同步已迁移的 iOS 模块

This commit is contained in:
2026-06-22 15:35:12 +08:00
parent 0a0d4fbd79
commit 08465fffde
48 changed files with 4455 additions and 15 deletions

View File

@ -12,6 +12,7 @@ import SwiftUI
enum AppRoute: Hashable { enum AppRoute: Hashable {
case placeholder(title: String) case placeholder(title: String)
case home(HomeRoute) case home(HomeRoute)
case profile(ProfileRoute)
/// NavigationStack push TabBar /// NavigationStack push TabBar
var hidesTabBarWhenPushed: Bool { var hidesTabBarWhenPushed: Bool {
@ -26,6 +27,8 @@ enum AppRoute: Hashable {
PlaceholderDetailView(title: title) PlaceholderDetailView(title: title)
case .home(let route): case .home(let route):
route.destinationView route.destinationView
case .profile(let route):
route.destinationView
} }
} }
} }
@ -52,6 +55,7 @@ final class RouterPath {
/// Tab Tab NavigationStack /// Tab Tab NavigationStack
final class AppRouter { final class AppRouter {
var selectedTab: AppTab = .home var selectedTab: AppTab = .home
var selectedOrdersEntry: OrdersEntry = .storeOrders
private var routers: [AppTab: RouterPath] = [:] private var routers: [AppTab: RouterPath] = [:]
/// Tab /// Tab
@ -80,9 +84,16 @@ final class AppRouter {
selectedTab = tab selectedTab = tab
} }
/// Tab
func selectOrders(entry: OrdersEntry) {
selectedOrdersEntry = entry
selectedTab = .orders
}
/// Tab Tab /// Tab Tab
func reset() { func reset() {
selectedTab = .home selectedTab = .home
selectedOrdersEntry = .storeOrders
routers.values.forEach { $0.reset() } routers.values.forEach { $0.reset() }
} }
} }

View File

@ -19,6 +19,8 @@ struct RootView: View {
@State private var authAPI: AuthAPI @State private var authAPI: AuthAPI
@State private var profileAPI: ProfileAPI @State private var profileAPI: ProfileAPI
@State private var accountContextAPI: AccountContextAPI @State private var accountContextAPI: AccountContextAPI
@State private var ordersAPI: OrdersAPI
@State private var statisticsAPI: StatisticsAPI
@State private var authSessionCoordinator: AuthSessionCoordinator @State private var authSessionCoordinator: AuthSessionCoordinator
@State private var sessionBootstrapper: SessionBootstrapper @State private var sessionBootstrapper: SessionBootstrapper
@ -31,6 +33,8 @@ struct RootView: View {
_authAPI = State(initialValue: AuthAPI(client: apiClient)) _authAPI = State(initialValue: AuthAPI(client: apiClient))
_profileAPI = State(initialValue: ProfileAPI(client: apiClient)) _profileAPI = State(initialValue: ProfileAPI(client: apiClient))
_accountContextAPI = State(initialValue: AccountContextAPI(client: apiClient)) _accountContextAPI = State(initialValue: AccountContextAPI(client: apiClient))
_ordersAPI = State(initialValue: OrdersAPI(client: apiClient))
_statisticsAPI = State(initialValue: StatisticsAPI(client: apiClient))
_authSessionCoordinator = State( _authSessionCoordinator = State(
initialValue: AuthSessionCoordinator( initialValue: AuthSessionCoordinator(
tokenStore: tokenStore, tokenStore: tokenStore,
@ -59,6 +63,8 @@ struct RootView: View {
.environment(authAPI) .environment(authAPI)
.environment(profileAPI) .environment(profileAPI)
.environment(accountContextAPI) .environment(accountContextAPI)
.environment(ordersAPI)
.environment(statisticsAPI)
.environment(authSessionCoordinator) .environment(authSessionCoordinator)
.task { .task {
apiClient.bindAuthTokenProvider { appSession.token } apiClient.bindAuthTokenProvider { appSession.token }

View File

@ -13,6 +13,9 @@ enum AppDesign {
static let primarySoft = Color(hex: 0xEFF6FF) static let primarySoft = Color(hex: 0xEFF6FF)
static let textPrimary = Color(hex: 0x1F2937) static let textPrimary = Color(hex: 0x1F2937)
static let textSecondary = Color(hex: 0x6B7280) 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 { 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)
}
}
}

View File

@ -32,6 +32,34 @@ struct ListPayload<T: Decodable>: Decodable {
} }
} }
/// total + data list
struct DataListPayload<T: Decodable>: 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 { private extension KeyedDecodingContainer {
/// IntDouble /// IntDouble
func decodeLossyInt(forKey key: Key) throws -> Int? { func decodeLossyInt(forKey key: Key) throws -> Int? {

View File

@ -23,12 +23,14 @@ enum HomeRoute: Hashable {
case profileSpace case profileSpace
case scenicSelection case scenicSelection
case moreFunctions case moreFunctions
case settings
case modulePlaceholder(uri: String, title: String) case modulePlaceholder(uri: String, title: String)
} }
/// URI Tab /// URI Tab
enum HomeMenuResolvedRoute: Equatable { enum HomeMenuResolvedRoute: Equatable {
case tab(AppTab) case tab(AppTab)
case orders(OrdersEntry)
case destination(HomeRoute) case destination(HomeRoute)
case unsupported(uri: String, title: String, reason: String) case unsupported(uri: String, title: String, reason: String)
case placeholder(uri: String, title: String) case placeholder(uri: String, title: String)

View File

@ -70,13 +70,15 @@ enum HomeMenuRouter {
static func resolve(uri: String, title: String) -> HomeMenuResolvedRoute { static func resolve(uri: String, title: String) -> HomeMenuResolvedRoute {
switch uri { switch uri {
case "photographer_orders", "/scenic-order-manage": case "photographer_orders", "/scenic-order-manage":
return .tab(.orders) return .orders(.storeOrders)
case "verification_order": case "verification_order":
return .tab(.orders) return .orders(.verificationOrders)
case "photographer_stats": case "photographer_stats":
return .tab(.statistics) return .tab(.statistics)
case "space_settings", "basic_info": case "space_settings", "basic_info":
return .destination(.profileSpace) return .destination(.profileSpace)
case "system_settings":
return .destination(.settings)
case "scenicselection": case "scenicselection":
return .destination(.scenicSelection) return .destination(.scenicSelection)
case "store", "more_functions": case "store", "more_functions":
@ -127,7 +129,6 @@ enum HomeMenuRouter {
"registration_invitation", "registration_invitation",
"photographer_invite", "photographer_invite",
"invite_record", "invite_record",
"system_settings",
"pilot_cert": "pilot_cert":
let resolvedTitle = title.isEmpty ? self.title(for: uri) : title let resolvedTitle = title.isEmpty ? self.title(for: uri) : title
return .destination(.modulePlaceholder(uri: uri, title: resolvedTitle)) return .destination(.modulePlaceholder(uri: uri, title: resolvedTitle))
@ -230,7 +231,7 @@ enum HomePermissionRouteAuditor {
return HomePermissionRouteAudit( return HomePermissionRouteAudit(
routable: entries.filter { entry in routable: entries.filter { entry in
switch entry.route { switch entry.route {
case .tab, .destination: case .tab, .orders, .destination:
return true return true
case .unsupported, .placeholder: case .unsupported, .placeholder:
return false return false

View File

@ -190,6 +190,8 @@ struct HomeMoreFunctionsView: View {
switch HomeMenuRouter.resolve(uri: item.uri, title: item.title) { switch HomeMenuRouter.resolve(uri: item.uri, title: item.title) {
case .tab(let tab): case .tab(let tab):
appRouter.select(tab) appRouter.select(tab)
case .orders(let entry):
appRouter.selectOrders(entry: entry)
case .destination(let route): case .destination(let route):
if route == .moreFunctions { if route == .moreFunctions {
return return

View File

@ -18,6 +18,8 @@ extension HomeRoute {
HomeScenicSelectionView() HomeScenicSelectionView()
case .moreFunctions: case .moreFunctions:
HomeMoreFunctionsView() HomeMoreFunctionsView()
case .settings:
SettingsCenterView()
case let .modulePlaceholder(uri, title): case let .modulePlaceholder(uri, title):
HomeMigrationModuleView(title: title, uri: uri) HomeMigrationModuleView(title: title, uri: uri)
} }

View File

@ -413,6 +413,8 @@ struct HomeView: View {
switch route { switch route {
case .tab(let tab): case .tab(let tab):
appRouter.select(tab) appRouter.select(tab)
case .orders(let entry):
appRouter.selectOrders(entry: entry)
case .destination(let route): case .destination(let route):
router.navigate(to: .home(route)) router.navigate(to: .home(route))
case .unsupported(let uri, let title, _): case .unsupported(let uri, let title, _):

View File

@ -17,7 +17,7 @@ Main 模块负责登录后的主界面 Tab 容器,以及当前尚未迁移页
- `statistics`:数据 - `statistics`:数据
- `profile`:我的 - `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` 作为选中状态。 2. `TabView` 使用 `appRouter.selectedTab` 作为选中状态。
3. 每个 Tab 创建自己的 `NavigationStack(path:)` 3. 每个 Tab 创建自己的 `NavigationStack(path:)`
4. 路径绑定来自 `appRouter.binding(for:)` 4. 路径绑定来自 `appRouter.binding(for:)`
5. 占位页点击“打开详情”时,通过当前 Tab 注入的 `RouterPath` push 一个 `AppRoute.placeholder` 5. Tab 内页面需要进入尚未迁移的子页面时,通过当前 Tab 注入的 `RouterPath` push 一个 `AppRoute.placeholder`
6. `navigationDestination` 根据 `AppRoute` 展示详情页。 6. `navigationDestination` 根据 `AppRoute` 展示详情页。
7. 子页面展示时读取 `AppRoute.hidesTabBarWhenPushed`,默认隐藏底部 TabBar。 7. 子页面展示时读取 `AppRoute.hidesTabBarWhenPushed`,默认隐藏底部 TabBar。

View File

@ -17,14 +17,14 @@ struct HomeRootView: View {
/// ///
struct OrdersRootView: View { struct OrdersRootView: View {
var body: some View { var body: some View {
PlaceholderTabRootView(title: "订单", systemImage: "doc.text") OrdersView()
} }
} }
/// ///
struct StatisticsRootView: View { struct StatisticsRootView: View {
var body: some View { var body: some View {
PlaceholderTabRootView(title: "数据", systemImage: "chart.bar") StatisticsView()
} }
} }

View File

@ -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<OrderEntity>
///
func writeOffList(scenicId: Int, page: Int, pageSize: Int) async throws -> ListPayload<WriteOffOrderItem>
///
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<OrderEntity> {
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<WriteOffOrderItem> {
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)
)
)
}
}

View File

@ -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 ""
}
/// IntDoubleBool
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
}
/// BoolInt
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
}
}

View File

@ -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 内点击详情、扫码、退款等未迁移能力时进入占位页,避免入口崩溃。

View File

@ -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
}()
}

View File

@ -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())
}
}

View File

@ -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 { func realNameInfo() async throws -> RealNameInfoResponse {
try await client.send( 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 {} extension ProfileAPI: UserProfileServing {}

View File

@ -80,25 +80,100 @@ struct RealNameInfoResponse: Decodable, Equatable {
/// ///
struct RealNameInfo: Decodable, Equatable { struct RealNameInfo: Decodable, Equatable {
let realName: String let realName: String
let idCardNo: String
let auditStatus: Int let auditStatus: Int
let auditStatusText: String? let auditStatusText: String?
let rejectReason: 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 /// JSON
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case realName = "real_name" case realName = "real_name"
case idCardNo = "id_card_no"
case auditStatus = "audit_status" case auditStatus = "audit_status"
case auditStatusText = "audit_status_text" case auditStatusText = "audit_status_text"
case rejectReason = "reject_reason" 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 { init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self) let container = try decoder.container(keyedBy: CodingKeys.self)
realName = try container.decodeLossyString(forKey: .realName) realName = try container.decodeLossyString(forKey: .realName)
idCardNo = try container.decodeLossyString(forKey: .idCardNo)
auditStatus = try container.decodeLossyInt(forKey: .auditStatus) ?? 0 auditStatus = try container.decodeLossyInt(forKey: .auditStatus) ?? 0
auditStatusText = try container.decodeIfPresent(String.self, forKey: .auditStatusText) auditStatusText = try container.decodeIfPresent(String.self, forKey: .auditStatusText)
rejectReason = try container.decodeIfPresent(String.self, forKey: .rejectReason) 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"
} }
} }

View File

@ -2,7 +2,7 @@
## 模块职责 ## 模块职责
Profile 模块负责“我的/个人信息”页面,包括用户资料展示、昵称编辑、密码修改、实名认证状态展示和退出登录。 Profile 模块负责“我的/个人信息”页面及其二级页面,包括用户资料展示、昵称编辑、账号切换、密码修改、实名认证、系统设置、协议页和退出登录。
该模块聚焦个人资料业务: 该模块聚焦个人资料业务:
- 拉取用户基础资料。 - 拉取用户基础资料。
@ -10,6 +10,9 @@ Profile 模块负责“我的/个人信息”页面,包括用户资料展示
- 展示头像、昵称、UID、手机号、账号状态和实名认证状态。 - 展示头像、昵称、UID、手机号、账号状态和实名认证状态。
- 修改昵称。 - 修改昵称。
- 修改密码。 - 修改密码。
- 切换当前景区/门店业务账号。
- 提交实名认证基础资料。
- 展示系统设置、版本号、下载链接和协议 H5 页面。
- 退出登录入口。 - 退出登录入口。
登录态清理和缓存清理由 App 模块的 `AuthSessionCoordinator` 统一处理。 登录态清理和缓存清理由 App 模块的 `AuthSessionCoordinator` 统一处理。
@ -18,10 +21,15 @@ Profile 模块负责“我的/个人信息”页面,包括用户资料展示
- `ProfileView`:个人信息页 UI负责展示资料、编辑入口、密码弹窗和退出确认。 - `ProfileView`:个人信息页 UI负责展示资料、编辑入口、密码弹窗和退出确认。
- `ProfileViewModel`:维护资料加载状态、编辑状态、保存状态和展示文案。 - `ProfileViewModel`:维护资料加载状态、编辑状态、保存状态和展示文案。
- `ProfileRoute`:个人中心二级页面路由。
- `AccountSwitchView` / `AccountSwitchViewModel`:账号切换页面和状态逻辑。
- `RealNameAuthView` / `RealNameAuthViewModel`:实名认证页面和表单逻辑。
- `SettingsCenterView` / `AgreementView`:设置中心和协议 H5 页面。
- `ProfileAPI`:封装用户资料、实名认证和资料更新接口。 - `ProfileAPI`:封装用户资料、实名认证和资料更新接口。
- `UserInfoResponse`:用户基础资料。 - `UserInfoResponse`:用户基础资料。
- `RealNameInfoResponse` / `RealNameInfo`:实名认证信息。 - `RealNameInfoResponse` / `RealNameInfo`:实名认证信息。
- `UpdateInfoRequest`:昵称、密码或头像更新请求体。 - `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. 用户点击退出登录。 1. 用户点击退出登录。
@ -72,4 +107,5 @@ Profile 模块负责“我的/个人信息”页面,包括用户资料展示
- `auditStatus == 3`:审核不通过。 - `auditStatus == 3`:审核不通过。
- 其他状态:审核中。 - 其他状态:审核中。
- 无实名认证信息时:点击去实名认证。 - 无实名认证信息时:点击去实名认证。
- 账号切换失败只展示 Toast不清空登录态。
- 实名认证失败只影响实名认证页面,不影响登录态和账号上下文。

View File

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

View File

@ -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:
"账号信息异常,请重新选择账号"
}
}
}

View File

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

View File

@ -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
}
}

View File

@ -14,6 +14,7 @@ struct ProfileView: View {
@Environment(PermissionContext.self) private var permissionContext @Environment(PermissionContext.self) private var permissionContext
@Environment(ScenicSpotContext.self) private var scenicSpotContext @Environment(ScenicSpotContext.self) private var scenicSpotContext
@Environment(AppRouter.self) private var appRouter @Environment(AppRouter.self) private var appRouter
@Environment(RouterPath.self) private var router
@Environment(ProfileAPI.self) private var profileAPI @Environment(ProfileAPI.self) private var profileAPI
@Environment(ToastCenter.self) private var toastCenter @Environment(ToastCenter.self) private var toastCenter
@Environment(AuthSessionCoordinator.self) private var authSessionCoordinator @Environment(AuthSessionCoordinator.self) private var authSessionCoordinator
@ -234,7 +235,7 @@ struct ProfileView: View {
divider divider
Button { Button {
toastCenter.show("账号切换待接入") router.navigate(to: .profile(.accountSwitch))
} label: { } label: {
infoRow(title: "当前账号") { infoRow(title: "当前账号") {
VStack(alignment: .trailing, spacing: 3) { VStack(alignment: .trailing, spacing: 3) {
@ -269,7 +270,7 @@ struct ProfileView: View {
divider divider
Button { Button {
toastCenter.show("实名认证页面待接入") router.navigate(to: .profile(.realNameAuth))
} label: { } label: {
infoRow(title: "认证状态") { infoRow(title: "认证状态") {
realNameStatusView realNameStatusView
@ -289,6 +290,17 @@ struct ProfileView: View {
infoRow(title: "当前景区") { infoRow(title: "当前景区") {
scenicStatusView scenicStatusView
} }
divider
Button {
router.navigate(to: .profile(.settings))
} label: {
infoRow(title: "系统设置") {
rowChevron
}
}
.buttonStyle(.plain)
} }
.padding(.horizontal, 24) .padding(.horizontal, 24)
.padding(.vertical, 4) .padding(.vertical, 4)

View File

@ -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<String>, 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<String>) -> some View {
formField(title, text: text, placeholder: "OSS 上传接入前可先填写图片 URL")
}
///
private func dateField(selection: Binding<Date>) -> 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<String> {
Binding(get: { viewModel.realName }, set: { viewModel.realName = $0 })
}
private var idCardNoBinding: Binding<String> {
Binding(get: { viewModel.idCardNo }, set: { viewModel.idCardNo = $0 })
}
private var smsCodeBinding: Binding<String> {
Binding(get: { viewModel.smsCode }, set: { viewModel.smsCode = $0 })
}
private var frontURLBinding: Binding<String> {
Binding(get: { viewModel.frontUrl }, set: { viewModel.frontUrl = $0 })
}
private var backURLBinding: Binding<String> {
Binding(get: { viewModel.backUrl }, set: { viewModel.backUrl = $0 })
}
private var startDateBinding: Binding<Date> {
Binding(get: { viewModel.startDate }, set: { viewModel.startDate = $0 })
}
private var endDateBinding: Binding<Date> {
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
}
}

View File

@ -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<Content: View>(@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<Bool>) {
_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
}
}
}

View File

@ -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<StatisticsDailyItem>
}
@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<StatisticsDailyItem> {
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
)
)
}
}

View File

@ -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 ""
}
/// IntDouble
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
}
}

View File

@ -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则继续加载下一页。加载更多失败时保留已有数据和当前页状态。

View File

@ -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
}
}

View File

@ -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())
}
}

View File

@ -0,0 +1,4 @@
{
"code": 100000,
"msg": "success"
}

View File

@ -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
}
]
}
}

View File

@ -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": ""
}
}
}

View File

@ -0,0 +1,4 @@
{
"code": 200001,
"msg": "登录已过期"
}

View File

@ -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"
}
]
}
}

View File

@ -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"
}
]
}
}

View File

@ -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"
}
}

View File

@ -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": "已认证"
}
}

View File

@ -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
}
]
}
}

View File

@ -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
}
]
}
}

View File

@ -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": ""
}
]
}
}

View File

@ -41,9 +41,9 @@ final class HomeMenuRouterTests: XCTestCase {
/// Tab HomeMenuRouter /// Tab HomeMenuRouter
func testTabRoutesStayCentralized() { func testTabRoutesStayCentralized() {
XCTAssertEqual(HomeMenuRouter.resolve(uri: "photographer_orders", title: ""), .tab(.orders)) XCTAssertEqual(HomeMenuRouter.resolve(uri: "photographer_orders", title: ""), .orders(.storeOrders))
XCTAssertEqual(HomeMenuRouter.resolve(uri: "/scenic-order-manage", title: ""), .tab(.orders)) XCTAssertEqual(HomeMenuRouter.resolve(uri: "/scenic-order-manage", title: ""), .orders(.storeOrders))
XCTAssertEqual(HomeMenuRouter.resolve(uri: "verification_order", title: ""), .tab(.orders)) XCTAssertEqual(HomeMenuRouter.resolve(uri: "verification_order", title: ""), .orders(.verificationOrders))
XCTAssertEqual(HomeMenuRouter.resolve(uri: "photographer_stats", title: ""), .tab(.statistics)) XCTAssertEqual(HomeMenuRouter.resolve(uri: "photographer_stats", title: ""), .tab(.statistics))
} }

View File

@ -15,4 +15,14 @@ final class NavigationRouterTests: XCTestCase {
XCTAssertTrue(AppRoute.placeholder(title: "详情").hidesTabBarWhenPushed) XCTAssertTrue(AppRoute.placeholder(title: "详情").hidesTabBarWhenPushed)
XCTAssertTrue(AppRoute.home(.moreFunctions).hidesTabBarWhenPushed) XCTAssertTrue(AppRoute.home(.moreFunctions).hidesTabBarWhenPushed)
} }
/// Tab
func testSelectOrdersUpdatesTabAndEntry() {
let router = AppRouter()
router.selectOrders(entry: .verificationOrders)
XCTAssertEqual(router.selectedTab, .orders)
XCTAssertEqual(router.selectedOrdersEntry, .verificationOrders)
}
} }

View File

@ -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<OrderEntity>.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<OrderEntity>.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<OrderEntity>.self, named: "order_v2_success"),
try TestFixture.payload(ListPayload<OrderEntity>.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<WriteOffOrderItem>.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<WriteOffOrderItem>.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<WriteOffOrderItem>.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<OrderEntity>] = []
var writeOffResponses: [ListPayload<WriteOffOrderItem>] = []
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<OrderEntity> {
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<WriteOffOrderItem> {
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) }
})
}

View File

@ -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)!
)
}
}

View File

@ -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<StatisticsDailyItem>.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<StatisticsDailyItem>.self, named: "statistics_daily_page1_total3"),
try TestFixture.payload(DataListPayload<StatisticsDailyItem>.self, named: "statistics_daily_page2_total3"),
try TestFixture.payload(DataListPayload<StatisticsDailyItem>.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<StatisticsDailyItem>.self, named: "statistics_daily_page1_total3"),
try TestFixture.payload(DataListPayload<StatisticsDailyItem>.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<StatisticsDailyItem>.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<StatisticsDailyItem>.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<StatisticsDailyItem>] = []
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<StatisticsDailyItem> {
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) }
})
}

View File

@ -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<T: Decodable>(_ type: T.Type, named name: String) throws -> T {
let envelope = try JSONDecoder().decode(APIEnvelope<T>.self, from: data(named: name))
guard let payload = envelope.data else {
throw FixtureError.emptyPayload
}
return payload
}
}
/// Fixture
enum FixtureError: Error {
case emptyPayload
}