新增门店订单与核销管理列表,对齐 Android 订单能力并完善账号级缓存。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -243,11 +243,12 @@
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
buildConfigurationList = 939AC7912FE3F832004B22E4 /* Build configuration list for PBXProject "suixinkan" */;
|
buildConfigurationList = 939AC7912FE3F832004B22E4 /* Build configuration list for PBXProject "suixinkan" */;
|
||||||
developmentRegion = en;
|
developmentRegion = "zh-Hans";
|
||||||
hasScannedForEncodings = 0;
|
hasScannedForEncodings = 0;
|
||||||
knownRegions = (
|
knownRegions = (
|
||||||
en,
|
en,
|
||||||
Base,
|
Base,
|
||||||
|
"zh-Hans",
|
||||||
);
|
);
|
||||||
mainGroup = 939AC78D2FE3F832004B22E4;
|
mainGroup = 939AC78D2FE3F832004B22E4;
|
||||||
minimizedProjectReferenceProxies = 1;
|
minimizedProjectReferenceProxies = 1;
|
||||||
|
|||||||
@ -15,7 +15,7 @@ App 模块负责应用入口、根视图切换、全局状态注入、主导航
|
|||||||
- `suixinkanApp`:SwiftUI 应用入口,挂载 `RootView`。
|
- `suixinkanApp`:SwiftUI 应用入口,挂载 `RootView`。
|
||||||
- `RootView`:创建 `AppSession`、`AccountContext`、`PermissionContext`、`ScenicSpotContext`、`AppRouter`、`ToastCenter`、`APIClient` 和业务 API,并注入 SwiftUI Environment。
|
- `RootView`:创建 `AppSession`、`AccountContext`、`PermissionContext`、`ScenicSpotContext`、`AppRouter`、`ToastCenter`、`APIClient` 和业务 API,并注入 SwiftUI Environment。
|
||||||
- `AppSession`:保存认证阶段和正式 token,只负责登录态,不承载业务资料。
|
- `AppSession`:保存认证阶段和正式 token,只负责登录态,不承载业务资料。
|
||||||
- `AccountContext`:保存当前账号资料、景区作用域和门店作用域。
|
- `AccountContext`:保存当前账号资料、账号类型、景区作用域和门店作用域,并提供账号级缓存前缀。
|
||||||
- `PermissionContext`:保存角色权限、当前角色和扁平化权限 URI。
|
- `PermissionContext`:保存角色权限、当前角色和扁平化权限 URI。
|
||||||
- `ScenicSpotContext`:保存当前景区下的景点/打卡点列表和加载状态。
|
- `ScenicSpotContext`:保存当前景区下的景点/打卡点列表和加载状态。
|
||||||
- `AppRouter`:保存当前 Tab 和每个 Tab 自己的导航路径。
|
- `AppRouter`:保存当前 Tab 和每个 Tab 自己的导航路径。
|
||||||
|
|||||||
@ -46,6 +46,9 @@ enum AppUITestRouteDriver {
|
|||||||
appRouter.select(tab)
|
appRouter.select(tab)
|
||||||
case .orders(let entry):
|
case .orders(let entry):
|
||||||
appRouter.selectOrders(entry: entry)
|
appRouter.selectOrders(entry: entry)
|
||||||
|
case .orderPush(let route):
|
||||||
|
appRouter.select(.orders)
|
||||||
|
appRouter.router(for: .orders).navigate(to: .orders(route))
|
||||||
case .destination(let homeRoute):
|
case .destination(let homeRoute):
|
||||||
appRouter.select(.home)
|
appRouter.select(.home)
|
||||||
appRouter.router(for: .home).navigate(to: .home(homeRoute))
|
appRouter.router(for: .home).navigate(to: .home(homeRoute))
|
||||||
|
|||||||
@ -51,6 +51,8 @@ extension OrdersRoute {
|
|||||||
MultiTravelTaskUploadView(initialOrderNumber: orderNumber)
|
MultiTravelTaskUploadView(initialOrderNumber: orderNumber)
|
||||||
case .orderTrailer(let orderNumber, let title):
|
case .orderTrailer(let orderNumber, let title):
|
||||||
OrderTrailerView(orderNumber: orderNumber, title: title)
|
OrderTrailerView(orderNumber: orderNumber, title: title)
|
||||||
|
case .writeOffList:
|
||||||
|
WriteOffListView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -107,10 +109,17 @@ final class AppRouter: ObservableObject {
|
|||||||
selectedTab = .orders
|
selectedTab = .orders
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 切换到核销订单入口,并暂存全局扫码结果等待订单页消费。
|
/// 切换到核销管理页,并暂存全局扫码结果等待核销页消费。
|
||||||
func routeToOrderVerification(scannedCode: String) {
|
func routeToOrderVerification(scannedCode: String) {
|
||||||
pendingOrderScanCode = scannedCode
|
pendingOrderScanCode = scannedCode
|
||||||
selectOrders(entry: .verificationOrders)
|
selectedTab = .orders
|
||||||
|
router(for: .orders).navigate(to: .orders(.writeOffList))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 打开核销管理页。
|
||||||
|
func openWriteOffList() {
|
||||||
|
selectedTab = .orders
|
||||||
|
router(for: .orders).navigate(to: .orders(.writeOffList))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 读取并清空待处理的全局扫码结果,确保同一次扫码只被处理一次。
|
/// 读取并清空待处理的全局扫码结果,确保同一次扫码只被处理一次。
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Combine
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
/// 当前账号的基础资料实体,用于跨页面展示昵称、手机号和头像。
|
/// 当前账号的基础资料实体,用于跨页面展示昵称、手机号和头像。
|
||||||
struct AccountProfile: Codable, Equatable {
|
struct AccountProfile: Codable, Equatable {
|
||||||
@ -61,14 +62,24 @@ struct BusinessScope: Codable, Equatable, Identifiable {
|
|||||||
/// 账号上下文状态中心,保存当前账号资料和景区/门店作用域。
|
/// 账号上下文状态中心,保存当前账号资料和景区/门店作用域。
|
||||||
final class AccountContext: ObservableObject {
|
final class AccountContext: ObservableObject {
|
||||||
@Published private(set) var profile: AccountProfile?
|
@Published private(set) var profile: AccountProfile?
|
||||||
|
@Published private(set) var accountType: String?
|
||||||
@Published private(set) var scenicScopes: [BusinessScope] = []
|
@Published private(set) var scenicScopes: [BusinessScope] = []
|
||||||
@Published private(set) var storeScopes: [BusinessScope] = []
|
@Published private(set) var storeScopes: [BusinessScope] = []
|
||||||
@Published var currentScenic: BusinessScope?
|
@Published var currentScenic: BusinessScope?
|
||||||
@Published var currentStore: BusinessScope?
|
@Published var currentStore: BusinessScope?
|
||||||
|
|
||||||
|
/// 当前账号缓存前缀,与 Android 的账号级缓存作用域保持一致。
|
||||||
|
var accountCachePrefix: String? {
|
||||||
|
let userId = profile?.userId.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
guard !userId.isEmpty else { return nil }
|
||||||
|
let type = accountType?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
return type.isEmpty ? userId : "\(type)_\(userId)"
|
||||||
|
}
|
||||||
|
|
||||||
/// 应用登录成功后写入账号资料。
|
/// 应用登录成功后写入账号资料。
|
||||||
func applyLogin(profile: AccountProfile? = nil) {
|
func applyLogin(profile: AccountProfile? = nil, accountType: String? = nil) {
|
||||||
self.profile = profile
|
self.profile = profile
|
||||||
|
self.accountType = accountType?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 替换当前账号资料,通常用于用户信息刷新后同步全局展示。
|
/// 替换当前账号资料,通常用于用户信息刷新后同步全局展示。
|
||||||
@ -122,6 +133,7 @@ final class AccountContext: ObservableObject {
|
|||||||
/// 清空账号上下文,通常在退出登录时调用。
|
/// 清空账号上下文,通常在退出登录时调用。
|
||||||
func reset() {
|
func reset() {
|
||||||
profile = nil
|
profile = nil
|
||||||
|
accountType = nil
|
||||||
scenicScopes = []
|
scenicScopes = []
|
||||||
storeScopes = []
|
storeScopes = []
|
||||||
currentScenic = nil
|
currentScenic = nil
|
||||||
|
|||||||
@ -63,7 +63,7 @@ final class AuthSessionCoordinator {
|
|||||||
let scenicScopes = response.scenicScopes
|
let scenicScopes = response.scenicScopes
|
||||||
let storeScopes = response.storeScopes
|
let storeScopes = response.storeScopes
|
||||||
let identity = response.currentAccountIdentity
|
let identity = response.currentAccountIdentity
|
||||||
accountContext.applyLogin(profile: profile)
|
accountContext.applyLogin(profile: profile, accountType: identity?.accountType)
|
||||||
accountContext.replaceScopes(scenic: scenicScopes, stores: storeScopes)
|
accountContext.replaceScopes(scenic: scenicScopes, stores: storeScopes)
|
||||||
appSession.beginRestoring(token: token)
|
appSession.beginRestoring(token: token)
|
||||||
|
|
||||||
@ -89,7 +89,7 @@ final class AuthSessionCoordinator {
|
|||||||
appSession.markLoggedIn(token: token)
|
appSession.markLoggedIn(token: token)
|
||||||
saveSnapshot(
|
saveSnapshot(
|
||||||
profile: accountContext.profile ?? profile,
|
profile: accountContext.profile ?? profile,
|
||||||
accountType: identity?.accountType,
|
accountType: accountContext.accountType ?? identity?.accountType,
|
||||||
businessUserId: identity?.businessUserId,
|
businessUserId: identity?.businessUserId,
|
||||||
permissionContext: permissionContext,
|
permissionContext: permissionContext,
|
||||||
accountContext: accountContext
|
accountContext: accountContext
|
||||||
@ -99,7 +99,7 @@ final class AuthSessionCoordinator {
|
|||||||
|
|
||||||
saveSnapshot(
|
saveSnapshot(
|
||||||
profile: accountContext.profile ?? profile,
|
profile: accountContext.profile ?? profile,
|
||||||
accountType: identity?.accountType,
|
accountType: accountContext.accountType ?? identity?.accountType,
|
||||||
businessUserId: identity?.businessUserId,
|
businessUserId: identity?.businessUserId,
|
||||||
permissionContext: permissionContext,
|
permissionContext: permissionContext,
|
||||||
accountContext: accountContext
|
accountContext: accountContext
|
||||||
@ -113,7 +113,7 @@ final class AuthSessionCoordinator {
|
|||||||
accountContext.replaceProfile(profile)
|
accountContext.replaceProfile(profile)
|
||||||
saveSnapshot(
|
saveSnapshot(
|
||||||
profile: profile,
|
profile: profile,
|
||||||
accountType: snapshotStore.load()?.accountType,
|
accountType: accountContext.accountType ?? snapshotStore.load()?.accountType,
|
||||||
businessUserId: snapshotStore.load()?.businessUserId,
|
businessUserId: snapshotStore.load()?.businessUserId,
|
||||||
currentRoleCode: snapshotStore.load()?.currentRoleCode,
|
currentRoleCode: snapshotStore.load()?.currentRoleCode,
|
||||||
accountContext: accountContext
|
accountContext: accountContext
|
||||||
|
|||||||
@ -83,7 +83,7 @@ final class SessionBootstrapper {
|
|||||||
/// 将缓存快照恢复到账号上下文。
|
/// 将缓存快照恢复到账号上下文。
|
||||||
private func restoreCachedSnapshot(to accountContext: AccountContext) -> AccountSnapshot? {
|
private func restoreCachedSnapshot(to accountContext: AccountContext) -> AccountSnapshot? {
|
||||||
guard let snapshot = snapshotStore.load() else { return nil }
|
guard let snapshot = snapshotStore.load() else { return nil }
|
||||||
accountContext.applyLogin(profile: snapshot.profile)
|
accountContext.applyLogin(profile: snapshot.profile, accountType: snapshot.accountType)
|
||||||
accountContext.replaceScopes(
|
accountContext.replaceScopes(
|
||||||
scenic: snapshot.scenicScopes,
|
scenic: snapshot.scenicScopes,
|
||||||
stores: snapshot.storeScopes,
|
stores: snapshot.storeScopes,
|
||||||
@ -99,7 +99,7 @@ final class SessionBootstrapper {
|
|||||||
snapshotStore.save(
|
snapshotStore.save(
|
||||||
AccountSnapshot(
|
AccountSnapshot(
|
||||||
profile: accountContext.profile,
|
profile: accountContext.profile,
|
||||||
accountType: existing?.accountType,
|
accountType: accountContext.accountType ?? existing?.accountType,
|
||||||
businessUserId: existing?.businessUserId,
|
businessUserId: existing?.businessUserId,
|
||||||
currentRoleCode: permissionContext.currentAppRole?.rawValue ?? existing?.currentRoleCode,
|
currentRoleCode: permissionContext.currentAppRole?.rawValue ?? existing?.currentRoleCode,
|
||||||
scenicScopes: accountContext.scenicScopes,
|
scenicScopes: accountContext.scenicScopes,
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
{
|
{
|
||||||
|
"filename" : "icon_1024.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"platform" : "ios",
|
"platform" : "ios",
|
||||||
"size" : "1024x1024"
|
"size" : "1024x1024"
|
||||||
@ -12,6 +13,7 @@
|
|||||||
"value" : "dark"
|
"value" : "dark"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"filename" : "icon_1024.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"platform" : "ios",
|
"platform" : "ios",
|
||||||
"size" : "1024x1024"
|
"size" : "1024x1024"
|
||||||
@ -23,6 +25,7 @@
|
|||||||
"value" : "tinted"
|
"value" : "tinted"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"filename" : "icon_1024.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"platform" : "ios",
|
"platform" : "ios",
|
||||||
"size" : "1024x1024"
|
"size" : "1024x1024"
|
||||||
|
|||||||
BIN
suixinkan/Assets.xcassets/AppIcon.appiconset/icon_1024.png
Normal file
BIN
suixinkan/Assets.xcassets/AppIcon.appiconset/icon_1024.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 106 KiB |
@ -16,6 +16,23 @@ enum AppDesign {
|
|||||||
static let placeholder = Color(hex: 0xA8B2C1)
|
static let placeholder = Color(hex: 0xA8B2C1)
|
||||||
static let success = Color(hex: 0x14964A)
|
static let success = Color(hex: 0x14964A)
|
||||||
static let warning = Color(hex: 0xFF7B00)
|
static let warning = Color(hex: 0xFF7B00)
|
||||||
|
|
||||||
|
// MARK: - 订单列表专用色值(对齐 Android OrderListScreen)
|
||||||
|
|
||||||
|
static let orderPageBackground = Color(hex: 0xF5F5F5)
|
||||||
|
static let orderInputBackground = Color(hex: 0xF4F4F4)
|
||||||
|
static let orderLabelColor = Color(hex: 0x4B5563)
|
||||||
|
static let orderBorderLight = Color(hex: 0xEEEEEE)
|
||||||
|
static let orderRefundText = Color(hex: 0xEF4444)
|
||||||
|
static let orderRefundBackground = Color(hex: 0xFFE7E7)
|
||||||
|
static let orderStatusPaidText = Color(hex: 0x0073FF)
|
||||||
|
static let orderStatusPaidBackground = Color(hex: 0xEFF6FF)
|
||||||
|
static let orderStatusCompletedText = Color(hex: 0x10B981)
|
||||||
|
static let orderStatusCompletedBackground = Color(hex: 0xF0FDF4)
|
||||||
|
static let orderStatusRefundedText = Color(hex: 0xEF4444)
|
||||||
|
static let orderStatusRefundedBackground = Color(hex: 0xFFE7E7)
|
||||||
|
static let orderStatusOtherText = Color(hex: 0xFF7B00)
|
||||||
|
static let orderStatusOtherBackground = Color(hex: 0xFFF0E2)
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Color {
|
extension Color {
|
||||||
|
|||||||
@ -132,7 +132,7 @@ final class PushNotificationManager: NSObject, UNUserNotificationCenterDelegate
|
|||||||
case .order:
|
case .order:
|
||||||
router.selectOrders(entry: .storeOrders)
|
router.selectOrders(entry: .storeOrders)
|
||||||
case .verificationOrder:
|
case .verificationOrder:
|
||||||
router.selectOrders(entry: .verificationOrders)
|
router.openWriteOffList()
|
||||||
case .task:
|
case .task:
|
||||||
navigateHomeRoute(.taskManagement)
|
navigateHomeRoute(.taskManagement)
|
||||||
case .queue:
|
case .queue:
|
||||||
|
|||||||
@ -115,7 +115,7 @@ final class AccountSnapshotStore {
|
|||||||
save(
|
save(
|
||||||
AccountSnapshot(
|
AccountSnapshot(
|
||||||
profile: accountContext.profile ?? existing?.profile,
|
profile: accountContext.profile ?? existing?.profile,
|
||||||
accountType: existing?.accountType,
|
accountType: accountContext.accountType ?? existing?.accountType,
|
||||||
businessUserId: existing?.businessUserId,
|
businessUserId: existing?.businessUserId,
|
||||||
currentRoleCode: currentRoleCode ?? existing?.currentRoleCode,
|
currentRoleCode: currentRoleCode ?? existing?.currentRoleCode,
|
||||||
scenicScopes: accountContext.scenicScopes,
|
scenicScopes: accountContext.scenicScopes,
|
||||||
|
|||||||
@ -18,8 +18,10 @@ Home 模块负责登录后的首页工作台,包括当前景区展示、工作
|
|||||||
|
|
||||||
## 常用应用
|
## 常用应用
|
||||||
|
|
||||||
`HomeCommonMenuStore` 使用 UserDefaults 按角色保存常用应用 URI(key:`home.common.menu.uris.role.{roleCode}`):
|
`HomeCommonMenuStore` 使用 UserDefaults 按账号和角色保存常用应用 URI:
|
||||||
|
- 当前版本使用账号 + 角色作用域保存常用应用 URI,避免不同业务账号的同一角色互相覆盖;旧版角色 key 会在首次读取时迁移。
|
||||||
- 首次无配置时,按 Android 规则从当前角色顶层权限 URI 生成默认值:权限多于 4 个取前 4 个,否则全部使用。
|
- 首次无配置时,按 Android 规则从当前角色顶层权限 URI 生成默认值:权限多于 4 个取前 4 个,否则全部使用。
|
||||||
|
- 首页常用应用按 Android `Constants.menuList` 白名单过滤;iOS 已接入但 Android 首页未登记的 URI 不进入首页常用应用。
|
||||||
- 读取时按当前角色权限过滤不可用 URI。
|
- 读取时按当前角色权限过滤不可用 URI。
|
||||||
- 添加和移除时按同义 URI 去重。
|
- 添加和移除时按同义 URI 去重。
|
||||||
- 不同角色的常用应用互不影响。
|
- 不同角色的常用应用互不影响。
|
||||||
|
|||||||
@ -72,6 +72,7 @@ enum HomeRoute: Hashable {
|
|||||||
enum HomeMenuResolvedRoute: Equatable {
|
enum HomeMenuResolvedRoute: Equatable {
|
||||||
case tab(AppTab)
|
case tab(AppTab)
|
||||||
case orders(OrdersEntry)
|
case orders(OrdersEntry)
|
||||||
|
case orderPush(OrdersRoute)
|
||||||
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)
|
||||||
|
|||||||
@ -73,7 +73,7 @@ enum HomeMenuRouter {
|
|||||||
case "photographer_orders", "/scenic-order-manage":
|
case "photographer_orders", "/scenic-order-manage":
|
||||||
return .orders(.storeOrders)
|
return .orders(.storeOrders)
|
||||||
case "verification_order":
|
case "verification_order":
|
||||||
return .orders(.verificationOrders)
|
return .orderPush(.writeOffList)
|
||||||
case "photographer_stats":
|
case "photographer_stats":
|
||||||
return .tab(.statistics)
|
return .tab(.statistics)
|
||||||
case "space_settings", "basic_info":
|
case "space_settings", "basic_info":
|
||||||
@ -330,7 +330,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, .orders, .destination:
|
case .tab, .orders, .orderPush, .destination:
|
||||||
return true
|
return true
|
||||||
case .unsupported, .placeholder:
|
case .unsupported, .placeholder:
|
||||||
return false
|
return false
|
||||||
|
|||||||
@ -10,6 +10,37 @@ import Foundation
|
|||||||
/// 首页常用应用存储服务,负责按当前角色权限过滤和持久化常用 URI。
|
/// 首页常用应用存储服务,负责按当前角色权限过滤和持久化常用 URI。
|
||||||
struct HomeCommonMenuStore {
|
struct HomeCommonMenuStore {
|
||||||
static let storageKeyPrefix = "home.common.menu.uris.role."
|
static let storageKeyPrefix = "home.common.menu.uris.role."
|
||||||
|
static let accountScopedStorageKeyPrefix = "home.common.menu.uris.account."
|
||||||
|
|
||||||
|
/// 与 Android `Constants.menuList` 对齐的首页菜单 URI 白名单。
|
||||||
|
static let androidHomeMenuURIs: Set<String> = [
|
||||||
|
"space_settings",
|
||||||
|
"wallet",
|
||||||
|
"cloud_management",
|
||||||
|
"asset_management",
|
||||||
|
"task_management",
|
||||||
|
"schedule_management",
|
||||||
|
"system_settings",
|
||||||
|
"message_center",
|
||||||
|
"checkin_points",
|
||||||
|
"sample_management",
|
||||||
|
"live_stream_management",
|
||||||
|
"verification_order",
|
||||||
|
"live_album",
|
||||||
|
"pm",
|
||||||
|
"location_report",
|
||||||
|
"registration_invitation",
|
||||||
|
"store",
|
||||||
|
"fly",
|
||||||
|
"pilot_cert",
|
||||||
|
"task_management_editor",
|
||||||
|
"pm_manager",
|
||||||
|
"pilot_controller",
|
||||||
|
"/scenic-queue",
|
||||||
|
"operating-area",
|
||||||
|
"/scenic-order-manage",
|
||||||
|
"cooperation_order"
|
||||||
|
]
|
||||||
|
|
||||||
private let defaults: UserDefaults
|
private let defaults: UserDefaults
|
||||||
|
|
||||||
@ -27,7 +58,7 @@ struct HomeCommonMenuStore {
|
|||||||
let trimmed = uri.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = uri.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !trimmed.isEmpty else { return nil }
|
guard !trimmed.isEmpty else { return nil }
|
||||||
let canonical = HomeMenuRouter.canonicalURI(for: trimmed, availableURIs: availableURIs)
|
let canonical = HomeMenuRouter.canonicalURI(for: trimmed, availableURIs: availableURIs)
|
||||||
return availableURIs.contains(canonical) ? canonical : nil
|
return Self.isAndroidHomeMenuURI(canonical) && availableURIs.contains(canonical) ? canonical : nil
|
||||||
}
|
}
|
||||||
let deduplicated = unique(resolved)
|
let deduplicated = unique(resolved)
|
||||||
if deduplicated.count > 4 {
|
if deduplicated.count > 4 {
|
||||||
@ -40,23 +71,24 @@ struct HomeCommonMenuStore {
|
|||||||
func load(
|
func load(
|
||||||
menuItems: [HomeMenuItem],
|
menuItems: [HomeMenuItem],
|
||||||
roleCode: String?,
|
roleCode: String?,
|
||||||
|
accountScope: String? = nil,
|
||||||
legacyRoleId: Int? = nil,
|
legacyRoleId: Int? = nil,
|
||||||
topLevelPermissionURIs: [String]
|
topLevelPermissionURIs: [String]
|
||||||
) -> [String] {
|
) -> [String] {
|
||||||
guard let roleCode, !roleCode.isEmpty else { return [] }
|
guard let roleCode, !roleCode.isEmpty else { return [] }
|
||||||
migrateLegacyStorageIfNeeded(roleCode: roleCode, legacyRoleId: legacyRoleId)
|
migrateLegacyStorageIfNeeded(roleCode: roleCode, accountScope: accountScope, legacyRoleId: legacyRoleId)
|
||||||
|
|
||||||
let availableURIs = Set(menuItems.map(\.uri))
|
let availableURIs = Set(menuItems.map(\.uri))
|
||||||
let key = storageKey(for: roleCode)
|
let key = Self.storageKey(roleCode: roleCode, accountScope: accountScope)
|
||||||
let saved = defaults.stringArray(forKey: key)
|
let saved = defaults.stringArray(forKey: key)
|
||||||
|
|
||||||
if let saved, !saved.isEmpty {
|
if let saved, !saved.isEmpty {
|
||||||
let filtered = Self.unique(saved.compactMap { uri -> String? in
|
let filtered = Self.unique(saved.compactMap { uri -> String? in
|
||||||
let resolved = HomeMenuRouter.canonicalURI(for: uri, availableURIs: availableURIs)
|
let resolved = HomeMenuRouter.canonicalURI(for: uri, availableURIs: availableURIs)
|
||||||
return availableURIs.contains(resolved) ? resolved : nil
|
return Self.isAndroidHomeMenuURI(resolved) && availableURIs.contains(resolved) ? resolved : nil
|
||||||
})
|
})
|
||||||
if !filtered.isEmpty {
|
if !filtered.isEmpty {
|
||||||
save(filtered, roleCode: roleCode)
|
save(filtered, roleCode: roleCode, accountScope: accountScope)
|
||||||
return filtered
|
return filtered
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -65,54 +97,63 @@ struct HomeCommonMenuStore {
|
|||||||
orderedTopLevelURIs: topLevelPermissionURIs,
|
orderedTopLevelURIs: topLevelPermissionURIs,
|
||||||
availableURIs: availableURIs
|
availableURIs: availableURIs
|
||||||
)
|
)
|
||||||
save(defaultURIs, roleCode: roleCode)
|
save(defaultURIs, roleCode: roleCode, accountScope: accountScope)
|
||||||
return defaultURIs
|
return defaultURIs
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 将指定 URI 加入常用应用,按别名避免重复添加。
|
/// 将指定 URI 加入常用应用,按别名避免重复添加。
|
||||||
func add(_ uri: String, current: [String], menuItems: [HomeMenuItem], roleCode: String?) -> [String] {
|
func add(_ uri: String, current: [String], menuItems: [HomeMenuItem], roleCode: String?, accountScope: String? = nil) -> [String] {
|
||||||
guard let roleCode, !roleCode.isEmpty else { return current }
|
guard let roleCode, !roleCode.isEmpty else { return current }
|
||||||
let availableURIs = Set(menuItems.map(\.uri))
|
let availableURIs = Set(menuItems.map(\.uri))
|
||||||
let resolved = HomeMenuRouter.canonicalURI(for: uri, availableURIs: availableURIs)
|
let resolved = HomeMenuRouter.canonicalURI(for: uri, availableURIs: availableURIs)
|
||||||
guard availableURIs.contains(resolved) else { return current }
|
guard Self.isAndroidHomeMenuURI(resolved), availableURIs.contains(resolved) else { return current }
|
||||||
guard !current.contains(where: { HomeMenuRouter.menuAliasKey(for: $0) == HomeMenuRouter.menuAliasKey(for: resolved) }) else {
|
guard !current.contains(where: { HomeMenuRouter.menuAliasKey(for: $0) == HomeMenuRouter.menuAliasKey(for: resolved) }) else {
|
||||||
return current
|
return current
|
||||||
}
|
}
|
||||||
let next = current + [resolved]
|
let next = current + [resolved]
|
||||||
save(next, roleCode: roleCode)
|
save(next, roleCode: roleCode, accountScope: accountScope)
|
||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 从常用应用中移除指定 URI,同义 URI 会一起移除。
|
/// 从常用应用中移除指定 URI,同义 URI 会一起移除。
|
||||||
func remove(_ uri: String, current: [String], roleCode: String?) -> [String] {
|
func remove(_ uri: String, current: [String], roleCode: String?, accountScope: String? = nil) -> [String] {
|
||||||
guard let roleCode, !roleCode.isEmpty else { return current }
|
guard let roleCode, !roleCode.isEmpty else { return current }
|
||||||
let aliasKey = HomeMenuRouter.menuAliasKey(for: uri)
|
let aliasKey = HomeMenuRouter.menuAliasKey(for: uri)
|
||||||
let next = current.filter { HomeMenuRouter.menuAliasKey(for: $0) != aliasKey }
|
let next = current.filter { HomeMenuRouter.menuAliasKey(for: $0) != aliasKey }
|
||||||
save(next, roleCode: roleCode)
|
save(next, roleCode: roleCode, accountScope: accountScope)
|
||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 保存指定角色的常用 URI。
|
/// 保存指定角色的常用 URI。
|
||||||
func save(_ uris: [String], roleCode: String) {
|
func save(_ uris: [String], roleCode: String, accountScope: String? = nil) {
|
||||||
defaults.set(Self.unique(uris), forKey: storageKey(for: roleCode))
|
defaults.set(Self.unique(uris), forKey: Self.storageKey(roleCode: roleCode, accountScope: accountScope))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 若新 key 无数据,则从 legacy role id key 一次性迁移常用菜单。
|
/// 若账号级 key 无数据,则从旧 roleCode 或 legacy role id key 一次性迁移常用菜单。
|
||||||
func migrateLegacyStorageIfNeeded(roleCode: String, legacyRoleId: Int?) {
|
func migrateLegacyStorageIfNeeded(roleCode: String, accountScope: String? = nil, legacyRoleId: Int?) {
|
||||||
let newKey = storageKey(for: roleCode)
|
let newKey = Self.storageKey(roleCode: roleCode, accountScope: accountScope)
|
||||||
guard defaults.stringArray(forKey: newKey) == nil,
|
guard defaults.stringArray(forKey: newKey) == nil else { return }
|
||||||
let legacyRoleId,
|
|
||||||
let legacyData = defaults.stringArray(forKey: legacyStorageKey(for: legacyRoleId)),
|
let legacyKeys = [
|
||||||
!legacyData.isEmpty else {
|
legacyStorageKey(for: roleCode),
|
||||||
|
legacyRoleId.map { legacyStorageKey(for: $0) }
|
||||||
|
].compactMap { $0 }
|
||||||
|
|
||||||
|
for legacyKey in legacyKeys {
|
||||||
|
guard let legacyData = defaults.stringArray(forKey: legacyKey), !legacyData.isEmpty else { continue }
|
||||||
|
defaults.set(legacyData, forKey: newKey)
|
||||||
|
defaults.removeObject(forKey: legacyKey)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defaults.set(legacyData, forKey: newKey)
|
|
||||||
defaults.removeObject(forKey: legacyStorageKey(for: legacyRoleId))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 生成按 role_code 隔离的 UserDefaults key。
|
/// 生成按账号和 role_code 隔离的 UserDefaults key。
|
||||||
private func storageKey(for roleCode: String) -> String {
|
static func storageKey(roleCode: String, accountScope: String?) -> String {
|
||||||
Self.storageKeyPrefix + roleCode
|
let normalizedScope = accountScope?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
if normalizedScope.isEmpty {
|
||||||
|
return storageKeyPrefix + roleCode
|
||||||
|
}
|
||||||
|
return accountScopedStorageKeyPrefix + normalizedScope + ".role." + roleCode
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 旧版按 numeric role id 存储的 key。
|
/// 旧版按 numeric role id 存储的 key。
|
||||||
@ -120,6 +161,16 @@ struct HomeCommonMenuStore {
|
|||||||
Self.storageKeyPrefix + String(roleId)
|
Self.storageKeyPrefix + String(roleId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 旧版按 role_code 存储的 key。
|
||||||
|
private func legacyStorageKey(for roleCode: String) -> String {
|
||||||
|
Self.storageKeyPrefix + roleCode
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 判断 URI 是否属于 Android 首页菜单白名单。
|
||||||
|
private static func isAndroidHomeMenuURI(_ uri: String) -> Bool {
|
||||||
|
androidHomeMenuURIs.contains(uri)
|
||||||
|
}
|
||||||
|
|
||||||
/// 按 URI 别名去重,保留首次出现的 URI。
|
/// 按 URI 别名去重,保留首次出现的 URI。
|
||||||
private static func unique(_ uris: [String]) -> [String] {
|
private static func unique(_ uris: [String]) -> [String] {
|
||||||
var seen = Set<String>()
|
var seen = Set<String>()
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import SwiftUI
|
|||||||
|
|
||||||
/// 首页全部功能视图,展示常用应用和当前角色拥有的更多功能。
|
/// 首页全部功能视图,展示常用应用和当前角色拥有的更多功能。
|
||||||
struct HomeMoreFunctionsView: View {
|
struct HomeMoreFunctionsView: View {
|
||||||
|
@EnvironmentObject private var accountContext: AccountContext
|
||||||
@EnvironmentObject private var permissionContext: PermissionContext
|
@EnvironmentObject private var permissionContext: PermissionContext
|
||||||
@EnvironmentObject private var appRouter: AppRouter
|
@EnvironmentObject private var appRouter: AppRouter
|
||||||
@EnvironmentObject private var router: RouterPath
|
@EnvironmentObject private var router: RouterPath
|
||||||
@ -124,13 +125,19 @@ struct HomeMoreFunctionsView: View {
|
|||||||
Button {
|
Button {
|
||||||
let roleCode = permissionContext.currentAppRole?.rawValue
|
let roleCode = permissionContext.currentAppRole?.rawValue
|
||||||
if isCommon {
|
if isCommon {
|
||||||
commonUris = commonMenuStore.remove(item.uri, current: commonUris, roleCode: roleCode)
|
commonUris = commonMenuStore.remove(
|
||||||
|
item.uri,
|
||||||
|
current: commonUris,
|
||||||
|
roleCode: roleCode,
|
||||||
|
accountScope: accountContext.accountCachePrefix
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
commonUris = commonMenuStore.add(
|
commonUris = commonMenuStore.add(
|
||||||
item.uri,
|
item.uri,
|
||||||
current: commonUris,
|
current: commonUris,
|
||||||
menuItems: viewModel.menuItems,
|
menuItems: viewModel.menuItems,
|
||||||
roleCode: roleCode
|
roleCode: roleCode,
|
||||||
|
accountScope: accountContext.accountCachePrefix
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
@ -187,6 +194,9 @@ struct HomeMoreFunctionsView: View {
|
|||||||
appRouter.select(tab)
|
appRouter.select(tab)
|
||||||
case .orders(let entry):
|
case .orders(let entry):
|
||||||
appRouter.selectOrders(entry: entry)
|
appRouter.selectOrders(entry: entry)
|
||||||
|
case .orderPush(let route):
|
||||||
|
appRouter.select(.orders)
|
||||||
|
appRouter.router(for: .orders).navigate(to: .orders(route))
|
||||||
case .destination(let route):
|
case .destination(let route):
|
||||||
if route == .moreFunctions {
|
if route == .moreFunctions {
|
||||||
return
|
return
|
||||||
@ -210,6 +220,7 @@ struct HomeMoreFunctionsView: View {
|
|||||||
commonUris = commonMenuStore.load(
|
commonUris = commonMenuStore.load(
|
||||||
menuItems: viewModel.menuItems,
|
menuItems: viewModel.menuItems,
|
||||||
roleCode: roleCode,
|
roleCode: roleCode,
|
||||||
|
accountScope: accountContext.accountCachePrefix,
|
||||||
legacyRoleId: legacyRoleId,
|
legacyRoleId: legacyRoleId,
|
||||||
topLevelPermissionURIs: permissionContext.topLevelPermissionURIs(for: roleCode)
|
topLevelPermissionURIs: permissionContext.topLevelPermissionURIs(for: roleCode)
|
||||||
)
|
)
|
||||||
|
|||||||
@ -395,6 +395,9 @@ struct HomeView: View {
|
|||||||
appRouter.select(tab)
|
appRouter.select(tab)
|
||||||
case .orders(let entry):
|
case .orders(let entry):
|
||||||
appRouter.selectOrders(entry: entry)
|
appRouter.selectOrders(entry: entry)
|
||||||
|
case .orderPush(let route):
|
||||||
|
appRouter.select(.orders)
|
||||||
|
appRouter.router(for: .orders).navigate(to: .orders(route))
|
||||||
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, _):
|
||||||
@ -415,6 +418,7 @@ struct HomeView: View {
|
|||||||
commonUris = commonMenuStore.load(
|
commonUris = commonMenuStore.load(
|
||||||
menuItems: viewModel.menuItems,
|
menuItems: viewModel.menuItems,
|
||||||
roleCode: roleCode,
|
roleCode: roleCode,
|
||||||
|
accountScope: accountContext.accountCachePrefix,
|
||||||
legacyRoleId: legacyRoleId,
|
legacyRoleId: legacyRoleId,
|
||||||
topLevelPermissionURIs: permissionContext.topLevelPermissionURIs(for: roleCode)
|
topLevelPermissionURIs: permissionContext.topLevelPermissionURIs(for: roleCode)
|
||||||
)
|
)
|
||||||
|
|||||||
@ -20,7 +20,7 @@ Main 模块负责登录后的主界面 Tab 容器,以及当前尚未迁移页
|
|||||||
- `statistics`:数据
|
- `statistics`:数据
|
||||||
- `profile`:我的
|
- `profile`:我的
|
||||||
|
|
||||||
`HomeRootView` 已接入真实的 `HomeView`,`OrdersRootView` 已接入真实的 `OrdersView`,`StatisticsRootView` 已接入真实的 `StatisticsView`,`ProfileRootView` 已接入真实的 `ProfileView`。
|
`HomeRootView` 已接入真实的 `HomeView`,`OrdersRootView` 已按角色接入 `StoreOrderListView` 或 `StoreAdminOrderListView`,`StatisticsRootView` 已接入真实的 `StatisticsView`,`ProfileRootView` 已接入真实的 `ProfileView`。
|
||||||
|
|
||||||
## 导航流程
|
## 导航流程
|
||||||
|
|
||||||
|
|||||||
@ -21,7 +21,17 @@ final class MainTabBadgeViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let result = try await api.writeOffList(scenicId: scenicId, storeId: storeId, page: 1, pageSize: 1)
|
let result = try await api.writeOffList(
|
||||||
|
scenicId: scenicId,
|
||||||
|
storeId: storeId,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 1,
|
||||||
|
orderStatus: nil,
|
||||||
|
orderVerifyStatus: nil,
|
||||||
|
startTime: nil,
|
||||||
|
endTime: nil,
|
||||||
|
userPhone: nil
|
||||||
|
)
|
||||||
pendingWriteOffCount = min(result.total, 99)
|
pendingWriteOffCount = min(result.total, 99)
|
||||||
} catch {
|
} catch {
|
||||||
pendingWriteOffCount = nil
|
pendingWriteOffCount = nil
|
||||||
|
|||||||
@ -14,10 +14,16 @@ struct HomeRootView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 订单根视图,占位承载后续订单模块迁移内容。
|
/// 订单根视图,按角色展示主订单列表或店长订单列表。
|
||||||
struct OrdersRootView: View {
|
struct OrdersRootView: View {
|
||||||
|
@EnvironmentObject private var permissionContext: PermissionContext
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
OrdersView()
|
if permissionContext.currentAppRole?.isStoreAdmin == true {
|
||||||
|
StoreAdminOrderListView()
|
||||||
|
} else {
|
||||||
|
StoreOrderListView()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -25,11 +25,51 @@ protocol OrderServing {
|
|||||||
) async throws -> ListPayload<OrderEntity>
|
) async throws -> ListPayload<OrderEntity>
|
||||||
|
|
||||||
/// 获取核销订单列表。
|
/// 获取核销订单列表。
|
||||||
func writeOffList(scenicId: Int, storeId: Int?, page: Int, pageSize: Int) async throws -> ListPayload<WriteOffOrderItem>
|
func writeOffList(
|
||||||
|
scenicId: Int,
|
||||||
|
storeId: Int?,
|
||||||
|
page: Int,
|
||||||
|
pageSize: Int,
|
||||||
|
orderStatus: Int?,
|
||||||
|
orderVerifyStatus: Int?,
|
||||||
|
startTime: String?,
|
||||||
|
endTime: String?,
|
||||||
|
userPhone: String?
|
||||||
|
) async throws -> ListPayload<WriteOffOrderItem>
|
||||||
|
|
||||||
/// 核销指定订单号。
|
/// 核销指定订单号。
|
||||||
func writeOff(orderNumber: String) async throws
|
func writeOff(orderNumber: String) async throws
|
||||||
|
|
||||||
|
/// 更新订单精修选择。
|
||||||
|
func updateRefinedChoice(orderNumber: String, isRefined: Int) async throws
|
||||||
|
|
||||||
|
/// 取消订单。
|
||||||
|
func cancelOrder(orderNumber: String) async throws
|
||||||
|
|
||||||
|
/// 赠送修图/视频数量。
|
||||||
|
func sendGift(orderNumber: String, photoNum: Int, videoNum: Int) async throws
|
||||||
|
|
||||||
|
/// 获取店长店铺订单列表。
|
||||||
|
func storeOrderList(
|
||||||
|
storeId: Int?,
|
||||||
|
page: Int,
|
||||||
|
pageSize: Int,
|
||||||
|
userPhone: String?,
|
||||||
|
orderStatus: Int?,
|
||||||
|
orderType: Int?,
|
||||||
|
startTime: String?,
|
||||||
|
endTime: String?
|
||||||
|
) async throws -> ListPayload<StoreOrderListItem>
|
||||||
|
|
||||||
|
/// 申请店长店铺订单退款。
|
||||||
|
func storeOrderRefund(
|
||||||
|
storeId: Int,
|
||||||
|
orderNumber: String,
|
||||||
|
refundType: OrderRefundMode,
|
||||||
|
refundAmount: String,
|
||||||
|
refundReason: String
|
||||||
|
) async throws
|
||||||
|
|
||||||
/// 获取门店订单详情。
|
/// 获取门店订单详情。
|
||||||
func storeOrderDetail(storeId: Int, orderNumber: String) async throws -> StoreOrderDetailResponse
|
func storeOrderDetail(storeId: Int, orderNumber: String) async throws -> StoreOrderDetailResponse
|
||||||
|
|
||||||
@ -111,7 +151,17 @@ final class OrdersAPI: OrderServing {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 获取核销订单列表。
|
/// 获取核销订单列表。
|
||||||
func writeOffList(scenicId: Int, storeId: Int? = nil, page: Int = 1, pageSize: Int = 20) async throws -> ListPayload<WriteOffOrderItem> {
|
func writeOffList(
|
||||||
|
scenicId: Int,
|
||||||
|
storeId: Int? = nil,
|
||||||
|
page: Int = 1,
|
||||||
|
pageSize: Int = 20,
|
||||||
|
orderStatus: Int? = nil,
|
||||||
|
orderVerifyStatus: Int? = nil,
|
||||||
|
startTime: String? = nil,
|
||||||
|
endTime: String? = nil,
|
||||||
|
userPhone: String? = nil
|
||||||
|
) async throws -> ListPayload<WriteOffOrderItem> {
|
||||||
var queryItems = [
|
var queryItems = [
|
||||||
URLQueryItem(name: "scenic_id", value: "\(scenicId)"),
|
URLQueryItem(name: "scenic_id", value: "\(scenicId)"),
|
||||||
URLQueryItem(name: "page", value: "\(max(page, 1))"),
|
URLQueryItem(name: "page", value: "\(max(page, 1))"),
|
||||||
@ -120,6 +170,21 @@ final class OrdersAPI: OrderServing {
|
|||||||
if let storeId, storeId > 0 {
|
if let storeId, storeId > 0 {
|
||||||
queryItems.append(URLQueryItem(name: "store_id", value: "\(storeId)"))
|
queryItems.append(URLQueryItem(name: "store_id", value: "\(storeId)"))
|
||||||
}
|
}
|
||||||
|
if let orderStatus, orderStatus > 0 {
|
||||||
|
queryItems.append(URLQueryItem(name: "order_status", value: "\(orderStatus)"))
|
||||||
|
}
|
||||||
|
if let orderVerifyStatus, orderVerifyStatus > 0 {
|
||||||
|
queryItems.append(URLQueryItem(name: "order_verify_status", value: "\(orderVerifyStatus)"))
|
||||||
|
}
|
||||||
|
if let startTime, !startTime.isEmpty {
|
||||||
|
queryItems.append(URLQueryItem(name: "start_time", value: startTime))
|
||||||
|
}
|
||||||
|
if let endTime, !endTime.isEmpty {
|
||||||
|
queryItems.append(URLQueryItem(name: "end_time", value: endTime))
|
||||||
|
}
|
||||||
|
if let userPhone, !userPhone.isEmpty {
|
||||||
|
queryItems.append(URLQueryItem(name: "user_phone", value: userPhone))
|
||||||
|
}
|
||||||
|
|
||||||
return try await client.send(
|
return try await client.send(
|
||||||
APIRequest(
|
APIRequest(
|
||||||
@ -256,4 +321,106 @@ final class OrdersAPI: OrderServing {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 更新订单精修选择。
|
||||||
|
func updateRefinedChoice(orderNumber: String, isRefined: Int) async throws {
|
||||||
|
let _: EmptyPayload = try await client.send(
|
||||||
|
APIRequest(
|
||||||
|
method: .post,
|
||||||
|
path: "/api/yf-handset-app/photog/order/refined",
|
||||||
|
queryItems: [
|
||||||
|
URLQueryItem(name: "order_number", value: orderNumber),
|
||||||
|
URLQueryItem(name: "is_refined", value: "\(isRefined)")
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取消订单。
|
||||||
|
func cancelOrder(orderNumber: String) async throws {
|
||||||
|
let _: EmptyPayload = try await client.send(
|
||||||
|
APIRequest(
|
||||||
|
method: .post,
|
||||||
|
path: "/api/yf-handset-app/photog/order/cancel",
|
||||||
|
body: CancelOrderRequest(orderNumber: orderNumber)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 赠送修图/视频数量。
|
||||||
|
func sendGift(orderNumber: String, photoNum: Int, videoNum: Int) async throws {
|
||||||
|
let _: EmptyPayload = try await client.send(
|
||||||
|
APIRequest(
|
||||||
|
method: .post,
|
||||||
|
path: "/api/yf-handset-app/photog/order/gift-retouch",
|
||||||
|
body: SendGiftRequest(orderNumber: orderNumber, photoNum: photoNum, videoNum: videoNum)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取店长店铺订单列表。
|
||||||
|
func storeOrderList(
|
||||||
|
storeId: Int? = nil,
|
||||||
|
page: Int = 1,
|
||||||
|
pageSize: Int = 20,
|
||||||
|
userPhone: String? = nil,
|
||||||
|
orderStatus: Int? = nil,
|
||||||
|
orderType: Int? = nil,
|
||||||
|
startTime: String? = nil,
|
||||||
|
endTime: String? = nil
|
||||||
|
) async throws -> ListPayload<StoreOrderListItem> {
|
||||||
|
var queryItems = [
|
||||||
|
URLQueryItem(name: "page", value: "\(max(page, 1))"),
|
||||||
|
URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))")
|
||||||
|
]
|
||||||
|
if let storeId, storeId > 0 {
|
||||||
|
queryItems.append(URLQueryItem(name: "store_id", value: "\(storeId)"))
|
||||||
|
}
|
||||||
|
if let userPhone, !userPhone.isEmpty {
|
||||||
|
queryItems.append(URLQueryItem(name: "user_phone", value: userPhone))
|
||||||
|
}
|
||||||
|
if let orderStatus, orderStatus > 0 {
|
||||||
|
queryItems.append(URLQueryItem(name: "order_status", value: "\(orderStatus)"))
|
||||||
|
}
|
||||||
|
if let orderType {
|
||||||
|
queryItems.append(URLQueryItem(name: "order_type", value: "\(orderType)"))
|
||||||
|
}
|
||||||
|
if let startTime, !startTime.isEmpty {
|
||||||
|
queryItems.append(URLQueryItem(name: "start_time", value: startTime))
|
||||||
|
}
|
||||||
|
if let endTime, !endTime.isEmpty {
|
||||||
|
queryItems.append(URLQueryItem(name: "end_time", value: endTime))
|
||||||
|
}
|
||||||
|
|
||||||
|
return try await client.send(
|
||||||
|
APIRequest(
|
||||||
|
method: .get,
|
||||||
|
path: "/api/app/store/order/list",
|
||||||
|
queryItems: queryItems
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 申请店长店铺订单退款。
|
||||||
|
func storeOrderRefund(
|
||||||
|
storeId: Int,
|
||||||
|
orderNumber: String,
|
||||||
|
refundType: OrderRefundMode,
|
||||||
|
refundAmount: String,
|
||||||
|
refundReason: String
|
||||||
|
) async throws {
|
||||||
|
let _: EmptyPayload = try await client.send(
|
||||||
|
APIRequest(
|
||||||
|
method: .post,
|
||||||
|
path: "/api/app/store/order/refund",
|
||||||
|
body: StoreOrderRefundRequest(
|
||||||
|
storeId: storeId,
|
||||||
|
orderNumber: orderNumber,
|
||||||
|
refundType: refundType.rawValue,
|
||||||
|
refundAmount: refundAmount,
|
||||||
|
refundReason: refundReason
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -29,6 +29,29 @@ enum OrderFilters {
|
|||||||
.init(id: -1, title: "需要精修"),
|
.init(id: -1, title: "需要精修"),
|
||||||
.init(id: -2, title: "无需精修")
|
.init(id: -2, title: "无需精修")
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/// 店长订单 Tab 筛选项(无精修筛选项)。
|
||||||
|
static let storeAdminStatusFilters: [OrderStatusFilter] = [
|
||||||
|
.init(id: 0, title: "全部"),
|
||||||
|
.init(id: 18, title: "已付定金"),
|
||||||
|
.init(id: 30, title: "已完成"),
|
||||||
|
.init(id: 50, title: "已退款")
|
||||||
|
]
|
||||||
|
|
||||||
|
/// 核销管理页状态 Tab 筛选项。
|
||||||
|
static let writeOffStatusFilters: [OrderStatusFilter] = [
|
||||||
|
.init(id: 0, title: "全部"),
|
||||||
|
.init(id: 18, title: "已支付"),
|
||||||
|
.init(id: 30, title: "已完成"),
|
||||||
|
.init(id: 1, title: "已核销"),
|
||||||
|
.init(id: 2, title: "未核销")
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 店铺订单类型常量,与 Android StoreOrderItem 约定一致。
|
||||||
|
enum StoreOrderType {
|
||||||
|
static let offlineProduct = 4
|
||||||
|
static let multiTravelShoot = 19
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 订单管理列表实体,承载订单卡片展示和筛选判断所需字段。
|
/// 订单管理列表实体,承载订单卡片展示和筛选判断所需字段。
|
||||||
@ -59,8 +82,9 @@ struct OrderEntity: Decodable, Identifiable, Equatable, Hashable {
|
|||||||
let depositPayTime: String
|
let depositPayTime: String
|
||||||
let createdAt: String
|
let createdAt: String
|
||||||
let photoTravel: OrderPhotoTravel?
|
let photoTravel: OrderPhotoTravel?
|
||||||
|
let multiTravel: OrderListMultiTravel?
|
||||||
let isNeedEdit: Bool
|
let isNeedEdit: Bool
|
||||||
let isRefined: Int
|
var isRefined: Int
|
||||||
let canBindReferral: Int
|
let canBindReferral: Int
|
||||||
let showOrderSource: Int
|
let showOrderSource: Int
|
||||||
let referralOrderNo: String
|
let referralOrderNo: String
|
||||||
@ -92,6 +116,7 @@ struct OrderEntity: Decodable, Identifiable, Equatable, Hashable {
|
|||||||
case depositPayTime = "deposit_pay_time"
|
case depositPayTime = "deposit_pay_time"
|
||||||
case createdAt = "created_at"
|
case createdAt = "created_at"
|
||||||
case photoTravel = "photo_travel"
|
case photoTravel = "photo_travel"
|
||||||
|
case multiTravel = "multi_travel"
|
||||||
case isNeedEdit
|
case isNeedEdit
|
||||||
case isRefined = "is_refined"
|
case isRefined = "is_refined"
|
||||||
case canBindReferral = "can_bind_referral"
|
case canBindReferral = "can_bind_referral"
|
||||||
@ -128,6 +153,7 @@ struct OrderEntity: Decodable, Identifiable, Equatable, Hashable {
|
|||||||
depositPayTime = try container.decodeLossyString(forKey: .depositPayTime)
|
depositPayTime = try container.decodeLossyString(forKey: .depositPayTime)
|
||||||
createdAt = try container.decodeLossyString(forKey: .createdAt)
|
createdAt = try container.decodeLossyString(forKey: .createdAt)
|
||||||
photoTravel = try container.decodeIfPresent(OrderPhotoTravel.self, forKey: .photoTravel)
|
photoTravel = try container.decodeIfPresent(OrderPhotoTravel.self, forKey: .photoTravel)
|
||||||
|
multiTravel = try container.decodeIfPresent(OrderListMultiTravel.self, forKey: .multiTravel)
|
||||||
isNeedEdit = try container.decodeLossyBool(forKey: .isNeedEdit) ?? true
|
isNeedEdit = try container.decodeLossyBool(forKey: .isNeedEdit) ?? true
|
||||||
isRefined = try container.decodeLossyInt(forKey: .isRefined) ?? 0
|
isRefined = try container.decodeLossyInt(forKey: .isRefined) ?? 0
|
||||||
canBindReferral = try container.decodeLossyInt(forKey: .canBindReferral) ?? 0
|
canBindReferral = try container.decodeLossyInt(forKey: .canBindReferral) ?? 0
|
||||||
@ -145,6 +171,10 @@ struct OrderPhotoTravel: Decodable, Equatable, Hashable {
|
|||||||
let checkInTime: String
|
let checkInTime: String
|
||||||
let retouchGiftPhotoNum: Int
|
let retouchGiftPhotoNum: Int
|
||||||
let retouchGiftVideoNum: Int
|
let retouchGiftVideoNum: Int
|
||||||
|
let canCancelOrder: Bool
|
||||||
|
let needCheckIn: Bool
|
||||||
|
let canGiftRetouch: Bool
|
||||||
|
let retouchGiftTime: String
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case orderPhotoNum = "order_photo_num"
|
case orderPhotoNum = "order_photo_num"
|
||||||
@ -152,6 +182,10 @@ struct OrderPhotoTravel: Decodable, Equatable, Hashable {
|
|||||||
case checkInTime = "check_in_time"
|
case checkInTime = "check_in_time"
|
||||||
case retouchGiftPhotoNum = "retouch_gift_photo_num"
|
case retouchGiftPhotoNum = "retouch_gift_photo_num"
|
||||||
case retouchGiftVideoNum = "retouch_gift_video_num"
|
case retouchGiftVideoNum = "retouch_gift_video_num"
|
||||||
|
case canCancelOrder = "can_cancel_order"
|
||||||
|
case needCheckIn = "need_check_in"
|
||||||
|
case canGiftRetouch = "can_gift_retouch"
|
||||||
|
case retouchGiftTime = "retouch_gift_time"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 宽松解码旅拍附加信息。
|
/// 宽松解码旅拍附加信息。
|
||||||
@ -162,6 +196,87 @@ struct OrderPhotoTravel: Decodable, Equatable, Hashable {
|
|||||||
checkInTime = try container.decodeLossyString(forKey: .checkInTime)
|
checkInTime = try container.decodeLossyString(forKey: .checkInTime)
|
||||||
retouchGiftPhotoNum = try container.decodeLossyInt(forKey: .retouchGiftPhotoNum) ?? 0
|
retouchGiftPhotoNum = try container.decodeLossyInt(forKey: .retouchGiftPhotoNum) ?? 0
|
||||||
retouchGiftVideoNum = try container.decodeLossyInt(forKey: .retouchGiftVideoNum) ?? 0
|
retouchGiftVideoNum = try container.decodeLossyInt(forKey: .retouchGiftVideoNum) ?? 0
|
||||||
|
canCancelOrder = try container.decodeLossyBool(forKey: .canCancelOrder) ?? false
|
||||||
|
needCheckIn = try container.decodeLossyBool(forKey: .needCheckIn) ?? false
|
||||||
|
canGiftRetouch = try container.decodeLossyBool(forKey: .canGiftRetouch) ?? false
|
||||||
|
retouchGiftTime = try container.decodeLossyString(forKey: .retouchGiftTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 订单列表多点位旅拍缩略图实体,仅用于列表卡片历史拍摄预览。
|
||||||
|
struct OrderListMultiTravel: Decodable, Equatable, Hashable {
|
||||||
|
let materialList: [OrderMediaFile]
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case materialList = "material_list"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 宽松解码列表缩略图字段。
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
materialList = try container.decodeIfPresent([OrderMediaFile].self, forKey: .materialList) ?? []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 店长订单 Tab 列表实体,对齐 Android StoreOrderItem。
|
||||||
|
struct StoreOrderListItem: Decodable, Identifiable, Equatable, Hashable {
|
||||||
|
var id: String { orderNumber }
|
||||||
|
|
||||||
|
let orderNumber: String
|
||||||
|
let orderType: Int
|
||||||
|
let orderTypeLabel: String
|
||||||
|
let createdAt: String
|
||||||
|
let orderStatus: Int
|
||||||
|
let orderStatusName: String
|
||||||
|
let actualPayAmount: String
|
||||||
|
let actualRefundAmount: String
|
||||||
|
let payTypeName: String
|
||||||
|
let payTime: String
|
||||||
|
let completeTime: String
|
||||||
|
let userId: Int
|
||||||
|
let phone: String
|
||||||
|
let projectId: Int
|
||||||
|
let projectName: String
|
||||||
|
let remark: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case orderNumber = "order_number"
|
||||||
|
case orderType = "order_type"
|
||||||
|
case orderTypeLabel = "order_type_label"
|
||||||
|
case createdAt = "created_at"
|
||||||
|
case orderStatus = "order_status"
|
||||||
|
case orderStatusName = "order_status_name"
|
||||||
|
case actualPayAmount = "actual_pay_amount"
|
||||||
|
case actualRefundAmount = "actual_refund_amount"
|
||||||
|
case payTypeName = "pay_type_name"
|
||||||
|
case payTime = "pay_time"
|
||||||
|
case completeTime = "complete_time"
|
||||||
|
case userId = "user_id"
|
||||||
|
case phone
|
||||||
|
case projectId = "project_id"
|
||||||
|
case projectName = "project_name"
|
||||||
|
case remark
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 宽松解码店长订单列表字段。
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
orderNumber = try container.decodeLossyString(forKey: .orderNumber)
|
||||||
|
orderType = try container.decodeLossyInt(forKey: .orderType) ?? 0
|
||||||
|
orderTypeLabel = try container.decodeLossyString(forKey: .orderTypeLabel)
|
||||||
|
createdAt = try container.decodeLossyString(forKey: .createdAt)
|
||||||
|
orderStatus = try container.decodeLossyInt(forKey: .orderStatus) ?? 0
|
||||||
|
orderStatusName = try container.decodeLossyString(forKey: .orderStatusName)
|
||||||
|
actualPayAmount = try container.decodeLossyString(forKey: .actualPayAmount)
|
||||||
|
actualRefundAmount = try container.decodeLossyString(forKey: .actualRefundAmount)
|
||||||
|
payTypeName = try container.decodeLossyString(forKey: .payTypeName)
|
||||||
|
payTime = try container.decodeLossyString(forKey: .payTime)
|
||||||
|
completeTime = try container.decodeLossyString(forKey: .completeTime)
|
||||||
|
userId = try container.decodeLossyInt(forKey: .userId) ?? 0
|
||||||
|
phone = try container.decodeLossyString(forKey: .phone)
|
||||||
|
projectId = try container.decodeLossyInt(forKey: .projectId) ?? 0
|
||||||
|
projectName = try container.decodeLossyString(forKey: .projectName)
|
||||||
|
remark = try container.decodeLossyString(forKey: .remark)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -283,6 +398,56 @@ enum OrderRefundMode: Int, CaseIterable, Identifiable, Hashable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 取消订单请求实体。
|
||||||
|
struct CancelOrderRequest: Encodable, Equatable {
|
||||||
|
let orderNumber: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case orderNumber = "order_number"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 赠送修图/视频请求实体。
|
||||||
|
struct SendGiftRequest: Encodable, Equatable {
|
||||||
|
let orderNumber: String
|
||||||
|
let photoNum: Int
|
||||||
|
let videoNum: Int
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case orderNumber = "order_number"
|
||||||
|
case photoNum = "photo_num"
|
||||||
|
case videoNum = "video_num "
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新精修选择请求实体。
|
||||||
|
struct UpdateRefinedRequest: Encodable, Equatable {
|
||||||
|
let orderNumber: String
|
||||||
|
let isRefined: Int
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case orderNumber = "order_number"
|
||||||
|
case isRefined = "is_refined"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 店长订单退款请求实体。
|
||||||
|
struct StoreOrderRefundRequest: Encodable, Equatable {
|
||||||
|
let storeId: Int
|
||||||
|
let orderNumber: String
|
||||||
|
let refundType: Int
|
||||||
|
let refundAmount: String
|
||||||
|
let refundReason: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case storeId = "store_id"
|
||||||
|
case orderNumber = "order_number"
|
||||||
|
case refundType = "refund_type"
|
||||||
|
case refundAmount = "refund_amount"
|
||||||
|
case refundReason = "refund_reason"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 普通订单退款请求实体。
|
/// 普通订单退款请求实体。
|
||||||
struct OrderRefundRequest: Encodable, Equatable {
|
struct OrderRefundRequest: Encodable, Equatable {
|
||||||
let orderNumber: String
|
let orderNumber: String
|
||||||
@ -305,6 +470,7 @@ enum OrdersRoute: Hashable {
|
|||||||
case historicalShooting(orderNumber: String)
|
case historicalShooting(orderNumber: String)
|
||||||
case multiTravelTaskUpload(orderNumber: String)
|
case multiTravelTaskUpload(orderNumber: String)
|
||||||
case orderTrailer(orderNumber: String, title: String)
|
case orderTrailer(orderNumber: String, title: String)
|
||||||
|
case writeOffList
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 订单详情接口响应实体,承载门店订单详情页的完整展示数据。
|
/// 订单详情接口响应实体,承载门店订单详情页的完整展示数据。
|
||||||
|
|||||||
@ -2,49 +2,89 @@
|
|||||||
|
|
||||||
## 模块职责
|
## 模块职责
|
||||||
|
|
||||||
Orders 模块负责登录后的订单 Tab。当前已覆盖订单管理、核销订单和押金订单相关入口,提供列表展示、筛选、分页、刷新、扫码核销、手动核销、押金核销、押金退款、历史拍摄、任务上传、尾片入口,以及 orderType 11/14 的订单来源(带客单绑定)闭环。
|
Orders 模块负责登录后的订单 Tab 与订单相关二级页面。当前已对齐 Android `zhiflyfollow` 订单列表能力:主订单列表(摄影师/非店长)、店长押金式订单 Tab、独立核销管理页,以及押金订单独立入口、历史拍摄、任务上传、尾片入口和 orderType 11/14 订单来源(带客单绑定)闭环。
|
||||||
|
|
||||||
订单 Tab 内的门店订单详情页与核销订单详情页暂未接入,列表卡片仅展示摘要信息。
|
订单 Tab 内不再展示「订单管理 / 核销订单」分段;核销管理通过首页菜单、TabBar 扫码或路由 push 进入独立页面。
|
||||||
|
|
||||||
当前仍不迁移独立视频播放器增强、尾片审核进度、上传后自动关联订单尾片等后端未体现的能力;这些能力后续按明确接口再补。
|
门店订单详情页与核销订单详情页暂未接入,列表卡片仅展示摘要信息与卡片内操作。
|
||||||
|
|
||||||
|
当前仍不迁移独立视频播放器增强、尾片审核进度、上传后自动关联订单尾片等后端未体现的能力;多点位核销的定位 + 打卡点 Sheet 若 iOS 尚无完整能力,列表内仅预留扫码入口。
|
||||||
|
|
||||||
|
## 页面结构
|
||||||
|
|
||||||
|
| 场景 | 视图 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 订单 Tab · 非店长 | `StoreOrderListView` | 对齐 Android `OrderListScreen` |
|
||||||
|
| 订单 Tab · 店长 | `StoreAdminOrderListView` | 对齐 Android `DepositOrderListScreen` |
|
||||||
|
| 核销管理 | `WriteOffListView` | 对齐 Android `WriteOffListScreen`,独立 push 页 |
|
||||||
|
| 押金订单入口 | `DepositOrderEntryView` | 首页 `deposit_order*` 仍保留独立入口 |
|
||||||
|
|
||||||
|
`OrdersRootView` 按 `AppRoleCode.storeAdmin` 分流店长 Tab 与主订单 Tab。
|
||||||
|
|
||||||
## 核心对象
|
## 核心对象
|
||||||
|
|
||||||
- `OrdersView`:订单 Tab 根视图,读取 `AccountContext`、`PermissionContext`、`AppRouter` 和 `OrdersAPI`。
|
- `StoreOrderListView`:主订单 Tab 根视图,组合 `OrderListHeaderView`、`OrderListCardView` 与 `OrdersViewModel`。
|
||||||
- `OrdersViewModel`:管理订单子入口、筛选条件、分页状态、加载状态和手动核销状态。
|
- `StoreAdminOrderListView`:店长订单 Tab,组合共享头部与店长卡片,使用 `StoreAdminOrderListViewModel`。
|
||||||
- `DepositOrderListViewModel`:管理押金订单分页、核销、退款和操作后刷新。
|
- `WriteOffListView`:核销管理页,使用 `WriteOffListViewModel`,含横向状态 pill、日期筛选与扫码/手动核销。
|
||||||
- `DepositOrderDetailViewModel`:管理押金订单详情加载,缺少门店时不发起请求。
|
- `OrderListHeaderView`:白底状态/日期筛选头部;手机号搜索使用系统 `.searchable`。
|
||||||
- `DepositOrderShootingInfoViewModel`:管理押金订单单个打卡点的底片、成片和评价。
|
- `OrderListCardView`:主订单卡片,对齐 Android `OrderView` 展示顺序与操作区。
|
||||||
- `OrderRefundViewModel`:管理普通订单退款金额校验和提交保护(详情页接入前保留逻辑层)。
|
- `OrderListComponents`:状态 Chip、信息行、操作按钮、精修单选等共享组件。
|
||||||
- `HistoricalShootingInfoViewModel`:管理多点位订单历史拍摄媒体展示。
|
- `OrderDialogViews`:退款、赠送修图/视频、店长退款、核销确认弹窗。
|
||||||
- `MultiTravelTaskUploadViewModel`:管理多点旅拍任务上传的订单号、已核销打卡点、云盘附件、本地附件、OSS 上传和提交。
|
- `OrdersViewModel`:主订单列表分页、筛选、卡片操作(退款/核销/精修/取消/赠送)与订单来源。
|
||||||
- `OrdersAPI`:封装订单列表、核销订单列表和订单核销接口。
|
- `StoreAdminOrderListViewModel`:店长订单列表与 `storeOrderList` / `storeOrderRefund`。
|
||||||
- `OrdersEntry`:表示订单 Tab 内部入口,包含订单管理和核销订单。
|
- `WriteOffListViewModel`:核销列表筛选、分页与核销提交。
|
||||||
- `OrdersRoute`:表示订单模块二级页面路由,包含押金详情、历史拍摄、任务上传和尾片入口。
|
- `DepositOrderListViewModel`:押金订单分页、核销、退款(独立押金入口)。
|
||||||
- `OrderNumberParser`:负责从扫码内容中解析订单号,支持 URL query、键值文本和纯订单号。
|
- `OrderRefundViewModel`:普通订单退款金额校验与提交。
|
||||||
|
- `OrdersAPI`:订单列表、核销列表、精修更新、取消、赠送、店长列表/退款等接口。
|
||||||
|
- `OrdersEntry`:订单 Tab 内部入口枚举,保留 `verificationOrders` 供测试/推送兼容。
|
||||||
|
- `OrdersRoute`:二级路由,含 `writeOffList`、押金详情、历史拍摄、任务上传、尾片入口。
|
||||||
|
- `OrderNumberParser`:从扫码内容解析订单号。
|
||||||
|
|
||||||
## 数据流程
|
## 数据流程
|
||||||
|
|
||||||
订单页面从 `AccountContext.currentScenic` 读取当前景区 ID,从 `PermissionContext.currentAppRole` 读取当前角色。景区管理员角色(`AppRoleCode.scenicAdmin`)使用景区管理员订单接口,其他角色使用摄影师订单接口。
|
### 主订单列表
|
||||||
|
|
||||||
订单管理支持状态筛选、手机号搜索、开始/结束日期筛选。筛选变更后从第一页重新加载,滚动到列表底部时加载下一页。列表卡片展示订单摘要,暂不支持点击进入详情页。
|
从 `AccountContext.currentScenic` 读取景区 ID,从 `PermissionContext.currentAppRole` 读取角色。景区管理员(`scenicAdmin`)使用景区管理员订单接口且卡片只读(隐藏退款/核销/拨号/精修/赠送/取消)。
|
||||||
|
|
||||||
核销订单支持扫码和手动输入订单号。扫码使用 AVFoundation,不缓存扫码结果;扫码成功后先用 `OrderNumberParser` 提取订单号,命中当前核销列表时滚动定位并高亮卡片,未命中时填入订单号并允许继续核销。核销前统一弹确认框,确认后调用核销接口。核销成功后重新拉取第一页核销订单,并重置分页状态;核销失败只清理提交状态,不刷新列表。
|
支持状态筛选(含精修筛选项)、手机号搜索、日期范围 Sheet 筛选。筛选变更后从第一页重新加载,滚动到底加载下一页。
|
||||||
|
|
||||||
押金订单入口由首页 `deposit_order_detail`、`deposit_order`、`deposit_order_shooting_info` 进入。没有订单上下文时先展示押金订单页,用户可以手输订单号进入详情,也可以从列表进入详情。押金详情复用门店订单详情接口,按当前门店 ID 和订单号加载;拍摄点列表进入拍摄信息页,展示评价、底片和成片。
|
卡片展示订单号、状态、创建时间、类型、订单来源、操作行、上传任务条(orderType 19)、精修单选、信息行、条件行、退款信息、备注、历史拍摄缩略图与取消订单按钮。操作受 `hideOrderWriteActions` 控制。
|
||||||
|
|
||||||
普通订单退款逻辑已保留在 `OrderRefundViewModel`,待详情页接入后再开放入口。
|
### 店长订单 Tab
|
||||||
|
|
||||||
历史拍摄按订单号请求 `/api/yf-handset-app/photog/order/multi-travel/shoot-history`,仅展示项目、拍摄点和已有媒体,不做上传、下载、删除或编辑。
|
从 `AccountContext.currentStore` 读取门店 ID,调用 `GET /api/app/store/order/list`。筛选为全部/已支付/已完成/已退款。卡片展示项目、金额、时间、UID、手机号与退款/部分退款;线下产品订单额外展示核销按钮。仅 `orderType == 19` 可进详情。
|
||||||
|
|
||||||
任务上传仅对 `orderType == 19` 的多点旅拍订单展示。页面先按订单号请求 `/api/yf-handset-app/photog/order/multi-travel/verified-scenic-spot-list` 获取已核销打卡点,再支持选择云盘素材和本地图片/视频;本地素材先通过 `OSSUploadService.uploadTaskFile` 上传,成功后和云盘文件一起提交到 `/api/yf-handset-app/photog/order/multi-travel/upload-material`。
|
### 核销管理
|
||||||
|
|
||||||
视频预告和尾片上传按旧工程现状统一进入 `OrderTrailerView`,该页只做订单尾片流程引导,并打开已迁移的 `AlbumTrailerEntryView` 完成相册预览上传,不新增独立订单尾片接口。
|
独立页面,调用 `writeOffList` 并支持 `order_status`、`order_verify_status`、`start_time`、`end_time`、`user_phone` 查询参数。横向 pill:全部/已支付/已完成/已核销/未核销。
|
||||||
|
|
||||||
订单来源(带客单绑定)仅对 `orderType == 11`(摄影师跟拍)或 `14`(线下扫码)展示,景区管理员只读不可操作。订单卡片嵌入 `OrderSourcePicker` Sheet:选择合作获客员 → 加载其可绑定带客单 → 确认绑定。全页流程使用 `ReferralOrderSelectView`。绑定接口为 `POST .../sale-user/bind-referral-order`;普通订单退款成功后若存在 `referralOrder.id`,异步调用 `POST .../sale-user/referral-orders/{id}/status`(status=3)。获客员与带客单数据由 `CooperationOrderAPI` 提供。
|
扫码使用 `OrderCodeScannerView`,命中列表时滚动高亮;核销前弹确认框,成功后刷新第一页。TabBar 全局扫码通过 `pendingOrderScanCode` 交给核销页消费。
|
||||||
|
|
||||||
|
### 押金订单(独立入口)
|
||||||
|
|
||||||
|
首页 `deposit_order_detail`、`deposit_order`、`deposit_order_shooting_info` 进入 `DepositOrderEntryView`,与 Tab 内店长列表 UI 分离但共用部分 API。
|
||||||
|
|
||||||
|
### 订单来源(带客单绑定)
|
||||||
|
|
||||||
|
仅对 `orderType == 11` 或 `14` 展示,景区管理员只读。绑定接口 `POST .../sale-user/bind-referral-order`;退款成功后若存在带客单则异步更新状态。
|
||||||
|
|
||||||
|
### 历史拍摄与任务上传
|
||||||
|
|
||||||
|
历史拍摄:`GET .../photog/order/multi-travel/shoot-history`。任务上传:仅 orderType 19,先拉已核销打卡点再上传素材。
|
||||||
|
|
||||||
## 路由边界
|
## 路由边界
|
||||||
|
|
||||||
首页 `photographer_orders` 和 `/scenic-order-manage` 会进入订单管理,`verification_order` 会进入核销订单,`deposit_order_detail`、`deposit_order`、`deposit_order_shooting_info` 会进入押金订单页。订单详情、核销订单详情、押金详情、押金拍摄信息、历史拍摄、任务上传和订单尾片已接入 `AppRoute.orders` 真实路由,push 后继续隐藏 TabBar。
|
| 入口 | 目标 |
|
||||||
|
|------|------|
|
||||||
|
| `photographer_orders`、`/scenic-order-manage` | 订单 Tab · 主列表或店长列表 |
|
||||||
|
| `verification_order` | push `OrdersRoute.writeOffList`(核销管理) |
|
||||||
|
| `deposit_order*` | push 押金订单页 |
|
||||||
|
| TabBar 扫码 | 核销管理页消费扫码结果 |
|
||||||
|
|
||||||
未明确接口的订单后续能力继续进入占位页,避免入口崩溃。
|
`AppRouter.routeToOrderVerification` 切换订单 Tab 并 push 核销页,不再设置 `selectedOrdersEntry`。
|
||||||
|
|
||||||
|
未明确接口的订单能力继续占位,避免入口崩溃。
|
||||||
|
|
||||||
|
## 设计常量
|
||||||
|
|
||||||
|
订单列表专用色值与间距定义在 `AppDesign`(页面背景 `#F5F5F5`、输入背景 `#F4F4F4`、状态 Chip 色等与 Android 对齐)。
|
||||||
|
|||||||
@ -36,8 +36,21 @@ final class OrdersViewModel: ObservableObject {
|
|||||||
@Published var orderSourceSelections: [String: OrderSourceSelection] = [:]
|
@Published var orderSourceSelections: [String: OrderSourceSelection] = [:]
|
||||||
@Published var loadingOrderSourceLeadsFor: String?
|
@Published var loadingOrderSourceLeadsFor: String?
|
||||||
|
|
||||||
|
@Published var refundingOrder: OrderEntity?
|
||||||
|
@Published var sendGiftContext: SendGiftContext?
|
||||||
|
|
||||||
private var loadingOrderSourcePeople = false
|
private var loadingOrderSourcePeople = false
|
||||||
|
|
||||||
|
/// 赠送修图/视频弹窗上下文。
|
||||||
|
struct SendGiftContext: Equatable, Identifiable {
|
||||||
|
var id: String { orderNumber }
|
||||||
|
let orderNumber: String
|
||||||
|
let nowPhotoNum: Int
|
||||||
|
let nowVideoNum: Int
|
||||||
|
var sendPhotoNum: Int
|
||||||
|
var sendVideoNum: Int
|
||||||
|
}
|
||||||
|
|
||||||
static let orderTypePhotographerFollow = 11
|
static let orderTypePhotographerFollow = 11
|
||||||
static let orderTypePhotographerFollowOfflineScan = 14
|
static let orderTypePhotographerFollowOfflineScan = 14
|
||||||
|
|
||||||
@ -125,7 +138,17 @@ final class OrdersViewModel: ObservableObject {
|
|||||||
if showLoading { loading = true }
|
if showLoading { loading = true }
|
||||||
defer { if showLoading { loading = false } }
|
defer { if showLoading { loading = false } }
|
||||||
|
|
||||||
let result = try await api.writeOffList(scenicId: scenicId, storeId: storeId, page: 1, pageSize: pageSize)
|
let result = try await api.writeOffList(
|
||||||
|
scenicId: scenicId,
|
||||||
|
storeId: storeId,
|
||||||
|
page: 1,
|
||||||
|
pageSize: pageSize,
|
||||||
|
orderStatus: nil,
|
||||||
|
orderVerifyStatus: nil,
|
||||||
|
startTime: nil,
|
||||||
|
endTime: nil,
|
||||||
|
userPhone: nil
|
||||||
|
)
|
||||||
writeOffOrders = result.list
|
writeOffOrders = result.list
|
||||||
writeOffTotal = result.total
|
writeOffTotal = result.total
|
||||||
writeOffPage = 1
|
writeOffPage = 1
|
||||||
@ -139,7 +162,17 @@ final class OrdersViewModel: ObservableObject {
|
|||||||
defer { loadingMore = false }
|
defer { loadingMore = false }
|
||||||
|
|
||||||
let nextPage = writeOffPage + 1
|
let nextPage = writeOffPage + 1
|
||||||
let result = try await api.writeOffList(scenicId: scenicId, storeId: storeId, page: nextPage, pageSize: pageSize)
|
let result = try await api.writeOffList(
|
||||||
|
scenicId: scenicId,
|
||||||
|
storeId: storeId,
|
||||||
|
page: nextPage,
|
||||||
|
pageSize: pageSize,
|
||||||
|
orderStatus: nil,
|
||||||
|
orderVerifyStatus: nil,
|
||||||
|
startTime: nil,
|
||||||
|
endTime: nil,
|
||||||
|
userPhone: nil
|
||||||
|
)
|
||||||
writeOffOrders.append(contentsOf: result.list)
|
writeOffOrders.append(contentsOf: result.list)
|
||||||
writeOffTotal = result.total
|
writeOffTotal = result.total
|
||||||
writeOffPage = nextPage
|
writeOffPage = nextPage
|
||||||
@ -158,13 +191,82 @@ final class OrdersViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try await api.writeOff(orderNumber: orderNumber)
|
try await api.writeOff(orderNumber: orderNumber)
|
||||||
let refreshed = try await api.writeOffList(scenicId: scenicId, storeId: storeId, page: 1, pageSize: pageSize)
|
let refreshed = try await api.writeOffList(
|
||||||
|
scenicId: scenicId,
|
||||||
|
storeId: storeId,
|
||||||
|
page: 1,
|
||||||
|
pageSize: pageSize,
|
||||||
|
orderStatus: nil,
|
||||||
|
orderVerifyStatus: nil,
|
||||||
|
startTime: nil,
|
||||||
|
endTime: nil,
|
||||||
|
userPhone: nil
|
||||||
|
)
|
||||||
writeOffOrders = refreshed.list
|
writeOffOrders = refreshed.list
|
||||||
writeOffTotal = refreshed.total
|
writeOffTotal = refreshed.total
|
||||||
writeOffPage = 1
|
writeOffPage = 1
|
||||||
writeOffHasMore = writeOffOrders.count < refreshed.total && !refreshed.list.isEmpty
|
writeOffHasMore = writeOffOrders.count < refreshed.total && !refreshed.list.isEmpty
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 判断订单卡片是否展示退款按钮。
|
||||||
|
func shouldShowRefundButton(order: OrderEntity, appRole: AppRoleCode?) -> Bool {
|
||||||
|
guard !Self.isScenicAdminReadOnly(appRole: appRole) else { return false }
|
||||||
|
return (order.orderStatus == 18 || order.orderStatus == 30) && order.orderType != 19
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 判断订单卡片是否展示核销按钮。
|
||||||
|
func shouldShowVerifyButton(order: OrderEntity, appRole: AppRoleCode?) -> Bool {
|
||||||
|
guard !Self.isScenicAdminReadOnly(appRole: appRole) else { return false }
|
||||||
|
if order.orderType == 19 { return true }
|
||||||
|
guard let photoTravel = order.photoTravel else { return false }
|
||||||
|
return photoTravel.needCheckIn && order.orderStatus != 30
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新订单精修选择并同步本地列表状态。
|
||||||
|
func updateRefinedChoice(api: OrderServing, orderNumber: String, isRefined: Int) async throws {
|
||||||
|
try await api.updateRefinedChoice(orderNumber: orderNumber, isRefined: isRefined)
|
||||||
|
if let index = storeOrders.firstIndex(where: { $0.orderNumber == orderNumber }) {
|
||||||
|
storeOrders[index].isRefined = isRefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取消订单并刷新列表。
|
||||||
|
func cancelOrder(api: OrderServing, scenicId: Int?, appRole: AppRoleCode?, orderNumber: String) async throws {
|
||||||
|
try await api.cancelOrder(orderNumber: orderNumber)
|
||||||
|
try await reloadStoreOrders(api: api, scenicId: scenicId, appRole: appRole, showLoading: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 打开赠送修图/视频弹窗。
|
||||||
|
func prepareSendGift(for order: OrderEntity) {
|
||||||
|
let photoTravel = order.photoTravel
|
||||||
|
sendGiftContext = SendGiftContext(
|
||||||
|
orderNumber: order.orderNumber,
|
||||||
|
nowPhotoNum: photoTravel?.orderPhotoNum ?? 0,
|
||||||
|
nowVideoNum: photoTravel?.orderVideoNum ?? 0,
|
||||||
|
sendPhotoNum: photoTravel?.retouchGiftPhotoNum ?? 0,
|
||||||
|
sendVideoNum: photoTravel?.retouchGiftVideoNum ?? 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 提交赠送修图/视频并刷新列表。
|
||||||
|
func submitSendGift(
|
||||||
|
api: OrderServing,
|
||||||
|
scenicId: Int?,
|
||||||
|
appRole: AppRoleCode?,
|
||||||
|
photoNum: Int,
|
||||||
|
videoNum: Int
|
||||||
|
) async throws {
|
||||||
|
guard let context = sendGiftContext else { return }
|
||||||
|
try await api.sendGift(orderNumber: context.orderNumber, photoNum: photoNum, videoNum: videoNum)
|
||||||
|
sendGiftContext = nil
|
||||||
|
try await reloadStoreOrders(api: api, scenicId: scenicId, appRole: appRole, showLoading: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前筛选标题。
|
||||||
|
var currentFilterTitle: String {
|
||||||
|
OrderFilters.statusFilters.first(where: { $0.id == selectedStatus })?.title ?? "全部"
|
||||||
|
}
|
||||||
|
|
||||||
/// 判断订单卡片是否展示订单来源入口。
|
/// 判断订单卡片是否展示订单来源入口。
|
||||||
func shouldShowOrderSource(order: OrderEntity, appRole: AppRoleCode?) -> Bool {
|
func shouldShowOrderSource(order: OrderEntity, appRole: AppRoleCode?) -> Bool {
|
||||||
guard !Self.isScenicAdminReadOnly(appRole: appRole) else { return false }
|
guard !Self.isScenicAdminReadOnly(appRole: appRole) else { return false }
|
||||||
|
|||||||
@ -0,0 +1,112 @@
|
|||||||
|
//
|
||||||
|
// StoreAdminOrderListViewModel.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
// Created by Codex on 2026/6/29.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
/// 店长订单 Tab ViewModel,对齐 Android DepositOrderListViewModel。
|
||||||
|
final class StoreAdminOrderListViewModel: ObservableObject {
|
||||||
|
@Published var searchPhone = ""
|
||||||
|
@Published var selectedStatus = 0
|
||||||
|
@Published var filterStartDate: Date?
|
||||||
|
@Published var filterEndDate: Date?
|
||||||
|
@Published private(set) var orders: [StoreOrderListItem] = []
|
||||||
|
@Published private(set) var loading = false
|
||||||
|
@Published private(set) var loadingMore = false
|
||||||
|
@Published private(set) var total = 0
|
||||||
|
@Published private(set) var page = 1
|
||||||
|
@Published private(set) var hasMore = false
|
||||||
|
|
||||||
|
private let pageSize = 20
|
||||||
|
|
||||||
|
/// 返回去除首尾空白后的手机号搜索值。
|
||||||
|
var normalizedPhone: String? {
|
||||||
|
let text = searchPhone.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return text.isEmpty ? nil : text
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前筛选标题。
|
||||||
|
var currentFilterTitle: String {
|
||||||
|
OrderFilters.storeAdminStatusFilters.first(where: { $0.id == selectedStatus })?.title ?? "全部"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 刷新店长订单列表。
|
||||||
|
func reload(api: OrderServing, storeId: Int?, showLoading: Bool = true) async throws {
|
||||||
|
guard let storeId, storeId > 0 else {
|
||||||
|
orders = []
|
||||||
|
total = 0
|
||||||
|
page = 1
|
||||||
|
hasMore = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if showLoading { loading = true }
|
||||||
|
defer { if showLoading { loading = false } }
|
||||||
|
|
||||||
|
let result = try await api.storeOrderList(
|
||||||
|
storeId: storeId,
|
||||||
|
page: 1,
|
||||||
|
pageSize: pageSize,
|
||||||
|
userPhone: normalizedPhone,
|
||||||
|
orderStatus: orderStatusQuery,
|
||||||
|
orderType: nil,
|
||||||
|
startTime: OrderDateRangeFormatter.queryValue(from: filterStartDate),
|
||||||
|
endTime: OrderDateRangeFormatter.queryValue(from: filterEndDate)
|
||||||
|
)
|
||||||
|
orders = result.list
|
||||||
|
total = result.total
|
||||||
|
page = 1
|
||||||
|
hasMore = orders.count < result.total && !result.list.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 加载下一页。
|
||||||
|
func loadMore(api: OrderServing, storeId: Int?) async throws {
|
||||||
|
guard !loadingMore, hasMore, let storeId, storeId > 0 else { return }
|
||||||
|
loadingMore = true
|
||||||
|
defer { loadingMore = false }
|
||||||
|
|
||||||
|
let nextPage = page + 1
|
||||||
|
let result = try await api.storeOrderList(
|
||||||
|
storeId: storeId,
|
||||||
|
page: nextPage,
|
||||||
|
pageSize: pageSize,
|
||||||
|
userPhone: normalizedPhone,
|
||||||
|
orderStatus: orderStatusQuery,
|
||||||
|
orderType: nil,
|
||||||
|
startTime: OrderDateRangeFormatter.queryValue(from: filterStartDate),
|
||||||
|
endTime: OrderDateRangeFormatter.queryValue(from: filterEndDate)
|
||||||
|
)
|
||||||
|
orders.append(contentsOf: result.list)
|
||||||
|
total = result.total
|
||||||
|
page = nextPage
|
||||||
|
hasMore = orders.count < result.total && !result.list.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 提交店长订单退款。
|
||||||
|
func refund(
|
||||||
|
api: OrderServing,
|
||||||
|
storeId: Int?,
|
||||||
|
orderNumber: String,
|
||||||
|
mode: OrderRefundMode,
|
||||||
|
amount: String,
|
||||||
|
reason: String
|
||||||
|
) async throws {
|
||||||
|
guard let storeId, storeId > 0 else { return }
|
||||||
|
try await api.storeOrderRefund(
|
||||||
|
storeId: storeId,
|
||||||
|
orderNumber: orderNumber,
|
||||||
|
refundType: mode,
|
||||||
|
refundAmount: amount,
|
||||||
|
refundReason: reason
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var orderStatusQuery: Int? {
|
||||||
|
selectedStatus > 0 ? selectedStatus : nil
|
||||||
|
}
|
||||||
|
}
|
||||||
141
suixinkan/Features/Orders/ViewModels/WriteOffListViewModel.swift
Normal file
141
suixinkan/Features/Orders/ViewModels/WriteOffListViewModel.swift
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
//
|
||||||
|
// WriteOffListViewModel.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
// Created by Codex on 2026/6/29.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
/// 核销管理页 ViewModel,对齐 Android WriteOffListViewModel。
|
||||||
|
final class WriteOffListViewModel: ObservableObject {
|
||||||
|
@Published var searchPhone = ""
|
||||||
|
@Published var selectedStatus = 0
|
||||||
|
@Published var filterStartDate: Date?
|
||||||
|
@Published var filterEndDate: Date?
|
||||||
|
@Published private(set) var orders: [WriteOffOrderItem] = []
|
||||||
|
@Published private(set) var loading = false
|
||||||
|
@Published private(set) var loadingMore = false
|
||||||
|
@Published private(set) var isVerifying = false
|
||||||
|
@Published private(set) var total = 0
|
||||||
|
@Published private(set) var page = 1
|
||||||
|
@Published private(set) var hasMore = false
|
||||||
|
|
||||||
|
private let pageSize = 20
|
||||||
|
|
||||||
|
/// 返回去除首尾空白后的手机号搜索值。
|
||||||
|
var normalizedPhone: String? {
|
||||||
|
let text = searchPhone.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return text.isEmpty ? nil : text
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 刷新核销列表。
|
||||||
|
func reload(api: OrderServing, scenicId: Int?, storeId: Int? = nil, showLoading: Bool = true) async throws {
|
||||||
|
guard let scenicId else {
|
||||||
|
orders = []
|
||||||
|
total = 0
|
||||||
|
page = 1
|
||||||
|
hasMore = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if showLoading { loading = true }
|
||||||
|
defer { if showLoading { loading = false } }
|
||||||
|
|
||||||
|
let result = try await api.writeOffList(
|
||||||
|
scenicId: scenicId,
|
||||||
|
storeId: storeId,
|
||||||
|
page: 1,
|
||||||
|
pageSize: pageSize,
|
||||||
|
orderStatus: orderStatusQuery,
|
||||||
|
orderVerifyStatus: verifyStatusQuery,
|
||||||
|
startTime: OrderDateRangeFormatter.queryValue(from: filterStartDate),
|
||||||
|
endTime: OrderDateRangeFormatter.queryValue(from: filterEndDate),
|
||||||
|
userPhone: normalizedPhone
|
||||||
|
)
|
||||||
|
orders = result.list
|
||||||
|
total = result.total
|
||||||
|
page = 1
|
||||||
|
hasMore = orders.count < result.total && !result.list.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 加载下一页。
|
||||||
|
func loadMore(api: OrderServing, scenicId: Int?, storeId: Int? = nil) async throws {
|
||||||
|
guard !loadingMore, hasMore, let scenicId else { return }
|
||||||
|
loadingMore = true
|
||||||
|
defer { loadingMore = false }
|
||||||
|
|
||||||
|
let nextPage = page + 1
|
||||||
|
let result = try await api.writeOffList(
|
||||||
|
scenicId: scenicId,
|
||||||
|
storeId: storeId,
|
||||||
|
page: nextPage,
|
||||||
|
pageSize: pageSize,
|
||||||
|
orderStatus: orderStatusQuery,
|
||||||
|
orderVerifyStatus: verifyStatusQuery,
|
||||||
|
startTime: OrderDateRangeFormatter.queryValue(from: filterStartDate),
|
||||||
|
endTime: OrderDateRangeFormatter.queryValue(from: filterEndDate),
|
||||||
|
userPhone: normalizedPhone
|
||||||
|
)
|
||||||
|
orders.append(contentsOf: result.list)
|
||||||
|
total = result.total
|
||||||
|
page = nextPage
|
||||||
|
hasMore = orders.count < result.total && !result.list.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 核销订单并刷新列表。
|
||||||
|
func verify(api: OrderServing, scenicId: Int, storeId: Int? = nil, orderNumber: String) async throws {
|
||||||
|
guard !isVerifying else { return }
|
||||||
|
isVerifying = true
|
||||||
|
defer { isVerifying = false }
|
||||||
|
|
||||||
|
try await api.writeOff(orderNumber: orderNumber)
|
||||||
|
try await reload(api: api, scenicId: scenicId, storeId: storeId, showLoading: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解析扫码结果并返回当前列表中的匹配订单。
|
||||||
|
func matchedWriteOffOrder(for rawCode: String) -> (orderNumber: String, matched: WriteOffOrderItem?)? {
|
||||||
|
guard let orderNumber = OrderNumberParser.parse(rawCode) else { return nil }
|
||||||
|
let matched = orders.first {
|
||||||
|
$0.orderNumber.caseInsensitiveCompare(orderNumber) == .orderedSame
|
||||||
|
}
|
||||||
|
return (matched?.orderNumber ?? orderNumber, matched)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 根据订单号查找当前列表中的订单。
|
||||||
|
func writeOffOrder(matching orderNumber: String) -> WriteOffOrderItem? {
|
||||||
|
orders.first { $0.orderNumber.caseInsensitiveCompare(orderNumber) == .orderedSame }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 判断订单是否仍可核销。
|
||||||
|
func canVerify(_ item: WriteOffOrderItem) -> Bool {
|
||||||
|
let status = displayVerificationStatus(for: item)
|
||||||
|
return !status.contains("已核销") && !status.contains("已退款") && !status.contains("已完成")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 返回核销状态展示文案。
|
||||||
|
func displayVerificationStatus(for item: WriteOffOrderItem) -> String {
|
||||||
|
let text = item.orderVerificationStatus.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return text.isEmpty ? "待核销" : text
|
||||||
|
}
|
||||||
|
|
||||||
|
private var orderStatusQuery: Int? {
|
||||||
|
switch selectedStatus {
|
||||||
|
case 18, 30:
|
||||||
|
return selectedStatus
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var verifyStatusQuery: Int? {
|
||||||
|
switch selectedStatus {
|
||||||
|
case 1, 2:
|
||||||
|
return selectedStatus
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
101
suixinkan/Features/Orders/Views/DateRangePickerSheet.swift
Normal file
101
suixinkan/Features/Orders/Views/DateRangePickerSheet.swift
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
//
|
||||||
|
// DateRangePickerSheet.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
// Created by Codex on 2026/6/29.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// 日期范围选择 Sheet,对齐 Android DateRangePickerDialog。
|
||||||
|
struct DateRangePickerSheet: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
let initialStartDate: Date?
|
||||||
|
let initialEndDate: Date?
|
||||||
|
let onConfirm: (Date?, Date?) -> Void
|
||||||
|
|
||||||
|
@State private var draftStartDate = Date()
|
||||||
|
@State private var draftEndDate = Date()
|
||||||
|
@State private var hasStartDate = false
|
||||||
|
@State private var hasEndDate = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Toggle("设置开始日期", isOn: $hasStartDate)
|
||||||
|
if hasStartDate {
|
||||||
|
DatePicker("开始日期", selection: $draftStartDate, displayedComponents: .date)
|
||||||
|
}
|
||||||
|
Toggle("设置结束日期", isOn: $hasEndDate)
|
||||||
|
if hasEndDate {
|
||||||
|
DatePicker("结束日期", selection: $draftEndDate, displayedComponents: .date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("时间筛选")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("清空") {
|
||||||
|
onConfirm(nil, nil)
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("确定") {
|
||||||
|
let start = hasStartDate ? draftStartDate : nil
|
||||||
|
let end = hasEndDate ? draftEndDate : nil
|
||||||
|
if let start, let end {
|
||||||
|
onConfirm(min(start, end), max(start, end))
|
||||||
|
} else {
|
||||||
|
onConfirm(start, end)
|
||||||
|
}
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
if let initialStartDate {
|
||||||
|
draftStartDate = initialStartDate
|
||||||
|
hasStartDate = true
|
||||||
|
}
|
||||||
|
if let initialEndDate {
|
||||||
|
draftEndDate = initialEndDate
|
||||||
|
hasEndDate = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 订单列表日期范围展示文案工具。
|
||||||
|
enum OrderDateRangeFormatter {
|
||||||
|
private static let formatter: DateFormatter = {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
formatter.dateFormat = "yyyy-MM-dd"
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
|
|
||||||
|
/// 格式化日期范围展示文案。
|
||||||
|
static func displayText(start: Date?, end: Date?, placeholder: String = "时间筛选") -> String {
|
||||||
|
let startText = start.map { formatter.string(from: $0) } ?? ""
|
||||||
|
let endText = end.map { formatter.string(from: $0) } ?? ""
|
||||||
|
switch (startText.isEmpty, endText.isEmpty) {
|
||||||
|
case (true, true):
|
||||||
|
return placeholder
|
||||||
|
case (false, false):
|
||||||
|
return "\(startText) - \(endText)"
|
||||||
|
case (false, true):
|
||||||
|
return "\(startText) - "
|
||||||
|
case (true, false):
|
||||||
|
return " - \(endText)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将日期格式化为 API 查询参数。
|
||||||
|
static func queryValue(from date: Date?) -> String? {
|
||||||
|
guard let date else { return nil }
|
||||||
|
return formatter.string(from: date)
|
||||||
|
}
|
||||||
|
}
|
||||||
207
suixinkan/Features/Orders/Views/OrderDialogViews.swift
Normal file
207
suixinkan/Features/Orders/Views/OrderDialogViews.swift
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
//
|
||||||
|
// OrderDialogViews.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
// Created by Codex on 2026/6/29.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// 普通订单退款弹窗,对齐 Android OrderRefundDialog。
|
||||||
|
struct OrderRefundDialogView: View {
|
||||||
|
@ObservedObject var viewModel: OrderRefundViewModel
|
||||||
|
let order: OrderEntity
|
||||||
|
let onDismiss: () -> Void
|
||||||
|
let onSuccess: () -> Void
|
||||||
|
|
||||||
|
@Environment(\.ordersAPI) private var ordersAPI
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section("订单号") {
|
||||||
|
Text(order.orderNumber)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("退款方式") {
|
||||||
|
Picker("退款方式", selection: $viewModel.mode) {
|
||||||
|
ForEach(OrderRefundMode.allCases) { mode in
|
||||||
|
Text(mode.title).tag(mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
|
||||||
|
if viewModel.mode == .partial {
|
||||||
|
TextField("退款金额", text: $viewModel.amount)
|
||||||
|
.keyboardType(.decimalPad)
|
||||||
|
} else {
|
||||||
|
Text("可退金额:¥\(viewModel.availableAmountText(for: order))")
|
||||||
|
.foregroundStyle(AppDesign.textSecondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("退款原因") {
|
||||||
|
TextField("请输入退款原因", text: $viewModel.reason, axis: .vertical)
|
||||||
|
.lineLimit(3...6)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let errorMessage = viewModel.errorMessage {
|
||||||
|
Section {
|
||||||
|
Text(errorMessage)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("订单退款")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("取消", action: onDismiss)
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button(viewModel.submitting ? "提交中" : "确认") {
|
||||||
|
Task {
|
||||||
|
let success = await viewModel.submit(api: ordersAPI, item: order)
|
||||||
|
if success {
|
||||||
|
onSuccess()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(viewModel.submitting)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 赠送修图/视频弹窗,对齐 Android SendGiftDialog。
|
||||||
|
struct SendGiftDialogView: View {
|
||||||
|
@State private var photoNum: Int
|
||||||
|
@State private var videoNum: Int
|
||||||
|
let context: OrdersViewModel.SendGiftContext
|
||||||
|
let onDismiss: () -> Void
|
||||||
|
let onConfirm: (Int, Int) -> Void
|
||||||
|
|
||||||
|
init(context: OrdersViewModel.SendGiftContext, onDismiss: @escaping () -> Void, onConfirm: @escaping (Int, Int) -> Void) {
|
||||||
|
self.context = context
|
||||||
|
self.onDismiss = onDismiss
|
||||||
|
self.onConfirm = onConfirm
|
||||||
|
_photoNum = State(initialValue: context.sendPhotoNum)
|
||||||
|
_videoNum = State(initialValue: context.sendVideoNum)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
stepperRow(
|
||||||
|
title: "修图张数(已有\(context.nowPhotoNum)张,最多赠20张)",
|
||||||
|
value: $photoNum,
|
||||||
|
range: 0...20
|
||||||
|
)
|
||||||
|
stepperRow(
|
||||||
|
title: "视频剪辑(已有\(context.nowVideoNum)条,最多赠5条)",
|
||||||
|
value: $videoNum,
|
||||||
|
range: 0...5
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("赠送修图/视频")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("取消", action: onDismiss)
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("确认") {
|
||||||
|
onConfirm(photoNum, videoNum)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stepperRow(title: String, value: Binding<Int>, range: ClosedRange<Int>) -> some View {
|
||||||
|
HStack {
|
||||||
|
Text(title)
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundStyle(AppDesign.orderLabelColor)
|
||||||
|
Spacer()
|
||||||
|
Button("-") { value.wrappedValue = max(range.lowerBound, value.wrappedValue - 1) }
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
Text("\(value.wrappedValue)")
|
||||||
|
.frame(minWidth: 30)
|
||||||
|
Button("+") { value.wrappedValue = min(range.upperBound, value.wrappedValue + 1) }
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 店长订单退款弹窗,支持全额/部分退款。
|
||||||
|
struct StoreOrderRefundDialogView: View {
|
||||||
|
let orderNumber: String
|
||||||
|
let actualPayAmount: String
|
||||||
|
let initialMode: OrderRefundMode
|
||||||
|
let onDismiss: () -> Void
|
||||||
|
let onConfirm: (OrderRefundMode, String, String) -> Void
|
||||||
|
|
||||||
|
@State private var mode: OrderRefundMode
|
||||||
|
@State private var amount = ""
|
||||||
|
@State private var reason = ""
|
||||||
|
|
||||||
|
init(
|
||||||
|
orderNumber: String,
|
||||||
|
actualPayAmount: String,
|
||||||
|
initialMode: OrderRefundMode,
|
||||||
|
onDismiss: @escaping () -> Void,
|
||||||
|
onConfirm: @escaping (OrderRefundMode, String, String) -> Void
|
||||||
|
) {
|
||||||
|
self.orderNumber = orderNumber
|
||||||
|
self.actualPayAmount = actualPayAmount
|
||||||
|
self.initialMode = initialMode
|
||||||
|
self.onDismiss = onDismiss
|
||||||
|
self.onConfirm = onConfirm
|
||||||
|
_mode = State(initialValue: initialMode)
|
||||||
|
_amount = State(initialValue: actualPayAmount)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section("订单号") {
|
||||||
|
Text(orderNumber)
|
||||||
|
}
|
||||||
|
Section("退款方式") {
|
||||||
|
Picker("退款方式", selection: $mode) {
|
||||||
|
ForEach(OrderRefundMode.allCases) { item in
|
||||||
|
Text(item.title).tag(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
if mode == .partial {
|
||||||
|
TextField("退款金额", text: $amount)
|
||||||
|
.keyboardType(.decimalPad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Section("退款原因") {
|
||||||
|
TextField("请输入退款原因", text: $reason, axis: .vertical)
|
||||||
|
.lineLimit(3...6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("申请退款")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("取消", action: onDismiss)
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("提交") {
|
||||||
|
let refundAmount = mode == .full ? actualPayAmount : amount
|
||||||
|
onConfirm(mode, refundAmount, reason)
|
||||||
|
}
|
||||||
|
.disabled(reason.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
237
suixinkan/Features/Orders/Views/OrderListCardView.swift
Normal file
237
suixinkan/Features/Orders/Views/OrderListCardView.swift
Normal file
@ -0,0 +1,237 @@
|
|||||||
|
//
|
||||||
|
// OrderListCardView.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
// Created by Codex on 2026/6/29.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// 主订单列表卡片,对齐 Android OrderView。
|
||||||
|
struct OrderListCardView: View {
|
||||||
|
let item: OrderEntity
|
||||||
|
let hideOrderWriteActions: Bool
|
||||||
|
let showOrderSource: Bool
|
||||||
|
let sourceSelection: OrderSourceSelection?
|
||||||
|
let orderSourcePeople: [OrderSourcePerson]
|
||||||
|
let leadsForPerson: (OrderSourcePerson) -> [OrderSourceLead]
|
||||||
|
let leadsLoading: Bool
|
||||||
|
|
||||||
|
let onRefund: () -> Void
|
||||||
|
let onVerify: () -> Void
|
||||||
|
let onUploadTask: () -> Void
|
||||||
|
let onRefinedChange: (Int) -> Void
|
||||||
|
let onSendGift: () -> Void
|
||||||
|
let onCancelOrder: () -> Void
|
||||||
|
let onHistoricalPhoto: () -> Void
|
||||||
|
let onCallPhone: () -> Void
|
||||||
|
let onOrderSourceSheetOpen: () -> Void
|
||||||
|
let onOrderSourcePersonClick: (OrderSourcePerson) -> Void
|
||||||
|
let onOrderSourceSelectionChange: (OrderSourceSelection) -> Void
|
||||||
|
let callSourcePhone: (String) -> Void
|
||||||
|
|
||||||
|
private var showRefundButton: Bool {
|
||||||
|
!hideOrderWriteActions &&
|
||||||
|
(item.orderStatus == 18 || item.orderStatus == 30) &&
|
||||||
|
item.orderType != 19
|
||||||
|
}
|
||||||
|
|
||||||
|
private var showVerifyButton: Bool {
|
||||||
|
!hideOrderWriteActions && (
|
||||||
|
(item.photoTravel?.needCheckIn == true && item.orderStatus != 30) ||
|
||||||
|
item.orderType == 19
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
HStack(alignment: .center) {
|
||||||
|
Text("订单号:\(item.orderNumber)")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundStyle(.black)
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.72)
|
||||||
|
Spacer()
|
||||||
|
OrderStatusChip(text: item.orderStatusName, status: item.orderStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(item.createdAt.isEmpty ? "--" : item.createdAt)
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(AppDesign.textSecondary)
|
||||||
|
.padding(.top, 4)
|
||||||
|
|
||||||
|
if !item.orderTypeLabel.isEmpty {
|
||||||
|
Text(item.orderTypeLabel)
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(AppDesign.primary)
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(AppDesign.primarySoft, in: RoundedRectangle(cornerRadius: 4))
|
||||||
|
.padding(.top, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
if showOrderSource {
|
||||||
|
OrderSourcePicker(
|
||||||
|
selection: sourceSelection,
|
||||||
|
people: orderSourcePeople,
|
||||||
|
leadsForPerson: leadsForPerson,
|
||||||
|
leadsLoading: leadsLoading,
|
||||||
|
onSheetOpen: onOrderSourceSheetOpen,
|
||||||
|
onPersonClick: onOrderSourcePersonClick,
|
||||||
|
onSelectionChange: onOrderSourceSelectionChange,
|
||||||
|
callPhone: callSourcePhone
|
||||||
|
)
|
||||||
|
.padding(.top, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
if showRefundButton || showVerifyButton {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
if showRefundButton {
|
||||||
|
OrderActionButton(
|
||||||
|
title: "退款",
|
||||||
|
textColor: AppDesign.orderRefundText,
|
||||||
|
backgroundColor: AppDesign.orderRefundBackground,
|
||||||
|
maxWidth: showVerifyButton ? nil : .infinity,
|
||||||
|
action: onRefund
|
||||||
|
)
|
||||||
|
.frame(maxWidth: showVerifyButton ? .infinity : nil)
|
||||||
|
}
|
||||||
|
if showVerifyButton {
|
||||||
|
OrderActionButton(title: "核销订单", action: onVerify)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, 12)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hideOrderWriteActions && item.orderType == 19 {
|
||||||
|
Button(action: onUploadTask) {
|
||||||
|
Text("上传任务")
|
||||||
|
.font(.system(size: 16))
|
||||||
|
.foregroundStyle(AppDesign.primary)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 48)
|
||||||
|
.background(AppDesign.primarySoft, in: RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.padding(.top, 12)
|
||||||
|
}
|
||||||
|
|
||||||
|
if item.orderStatus == 18 || item.orderStatus == 30 {
|
||||||
|
OrderRefinedChoiceSection(
|
||||||
|
currentSelection: item.isRefined,
|
||||||
|
readOnly: hideOrderWriteActions,
|
||||||
|
onSelectionChange: onRefinedChange
|
||||||
|
)
|
||||||
|
.padding(.top, 12)
|
||||||
|
}
|
||||||
|
|
||||||
|
if item.orderStatus == 50,
|
||||||
|
!item.actualRefundAmount.isEmpty,
|
||||||
|
item.actualRefundAmount != "0" {
|
||||||
|
OrderInfoRow(title: "退款金额", value: "¥\(item.actualRefundAmount)", valueColor: AppDesign.primary)
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
OrderInfoRow(title: "付款金额", value: "¥\(item.actualPayAmount)", valueColor: AppDesign.primary)
|
||||||
|
OrderInfoRow(
|
||||||
|
title: "付款时间",
|
||||||
|
value: OrderListUI.displayPayTime(payTime: item.payTime, depositPayTime: item.depositPayTime)
|
||||||
|
)
|
||||||
|
OrderInfoRow(title: "完成时间", value: item.completeTime.isEmpty ? "--" : item.completeTime)
|
||||||
|
OrderInfoRow(title: "付款方式", value: item.payTypeName.isEmpty ? "--" : item.payTypeName)
|
||||||
|
OrderInfoRow(title: "用户 UID", value: "\(item.userId)")
|
||||||
|
OrderInfoRow(title: "手机号", value: OrderListUI.maskPhone(item.phone)) {
|
||||||
|
HStack(spacing: 7) {
|
||||||
|
Text(OrderListUI.maskPhone(item.phone))
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundStyle(.black)
|
||||||
|
if !hideOrderWriteActions {
|
||||||
|
OrderPhoneCallButton(action: onCallPhone)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OrderInfoRow(title: "关联项目", value: item.projectName.isEmpty ? "--" : item.projectName)
|
||||||
|
|
||||||
|
if let photoTravel = item.photoTravel, !photoTravel.checkInTime.isEmpty {
|
||||||
|
OrderInfoRow(title: "核销时间", value: photoTravel.checkInTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let photoTravel = item.photoTravel,
|
||||||
|
item.orderType != 19,
|
||||||
|
item.orderType != 14 {
|
||||||
|
OrderGiftStepperRow(
|
||||||
|
title: "修图张数",
|
||||||
|
valueText: "\(photoTravel.orderPhotoNum + photoTravel.retouchGiftPhotoNum) 张",
|
||||||
|
editable: photoTravel.canGiftRetouch && !hideOrderWriteActions,
|
||||||
|
onTap: onSendGift
|
||||||
|
)
|
||||||
|
OrderGiftStepperRow(
|
||||||
|
title: "视频剪辑",
|
||||||
|
valueText: "\(photoTravel.orderVideoNum + photoTravel.retouchGiftVideoNum) 条",
|
||||||
|
editable: photoTravel.canGiftRetouch && !hideOrderWriteActions,
|
||||||
|
onTap: onSendGift
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, 13)
|
||||||
|
|
||||||
|
if !item.remark.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
Divider().overlay(AppDesign.orderInputBackground).padding(.top, 14)
|
||||||
|
Text("备注信息:")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundStyle(AppDesign.orderLabelColor)
|
||||||
|
.padding(.top, 10)
|
||||||
|
Text(item.remark)
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundStyle(AppDesign.orderLabelColor)
|
||||||
|
.padding(.top, 6)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let materials = item.multiTravel?.materialList, !materials.isEmpty {
|
||||||
|
Divider().overlay(AppDesign.orderInputBackground).padding(.top, 12)
|
||||||
|
Text("历史拍摄信息:")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundStyle(AppDesign.orderLabelColor)
|
||||||
|
.padding(.top, 12)
|
||||||
|
|
||||||
|
Button(action: onHistoricalPhoto) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ForEach(Array(materials.prefix(3).enumerated()), id: \.offset) { index, material in
|
||||||
|
ZStack {
|
||||||
|
RemoteImage(
|
||||||
|
urlString: material.previewURLString,
|
||||||
|
contentMode: .fill
|
||||||
|
) {
|
||||||
|
Color(hex: 0xF4F4F4)
|
||||||
|
}
|
||||||
|
.aspectRatio(1.59, contentMode: .fill)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
|
||||||
|
if materials.count > 3 && index == 2 {
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.fill(Color.black.opacity(0.5))
|
||||||
|
Text("查看更多")
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
if item.photoTravel?.canCancelOrder == true && !hideOrderWriteActions {
|
||||||
|
OrderActionButton(title: "取消订单", action: onCancelOrder)
|
||||||
|
.padding(.top, 12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(.white, in: RoundedRectangle(cornerRadius: 12))
|
||||||
|
.accessibilityIdentifier("orders.store.card.\(item.orderNumber)")
|
||||||
|
}
|
||||||
|
}
|
||||||
276
suixinkan/Features/Orders/Views/OrderListComponents.swift
Normal file
276
suixinkan/Features/Orders/Views/OrderListComponents.swift
Normal file
@ -0,0 +1,276 @@
|
|||||||
|
//
|
||||||
|
// OrderListComponents.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
// Created by Codex on 2026/6/29.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// 订单列表 UI 工具,集中状态色与通用展示逻辑。
|
||||||
|
enum OrderListUI {
|
||||||
|
/// 返回订单状态对应的文字色和背景色。
|
||||||
|
static func statusColors(for status: Int) -> (text: Color, background: Color) {
|
||||||
|
switch status {
|
||||||
|
case 18:
|
||||||
|
return (AppDesign.orderStatusPaidText, AppDesign.orderStatusPaidBackground)
|
||||||
|
case 30:
|
||||||
|
return (AppDesign.orderStatusCompletedText, AppDesign.orderStatusCompletedBackground)
|
||||||
|
case 50:
|
||||||
|
return (AppDesign.orderStatusRefundedText, AppDesign.orderStatusRefundedBackground)
|
||||||
|
default:
|
||||||
|
return (AppDesign.orderStatusOtherText, AppDesign.orderStatusOtherBackground)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 掩码手机号,保留前三后四。
|
||||||
|
static func maskPhone(_ phone: String) -> String {
|
||||||
|
let trimmed = phone.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard trimmed.count >= 7 else { return trimmed.isEmpty ? "--" : trimmed }
|
||||||
|
return "\(trimmed.prefix(3))****\(trimmed.suffix(4))"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 付款时间展示,兼容 depositPayTime 兜底。
|
||||||
|
static func displayPayTime(payTime: String, depositPayTime: String) -> String {
|
||||||
|
if payTime.isEmpty {
|
||||||
|
return depositPayTime.isEmpty ? "--" : depositPayTime
|
||||||
|
}
|
||||||
|
if payTime.hasPrefix("1970") || payTime == "0" {
|
||||||
|
return depositPayTime.isEmpty ? payTime : depositPayTime
|
||||||
|
}
|
||||||
|
return payTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 订单状态 Chip,对齐 Android OrderView 状态标签。
|
||||||
|
struct OrderStatusChip: View {
|
||||||
|
let text: String
|
||||||
|
let status: Int
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let colors = OrderListUI.statusColors(for: status)
|
||||||
|
Text(text.isEmpty ? "--" : text)
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundStyle(colors.text)
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(colors.background, in: RoundedRectangle(cornerRadius: 4))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 订单卡片信息行,标签在左、值在右。
|
||||||
|
struct OrderInfoRow: View {
|
||||||
|
let title: String
|
||||||
|
let value: String
|
||||||
|
var valueColor: Color = .black
|
||||||
|
var trailing: AnyView?
|
||||||
|
|
||||||
|
init(title: String, value: String, valueColor: Color = .black, @ViewBuilder trailing: () -> some View = { EmptyView() }) {
|
||||||
|
self.title = title
|
||||||
|
self.value = value
|
||||||
|
self.valueColor = valueColor
|
||||||
|
let built = trailing()
|
||||||
|
self.trailing = built is EmptyView ? nil : AnyView(built)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .center, spacing: 8) {
|
||||||
|
Text(title)
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundStyle(AppDesign.orderLabelColor)
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
if let trailing {
|
||||||
|
trailing
|
||||||
|
} else {
|
||||||
|
Text(value)
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundStyle(valueColor)
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
|
.lineLimit(2)
|
||||||
|
.minimumScaleFactor(0.75)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 订单卡片操作按钮,对齐 Android AppButton 样式。
|
||||||
|
struct OrderActionButton: View {
|
||||||
|
let title: String
|
||||||
|
var textColor: Color = .white
|
||||||
|
var backgroundColor: Color = AppDesign.primary
|
||||||
|
var maxWidth: CGFloat? = .infinity
|
||||||
|
let action: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: action) {
|
||||||
|
Text(title)
|
||||||
|
.font(.system(size: 16))
|
||||||
|
.foregroundStyle(textColor)
|
||||||
|
.frame(maxWidth: maxWidth)
|
||||||
|
.frame(height: 48)
|
||||||
|
.background(backgroundColor, in: RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 订单卡片拨号按钮。
|
||||||
|
struct OrderPhoneCallButton: View {
|
||||||
|
let action: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: action) {
|
||||||
|
Image(systemName: "phone.fill")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(AppDesign.primary)
|
||||||
|
.frame(width: 24, height: 24)
|
||||||
|
.background(AppDesign.primarySoft, in: Circle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 需要剪辑修图单选区,对齐 Android ExclusiveChoiceButtons。
|
||||||
|
struct OrderRefinedChoiceSection: View {
|
||||||
|
let currentSelection: Int
|
||||||
|
let readOnly: Bool
|
||||||
|
let onSelectionChange: (Int) -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
Text("需要剪辑修图")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundStyle(AppDesign.orderLabelColor)
|
||||||
|
Spacer()
|
||||||
|
if readOnly {
|
||||||
|
Text(refinedDisplayText)
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundStyle(.black)
|
||||||
|
} else {
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
refinedChoiceButton(title: "是", value: 1)
|
||||||
|
refinedChoiceButton(title: "否", value: 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var refinedDisplayText: String {
|
||||||
|
switch currentSelection {
|
||||||
|
case 1: return "是"
|
||||||
|
case 2: return "否"
|
||||||
|
default: return "--"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refinedChoiceButton(title: String, value: Int) -> some View {
|
||||||
|
let selected = currentSelection == value
|
||||||
|
return Button {
|
||||||
|
onSelectionChange(value)
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.stroke(AppDesign.primary, lineWidth: selected ? 0 : 1.5)
|
||||||
|
.background(selected ? Circle().fill(AppDesign.primary) : nil)
|
||||||
|
.frame(width: 20, height: 20)
|
||||||
|
if selected {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
.font(.system(size: 10, weight: .bold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(title)
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundStyle(.black)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 修图/视频数量赠送步进控件。
|
||||||
|
struct OrderGiftStepperRow: View {
|
||||||
|
let title: String
|
||||||
|
let valueText: String
|
||||||
|
let editable: Bool
|
||||||
|
let onTap: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
Text(title)
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundStyle(AppDesign.orderLabelColor)
|
||||||
|
Spacer()
|
||||||
|
if editable {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
giftStepButton(symbol: "-", action: onTap)
|
||||||
|
Text(valueText)
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundStyle(.black)
|
||||||
|
giftStepButton(symbol: "+", action: onTap)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text(valueText)
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundStyle(.black)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func giftStepButton(symbol: String, action: @escaping () -> Void) -> some View {
|
||||||
|
Button(action: action) {
|
||||||
|
Text(symbol)
|
||||||
|
.font(.system(size: 16, weight: .bold))
|
||||||
|
.foregroundStyle(Color(hex: 0xE5E7EB))
|
||||||
|
.frame(width: 24, height: 24)
|
||||||
|
.overlay(Circle().stroke(Color(hex: 0xE5E7EB), lineWidth: 1))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 核销管理页横向 pill Tab。
|
||||||
|
struct WriteOffStatusTabBar: View {
|
||||||
|
let filters: [OrderStatusFilter]
|
||||||
|
let selectedStatus: Int
|
||||||
|
let onSelect: (Int) -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ForEach(filters) { filter in
|
||||||
|
let selected = selectedStatus == filter.id
|
||||||
|
Button {
|
||||||
|
onSelect(filter.id)
|
||||||
|
} label: {
|
||||||
|
Text(filter.title)
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundStyle(selected ? .white : AppDesign.orderLabelColor)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.frame(height: 32)
|
||||||
|
.background(
|
||||||
|
selected ? AppDesign.primary : Color(hex: 0xF3F4F6),
|
||||||
|
in: RoundedRectangle(cornerRadius: 16)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 订单列表 `.searchable` 搜索行为,区分「输入中」与「已提交查询」,清空时重新拉全量。
|
||||||
|
enum OrderListSearchBehavior {
|
||||||
|
/// 归一化搜索文本。
|
||||||
|
static func normalized(_ text: String) -> String {
|
||||||
|
text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清空搜索框后是否需要重新请求(此前已提交过非空查询)。
|
||||||
|
static func shouldReloadAfterClear(newText: String, appliedQuery: String) -> Bool {
|
||||||
|
normalized(newText).isEmpty && !appliedQuery.isEmpty
|
||||||
|
}
|
||||||
|
}
|
||||||
71
suixinkan/Features/Orders/Views/OrderListHeaderView.swift
Normal file
71
suixinkan/Features/Orders/Views/OrderListHeaderView.swift
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
//
|
||||||
|
// OrderListHeaderView.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
// Created by Codex on 2026/6/29.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// 订单列表筛选头部,展示状态与时间筛选;手机号搜索由页面级 `.searchable` 提供。
|
||||||
|
struct OrderListHeaderView: View {
|
||||||
|
let filterTitle: String
|
||||||
|
let timeFilterTitle: String
|
||||||
|
let statusFilters: [OrderStatusFilter]
|
||||||
|
let selectedStatus: Int
|
||||||
|
let onStatusSelected: (Int) -> Void
|
||||||
|
let onTimeFilterTap: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Menu {
|
||||||
|
ForEach(statusFilters) { filter in
|
||||||
|
Button(filter.title) {
|
||||||
|
onStatusSelected(filter.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
OrderListFilterPill(title: filterTitle, showsChevron: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(action: onTimeFilterTap) {
|
||||||
|
OrderListFilterPill(title: timeFilterTitle, showsCalendar: true)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(.white)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 订单列表筛选 pill 按钮样式。
|
||||||
|
private struct OrderListFilterPill: View {
|
||||||
|
let title: String
|
||||||
|
var showsChevron = false
|
||||||
|
var showsCalendar = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Text(title)
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundStyle(AppDesign.orderLabelColor)
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.75)
|
||||||
|
Spacer(minLength: 4)
|
||||||
|
if showsChevron {
|
||||||
|
Image(systemName: "chevron.down")
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
.foregroundStyle(AppDesign.orderLabelColor)
|
||||||
|
}
|
||||||
|
if showsCalendar {
|
||||||
|
Image(systemName: "calendar")
|
||||||
|
.font(.system(size: 15))
|
||||||
|
.foregroundStyle(AppDesign.orderLabelColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 35)
|
||||||
|
.background(AppDesign.orderInputBackground, in: RoundedRectangle(cornerRadius: 4))
|
||||||
|
}
|
||||||
|
}
|
||||||
262
suixinkan/Features/Orders/Views/StoreAdminOrderListView.swift
Normal file
262
suixinkan/Features/Orders/Views/StoreAdminOrderListView.swift
Normal file
@ -0,0 +1,262 @@
|
|||||||
|
//
|
||||||
|
// StoreAdminOrderListView.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
// Created by Codex on 2026/6/29.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// 店长角色订单 Tab 列表,对齐 Android DepositOrderListScreen。
|
||||||
|
struct StoreAdminOrderListView: View {
|
||||||
|
@EnvironmentObject private var accountContext: AccountContext
|
||||||
|
@EnvironmentObject private var router: RouterPath
|
||||||
|
@Environment(\.ordersAPI) private var ordersAPI
|
||||||
|
@EnvironmentObject private var toastCenter: ToastCenter
|
||||||
|
@Environment(\.globalLoading) private var globalLoading
|
||||||
|
@Environment(\.openURL) private var openURL
|
||||||
|
|
||||||
|
@StateObject private var viewModel = StoreAdminOrderListViewModel()
|
||||||
|
|
||||||
|
@State private var showDateFilterSheet = false
|
||||||
|
@State private var refundOrder: StoreOrderListItem?
|
||||||
|
@State private var refundMode: OrderRefundMode = .full
|
||||||
|
@State private var showScanner = false
|
||||||
|
@State private var appliedSearchQuery = ""
|
||||||
|
|
||||||
|
private var currentStoreId: Int? { accountContext.currentStore?.id }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if currentStoreId == nil {
|
||||||
|
AppContentUnavailableView(
|
||||||
|
"缺少门店上下文",
|
||||||
|
systemImage: "storefront",
|
||||||
|
description: Text("请先选择门店后查看订单。")
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
listContent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(AppDesign.orderPageBackground.ignoresSafeArea())
|
||||||
|
.navigationTitle("订单")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.searchable(text: $viewModel.searchPhone, prompt: "输入游客手机号")
|
||||||
|
.onSubmit(of: .search) {
|
||||||
|
appliedSearchQuery = OrderListSearchBehavior.normalized(viewModel.searchPhone)
|
||||||
|
Task { await reload() }
|
||||||
|
}
|
||||||
|
.onChange(of: viewModel.searchPhone) { newValue in
|
||||||
|
guard OrderListSearchBehavior.shouldReloadAfterClear(newText: newValue, appliedQuery: appliedSearchQuery) else { return }
|
||||||
|
appliedSearchQuery = ""
|
||||||
|
Task { await reload() }
|
||||||
|
}
|
||||||
|
.task { await reload() }
|
||||||
|
.onChange(of: accountContext.currentStore?.id) { _ in
|
||||||
|
Task { await reload() }
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showDateFilterSheet) {
|
||||||
|
DateRangePickerSheet(
|
||||||
|
initialStartDate: viewModel.filterStartDate,
|
||||||
|
initialEndDate: viewModel.filterEndDate
|
||||||
|
) { start, end in
|
||||||
|
viewModel.filterStartDate = start
|
||||||
|
viewModel.filterEndDate = end
|
||||||
|
Task { await reload() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(item: $refundOrder) { order in
|
||||||
|
StoreOrderRefundDialogView(
|
||||||
|
orderNumber: order.orderNumber,
|
||||||
|
actualPayAmount: order.actualPayAmount,
|
||||||
|
initialMode: refundMode,
|
||||||
|
onDismiss: { refundOrder = nil },
|
||||||
|
onConfirm: { mode, amount, reason in
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await viewModel.refund(
|
||||||
|
api: ordersAPI,
|
||||||
|
storeId: currentStoreId,
|
||||||
|
orderNumber: order.orderNumber,
|
||||||
|
mode: mode,
|
||||||
|
amount: amount,
|
||||||
|
reason: reason
|
||||||
|
)
|
||||||
|
refundOrder = nil
|
||||||
|
toastCenter.show("退款申请已提交")
|
||||||
|
await reload(showLoading: false)
|
||||||
|
} catch {
|
||||||
|
toastCenter.show(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.fullScreenCover(isPresented: $showScanner) {
|
||||||
|
OrderScannerPage(
|
||||||
|
onClose: { showScanner = false },
|
||||||
|
onSuccess: { _ in
|
||||||
|
showScanner = false
|
||||||
|
toastCenter.show("核销成功")
|
||||||
|
Task { await reload(showLoading: false) }
|
||||||
|
},
|
||||||
|
onFailure: { error in
|
||||||
|
showScanner = false
|
||||||
|
toastCenter.show(error.localizedDescription)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var listContent: some View {
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
|
||||||
|
Section {
|
||||||
|
LazyVStack(spacing: 12) {
|
||||||
|
if viewModel.loading && viewModel.orders.isEmpty {
|
||||||
|
ProgressView().frame(maxWidth: .infinity).padding(.vertical, 40)
|
||||||
|
} else if viewModel.orders.isEmpty {
|
||||||
|
AppContentUnavailableView("暂无订单", systemImage: "tray")
|
||||||
|
.frame(minHeight: 260)
|
||||||
|
} else {
|
||||||
|
ForEach(viewModel.orders) { item in
|
||||||
|
StoreAdminOrderCardView(
|
||||||
|
item: item,
|
||||||
|
onTap: { handleOrderTap(item) },
|
||||||
|
onRefund: {
|
||||||
|
refundMode = .full
|
||||||
|
refundOrder = item
|
||||||
|
},
|
||||||
|
onPartialRefund: {
|
||||||
|
refundMode = .partial
|
||||||
|
refundOrder = item
|
||||||
|
},
|
||||||
|
onVerify: { showScanner = true },
|
||||||
|
onCallPhone: { dialPhone(item.phone) }
|
||||||
|
)
|
||||||
|
.onAppear {
|
||||||
|
guard item.id == viewModel.orders.last?.id else { return }
|
||||||
|
Task { await loadMore() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if viewModel.loadingMore {
|
||||||
|
ProgressView().frame(maxWidth: .infinity).padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.bottom, 16)
|
||||||
|
} header: {
|
||||||
|
OrderListHeaderView(
|
||||||
|
filterTitle: viewModel.currentFilterTitle,
|
||||||
|
timeFilterTitle: OrderDateRangeFormatter.displayText(
|
||||||
|
start: viewModel.filterStartDate,
|
||||||
|
end: viewModel.filterEndDate
|
||||||
|
),
|
||||||
|
statusFilters: OrderFilters.storeAdminStatusFilters,
|
||||||
|
selectedStatus: viewModel.selectedStatus,
|
||||||
|
onStatusSelected: { status in
|
||||||
|
viewModel.selectedStatus = status
|
||||||
|
Task { await reload() }
|
||||||
|
},
|
||||||
|
onTimeFilterTap: { showDateFilterSheet = true }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.refreshable { await reload(showLoading: false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleOrderTap(_ item: StoreOrderListItem) {
|
||||||
|
if item.orderType == StoreOrderType.multiTravelShoot {
|
||||||
|
router.navigate(to: .orders(.depositDetail(orderNumber: item.orderNumber)))
|
||||||
|
} else {
|
||||||
|
toastCenter.show("该订单不可查看详情")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func reload(showLoading: Bool = true) async {
|
||||||
|
do {
|
||||||
|
try await globalLoading.withOptionalLoading(showLoading && viewModel.orders.isEmpty, message: "加载中...") {
|
||||||
|
try await viewModel.reload(api: ordersAPI, storeId: currentStoreId, showLoading: showLoading)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toastCenter.show(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadMore() async {
|
||||||
|
do {
|
||||||
|
try await viewModel.loadMore(api: ordersAPI, storeId: currentStoreId)
|
||||||
|
} catch {
|
||||||
|
toastCenter.show(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func dialPhone(_ phone: String) {
|
||||||
|
let trimmed = phone.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty, let url = URL(string: "tel:\(trimmed)") else { return }
|
||||||
|
openURL(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 店长订单卡片,对齐 Android DepositOrderCard。
|
||||||
|
private struct StoreAdminOrderCardView: View {
|
||||||
|
let item: StoreOrderListItem
|
||||||
|
let onTap: () -> Void
|
||||||
|
let onRefund: () -> Void
|
||||||
|
let onPartialRefund: () -> Void
|
||||||
|
let onVerify: () -> Void
|
||||||
|
let onCallPhone: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Button(action: onTap) {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Text("订单号:\(item.orderNumber)")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundStyle(.black)
|
||||||
|
Spacer()
|
||||||
|
OrderStatusChip(text: item.orderStatusName, status: item.orderStatus)
|
||||||
|
}
|
||||||
|
OrderInfoRow(title: "项目名称", value: item.projectName.isEmpty ? "--" : item.projectName)
|
||||||
|
OrderInfoRow(title: "订单金额", value: "¥\(item.actualPayAmount)", valueColor: AppDesign.primary)
|
||||||
|
OrderInfoRow(title: "付款时间", value: item.payTime.isEmpty ? "--" : item.payTime)
|
||||||
|
OrderInfoRow(title: "完成时间", value: item.completeTime.isEmpty ? "--" : item.completeTime)
|
||||||
|
OrderInfoRow(title: "用户 UID", value: "\(item.userId)")
|
||||||
|
OrderInfoRow(title: "手机号", value: OrderListUI.maskPhone(item.phone)) {
|
||||||
|
HStack(spacing: 7) {
|
||||||
|
Text(OrderListUI.maskPhone(item.phone))
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
OrderPhoneCallButton(action: onCallPhone)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
OrderActionButton(
|
||||||
|
title: "退款",
|
||||||
|
textColor: AppDesign.orderRefundText,
|
||||||
|
backgroundColor: AppDesign.orderRefundBackground,
|
||||||
|
action: onRefund
|
||||||
|
)
|
||||||
|
OrderActionButton(
|
||||||
|
title: "部分退款",
|
||||||
|
textColor: AppDesign.orderLabelColor,
|
||||||
|
backgroundColor: .white,
|
||||||
|
action: onPartialRefund
|
||||||
|
)
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 8).stroke(AppDesign.orderBorderLight, lineWidth: 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
if item.orderType == StoreOrderType.offlineProduct {
|
||||||
|
OrderActionButton(title: "核销", action: onVerify)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(.white, in: RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
}
|
||||||
382
suixinkan/Features/Orders/Views/StoreOrderListView.swift
Normal file
382
suixinkan/Features/Orders/Views/StoreOrderListView.swift
Normal file
@ -0,0 +1,382 @@
|
|||||||
|
//
|
||||||
|
// StoreOrderListView.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
// Created by Codex on 2026/6/29.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// 摄影师/非店长角色的订单 Tab 主列表,对齐 Android OrderListScreen。
|
||||||
|
struct StoreOrderListView: View {
|
||||||
|
@EnvironmentObject private var accountContext: AccountContext
|
||||||
|
@EnvironmentObject private var permissionContext: PermissionContext
|
||||||
|
@EnvironmentObject private var appRouter: AppRouter
|
||||||
|
@EnvironmentObject private var router: RouterPath
|
||||||
|
@Environment(\.ordersAPI) private var ordersAPI
|
||||||
|
@Environment(\.cooperationOrderAPI) private var cooperationOrderAPI
|
||||||
|
@EnvironmentObject private var toastCenter: ToastCenter
|
||||||
|
@Environment(\.globalLoading) private var globalLoading
|
||||||
|
@Environment(\.openURL) private var openURL
|
||||||
|
|
||||||
|
@StateObject private var viewModel = OrdersViewModel()
|
||||||
|
@StateObject private var refundViewModel = OrderRefundViewModel()
|
||||||
|
|
||||||
|
@State private var showDateFilterSheet = false
|
||||||
|
@State private var showScanner = false
|
||||||
|
@State private var pendingVerifyOrderNumber: String?
|
||||||
|
@State private var showVerifyConfirmation = false
|
||||||
|
@State private var bindAcquirerSaleUserId: Int?
|
||||||
|
@State private var referralBindOrderNumber: String?
|
||||||
|
@State private var appliedSearchQuery = ""
|
||||||
|
|
||||||
|
private var currentScenicId: Int? { accountContext.currentScenic?.id }
|
||||||
|
private var currentStoreId: Int? { accountContext.currentStore?.id }
|
||||||
|
private var currentAppRole: AppRoleCode? { permissionContext.currentAppRole }
|
||||||
|
private var hideOrderWriteActions: Bool { OrdersViewModel.isScenicAdminReadOnly(appRole: currentAppRole) }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if currentScenicId == nil {
|
||||||
|
AppContentUnavailableView(
|
||||||
|
"缺少经营上下文",
|
||||||
|
systemImage: "mountain.2",
|
||||||
|
description: Text("请先在首页选择景区后查看订单。")
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
orderListContent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(AppDesign.orderPageBackground.ignoresSafeArea())
|
||||||
|
.navigationTitle("订单")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.searchable(text: $viewModel.searchPhone, prompt: "输入手机号搜索")
|
||||||
|
.onSubmit(of: .search) {
|
||||||
|
appliedSearchQuery = OrderListSearchBehavior.normalized(viewModel.searchPhone)
|
||||||
|
Task { await reload() }
|
||||||
|
}
|
||||||
|
.onChange(of: viewModel.searchPhone) { newValue in
|
||||||
|
guard OrderListSearchBehavior.shouldReloadAfterClear(newText: newValue, appliedQuery: appliedSearchQuery) else { return }
|
||||||
|
appliedSearchQuery = ""
|
||||||
|
Task { await reload() }
|
||||||
|
}
|
||||||
|
.task { await reload() }
|
||||||
|
.onChange(of: accountContext.currentScenic?.id) { _ in
|
||||||
|
Task { await reload() }
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showDateFilterSheet) {
|
||||||
|
DateRangePickerSheet(
|
||||||
|
initialStartDate: viewModel.filterStartDate,
|
||||||
|
initialEndDate: viewModel.filterEndDate
|
||||||
|
) { start, end in
|
||||||
|
viewModel.filterStartDate = start
|
||||||
|
viewModel.filterEndDate = end
|
||||||
|
Task { await reload() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(item: refundOrderBinding) { order in
|
||||||
|
OrderRefundDialogView(
|
||||||
|
viewModel: refundViewModel,
|
||||||
|
order: order,
|
||||||
|
onDismiss: { viewModel.refundingOrder = nil },
|
||||||
|
onSuccess: {
|
||||||
|
viewModel.refundingOrder = nil
|
||||||
|
toastCenter.show("退款申请已提交")
|
||||||
|
Task { await reload(showLoading: false) }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.sheet(item: sendGiftBinding) { context in
|
||||||
|
SendGiftDialogView(
|
||||||
|
context: context,
|
||||||
|
onDismiss: { viewModel.sendGiftContext = nil },
|
||||||
|
onConfirm: { photo, video in
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await viewModel.submitSendGift(
|
||||||
|
api: ordersAPI,
|
||||||
|
scenicId: currentScenicId,
|
||||||
|
appRole: currentAppRole,
|
||||||
|
photoNum: photo,
|
||||||
|
videoNum: video
|
||||||
|
)
|
||||||
|
toastCenter.show("赠送成功")
|
||||||
|
} catch {
|
||||||
|
toastCenter.show(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.fullScreenCover(isPresented: $showScanner) {
|
||||||
|
OrderScannerPage(
|
||||||
|
onClose: { showScanner = false },
|
||||||
|
onSuccess: { rawCode in
|
||||||
|
showScanner = false
|
||||||
|
handleScanResult(rawCode)
|
||||||
|
},
|
||||||
|
onFailure: { error in
|
||||||
|
showScanner = false
|
||||||
|
toastCenter.show(error.localizedDescription)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.confirmationDialog("确认取消该订单?", isPresented: $showCancelConfirmation, titleVisibility: .visible) {
|
||||||
|
Button("确认取消", role: .destructive) {
|
||||||
|
guard let orderNumber = pendingCancelOrderNumber else { return }
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await viewModel.cancelOrder(
|
||||||
|
api: ordersAPI,
|
||||||
|
scenicId: currentScenicId,
|
||||||
|
appRole: currentAppRole,
|
||||||
|
orderNumber: orderNumber
|
||||||
|
)
|
||||||
|
pendingCancelOrderNumber = nil
|
||||||
|
toastCenter.show("订单已取消")
|
||||||
|
} catch {
|
||||||
|
toastCenter.show(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button("取消", role: .cancel) { pendingCancelOrderNumber = nil }
|
||||||
|
}
|
||||||
|
.confirmationDialog("确认核销该订单?", isPresented: $showVerifyConfirmation, titleVisibility: .visible) {
|
||||||
|
Button("确认核销") {
|
||||||
|
guard let orderNumber = pendingVerifyOrderNumber else { return }
|
||||||
|
Task { await verify(orderNumber: orderNumber) }
|
||||||
|
}
|
||||||
|
Button("取消", role: .cancel) { pendingVerifyOrderNumber = nil }
|
||||||
|
}
|
||||||
|
.fullScreenCover(item: bindAcquirerBinding) { item in
|
||||||
|
NavigationStack {
|
||||||
|
BindAcquirerView(saleUserId: item.saleUserId, onSuccess: {
|
||||||
|
bindAcquirerSaleUserId = nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.appNavigationDestination(item: referralBindBinding) { item in
|
||||||
|
ReferralOrderSelectView(orderNumber: item.orderNumber) {
|
||||||
|
Task { await reload(showLoading: false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@State private var pendingCancelOrderNumber: String?
|
||||||
|
@State private var showCancelConfirmation = false
|
||||||
|
|
||||||
|
private var orderListContent: some View {
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
|
||||||
|
Section {
|
||||||
|
LazyVStack(spacing: 12) {
|
||||||
|
if viewModel.loading && viewModel.storeOrders.isEmpty {
|
||||||
|
ProgressView().frame(maxWidth: .infinity).padding(.vertical, 40)
|
||||||
|
} else if viewModel.storeOrders.isEmpty {
|
||||||
|
AppContentUnavailableView("暂无订单", systemImage: "tray", description: Text("可切换筛选条件或下拉刷新。"))
|
||||||
|
.frame(minHeight: 260)
|
||||||
|
} else {
|
||||||
|
ForEach(viewModel.storeOrders) { item in
|
||||||
|
OrderListCardView(
|
||||||
|
item: item,
|
||||||
|
hideOrderWriteActions: hideOrderWriteActions,
|
||||||
|
showOrderSource: viewModel.shouldShowOrderSource(order: item, appRole: currentAppRole),
|
||||||
|
sourceSelection: viewModel.getOrderSourceSelection(for: item),
|
||||||
|
orderSourcePeople: viewModel.orderSourcePeople,
|
||||||
|
leadsForPerson: { person in
|
||||||
|
viewModel.orderSourceLeads[person.key] ?? []
|
||||||
|
},
|
||||||
|
leadsLoading: viewModel.loadingOrderSourceLeadsFor != nil,
|
||||||
|
onRefund: {
|
||||||
|
refundViewModel.begin(item: item)
|
||||||
|
viewModel.refundingOrder = item
|
||||||
|
},
|
||||||
|
onVerify: {
|
||||||
|
pendingVerifyOrderNumber = item.orderNumber
|
||||||
|
showScanner = true
|
||||||
|
},
|
||||||
|
onUploadTask: {
|
||||||
|
router.navigate(to: .orders(.multiTravelTaskUpload(orderNumber: item.orderNumber)))
|
||||||
|
},
|
||||||
|
onRefinedChange: { value in
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await viewModel.updateRefinedChoice(
|
||||||
|
api: ordersAPI,
|
||||||
|
orderNumber: item.orderNumber,
|
||||||
|
isRefined: value
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
toastCenter.show(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSendGift: { viewModel.prepareSendGift(for: item) },
|
||||||
|
onCancelOrder: {
|
||||||
|
pendingCancelOrderNumber = item.orderNumber
|
||||||
|
showCancelConfirmation = true
|
||||||
|
},
|
||||||
|
onHistoricalPhoto: {
|
||||||
|
router.navigate(to: .orders(.historicalShooting(orderNumber: item.orderNumber)))
|
||||||
|
},
|
||||||
|
onCallPhone: { dialPhone(item.phone) },
|
||||||
|
onOrderSourceSheetOpen: {
|
||||||
|
Task {
|
||||||
|
await viewModel.prepareOrderSourceSheet(
|
||||||
|
api: cooperationOrderAPI,
|
||||||
|
storeId: currentStoreId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onOrderSourcePersonClick: { person in
|
||||||
|
Task {
|
||||||
|
await viewModel.loadOrderSourceLeads(api: cooperationOrderAPI, person: person)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onOrderSourceSelectionChange: { selection in
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await viewModel.bindOrderSource(
|
||||||
|
api: cooperationOrderAPI,
|
||||||
|
orderNumber: item.orderNumber,
|
||||||
|
selection: selection
|
||||||
|
)
|
||||||
|
toastCenter.show("绑定成功")
|
||||||
|
await reload(showLoading: false)
|
||||||
|
} catch {
|
||||||
|
toastCenter.show(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
callSourcePhone: dialPhone
|
||||||
|
)
|
||||||
|
.onAppear {
|
||||||
|
guard item.id == viewModel.storeOrders.last?.id else { return }
|
||||||
|
Task { await loadMore() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if viewModel.loadingMore {
|
||||||
|
ProgressView().frame(maxWidth: .infinity).padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.bottom, 16)
|
||||||
|
} header: {
|
||||||
|
OrderListHeaderView(
|
||||||
|
filterTitle: viewModel.currentFilterTitle,
|
||||||
|
timeFilterTitle: OrderDateRangeFormatter.displayText(
|
||||||
|
start: viewModel.filterStartDate,
|
||||||
|
end: viewModel.filterEndDate
|
||||||
|
),
|
||||||
|
statusFilters: OrderFilters.statusFilters,
|
||||||
|
selectedStatus: viewModel.selectedStatus,
|
||||||
|
onStatusSelected: { status in
|
||||||
|
viewModel.selectedStatus = status
|
||||||
|
Task { await reload() }
|
||||||
|
},
|
||||||
|
onTimeFilterTap: { showDateFilterSheet = true }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.refreshable { await reload(showLoading: false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var refundOrderBinding: Binding<OrderEntity?> {
|
||||||
|
Binding(
|
||||||
|
get: { viewModel.refundingOrder },
|
||||||
|
set: { viewModel.refundingOrder = $0 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var sendGiftBinding: Binding<OrdersViewModel.SendGiftContext?> {
|
||||||
|
Binding(
|
||||||
|
get: { viewModel.sendGiftContext },
|
||||||
|
set: { viewModel.sendGiftContext = $0 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var bindAcquirerBinding: Binding<BindAcquirerSheetItem?> {
|
||||||
|
Binding(
|
||||||
|
get: { bindAcquirerSaleUserId.map(BindAcquirerSheetItem.init(saleUserId:)) },
|
||||||
|
set: { bindAcquirerSaleUserId = $0?.saleUserId }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var referralBindBinding: Binding<OrderNumberSheetItem?> {
|
||||||
|
Binding(
|
||||||
|
get: { referralBindOrderNumber.map(OrderNumberSheetItem.init(orderNumber:)) },
|
||||||
|
set: { referralBindOrderNumber = $0?.orderNumber }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func reload(showLoading: Bool = true) async {
|
||||||
|
do {
|
||||||
|
try await globalLoading.withOptionalLoading(showLoading && viewModel.storeOrders.isEmpty, message: "加载中...") {
|
||||||
|
try await viewModel.reloadStoreOrders(
|
||||||
|
api: ordersAPI,
|
||||||
|
scenicId: currentScenicId,
|
||||||
|
appRole: currentAppRole,
|
||||||
|
showLoading: showLoading
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toastCenter.show(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadMore() async {
|
||||||
|
do {
|
||||||
|
try await viewModel.loadMoreStoreOrders(
|
||||||
|
api: ordersAPI,
|
||||||
|
scenicId: currentScenicId,
|
||||||
|
appRole: currentAppRole
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
toastCenter.show(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func verify(orderNumber: String) async {
|
||||||
|
guard let scenicId = currentScenicId else { return }
|
||||||
|
do {
|
||||||
|
try await globalLoading.withLoading(message: "核销中...") {
|
||||||
|
try await viewModel.verify(
|
||||||
|
api: ordersAPI,
|
||||||
|
scenicId: scenicId,
|
||||||
|
storeId: currentStoreId,
|
||||||
|
orderNumber: orderNumber
|
||||||
|
)
|
||||||
|
}
|
||||||
|
pendingVerifyOrderNumber = nil
|
||||||
|
toastCenter.show("核销成功")
|
||||||
|
} catch {
|
||||||
|
toastCenter.show(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleScanResult(_ rawCode: String) {
|
||||||
|
if SaleUserQrParser.isSaleUserBindQR(rawCode) {
|
||||||
|
guard let saleUserId = SaleUserQrParser.parseSaleUserId(rawCode), saleUserId > 0 else {
|
||||||
|
toastCenter.show("请扫描正确的获客员二维码")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bindAcquirerSaleUserId = saleUserId
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let orderNumber = OrderNumberParser.parse(rawCode) else {
|
||||||
|
toastCenter.show("扫码成功,但未识别到有效订单号。")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pendingVerifyOrderNumber = orderNumber
|
||||||
|
showVerifyConfirmation = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func dialPhone(_ phone: String) {
|
||||||
|
let trimmed = phone.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty, let url = URL(string: "tel:\(trimmed)") else { return }
|
||||||
|
openURL(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
309
suixinkan/Features/Orders/Views/WriteOffListView.swift
Normal file
309
suixinkan/Features/Orders/Views/WriteOffListView.swift
Normal file
@ -0,0 +1,309 @@
|
|||||||
|
//
|
||||||
|
// WriteOffListView.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
// Created by Codex on 2026/6/29.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// 核销管理独立页,对齐 Android WriteOffListScreen。
|
||||||
|
struct WriteOffListView: View {
|
||||||
|
@EnvironmentObject private var accountContext: AccountContext
|
||||||
|
@EnvironmentObject private var appRouter: AppRouter
|
||||||
|
@Environment(\.ordersAPI) private var ordersAPI
|
||||||
|
@EnvironmentObject private var toastCenter: ToastCenter
|
||||||
|
@Environment(\.globalLoading) private var globalLoading
|
||||||
|
@Environment(\.openURL) private var openURL
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
@StateObject private var viewModel = WriteOffListViewModel()
|
||||||
|
|
||||||
|
@State private var manualOrderNumber = ""
|
||||||
|
@State private var showScanner = false
|
||||||
|
@State private var showVerifyConfirmation = false
|
||||||
|
@State private var pendingVerifyOrderNumber: String?
|
||||||
|
@State private var matchedScannedOrderNumber: String?
|
||||||
|
@State private var showStartDateSheet = false
|
||||||
|
@State private var showEndDateSheet = false
|
||||||
|
@State private var appliedSearchQuery = ""
|
||||||
|
|
||||||
|
private var currentScenicId: Int? { accountContext.currentScenic?.id }
|
||||||
|
private var currentStoreId: Int? { accountContext.currentStore?.id }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if currentScenicId == nil {
|
||||||
|
AppContentUnavailableView("缺少经营上下文", systemImage: "mountain.2")
|
||||||
|
} else {
|
||||||
|
listContent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(AppDesign.orderPageBackground.ignoresSafeArea())
|
||||||
|
.navigationTitle("核销管理")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.searchable(text: $viewModel.searchPhone, prompt: "搜索手机号")
|
||||||
|
.onSubmit(of: .search) {
|
||||||
|
appliedSearchQuery = OrderListSearchBehavior.normalized(viewModel.searchPhone)
|
||||||
|
Task { await reload() }
|
||||||
|
}
|
||||||
|
.onChange(of: viewModel.searchPhone) { newValue in
|
||||||
|
guard OrderListSearchBehavior.shouldReloadAfterClear(newText: newValue, appliedQuery: appliedSearchQuery) else { return }
|
||||||
|
appliedSearchQuery = ""
|
||||||
|
Task { await reload() }
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
await reload()
|
||||||
|
await consumePendingScanCodeIfNeeded()
|
||||||
|
}
|
||||||
|
.onChange(of: appRouter.pendingOrderScanCode) { _ in
|
||||||
|
Task { await consumePendingScanCodeIfNeeded() }
|
||||||
|
}
|
||||||
|
.fullScreenCover(isPresented: $showScanner) {
|
||||||
|
OrderScannerPage(
|
||||||
|
onClose: { showScanner = false },
|
||||||
|
onSuccess: { rawCode in
|
||||||
|
showScanner = false
|
||||||
|
handleScanResult(rawCode)
|
||||||
|
},
|
||||||
|
onFailure: { error in
|
||||||
|
showScanner = false
|
||||||
|
toastCenter.show(error.localizedDescription)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.confirmationDialog("确认核销该订单?", isPresented: $showVerifyConfirmation, titleVisibility: .visible) {
|
||||||
|
Button("确认核销") {
|
||||||
|
guard let orderNumber = pendingVerifyOrderNumber else { return }
|
||||||
|
Task { await verify(orderNumber: orderNumber) }
|
||||||
|
}
|
||||||
|
Button("取消", role: .cancel) {
|
||||||
|
pendingVerifyOrderNumber = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showStartDateSheet) {
|
||||||
|
singleDateSheet(title: "开始时间", date: viewModel.filterStartDate) { date in
|
||||||
|
viewModel.filterStartDate = date
|
||||||
|
Task { await reload() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showEndDateSheet) {
|
||||||
|
singleDateSheet(title: "结束时间", date: viewModel.filterEndDate) { date in
|
||||||
|
viewModel.filterEndDate = date
|
||||||
|
Task { await reload() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var listContent: some View {
|
||||||
|
ScrollViewReader { scrollProxy in
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
WriteOffStatusTabBar(
|
||||||
|
filters: OrderFilters.writeOffStatusFilters,
|
||||||
|
selectedStatus: viewModel.selectedStatus,
|
||||||
|
onSelect: { status in
|
||||||
|
viewModel.selectedStatus = status
|
||||||
|
Task { await reload() }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
dateFilterSection
|
||||||
|
|
||||||
|
if viewModel.loading && viewModel.orders.isEmpty {
|
||||||
|
ProgressView().frame(maxWidth: .infinity).padding(.vertical, 40)
|
||||||
|
} else if viewModel.orders.isEmpty {
|
||||||
|
AppContentUnavailableView("暂无核销订单", systemImage: "tray")
|
||||||
|
.frame(minHeight: 220)
|
||||||
|
} else {
|
||||||
|
ForEach(viewModel.orders) { item in
|
||||||
|
writeOffCard(item)
|
||||||
|
.id(item.orderNumber)
|
||||||
|
.onAppear {
|
||||||
|
guard item.id == viewModel.orders.last?.id else { return }
|
||||||
|
Task { await loadMore() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if viewModel.loadingMore {
|
||||||
|
ProgressView().frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.bottom, 16)
|
||||||
|
}
|
||||||
|
.onChange(of: matchedScannedOrderNumber) { orderNumber in
|
||||||
|
guard let orderNumber else { return }
|
||||||
|
withAnimation(.easeInOut(duration: 0.25)) {
|
||||||
|
scrollProxy.scrollTo(orderNumber, anchor: .center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.refreshable { await reload(showLoading: false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var dateFilterSection: some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
dateFilterButton(title: "开始时间", date: viewModel.filterStartDate) {
|
||||||
|
showStartDateSheet = true
|
||||||
|
}
|
||||||
|
dateFilterButton(title: "结束时间", date: viewModel.filterEndDate) {
|
||||||
|
showEndDateSheet = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func dateFilterButton(title: String, date: Date?, action: @escaping () -> Void) -> some View {
|
||||||
|
Button(action: action) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "calendar")
|
||||||
|
Text(date.map { OrderDateRangeFormatter.displayText(start: $0, end: nil, placeholder: title) } ?? title)
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
.foregroundStyle(AppDesign.orderLabelColor)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 35)
|
||||||
|
.background(AppDesign.orderInputBackground, in: RoundedRectangle(cornerRadius: 4))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func writeOffCard(_ item: WriteOffOrderItem) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Text("订单号:\(item.orderNumber)")
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
Spacer()
|
||||||
|
OrderStatusChip(
|
||||||
|
text: viewModel.displayVerificationStatus(for: item),
|
||||||
|
status: viewModel.canVerify(item) ? 18 : 30
|
||||||
|
)
|
||||||
|
}
|
||||||
|
OrderInfoRow(title: "项目名称", value: item.projectName.isEmpty ? "--" : item.projectName)
|
||||||
|
OrderInfoRow(title: "手机号", value: item.userPhone.isEmpty ? "--" : item.userPhone) {
|
||||||
|
HStack(spacing: 7) {
|
||||||
|
Text(item.userPhone)
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
OrderPhoneCallButton {
|
||||||
|
dialPhone(item.userPhone)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OrderInfoRow(title: "订单状态", value: item.orderStatusName.isEmpty ? "--" : item.orderStatusName)
|
||||||
|
HStack {
|
||||||
|
Text("¥\(item.orderAmount)")
|
||||||
|
.font(.system(size: 18, weight: .bold))
|
||||||
|
.foregroundStyle(AppDesign.primary)
|
||||||
|
Spacer()
|
||||||
|
Button(viewModel.canVerify(item) ? "核销" : "已处理") {
|
||||||
|
pendingVerifyOrderNumber = item.orderNumber
|
||||||
|
showVerifyConfirmation = true
|
||||||
|
}
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.frame(height: 32)
|
||||||
|
.background(
|
||||||
|
viewModel.canVerify(item) ? AppDesign.primary : Color(hex: 0x9CA3AF),
|
||||||
|
in: RoundedRectangle(cornerRadius: 8)
|
||||||
|
)
|
||||||
|
.disabled(!viewModel.canVerify(item) || viewModel.isVerifying)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(15)
|
||||||
|
.background(
|
||||||
|
matchedScannedOrderNumber == item.orderNumber ? Color(hex: 0xEEF6FF) : .white,
|
||||||
|
in: RoundedRectangle(cornerRadius: 12)
|
||||||
|
)
|
||||||
|
.overlay {
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.stroke(matchedScannedOrderNumber == item.orderNumber ? AppDesign.primary : .clear, lineWidth: 2)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func singleDateSheet(title: String, date: Date?, onConfirm: @escaping (Date?) -> Void) -> some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
DatePicker(title, selection: Binding(
|
||||||
|
get: { date ?? Date() },
|
||||||
|
set: { onConfirm($0) }
|
||||||
|
), displayedComponents: .date)
|
||||||
|
}
|
||||||
|
.navigationTitle(title)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("清空") { onConfirm(nil) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func reload(showLoading: Bool = true) async {
|
||||||
|
do {
|
||||||
|
try await globalLoading.withOptionalLoading(showLoading && viewModel.orders.isEmpty, message: "加载中...") {
|
||||||
|
try await viewModel.reload(
|
||||||
|
api: ordersAPI,
|
||||||
|
scenicId: currentScenicId,
|
||||||
|
storeId: currentStoreId,
|
||||||
|
showLoading: showLoading
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toastCenter.show(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadMore() async {
|
||||||
|
do {
|
||||||
|
try await viewModel.loadMore(api: ordersAPI, scenicId: currentScenicId, storeId: currentStoreId)
|
||||||
|
} catch {
|
||||||
|
toastCenter.show(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func verify(orderNumber: String) async {
|
||||||
|
guard let scenicId = currentScenicId else { return }
|
||||||
|
do {
|
||||||
|
try await globalLoading.withLoading(message: "核销中...") {
|
||||||
|
try await viewModel.verify(
|
||||||
|
api: ordersAPI,
|
||||||
|
scenicId: scenicId,
|
||||||
|
storeId: currentStoreId,
|
||||||
|
orderNumber: orderNumber
|
||||||
|
)
|
||||||
|
}
|
||||||
|
manualOrderNumber = ""
|
||||||
|
pendingVerifyOrderNumber = nil
|
||||||
|
matchedScannedOrderNumber = nil
|
||||||
|
toastCenter.show("核销成功")
|
||||||
|
} catch {
|
||||||
|
toastCenter.show(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func consumePendingScanCodeIfNeeded() async {
|
||||||
|
guard let rawCode = appRouter.consumePendingOrderScanCode() else { return }
|
||||||
|
handleScanResult(rawCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleScanResult(_ rawCode: String) {
|
||||||
|
guard let result = viewModel.matchedWriteOffOrder(for: rawCode) else {
|
||||||
|
toastCenter.show("扫码成功,但未识别到有效订单号。")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
manualOrderNumber = result.orderNumber
|
||||||
|
pendingVerifyOrderNumber = result.orderNumber
|
||||||
|
if let matched = result.matched {
|
||||||
|
matchedScannedOrderNumber = matched.orderNumber
|
||||||
|
}
|
||||||
|
showVerifyConfirmation = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func dialPhone(_ phone: String) {
|
||||||
|
let trimmed = phone.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty, let url = URL(string: "tel:\(trimmed)") else { return }
|
||||||
|
openURL(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -37,8 +37,8 @@ DEBUG 构建下,“我的”列表会额外展示“首页调试”入口,
|
|||||||
|
|
||||||
## 加载流程
|
## 加载流程
|
||||||
|
|
||||||
1. `ProfileView.task` 进入页面时调用 `reloadProfile(showToast: false)`。
|
1. `ProfileView.task` 进入页面时调用 `reloadProfile()`。
|
||||||
2. 下拉刷新时调用 `reloadProfile(showToast: true)`。
|
2. 下拉刷新时同样调用 `reloadProfile()`,成功后不展示 Toast。
|
||||||
3. `ProfileViewModel.reload` 并发请求:
|
3. `ProfileViewModel.reload` 并发请求:
|
||||||
- `ProfileAPI.userInfo`
|
- `ProfileAPI.userInfo`
|
||||||
- `ProfileAPI.realNameInfo`
|
- `ProfileAPI.realNameInfo`
|
||||||
@ -111,6 +111,7 @@ DEBUG 构建下,“我的”列表会额外展示“首页调试”入口,
|
|||||||
|
|
||||||
## 状态展示规则
|
## 状态展示规则
|
||||||
|
|
||||||
|
- 进入个人信息页时,先用 `AccountContext` 中已恢复的本地缓存(`AccountSnapshot`)预填充昵称、手机号和头像;接口返回后再替换为最新数据。
|
||||||
- 昵称为空时展示“未设置昵称”。
|
- 昵称为空时展示“未设置昵称”。
|
||||||
- 真实姓名、手机号和账号状态为空时展示 `--`。
|
- 真实姓名、手机号和账号状态为空时展示 `--`。
|
||||||
- 实名认证状态:
|
- 实名认证状态:
|
||||||
|
|||||||
@ -20,9 +20,13 @@ final class ProfileViewModel: ObservableObject {
|
|||||||
@Published private(set) var pendingAvatarData: Data?
|
@Published private(set) var pendingAvatarData: Data?
|
||||||
@Published private(set) var pendingAvatarFileName: String?
|
@Published private(set) var pendingAvatarFileName: String?
|
||||||
@Published private(set) var avatarUploadProgress: Int?
|
@Published private(set) var avatarUploadProgress: Int?
|
||||||
|
/// 接口返回前使用的本地账号资料缓存,来自 AccountContext / AccountSnapshot。
|
||||||
|
private var cachedProfile: AccountProfile?
|
||||||
|
|
||||||
var displayNickname: String {
|
var displayNickname: String {
|
||||||
nonEmpty(userInfo?.nickname) ?? "未设置昵称"
|
nonEmpty(userInfo?.nickname)
|
||||||
|
?? nonEmpty(cachedProfile?.displayName)
|
||||||
|
?? "未设置昵称"
|
||||||
}
|
}
|
||||||
|
|
||||||
var displayRealName: String {
|
var displayRealName: String {
|
||||||
@ -30,11 +34,11 @@ final class ProfileViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var displayPhone: String {
|
var displayPhone: String {
|
||||||
nonEmpty(userInfo?.phone) ?? "--"
|
nonEmpty(userInfo?.phone) ?? nonEmpty(cachedProfile?.phone) ?? "--"
|
||||||
}
|
}
|
||||||
|
|
||||||
var displayAvatarURL: String {
|
var displayAvatarURL: String {
|
||||||
nonEmpty(userInfo?.avatar) ?? ""
|
nonEmpty(userInfo?.avatar) ?? nonEmpty(cachedProfile?.avatarURL) ?? ""
|
||||||
}
|
}
|
||||||
|
|
||||||
var accountStatusText: String {
|
var accountStatusText: String {
|
||||||
@ -53,6 +57,12 @@ final class ProfileViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 用本地缓存的账号资料预填充展示字段,避免冷启动或重新进入页面时先显示占位文案。
|
||||||
|
func applyCachedProfile(_ profile: AccountProfile?) {
|
||||||
|
guard userInfo == nil else { return }
|
||||||
|
cachedProfile = profile
|
||||||
|
}
|
||||||
|
|
||||||
/// 重新加载用户资料和实名认证状态。
|
/// 重新加载用户资料和实名认证状态。
|
||||||
func reload(api: ProfileAPI) async throws {
|
func reload(api: ProfileAPI) async throws {
|
||||||
guard !isLoading else { return }
|
guard !isLoading else { return }
|
||||||
|
|||||||
@ -46,6 +46,9 @@ struct DebugHomeMenuPreviewView: View {
|
|||||||
appRouter.select(tab)
|
appRouter.select(tab)
|
||||||
case .orders(let entry):
|
case .orders(let entry):
|
||||||
appRouter.selectOrders(entry: entry)
|
appRouter.selectOrders(entry: entry)
|
||||||
|
case .orderPush(let route):
|
||||||
|
appRouter.select(.orders)
|
||||||
|
appRouter.router(for: .orders).navigate(to: .orders(route))
|
||||||
case .destination(let homeRoute):
|
case .destination(let homeRoute):
|
||||||
router.navigate(to: .home(homeRoute))
|
router.navigate(to: .home(homeRoute))
|
||||||
case .unsupported(_, let title, let reason):
|
case .unsupported(_, let title, let reason):
|
||||||
@ -102,6 +105,8 @@ private struct DebugHomeMenuRow: View {
|
|||||||
return "Tab"
|
return "Tab"
|
||||||
case .orders:
|
case .orders:
|
||||||
return "订单"
|
return "订单"
|
||||||
|
case .orderPush:
|
||||||
|
return "订单页"
|
||||||
case .destination(let homeRoute):
|
case .destination(let homeRoute):
|
||||||
if case .modulePlaceholder = homeRoute {
|
if case .modulePlaceholder = homeRoute {
|
||||||
return "占位"
|
return "占位"
|
||||||
@ -119,7 +124,7 @@ private struct DebugHomeMenuRow: View {
|
|||||||
switch route {
|
switch route {
|
||||||
case .tab:
|
case .tab:
|
||||||
return "rectangle.grid.1x2"
|
return "rectangle.grid.1x2"
|
||||||
case .orders:
|
case .orders, .orderPush:
|
||||||
return "doc.text"
|
return "doc.text"
|
||||||
case .destination(let homeRoute):
|
case .destination(let homeRoute):
|
||||||
if case .modulePlaceholder = homeRoute {
|
if case .modulePlaceholder = homeRoute {
|
||||||
@ -136,7 +141,7 @@ private struct DebugHomeMenuRow: View {
|
|||||||
/// 返回当前路由类型颜色。
|
/// 返回当前路由类型颜色。
|
||||||
private var iconColor: Color {
|
private var iconColor: Color {
|
||||||
switch route {
|
switch route {
|
||||||
case .tab, .orders, .destination:
|
case .tab, .orders, .orderPush, .destination:
|
||||||
AppDesign.primary
|
AppDesign.primary
|
||||||
case .unsupported:
|
case .unsupported:
|
||||||
.red
|
.red
|
||||||
|
|||||||
@ -51,10 +51,11 @@ struct ProfileView: View {
|
|||||||
.navigationTitle("个人信息")
|
.navigationTitle("个人信息")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.task {
|
.task {
|
||||||
await reloadProfile(showToast: false)
|
viewModel.applyCachedProfile(accountContext.profile)
|
||||||
|
await reloadProfile()
|
||||||
}
|
}
|
||||||
.refreshable {
|
.refreshable {
|
||||||
await reloadProfile(showToast: true)
|
await reloadProfile()
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showsPasswordSheet) {
|
.sheet(isPresented: $showsPasswordSheet) {
|
||||||
PasswordUpdateSheet { password in
|
PasswordUpdateSheet { password in
|
||||||
@ -517,9 +518,9 @@ struct ProfileView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 重新拉取个人资料,并同步更新全局账号资料。
|
/// 重新拉取个人资料,并同步更新全局账号资料。
|
||||||
private func reloadProfile(showToast: Bool) async {
|
private func reloadProfile() async {
|
||||||
do {
|
do {
|
||||||
try await globalLoading.withOptionalLoading(!showToast && viewModel.userInfo == nil, message: "加载中...") {
|
try await globalLoading.withOptionalLoading(viewModel.userInfo == nil, message: "加载中...") {
|
||||||
try await viewModel.reload(api: profileAPI)
|
try await viewModel.reload(api: profileAPI)
|
||||||
}
|
}
|
||||||
if let userInfo = viewModel.userInfo {
|
if let userInfo = viewModel.userInfo {
|
||||||
@ -527,9 +528,6 @@ struct ProfileView: View {
|
|||||||
} else {
|
} else {
|
||||||
accountContext.replaceProfile(viewModel.accountProfileFallback(accountContext.profile))
|
accountContext.replaceProfile(viewModel.accountProfileFallback(accountContext.profile))
|
||||||
}
|
}
|
||||||
if showToast {
|
|
||||||
toastCenter.show("个人信息已刷新")
|
|
||||||
}
|
|
||||||
} catch is CancellationError {
|
} catch is CancellationError {
|
||||||
return
|
return
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@ -11,6 +11,18 @@ import XCTest
|
|||||||
@MainActor
|
@MainActor
|
||||||
/// 账号上下文测试,覆盖景区、门店和角色权限的选择恢复。
|
/// 账号上下文测试,覆盖景区、门店和角色权限的选择恢复。
|
||||||
final class AccountContextTests: XCTestCase {
|
final class AccountContextTests: XCTestCase {
|
||||||
|
/// 测试账号缓存前缀与 Android 的账号作用域规则一致。
|
||||||
|
func testAccountCachePrefixUsesAccountTypeAndUserId() {
|
||||||
|
let context = AccountContext()
|
||||||
|
|
||||||
|
context.applyLogin(
|
||||||
|
profile: AccountProfile(userId: "101", displayName: "测试账号"),
|
||||||
|
accountType: "store_user"
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(context.accountCachePrefix, "store_user_101")
|
||||||
|
}
|
||||||
|
|
||||||
/// 测试 replaceScopes 能按缓存 ID 恢复当前景区和门店。
|
/// 测试 replaceScopes 能按缓存 ID 恢复当前景区和门店。
|
||||||
func testReplaceScopesRestoresCurrentSelectionsById() {
|
func testReplaceScopesRestoresCurrentSelectionsById() {
|
||||||
let context = AccountContext()
|
let context = AccountContext()
|
||||||
|
|||||||
45
suixinkanTests/Fixtures/store_order_list_success.json
Normal file
45
suixinkanTests/Fixtures/store_order_list_success.json
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"code": 100000,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"total": "2",
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"order_number": "STORE001",
|
||||||
|
"order_type": "19",
|
||||||
|
"order_type_label": "多点旅拍",
|
||||||
|
"created_at": "2026-05-23 09:00:00",
|
||||||
|
"order_status": "18",
|
||||||
|
"order_status_name": "已付定金",
|
||||||
|
"actual_pay_amount": "199.00",
|
||||||
|
"actual_refund_amount": "0",
|
||||||
|
"pay_type_name": "微信支付",
|
||||||
|
"pay_time": "2026-05-23 09:01:00",
|
||||||
|
"complete_time": "",
|
||||||
|
"user_id": "8001",
|
||||||
|
"phone": "13800000000",
|
||||||
|
"project_id": "6001",
|
||||||
|
"project_name": "店长套餐",
|
||||||
|
"remark": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order_number": "STORE002",
|
||||||
|
"order_type": 4,
|
||||||
|
"order_type_label": "线下产品",
|
||||||
|
"created_at": "2026-05-22 18:30:00",
|
||||||
|
"order_status": 30,
|
||||||
|
"order_status_name": "已完成",
|
||||||
|
"actual_pay_amount": "88.00",
|
||||||
|
"actual_refund_amount": "0",
|
||||||
|
"pay_type_name": "余额支付",
|
||||||
|
"pay_time": "2026-05-22 18:31:00",
|
||||||
|
"complete_time": "2026-05-22 19:00:00",
|
||||||
|
"user_id": 8002,
|
||||||
|
"phone": "13900000000",
|
||||||
|
"project_id": 6002,
|
||||||
|
"project_name": "线下体验",
|
||||||
|
"remark": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -76,7 +76,7 @@ final class HomeCommonMenuStoreTests: XCTestCase {
|
|||||||
XCTAssertEqual(uris, ["space_settings", "wallet", "cloud_management"])
|
XCTAssertEqual(uris, ["space_settings", "wallet", "cloud_management"])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 测试已保存 URI 会按当前权限过滤并解析同义 URI。
|
/// 测试已保存 URI 会按当前权限和 Android 白名单过滤并解析同义 URI。
|
||||||
func testLoadFiltersSavedUrisByCurrentPermissions() {
|
func testLoadFiltersSavedUrisByCurrentPermissions() {
|
||||||
let defaults = makeIsolatedDefaults()
|
let defaults = makeIsolatedDefaults()
|
||||||
defaults.set(["task_management", "unknown", "pm"], forKey: HomeCommonMenuStore.storageKeyPrefix + "photographer")
|
defaults.set(["task_management", "unknown", "pm"], forKey: HomeCommonMenuStore.storageKeyPrefix + "photographer")
|
||||||
@ -92,7 +92,41 @@ final class HomeCommonMenuStoreTests: XCTestCase {
|
|||||||
topLevelPermissionURIs: ["task_management_editor", "project_edit"]
|
topLevelPermissionURIs: ["task_management_editor", "project_edit"]
|
||||||
)
|
)
|
||||||
|
|
||||||
XCTAssertEqual(uris, ["task_management_editor", "project_edit"])
|
XCTAssertEqual(uris, ["task_management_editor"])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试同一角色不同账号使用独立的常用应用缓存。
|
||||||
|
func testLoadUsesAccountScopedStorageKeys() {
|
||||||
|
let defaults = makeIsolatedDefaults()
|
||||||
|
defaults.set(
|
||||||
|
["wallet"],
|
||||||
|
forKey: HomeCommonMenuStore.storageKey(roleCode: "photographer", accountScope: "scenic_user_101")
|
||||||
|
)
|
||||||
|
defaults.set(
|
||||||
|
["cloud_management"],
|
||||||
|
forKey: HomeCommonMenuStore.storageKey(roleCode: "photographer", accountScope: "store_user_101")
|
||||||
|
)
|
||||||
|
let store = HomeCommonMenuStore(defaults: defaults)
|
||||||
|
let menus = [
|
||||||
|
menu("wallet"),
|
||||||
|
menu("cloud_management")
|
||||||
|
]
|
||||||
|
|
||||||
|
let scenic = store.load(
|
||||||
|
menuItems: menus,
|
||||||
|
roleCode: "photographer",
|
||||||
|
accountScope: "scenic_user_101",
|
||||||
|
topLevelPermissionURIs: ["wallet", "cloud_management"]
|
||||||
|
)
|
||||||
|
let storeAccount = store.load(
|
||||||
|
menuItems: menus,
|
||||||
|
roleCode: "photographer",
|
||||||
|
accountScope: "store_user_101",
|
||||||
|
topLevelPermissionURIs: ["wallet", "cloud_management"]
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(scenic, ["wallet"])
|
||||||
|
XCTAssertEqual(storeAccount, ["cloud_management"])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 测试不同角色使用独立存储 key。
|
/// 测试不同角色使用独立存储 key。
|
||||||
@ -183,6 +217,94 @@ final class HomeCommonMenuStoreTests: XCTestCase {
|
|||||||
XCTAssertNil(defaults.stringArray(forKey: HomeCommonMenuStore.storageKeyPrefix + "46"))
|
XCTAssertNil(defaults.stringArray(forKey: HomeCommonMenuStore.storageKeyPrefix + "46"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 测试旧 roleCode key 会迁移到账号作用域 key。
|
||||||
|
func testLoadMigratesLegacyRoleCodeStorageKeyToAccountScopedKey() {
|
||||||
|
let defaults = makeIsolatedDefaults()
|
||||||
|
defaults.set(["wallet", "cloud_management"], forKey: HomeCommonMenuStore.storageKeyPrefix + "photographer")
|
||||||
|
let store = HomeCommonMenuStore(defaults: defaults)
|
||||||
|
let menus = [menu("wallet"), menu("cloud_management")]
|
||||||
|
|
||||||
|
let uris = store.load(
|
||||||
|
menuItems: menus,
|
||||||
|
roleCode: "photographer",
|
||||||
|
accountScope: "scenic_user_101",
|
||||||
|
topLevelPermissionURIs: ["wallet", "cloud_management"]
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(uris, ["wallet", "cloud_management"])
|
||||||
|
XCTAssertEqual(
|
||||||
|
defaults.stringArray(forKey: HomeCommonMenuStore.storageKey(roleCode: "photographer", accountScope: "scenic_user_101")),
|
||||||
|
["wallet", "cloud_management"]
|
||||||
|
)
|
||||||
|
XCTAssertNil(defaults.stringArray(forKey: HomeCommonMenuStore.storageKeyPrefix + "photographer"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试 Android 白名单外的 URI 不会进入默认常用应用。
|
||||||
|
func testDefaultCommonURIsExcludeMenusMissingFromAndroidCatalog() {
|
||||||
|
let defaults = makeIsolatedDefaults()
|
||||||
|
let store = HomeCommonMenuStore(defaults: defaults)
|
||||||
|
let menus = [
|
||||||
|
menu("wallet"),
|
||||||
|
menu("album_list"),
|
||||||
|
menu("cloud_storage_transit"),
|
||||||
|
menu("message_center")
|
||||||
|
]
|
||||||
|
|
||||||
|
let uris = store.load(
|
||||||
|
menuItems: menus,
|
||||||
|
roleCode: "photographer",
|
||||||
|
accountScope: "scenic_user_101",
|
||||||
|
topLevelPermissionURIs: ["wallet", "album_list", "cloud_storage_transit", "message_center"]
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(uris, ["wallet", "message_center"])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试已保存常用应用超过 4 个时只过滤权限,不会被强制截断。
|
||||||
|
func testSavedCommonURIsAreNotTruncatedToFour() {
|
||||||
|
let defaults = makeIsolatedDefaults()
|
||||||
|
defaults.set(
|
||||||
|
["space_settings", "wallet", "cloud_management", "asset_management", "message_center"],
|
||||||
|
forKey: HomeCommonMenuStore.storageKey(roleCode: "photographer", accountScope: "scenic_user_101")
|
||||||
|
)
|
||||||
|
let store = HomeCommonMenuStore(defaults: defaults)
|
||||||
|
let menus = [
|
||||||
|
menu("space_settings"),
|
||||||
|
menu("wallet"),
|
||||||
|
menu("cloud_management"),
|
||||||
|
menu("asset_management"),
|
||||||
|
menu("message_center")
|
||||||
|
]
|
||||||
|
|
||||||
|
let uris = store.load(
|
||||||
|
menuItems: menus,
|
||||||
|
roleCode: "photographer",
|
||||||
|
accountScope: "scenic_user_101",
|
||||||
|
topLevelPermissionURIs: menus.map(\.uri)
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(uris, ["space_settings", "wallet", "cloud_management", "asset_management", "message_center"])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试默认常用只取顶层权限,不会用子权限补位。
|
||||||
|
func testDefaultCommonURIsUseOnlyTopLevelPermissions() {
|
||||||
|
let defaults = makeIsolatedDefaults()
|
||||||
|
let store = HomeCommonMenuStore(defaults: defaults)
|
||||||
|
let menus = [
|
||||||
|
menu("task_management"),
|
||||||
|
menu("message_center")
|
||||||
|
]
|
||||||
|
|
||||||
|
let uris = store.load(
|
||||||
|
menuItems: menus,
|
||||||
|
roleCode: "photographer",
|
||||||
|
accountScope: "scenic_user_101",
|
||||||
|
topLevelPermissionURIs: ["task_management"]
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(uris, ["task_management"])
|
||||||
|
}
|
||||||
|
|
||||||
/// 创建测试菜单实体。
|
/// 创建测试菜单实体。
|
||||||
private func menu(_ uri: String) -> HomeMenuItem {
|
private func menu(_ uri: String) -> HomeMenuItem {
|
||||||
HomeMenuItem(title: HomeMenuRouter.title(for: uri), uri: uri, iconSrc: nil)
|
HomeMenuItem(title: HomeMenuRouter.title(for: uri), uri: uri, iconSrc: nil)
|
||||||
|
|||||||
@ -73,7 +73,7 @@ final class HomeMenuRouterTests: XCTestCase {
|
|||||||
func testTabRoutesStayCentralized() {
|
func testTabRoutesStayCentralized() {
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "photographer_orders", title: ""), .orders(.storeOrders))
|
XCTAssertEqual(HomeMenuRouter.resolve(uri: "photographer_orders", title: ""), .orders(.storeOrders))
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "/scenic-order-manage", title: ""), .orders(.storeOrders))
|
XCTAssertEqual(HomeMenuRouter.resolve(uri: "/scenic-order-manage", title: ""), .orders(.storeOrders))
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "verification_order", title: ""), .orders(.verificationOrders))
|
XCTAssertEqual(HomeMenuRouter.resolve(uri: "verification_order", title: ""), .orderPush(.writeOffList))
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "photographer_stats", title: ""), .tab(.statistics))
|
XCTAssertEqual(HomeMenuRouter.resolve(uri: "photographer_stats", title: ""), .tab(.statistics))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -88,7 +88,17 @@ private final class MockMainTabOrderService: OrderServing {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 记录核销列表请求并返回预设响应。
|
/// 记录核销列表请求并返回预设响应。
|
||||||
func writeOffList(scenicId: Int, storeId: Int?, page: Int, pageSize: Int) async throws -> ListPayload<WriteOffOrderItem> {
|
func writeOffList(
|
||||||
|
scenicId: Int,
|
||||||
|
storeId: Int?,
|
||||||
|
page: Int,
|
||||||
|
pageSize: Int,
|
||||||
|
orderStatus: Int?,
|
||||||
|
orderVerifyStatus: Int?,
|
||||||
|
startTime: String?,
|
||||||
|
endTime: String?,
|
||||||
|
userPhone: String?
|
||||||
|
) async throws -> ListPayload<WriteOffOrderItem> {
|
||||||
writeOffListCalls.append(.init(scenicId: scenicId, storeId: storeId, page: page, pageSize: pageSize))
|
writeOffListCalls.append(.init(scenicId: scenicId, storeId: storeId, page: page, pageSize: pageSize))
|
||||||
if let writeOffError {
|
if let writeOffError {
|
||||||
throw writeOffError
|
throw writeOffError
|
||||||
@ -96,6 +106,29 @@ private final class MockMainTabOrderService: OrderServing {
|
|||||||
return writeOffResponse
|
return writeOffResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func updateRefinedChoice(orderNumber: String, isRefined: Int) async throws {}
|
||||||
|
func cancelOrder(orderNumber: String) async throws {}
|
||||||
|
func sendGift(orderNumber: String, photoNum: Int, videoNum: Int) async throws {}
|
||||||
|
func storeOrderList(
|
||||||
|
storeId: Int?,
|
||||||
|
page: Int,
|
||||||
|
pageSize: Int,
|
||||||
|
userPhone: String?,
|
||||||
|
orderStatus: Int?,
|
||||||
|
orderType: Int?,
|
||||||
|
startTime: String?,
|
||||||
|
endTime: String?
|
||||||
|
) async throws -> ListPayload<StoreOrderListItem> {
|
||||||
|
ListPayload(total: 0, list: [])
|
||||||
|
}
|
||||||
|
func storeOrderRefund(
|
||||||
|
storeId: Int,
|
||||||
|
orderNumber: String,
|
||||||
|
refundType: OrderRefundMode,
|
||||||
|
refundAmount: String,
|
||||||
|
refundReason: String
|
||||||
|
) async throws {}
|
||||||
|
|
||||||
/// 角标测试不会触发核销操作。
|
/// 角标测试不会触发核销操作。
|
||||||
func writeOff(orderNumber: String) async throws {}
|
func writeOff(orderNumber: String) async throws {}
|
||||||
|
|
||||||
|
|||||||
@ -30,14 +30,14 @@ final class NavigationRouterTests: XCTestCase {
|
|||||||
XCTAssertEqual(router.selectedOrdersEntry, .verificationOrders)
|
XCTAssertEqual(router.selectedOrdersEntry, .verificationOrders)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 测试全局扫码核销路由会切到订单核销入口并保存扫码结果。
|
/// 测试全局扫码核销路由会切到核销管理页并保存扫码结果。
|
||||||
func testRouteToOrderVerificationStoresPendingScanCode() {
|
func testRouteToOrderVerificationStoresPendingScanCode() {
|
||||||
let router = AppRouter()
|
let router = AppRouter()
|
||||||
|
|
||||||
router.routeToOrderVerification(scannedCode: "order_number=VERIFY001")
|
router.routeToOrderVerification(scannedCode: "order_number=VERIFY001")
|
||||||
|
|
||||||
XCTAssertEqual(router.selectedTab, .orders)
|
XCTAssertEqual(router.selectedTab, .orders)
|
||||||
XCTAssertEqual(router.selectedOrdersEntry, .verificationOrders)
|
XCTAssertEqual(router.router(for: .orders).path.last, .orders(.writeOffList))
|
||||||
XCTAssertEqual(router.pendingOrderScanCode, "order_number=VERIFY001")
|
XCTAssertEqual(router.pendingOrderScanCode, "order_number=VERIFY001")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -447,10 +447,43 @@ private final class LongTailMockOrderService: OrderServing {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 核销列表接口在长尾测试中不使用。
|
/// 核销列表接口在长尾测试中不使用。
|
||||||
func writeOffList(scenicId: Int, storeId: Int?, page: Int, pageSize: Int) async throws -> ListPayload<WriteOffOrderItem> {
|
func writeOffList(
|
||||||
|
scenicId: Int,
|
||||||
|
storeId: Int?,
|
||||||
|
page: Int,
|
||||||
|
pageSize: Int,
|
||||||
|
orderStatus: Int?,
|
||||||
|
orderVerifyStatus: Int?,
|
||||||
|
startTime: String?,
|
||||||
|
endTime: String?,
|
||||||
|
userPhone: String?
|
||||||
|
) async throws -> ListPayload<WriteOffOrderItem> {
|
||||||
ListPayload(total: 0, list: [])
|
ListPayload(total: 0, list: [])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func updateRefinedChoice(orderNumber: String, isRefined: Int) async throws {}
|
||||||
|
func cancelOrder(orderNumber: String) async throws {}
|
||||||
|
func sendGift(orderNumber: String, photoNum: Int, videoNum: Int) async throws {}
|
||||||
|
func storeOrderList(
|
||||||
|
storeId: Int?,
|
||||||
|
page: Int,
|
||||||
|
pageSize: Int,
|
||||||
|
userPhone: String?,
|
||||||
|
orderStatus: Int?,
|
||||||
|
orderType: Int?,
|
||||||
|
startTime: String?,
|
||||||
|
endTime: String?
|
||||||
|
) async throws -> ListPayload<StoreOrderListItem> {
|
||||||
|
ListPayload(total: 0, list: [])
|
||||||
|
}
|
||||||
|
func storeOrderRefund(
|
||||||
|
storeId: Int,
|
||||||
|
orderNumber: String,
|
||||||
|
refundType: OrderRefundMode,
|
||||||
|
refundAmount: String,
|
||||||
|
refundReason: String
|
||||||
|
) async throws {}
|
||||||
|
|
||||||
/// 普通核销接口在长尾测试中不使用。
|
/// 普通核销接口在长尾测试中不使用。
|
||||||
func writeOff(orderNumber: String) async throws {}
|
func writeOff(orderNumber: String) async throws {}
|
||||||
|
|
||||||
|
|||||||
@ -223,6 +223,93 @@ final class OrdersViewModelTests: XCTestCase {
|
|||||||
XCTAssertEqual(query["end_time"], "2026-05-31")
|
XCTAssertEqual(query["end_time"], "2026-05-31")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 测试精修选择更新会同步本地列表状态。
|
||||||
|
func testUpdateRefinedChoiceUpdatesLocalOrder() 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, appRole: nil)
|
||||||
|
|
||||||
|
try await viewModel.updateRefinedChoice(api: api, orderNumber: "ORDV2001", isRefined: 0)
|
||||||
|
|
||||||
|
XCTAssertEqual(api.refinedUpdates.count, 1)
|
||||||
|
XCTAssertEqual(api.refinedUpdates[0].0, "ORDV2001")
|
||||||
|
XCTAssertEqual(api.refinedUpdates[0].1, 0)
|
||||||
|
let order = try XCTUnwrap(viewModel.storeOrders.first { $0.orderNumber == "ORDV2001" })
|
||||||
|
XCTAssertEqual(order.isRefined, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试取消订单会调用接口并刷新列表。
|
||||||
|
func testCancelOrderRefreshesStoreOrders() 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()
|
||||||
|
try await viewModel.reloadStoreOrders(api: api, scenicId: 88, appRole: nil)
|
||||||
|
|
||||||
|
try await viewModel.cancelOrder(api: api, scenicId: 88, appRole: nil, orderNumber: "ORDV2001")
|
||||||
|
|
||||||
|
XCTAssertEqual(api.cancelledOrders, ["ORDV2001"])
|
||||||
|
XCTAssertEqual(api.orderListCalls.count, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试赠送弹窗上下文与提交流程。
|
||||||
|
func testSendGiftFlowPreparesAndSubmits() 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()
|
||||||
|
try await viewModel.reloadStoreOrders(api: api, scenicId: 88, appRole: nil)
|
||||||
|
let order = try XCTUnwrap(viewModel.storeOrders.first)
|
||||||
|
|
||||||
|
viewModel.prepareSendGift(for: order)
|
||||||
|
XCTAssertEqual(viewModel.sendGiftContext?.orderNumber, order.orderNumber)
|
||||||
|
XCTAssertEqual(viewModel.sendGiftContext?.sendPhotoNum, 2)
|
||||||
|
|
||||||
|
try await viewModel.submitSendGift(api: api, scenicId: 88, appRole: nil, photoNum: 3, videoNum: 1)
|
||||||
|
|
||||||
|
XCTAssertEqual(api.sendGiftCalls.count, 1)
|
||||||
|
XCTAssertEqual(api.sendGiftCalls[0].0, order.orderNumber)
|
||||||
|
XCTAssertEqual(api.sendGiftCalls[0].1, 3)
|
||||||
|
XCTAssertEqual(api.sendGiftCalls[0].2, 1)
|
||||||
|
XCTAssertNil(viewModel.sendGiftContext)
|
||||||
|
XCTAssertEqual(api.orderListCalls.count, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试退款按钮展示条件。
|
||||||
|
func testShouldShowRefundButtonConditions() 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, appRole: nil)
|
||||||
|
|
||||||
|
let multiTravel = try XCTUnwrap(viewModel.storeOrders.first { $0.orderType == 19 })
|
||||||
|
let deposit = try XCTUnwrap(viewModel.storeOrders.first { $0.orderType == 4 })
|
||||||
|
|
||||||
|
XCTAssertFalse(viewModel.shouldShowRefundButton(order: multiTravel, appRole: nil))
|
||||||
|
XCTAssertTrue(viewModel.shouldShowRefundButton(order: deposit, appRole: nil))
|
||||||
|
XCTAssertFalse(viewModel.shouldShowRefundButton(order: deposit, appRole: .scenicAdmin))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试核销按钮展示条件。
|
||||||
|
func testShouldShowVerifyButtonConditions() 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, appRole: nil)
|
||||||
|
|
||||||
|
let multiTravel = try XCTUnwrap(viewModel.storeOrders.first { $0.orderType == 19 })
|
||||||
|
let deposit = try XCTUnwrap(viewModel.storeOrders.first { $0.orderType == 4 })
|
||||||
|
|
||||||
|
XCTAssertTrue(viewModel.shouldShowVerifyButton(order: multiTravel, appRole: nil))
|
||||||
|
XCTAssertFalse(viewModel.shouldShowVerifyButton(order: deposit, appRole: nil))
|
||||||
|
XCTAssertFalse(viewModel.shouldShowVerifyButton(order: multiTravel, appRole: .scenicAdmin))
|
||||||
|
}
|
||||||
|
|
||||||
private static func date(_ text: String) -> Date {
|
private static func date(_ text: String) -> Date {
|
||||||
let formatter = DateFormatter()
|
let formatter = DateFormatter()
|
||||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||||
@ -261,6 +348,9 @@ private final class MockOrderService: OrderServing {
|
|||||||
private(set) var writeOffListCalls: [WriteOffListCall] = []
|
private(set) var writeOffListCalls: [WriteOffListCall] = []
|
||||||
private(set) var detailCalls: [(storeId: Int, orderNumber: String)] = []
|
private(set) var detailCalls: [(storeId: Int, orderNumber: String)] = []
|
||||||
private(set) var verifiedOrderNumbers: [String] = []
|
private(set) var verifiedOrderNumbers: [String] = []
|
||||||
|
private(set) var refinedUpdates: [(String, Int)] = []
|
||||||
|
private(set) var cancelledOrders: [String] = []
|
||||||
|
private(set) var sendGiftCalls: [(String, Int, Int)] = []
|
||||||
|
|
||||||
/// 返回测试订单管理列表。
|
/// 返回测试订单管理列表。
|
||||||
func orderList(
|
func orderList(
|
||||||
@ -291,11 +381,50 @@ private final class MockOrderService: OrderServing {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 返回测试核销订单列表。
|
/// 返回测试核销订单列表。
|
||||||
func writeOffList(scenicId: Int, storeId: Int?, page: Int, pageSize: Int) async throws -> ListPayload<WriteOffOrderItem> {
|
func writeOffList(
|
||||||
|
scenicId: Int,
|
||||||
|
storeId: Int?,
|
||||||
|
page: Int,
|
||||||
|
pageSize: Int,
|
||||||
|
orderStatus: Int?,
|
||||||
|
orderVerifyStatus: Int?,
|
||||||
|
startTime: String?,
|
||||||
|
endTime: String?,
|
||||||
|
userPhone: String?
|
||||||
|
) async throws -> ListPayload<WriteOffOrderItem> {
|
||||||
writeOffListCalls.append(WriteOffListCall(scenicId: scenicId, storeId: storeId, page: page, pageSize: pageSize))
|
writeOffListCalls.append(WriteOffListCall(scenicId: scenicId, storeId: storeId, page: page, pageSize: pageSize))
|
||||||
return writeOffResponses.isEmpty ? ListPayload(total: 0, list: []) : writeOffResponses.removeFirst()
|
return writeOffResponses.isEmpty ? ListPayload(total: 0, list: []) : writeOffResponses.removeFirst()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func updateRefinedChoice(orderNumber: String, isRefined: Int) async throws {
|
||||||
|
refinedUpdates.append((orderNumber, isRefined))
|
||||||
|
}
|
||||||
|
func cancelOrder(orderNumber: String) async throws {
|
||||||
|
cancelledOrders.append(orderNumber)
|
||||||
|
}
|
||||||
|
func sendGift(orderNumber: String, photoNum: Int, videoNum: Int) async throws {
|
||||||
|
sendGiftCalls.append((orderNumber, photoNum, videoNum))
|
||||||
|
}
|
||||||
|
func storeOrderList(
|
||||||
|
storeId: Int?,
|
||||||
|
page: Int,
|
||||||
|
pageSize: Int,
|
||||||
|
userPhone: String?,
|
||||||
|
orderStatus: Int?,
|
||||||
|
orderType: Int?,
|
||||||
|
startTime: String?,
|
||||||
|
endTime: String?
|
||||||
|
) async throws -> ListPayload<StoreOrderListItem> {
|
||||||
|
ListPayload(total: 0, list: [])
|
||||||
|
}
|
||||||
|
func storeOrderRefund(
|
||||||
|
storeId: Int,
|
||||||
|
orderNumber: String,
|
||||||
|
refundType: OrderRefundMode,
|
||||||
|
refundAmount: String,
|
||||||
|
refundReason: String
|
||||||
|
) async throws {}
|
||||||
|
|
||||||
/// 记录测试核销订单号。
|
/// 记录测试核销订单号。
|
||||||
func writeOff(orderNumber: String) async throws {
|
func writeOff(orderNumber: String) async throws {
|
||||||
verifiedOrderNumbers.append(orderNumber)
|
verifiedOrderNumbers.append(orderNumber)
|
||||||
|
|||||||
@ -171,6 +171,35 @@ final class ProfileSecondaryPagesTests: XCTestCase {
|
|||||||
XCTAssertFalse(RealNameAuthViewModel.isValidMainlandIDCardNumber("123"))
|
XCTAssertFalse(RealNameAuthViewModel.isValidMainlandIDCardNumber("123"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 测试接口返回前使用本地缓存展示昵称、手机号和头像。
|
||||||
|
func testProfileDisplayFieldsUseCachedProfileBeforeUserInfoLoads() {
|
||||||
|
let viewModel = ProfileViewModel()
|
||||||
|
viewModel.applyCachedProfile(
|
||||||
|
AccountProfile(
|
||||||
|
userId: "101",
|
||||||
|
displayName: "缓存昵称",
|
||||||
|
phone: "13800000000",
|
||||||
|
avatarURL: "https://cdn.example.com/avatar.jpg"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(viewModel.displayNickname, "缓存昵称")
|
||||||
|
XCTAssertEqual(viewModel.displayPhone, "13800000000")
|
||||||
|
XCTAssertEqual(viewModel.displayAvatarURL, "https://cdn.example.com/avatar.jpg")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试接口数据已存在时不再被本地缓存覆盖。
|
||||||
|
func testApplyCachedProfileIgnoredAfterUserInfoLoaded() {
|
||||||
|
let viewModel = ProfileViewModel()
|
||||||
|
viewModel.userInfo = UserInfoResponse(nickname: "接口昵称", phone: "13900000000")
|
||||||
|
viewModel.applyCachedProfile(
|
||||||
|
AccountProfile(userId: "101", displayName: "缓存昵称", phone: "13800000000")
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(viewModel.displayNickname, "接口昵称")
|
||||||
|
XCTAssertEqual(viewModel.displayPhone, "13900000000")
|
||||||
|
}
|
||||||
|
|
||||||
/// 测试头像变更时先上传 OSS,再回写头像 URL。
|
/// 测试头像变更时先上传 OSS,再回写头像 URL。
|
||||||
func testProfileAvatarUploadUpdatesAvatarURL() async throws {
|
func testProfileAvatarUploadUpdatesAvatarURL() async throws {
|
||||||
let session = ProfileSecondaryURLSession(responses: [
|
let session = ProfileSecondaryURLSession(responses: [
|
||||||
|
|||||||
@ -94,7 +94,7 @@ final class PushNotificationTests: XCTestCase {
|
|||||||
|
|
||||||
manager.handleRemoteNotification(PushPayload(userInfo: ["route": "verification_order"]))
|
manager.handleRemoteNotification(PushPayload(userInfo: ["route": "verification_order"]))
|
||||||
XCTAssertEqual(router.selectedTab, .orders)
|
XCTAssertEqual(router.selectedTab, .orders)
|
||||||
XCTAssertEqual(router.selectedOrdersEntry, .verificationOrders)
|
XCTAssertEqual(router.router(for: .orders).path.last, .orders(.writeOffList))
|
||||||
}
|
}
|
||||||
|
|
||||||
private func pushManager(router: AppRouter) -> PushNotificationManager {
|
private func pushManager(router: AppRouter) -> PushNotificationManager {
|
||||||
|
|||||||
224
suixinkanTests/StoreAdminOrderListViewModelTests.swift
Normal file
224
suixinkanTests/StoreAdminOrderListViewModelTests.swift
Normal file
@ -0,0 +1,224 @@
|
|||||||
|
//
|
||||||
|
// StoreAdminOrderListViewModelTests.swift
|
||||||
|
// suixinkanTests
|
||||||
|
//
|
||||||
|
// Created by Codex on 2026/6/29.
|
||||||
|
//
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
@testable import suixinkan
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
/// 店长订单 Tab ViewModel 测试,覆盖列表分页、筛选与退款参数。
|
||||||
|
final class StoreAdminOrderListViewModelTests: XCTestCase {
|
||||||
|
/// 测试无门店时清空列表且不请求接口。
|
||||||
|
func testReloadClearsDataWhenStoreIsMissing() async throws {
|
||||||
|
let api = StoreAdminMockOrderService()
|
||||||
|
api.responses = [try TestFixture.payload(ListPayload<StoreOrderListItem>.self, named: "store_order_list_success")]
|
||||||
|
let viewModel = StoreAdminOrderListViewModel()
|
||||||
|
|
||||||
|
try await viewModel.reload(api: api, storeId: 66)
|
||||||
|
XCTAssertFalse(viewModel.orders.isEmpty)
|
||||||
|
|
||||||
|
try await viewModel.reload(api: api, storeId: nil)
|
||||||
|
|
||||||
|
XCTAssertTrue(viewModel.orders.isEmpty)
|
||||||
|
XCTAssertEqual(viewModel.total, 0)
|
||||||
|
XCTAssertEqual(viewModel.page, 1)
|
||||||
|
XCTAssertFalse(viewModel.hasMore)
|
||||||
|
XCTAssertEqual(api.listCalls.count, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试筛选参数会传入服务层并重置到第一页。
|
||||||
|
func testReloadUsesFilterQueries() async throws {
|
||||||
|
let api = StoreAdminMockOrderService()
|
||||||
|
api.responses = [
|
||||||
|
try TestFixture.payload(ListPayload<StoreOrderListItem>.self, named: "store_order_list_success"),
|
||||||
|
try TestFixture.payload(ListPayload<StoreOrderListItem>.self, named: "store_order_list_success")
|
||||||
|
]
|
||||||
|
let viewModel = StoreAdminOrderListViewModel()
|
||||||
|
viewModel.searchPhone = " 13800000000 "
|
||||||
|
viewModel.selectedStatus = 30
|
||||||
|
viewModel.filterStartDate = Self.date("2026-05-01")
|
||||||
|
viewModel.filterEndDate = Self.date("2026-05-31")
|
||||||
|
|
||||||
|
try await viewModel.reload(api: api, storeId: 66)
|
||||||
|
XCTAssertEqual(api.listCalls.count, 1)
|
||||||
|
XCTAssertEqual(api.listCalls[0].storeId, 66)
|
||||||
|
XCTAssertEqual(api.listCalls[0].userPhone, "13800000000")
|
||||||
|
XCTAssertEqual(api.listCalls[0].orderStatus, 30)
|
||||||
|
XCTAssertEqual(api.listCalls[0].startTime, "2026-05-01")
|
||||||
|
XCTAssertEqual(api.listCalls[0].endTime, "2026-05-31")
|
||||||
|
|
||||||
|
viewModel.selectedStatus = 0
|
||||||
|
viewModel.searchPhone = ""
|
||||||
|
try await viewModel.reload(api: api, storeId: 66)
|
||||||
|
XCTAssertEqual(api.listCalls[1].orderStatus, nil)
|
||||||
|
XCTAssertNil(api.listCalls[1].userPhone)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试最后一页不会继续请求。
|
||||||
|
func testLoadMoreStopsAfterLastPage() async throws {
|
||||||
|
let api = StoreAdminMockOrderService()
|
||||||
|
api.responses = [try TestFixture.payload(ListPayload<StoreOrderListItem>.self, named: "store_order_list_success")]
|
||||||
|
let viewModel = StoreAdminOrderListViewModel()
|
||||||
|
|
||||||
|
try await viewModel.reload(api: api, storeId: 66)
|
||||||
|
try await viewModel.loadMore(api: api, storeId: 66)
|
||||||
|
|
||||||
|
XCTAssertEqual(viewModel.orders.count, 2)
|
||||||
|
XCTAssertEqual(api.listCalls.count, 1)
|
||||||
|
XCTAssertFalse(viewModel.hasMore)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试店长退款会传入门店与订单参数。
|
||||||
|
func testRefundUsesStoreParameters() async throws {
|
||||||
|
let api = StoreAdminMockOrderService()
|
||||||
|
let viewModel = StoreAdminOrderListViewModel()
|
||||||
|
|
||||||
|
try await viewModel.refund(
|
||||||
|
api: api,
|
||||||
|
storeId: 66,
|
||||||
|
orderNumber: "STORE001",
|
||||||
|
mode: .partial,
|
||||||
|
amount: "50.00",
|
||||||
|
reason: "部分退款"
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(api.refundCalls.count, 1)
|
||||||
|
XCTAssertEqual(api.refundCalls[0].storeId, 66)
|
||||||
|
XCTAssertEqual(api.refundCalls[0].orderNumber, "STORE001")
|
||||||
|
XCTAssertEqual(api.refundCalls[0].mode, .partial)
|
||||||
|
XCTAssertEqual(api.refundCalls[0].amount, "50.00")
|
||||||
|
XCTAssertEqual(api.refundCalls[0].reason, "部分退款")
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
/// 店长订单列表测试替身,记录 storeOrderList 与退款调用。
|
||||||
|
private final class StoreAdminMockOrderService: OrderServing {
|
||||||
|
struct ListCall: Equatable {
|
||||||
|
let storeId: Int?
|
||||||
|
let page: Int
|
||||||
|
let userPhone: String?
|
||||||
|
let orderStatus: Int?
|
||||||
|
let startTime: String?
|
||||||
|
let endTime: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RefundCall: Equatable {
|
||||||
|
let storeId: Int
|
||||||
|
let orderNumber: String
|
||||||
|
let mode: OrderRefundMode
|
||||||
|
let amount: String
|
||||||
|
let reason: String
|
||||||
|
}
|
||||||
|
|
||||||
|
var responses: [ListPayload<StoreOrderListItem>] = []
|
||||||
|
private(set) var listCalls: [ListCall] = []
|
||||||
|
private(set) var refundCalls: [RefundCall] = []
|
||||||
|
|
||||||
|
func orderList(
|
||||||
|
scenicId: Int,
|
||||||
|
page: Int,
|
||||||
|
pageSize: Int,
|
||||||
|
orderStatus: Int?,
|
||||||
|
userPhone: String?,
|
||||||
|
startTime: String?,
|
||||||
|
endTime: String?,
|
||||||
|
isRefined: Int?,
|
||||||
|
isScenicAdmin: Bool
|
||||||
|
) async throws -> ListPayload<OrderEntity> {
|
||||||
|
ListPayload(total: 0, list: [])
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeOffList(
|
||||||
|
scenicId: Int,
|
||||||
|
storeId: Int?,
|
||||||
|
page: Int,
|
||||||
|
pageSize: Int,
|
||||||
|
orderStatus: Int?,
|
||||||
|
orderVerifyStatus: Int?,
|
||||||
|
startTime: String?,
|
||||||
|
endTime: String?,
|
||||||
|
userPhone: String?
|
||||||
|
) async throws -> ListPayload<WriteOffOrderItem> {
|
||||||
|
ListPayload(total: 0, list: [])
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateRefinedChoice(orderNumber: String, isRefined: Int) async throws {}
|
||||||
|
func cancelOrder(orderNumber: String) async throws {}
|
||||||
|
func sendGift(orderNumber: String, photoNum: Int, videoNum: Int) async throws {}
|
||||||
|
|
||||||
|
func storeOrderList(
|
||||||
|
storeId: Int?,
|
||||||
|
page: Int,
|
||||||
|
pageSize: Int,
|
||||||
|
userPhone: String?,
|
||||||
|
orderStatus: Int?,
|
||||||
|
orderType: Int?,
|
||||||
|
startTime: String?,
|
||||||
|
endTime: String?
|
||||||
|
) async throws -> ListPayload<StoreOrderListItem> {
|
||||||
|
listCalls.append(
|
||||||
|
ListCall(
|
||||||
|
storeId: storeId,
|
||||||
|
page: page,
|
||||||
|
userPhone: userPhone,
|
||||||
|
orderStatus: orderStatus,
|
||||||
|
startTime: startTime,
|
||||||
|
endTime: endTime
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return responses.isEmpty ? ListPayload(total: 0, list: []) : responses.removeFirst()
|
||||||
|
}
|
||||||
|
|
||||||
|
func storeOrderRefund(
|
||||||
|
storeId: Int,
|
||||||
|
orderNumber: String,
|
||||||
|
refundType: OrderRefundMode,
|
||||||
|
refundAmount: String,
|
||||||
|
refundReason: String
|
||||||
|
) async throws {
|
||||||
|
refundCalls.append(
|
||||||
|
RefundCall(
|
||||||
|
storeId: storeId,
|
||||||
|
orderNumber: orderNumber,
|
||||||
|
mode: refundType,
|
||||||
|
amount: refundAmount,
|
||||||
|
reason: refundReason
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeOff(orderNumber: String) async throws {}
|
||||||
|
func storeOrderDetail(storeId: Int, orderNumber: String) async throws -> StoreOrderDetailResponse {
|
||||||
|
try TestFixture.payload(StoreOrderDetailResponse.self, named: "store_order_detail_success")
|
||||||
|
}
|
||||||
|
func depositOrderList(scenicId: Int, page: Int, pageSize: Int) async throws -> ListPayload<DepositOrderListItem> {
|
||||||
|
ListPayload(total: 0, list: [])
|
||||||
|
}
|
||||||
|
func depositOrderWriteOff(orderNumber: String) async throws {}
|
||||||
|
func depositOrderRefund(orderNumber: String, refundReason: String) async throws {}
|
||||||
|
func storeOrderShootingDetail(
|
||||||
|
storeId: Int,
|
||||||
|
orderNumber: String,
|
||||||
|
scenicSpotId: Int,
|
||||||
|
photogUid: Int
|
||||||
|
) async throws -> StoreOrderShootingDetailResponse {
|
||||||
|
try TestFixture.payload(StoreOrderShootingDetailResponse.self, named: "store_order_shooting_detail_success")
|
||||||
|
}
|
||||||
|
func orderRefund(orderNumber: String, refundType: OrderRefundMode, refundAmount: String, refundReason: String) async throws {}
|
||||||
|
func multiTravelShootHistory(orderNumber: String) async throws -> MultiTravelShootHistoryResponse {
|
||||||
|
try TestFixture.payload(MultiTravelShootHistoryResponse.self, named: "multi_travel_shoot_history_success")
|
||||||
|
}
|
||||||
|
func multiTravelVerifiedScenicSpotList(orderNumber: String) async throws -> [MultiTravelVerifiedScenicSpotItem] { [] }
|
||||||
|
func multiTravelUploadMaterial(_ request: MultiTravelUploadMaterialRequest) async throws {}
|
||||||
|
}
|
||||||
222
suixinkanTests/WriteOffListViewModelTests.swift
Normal file
222
suixinkanTests/WriteOffListViewModelTests.swift
Normal file
@ -0,0 +1,222 @@
|
|||||||
|
//
|
||||||
|
// WriteOffListViewModelTests.swift
|
||||||
|
// suixinkanTests
|
||||||
|
//
|
||||||
|
// Created by Codex on 2026/6/29.
|
||||||
|
//
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
@testable import suixinkan
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
/// 核销管理页 ViewModel 测试,覆盖筛选、分页与核销流程。
|
||||||
|
final class WriteOffListViewModelTests: XCTestCase {
|
||||||
|
/// 测试无景区时清空列表且不请求接口。
|
||||||
|
func testReloadClearsDataWhenScenicIsMissing() async throws {
|
||||||
|
let api = WriteOffMockOrderService()
|
||||||
|
api.responses = [try TestFixture.payload(ListPayload<WriteOffOrderItem>.self, named: "writeoff_list_success")]
|
||||||
|
let viewModel = WriteOffListViewModel()
|
||||||
|
|
||||||
|
try await viewModel.reload(api: api, scenicId: 88, storeId: 66)
|
||||||
|
XCTAssertFalse(viewModel.orders.isEmpty)
|
||||||
|
|
||||||
|
try await viewModel.reload(api: api, scenicId: nil)
|
||||||
|
|
||||||
|
XCTAssertTrue(viewModel.orders.isEmpty)
|
||||||
|
XCTAssertEqual(viewModel.total, 0)
|
||||||
|
XCTAssertEqual(api.listCalls.count, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试状态 Tab 与搜索参数会传入服务层。
|
||||||
|
func testReloadUsesStatusAndPhoneFilters() async throws {
|
||||||
|
let api = WriteOffMockOrderService()
|
||||||
|
api.responses = [
|
||||||
|
try TestFixture.payload(ListPayload<WriteOffOrderItem>.self, named: "writeoff_list_success"),
|
||||||
|
try TestFixture.payload(ListPayload<WriteOffOrderItem>.self, named: "writeoff_list_success")
|
||||||
|
]
|
||||||
|
let viewModel = WriteOffListViewModel()
|
||||||
|
viewModel.searchPhone = "13800000000"
|
||||||
|
viewModel.selectedStatus = 18
|
||||||
|
viewModel.filterStartDate = Self.date("2026-05-01")
|
||||||
|
viewModel.filterEndDate = Self.date("2026-05-31")
|
||||||
|
|
||||||
|
try await viewModel.reload(api: api, scenicId: 88, storeId: 66)
|
||||||
|
|
||||||
|
XCTAssertEqual(api.listCalls[0].orderStatus, 18)
|
||||||
|
XCTAssertNil(api.listCalls[0].orderVerifyStatus)
|
||||||
|
XCTAssertEqual(api.listCalls[0].userPhone, "13800000000")
|
||||||
|
XCTAssertEqual(api.listCalls[0].startTime, "2026-05-01")
|
||||||
|
XCTAssertEqual(api.listCalls[0].endTime, "2026-05-31")
|
||||||
|
|
||||||
|
viewModel.selectedStatus = 1
|
||||||
|
try await viewModel.reload(api: api, scenicId: 88)
|
||||||
|
|
||||||
|
XCTAssertNil(api.listCalls[1].orderStatus)
|
||||||
|
XCTAssertEqual(api.listCalls[1].orderVerifyStatus, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试核销成功后刷新列表。
|
||||||
|
func testVerifyRefreshesListAfterSuccess() async throws {
|
||||||
|
let api = WriteOffMockOrderService()
|
||||||
|
api.responses = [try TestFixture.payload(ListPayload<WriteOffOrderItem>.self, named: "writeoff_list_success")]
|
||||||
|
let viewModel = WriteOffListViewModel()
|
||||||
|
|
||||||
|
try await viewModel.verify(api: api, scenicId: 88, storeId: 66, orderNumber: "VERIFY001")
|
||||||
|
|
||||||
|
XCTAssertEqual(api.verifiedOrderNumbers, ["VERIFY001"])
|
||||||
|
XCTAssertEqual(api.listCalls.count, 1)
|
||||||
|
XCTAssertEqual(viewModel.orders.count, 1)
|
||||||
|
XCTAssertFalse(viewModel.isVerifying)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试扫码命中当前列表时返回匹配订单。
|
||||||
|
func testMatchedWriteOffOrderFindsCurrentListItem() async throws {
|
||||||
|
let api = WriteOffMockOrderService()
|
||||||
|
api.responses = [try TestFixture.payload(ListPayload<WriteOffOrderItem>.self, named: "writeoff_list_success")]
|
||||||
|
let viewModel = WriteOffListViewModel()
|
||||||
|
try await viewModel.reload(api: api, scenicId: 88)
|
||||||
|
|
||||||
|
let result = try XCTUnwrap(viewModel.matchedWriteOffOrder(for: "order_number=VERIFY001"))
|
||||||
|
XCTAssertEqual(result.orderNumber, "VERIFY001")
|
||||||
|
XCTAssertEqual(result.matched?.orderNumber, "VERIFY001")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试 canVerify 会排除已核销状态。
|
||||||
|
func testCanVerifyExcludesVerifiedStatus() throws {
|
||||||
|
let viewModel = WriteOffListViewModel()
|
||||||
|
let verified = try JSONDecoder().decode(
|
||||||
|
WriteOffOrderItem.self,
|
||||||
|
from: Data(
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"order_number": "V2",
|
||||||
|
"order_verification_status": "已核销",
|
||||||
|
"pay_time": "",
|
||||||
|
"project_name": "",
|
||||||
|
"user_phone": "",
|
||||||
|
"order_amount": "",
|
||||||
|
"order_status_name": "",
|
||||||
|
"order_verification_time": ""
|
||||||
|
}
|
||||||
|
""".utf8
|
||||||
|
)
|
||||||
|
)
|
||||||
|
XCTAssertFalse(viewModel.canVerify(verified))
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
/// 核销列表测试替身,记录 writeOffList 与核销调用。
|
||||||
|
private final class WriteOffMockOrderService: OrderServing {
|
||||||
|
struct ListCall: Equatable {
|
||||||
|
let scenicId: Int
|
||||||
|
let storeId: Int?
|
||||||
|
let page: Int
|
||||||
|
let orderStatus: Int?
|
||||||
|
let orderVerifyStatus: Int?
|
||||||
|
let startTime: String?
|
||||||
|
let endTime: String?
|
||||||
|
let userPhone: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
var responses: [ListPayload<WriteOffOrderItem>] = []
|
||||||
|
private(set) var listCalls: [ListCall] = []
|
||||||
|
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> {
|
||||||
|
ListPayload(total: 0, list: [])
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeOffList(
|
||||||
|
scenicId: Int,
|
||||||
|
storeId: Int?,
|
||||||
|
page: Int,
|
||||||
|
pageSize: Int,
|
||||||
|
orderStatus: Int?,
|
||||||
|
orderVerifyStatus: Int?,
|
||||||
|
startTime: String?,
|
||||||
|
endTime: String?,
|
||||||
|
userPhone: String?
|
||||||
|
) async throws -> ListPayload<WriteOffOrderItem> {
|
||||||
|
listCalls.append(
|
||||||
|
ListCall(
|
||||||
|
scenicId: scenicId,
|
||||||
|
storeId: storeId,
|
||||||
|
page: page,
|
||||||
|
orderStatus: orderStatus,
|
||||||
|
orderVerifyStatus: orderVerifyStatus,
|
||||||
|
startTime: startTime,
|
||||||
|
endTime: endTime,
|
||||||
|
userPhone: userPhone
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return responses.isEmpty ? ListPayload(total: 0, list: []) : responses.removeFirst()
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateRefinedChoice(orderNumber: String, isRefined: Int) async throws {}
|
||||||
|
func cancelOrder(orderNumber: String) async throws {}
|
||||||
|
func sendGift(orderNumber: String, photoNum: Int, videoNum: Int) async throws {}
|
||||||
|
func storeOrderList(
|
||||||
|
storeId: Int?,
|
||||||
|
page: Int,
|
||||||
|
pageSize: Int,
|
||||||
|
userPhone: String?,
|
||||||
|
orderStatus: Int?,
|
||||||
|
orderType: Int?,
|
||||||
|
startTime: String?,
|
||||||
|
endTime: String?
|
||||||
|
) async throws -> ListPayload<StoreOrderListItem> {
|
||||||
|
ListPayload(total: 0, list: [])
|
||||||
|
}
|
||||||
|
func storeOrderRefund(
|
||||||
|
storeId: Int,
|
||||||
|
orderNumber: String,
|
||||||
|
refundType: OrderRefundMode,
|
||||||
|
refundAmount: String,
|
||||||
|
refundReason: String
|
||||||
|
) async throws {}
|
||||||
|
|
||||||
|
func writeOff(orderNumber: String) async throws {
|
||||||
|
verifiedOrderNumbers.append(orderNumber)
|
||||||
|
}
|
||||||
|
|
||||||
|
func storeOrderDetail(storeId: Int, orderNumber: String) async throws -> StoreOrderDetailResponse {
|
||||||
|
try TestFixture.payload(StoreOrderDetailResponse.self, named: "store_order_detail_success")
|
||||||
|
}
|
||||||
|
func depositOrderList(scenicId: Int, page: Int, pageSize: Int) async throws -> ListPayload<DepositOrderListItem> {
|
||||||
|
ListPayload(total: 0, list: [])
|
||||||
|
}
|
||||||
|
func depositOrderWriteOff(orderNumber: String) async throws {}
|
||||||
|
func depositOrderRefund(orderNumber: String, refundReason: String) async throws {}
|
||||||
|
func storeOrderShootingDetail(
|
||||||
|
storeId: Int,
|
||||||
|
orderNumber: String,
|
||||||
|
scenicSpotId: Int,
|
||||||
|
photogUid: Int
|
||||||
|
) async throws -> StoreOrderShootingDetailResponse {
|
||||||
|
try TestFixture.payload(StoreOrderShootingDetailResponse.self, named: "store_order_shooting_detail_success")
|
||||||
|
}
|
||||||
|
func orderRefund(orderNumber: String, refundType: OrderRefundMode, refundAmount: String, refundReason: String) async throws {}
|
||||||
|
func multiTravelShootHistory(orderNumber: String) async throws -> MultiTravelShootHistoryResponse {
|
||||||
|
try TestFixture.payload(MultiTravelShootHistoryResponse.self, named: "multi_travel_shoot_history_success")
|
||||||
|
}
|
||||||
|
func multiTravelVerifiedScenicSpotList(orderNumber: String) async throws -> [MultiTravelVerifiedScenicSpotItem] { [] }
|
||||||
|
func multiTravelUploadMaterial(_ request: MultiTravelUploadMaterialRequest) async throws {}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user