同步已迁移的 iOS 模块
This commit is contained in:
@ -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() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 }
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
/// 将 Int、Double 或数字字符串宽松解码为整数。
|
/// 将 Int、Double 或数字字符串宽松解码为整数。
|
||||||
func decodeLossyInt(forKey key: Key) throws -> Int? {
|
func decodeLossyInt(forKey key: Key) throws -> Int? {
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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, _):
|
||||||
|
|||||||
@ -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。
|
||||||
|
|
||||||
|
|||||||
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
112
suixinkan/Features/Orders/API/OrdersAPI.swift
Normal file
112
suixinkan/Features/Orders/API/OrdersAPI.swift
Normal 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)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
261
suixinkan/Features/Orders/Models/OrderModels.swift
Normal file
261
suixinkan/Features/Orders/Models/OrderModels.swift
Normal 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 ""
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将 Int、Double、Bool 或数字字符串宽松解码成整数。
|
||||||
|
func decodeLossyInt(forKey key: Key) throws -> Int? {
|
||||||
|
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||||
|
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if let intValue = Int(text) {
|
||||||
|
return intValue
|
||||||
|
}
|
||||||
|
if let doubleValue = Double(text) {
|
||||||
|
return Int(doubleValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let value = try? decodeIfPresent(Double.self, forKey: key) {
|
||||||
|
return Int(value)
|
||||||
|
}
|
||||||
|
if let value = try? decodeIfPresent(Bool.self, forKey: key) {
|
||||||
|
return value ? 1 : 0
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将 Bool、Int 或字符串宽松解码成布尔值。
|
||||||
|
func decodeLossyBool(forKey key: Key) throws -> Bool? {
|
||||||
|
if let value = try? decodeIfPresent(Bool.self, forKey: key) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||||
|
return value != 0
|
||||||
|
}
|
||||||
|
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||||
|
switch value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
|
||||||
|
case "1", "true", "yes":
|
||||||
|
return true
|
||||||
|
case "0", "false", "no":
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
26
suixinkan/Features/Orders/Orders.md
Normal file
26
suixinkan/Features/Orders/Orders.md
Normal 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 内点击详情、扫码、退款等未迁移能力时进入占位页,避免入口崩溃。
|
||||||
196
suixinkan/Features/Orders/ViewModels/OrdersViewModel.swift
Normal file
196
suixinkan/Features/Orders/ViewModels/OrdersViewModel.swift
Normal 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
|
||||||
|
}()
|
||||||
|
}
|
||||||
622
suixinkan/Features/Orders/Views/OrdersView.swift
Normal file
622
suixinkan/Features/Orders/Views/OrdersView.swift
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 {}
|
||||||
|
|||||||
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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,不清空登录态。
|
||||||
|
- 实名认证失败只影响实名认证页面,不影响登录态和账号上下文。
|
||||||
|
|||||||
31
suixinkan/Features/Profile/Routing/ProfileRoute.swift
Normal file
31
suixinkan/Features/Profile/Routing/ProfileRoute.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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:
|
||||||
|
"账号信息异常,请重新选择账号"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
260
suixinkan/Features/Profile/Views/AccountSwitchView.swift
Normal file
260
suixinkan/Features/Profile/Views/AccountSwitchView.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
|||||||
383
suixinkan/Features/Profile/Views/RealNameAuthView.swift
Normal file
383
suixinkan/Features/Profile/Views/RealNameAuthView.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
246
suixinkan/Features/Profile/Views/SettingsFlowViews.swift
Normal file
246
suixinkan/Features/Profile/Views/SettingsFlowViews.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
82
suixinkan/Features/Statistics/API/StatisticsAPI.swift
Normal file
82
suixinkan/Features/Statistics/API/StatisticsAPI.swift
Normal 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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
206
suixinkan/Features/Statistics/Models/StatisticsModels.swift
Normal file
206
suixinkan/Features/Statistics/Models/StatisticsModels.swift
Normal 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 ""
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将 Int、Double 或数字字符串宽松解码成整数。
|
||||||
|
func decodeLossyInt(forKey key: Key) throws -> Int? {
|
||||||
|
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||||
|
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if let intValue = Int(text) {
|
||||||
|
return intValue
|
||||||
|
}
|
||||||
|
if let doubleValue = Double(text) {
|
||||||
|
return Int(doubleValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let value = try? decodeIfPresent(Double.self, forKey: key) {
|
||||||
|
return Int(value)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
26
suixinkan/Features/Statistics/Statistics.md
Normal file
26
suixinkan/Features/Statistics/Statistics.md
Normal 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,则继续加载下一页。加载更多失败时保留已有数据和当前页状态。
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
321
suixinkan/Features/Statistics/Views/StatisticsView.swift
Normal file
321
suixinkan/Features/Statistics/Views/StatisticsView.swift
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
4
suixinkanTests/Fixtures/empty_success.json
Normal file
4
suixinkanTests/Fixtures/empty_success.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"code": 100000,
|
||||||
|
"msg": "success"
|
||||||
|
}
|
||||||
83
suixinkanTests/Fixtures/order_v2_success.json
Normal file
83
suixinkanTests/Fixtures/order_v2_success.json
Normal 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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
26
suixinkanTests/Fixtures/real_name_info_success.json
Normal file
26
suixinkanTests/Fixtures/real_name_info_success.json
Normal 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": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
4
suixinkanTests/Fixtures/server_code_error.json
Normal file
4
suixinkanTests/Fixtures/server_code_error.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"code": 200001,
|
||||||
|
"msg": "登录已过期"
|
||||||
|
}
|
||||||
16
suixinkanTests/Fixtures/statistics_daily_page1_total3.json
Normal file
16
suixinkanTests/Fixtures/statistics_daily_page1_total3.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
23
suixinkanTests/Fixtures/statistics_daily_page2_total3.json
Normal file
23
suixinkanTests/Fixtures/statistics_daily_page2_total3.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
11
suixinkanTests/Fixtures/statistics_summary_success.json
Normal file
11
suixinkanTests/Fixtures/statistics_summary_success.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
13
suixinkanTests/Fixtures/user_info_success.json
Normal file
13
suixinkanTests/Fixtures/user_info_success.json
Normal 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": "已认证"
|
||||||
|
}
|
||||||
|
}
|
||||||
67
suixinkanTests/Fixtures/v9_login_multi_success.json
Normal file
67
suixinkanTests/Fixtures/v9_login_multi_success.json
Normal 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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
39
suixinkanTests/Fixtures/v9_set_store_user_success.json
Normal file
39
suixinkanTests/Fixtures/v9_set_store_user_success.json
Normal 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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
19
suixinkanTests/Fixtures/writeoff_list_success.json
Normal file
19
suixinkanTests/Fixtures/writeoff_list_success.json
Normal 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": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
306
suixinkanTests/OrdersViewModelTests.swift
Normal file
306
suixinkanTests/OrdersViewModelTests.swift
Normal 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) }
|
||||||
|
})
|
||||||
|
}
|
||||||
201
suixinkanTests/ProfileSecondaryPagesTests.swift
Normal file
201
suixinkanTests/ProfileSecondaryPagesTests.swift
Normal 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)!
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
238
suixinkanTests/StatisticsViewModelTests.swift
Normal file
238
suixinkanTests/StatisticsViewModelTests.swift
Normal 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) }
|
||||||
|
})
|
||||||
|
}
|
||||||
35
suixinkanTests/TestFixture.swift
Normal file
35
suixinkanTests/TestFixture.swift
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user