新增门店订单与核销管理列表,对齐 Android 订单能力并完善账号级缓存。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -15,7 +15,7 @@ App 模块负责应用入口、根视图切换、全局状态注入、主导航
|
||||
- `suixinkanApp`:SwiftUI 应用入口,挂载 `RootView`。
|
||||
- `RootView`:创建 `AppSession`、`AccountContext`、`PermissionContext`、`ScenicSpotContext`、`AppRouter`、`ToastCenter`、`APIClient` 和业务 API,并注入 SwiftUI Environment。
|
||||
- `AppSession`:保存认证阶段和正式 token,只负责登录态,不承载业务资料。
|
||||
- `AccountContext`:保存当前账号资料、景区作用域和门店作用域。
|
||||
- `AccountContext`:保存当前账号资料、账号类型、景区作用域和门店作用域,并提供账号级缓存前缀。
|
||||
- `PermissionContext`:保存角色权限、当前角色和扁平化权限 URI。
|
||||
- `ScenicSpotContext`:保存当前景区下的景点/打卡点列表和加载状态。
|
||||
- `AppRouter`:保存当前 Tab 和每个 Tab 自己的导航路径。
|
||||
|
||||
@ -46,6 +46,9 @@ enum AppUITestRouteDriver {
|
||||
appRouter.select(tab)
|
||||
case .orders(let entry):
|
||||
appRouter.selectOrders(entry: entry)
|
||||
case .orderPush(let route):
|
||||
appRouter.select(.orders)
|
||||
appRouter.router(for: .orders).navigate(to: .orders(route))
|
||||
case .destination(let homeRoute):
|
||||
appRouter.select(.home)
|
||||
appRouter.router(for: .home).navigate(to: .home(homeRoute))
|
||||
|
||||
@ -51,6 +51,8 @@ extension OrdersRoute {
|
||||
MultiTravelTaskUploadView(initialOrderNumber: orderNumber)
|
||||
case .orderTrailer(let orderNumber, let title):
|
||||
OrderTrailerView(orderNumber: orderNumber, title: title)
|
||||
case .writeOffList:
|
||||
WriteOffListView()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -107,10 +109,17 @@ final class AppRouter: ObservableObject {
|
||||
selectedTab = .orders
|
||||
}
|
||||
|
||||
/// 切换到核销订单入口,并暂存全局扫码结果等待订单页消费。
|
||||
/// 切换到核销管理页,并暂存全局扫码结果等待核销页消费。
|
||||
func routeToOrderVerification(scannedCode: String) {
|
||||
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 Foundation
|
||||
|
||||
/// 当前账号的基础资料实体,用于跨页面展示昵称、手机号和头像。
|
||||
struct AccountProfile: Codable, Equatable {
|
||||
@ -61,14 +62,24 @@ struct BusinessScope: Codable, Equatable, Identifiable {
|
||||
/// 账号上下文状态中心,保存当前账号资料和景区/门店作用域。
|
||||
final class AccountContext: ObservableObject {
|
||||
@Published private(set) var profile: AccountProfile?
|
||||
@Published private(set) var accountType: String?
|
||||
@Published private(set) var scenicScopes: [BusinessScope] = []
|
||||
@Published private(set) var storeScopes: [BusinessScope] = []
|
||||
@Published var currentScenic: 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.accountType = accountType?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
/// 替换当前账号资料,通常用于用户信息刷新后同步全局展示。
|
||||
@ -122,6 +133,7 @@ final class AccountContext: ObservableObject {
|
||||
/// 清空账号上下文,通常在退出登录时调用。
|
||||
func reset() {
|
||||
profile = nil
|
||||
accountType = nil
|
||||
scenicScopes = []
|
||||
storeScopes = []
|
||||
currentScenic = nil
|
||||
|
||||
@ -63,7 +63,7 @@ final class AuthSessionCoordinator {
|
||||
let scenicScopes = response.scenicScopes
|
||||
let storeScopes = response.storeScopes
|
||||
let identity = response.currentAccountIdentity
|
||||
accountContext.applyLogin(profile: profile)
|
||||
accountContext.applyLogin(profile: profile, accountType: identity?.accountType)
|
||||
accountContext.replaceScopes(scenic: scenicScopes, stores: storeScopes)
|
||||
appSession.beginRestoring(token: token)
|
||||
|
||||
@ -89,7 +89,7 @@ final class AuthSessionCoordinator {
|
||||
appSession.markLoggedIn(token: token)
|
||||
saveSnapshot(
|
||||
profile: accountContext.profile ?? profile,
|
||||
accountType: identity?.accountType,
|
||||
accountType: accountContext.accountType ?? identity?.accountType,
|
||||
businessUserId: identity?.businessUserId,
|
||||
permissionContext: permissionContext,
|
||||
accountContext: accountContext
|
||||
@ -99,7 +99,7 @@ final class AuthSessionCoordinator {
|
||||
|
||||
saveSnapshot(
|
||||
profile: accountContext.profile ?? profile,
|
||||
accountType: identity?.accountType,
|
||||
accountType: accountContext.accountType ?? identity?.accountType,
|
||||
businessUserId: identity?.businessUserId,
|
||||
permissionContext: permissionContext,
|
||||
accountContext: accountContext
|
||||
@ -113,7 +113,7 @@ final class AuthSessionCoordinator {
|
||||
accountContext.replaceProfile(profile)
|
||||
saveSnapshot(
|
||||
profile: profile,
|
||||
accountType: snapshotStore.load()?.accountType,
|
||||
accountType: accountContext.accountType ?? snapshotStore.load()?.accountType,
|
||||
businessUserId: snapshotStore.load()?.businessUserId,
|
||||
currentRoleCode: snapshotStore.load()?.currentRoleCode,
|
||||
accountContext: accountContext
|
||||
|
||||
@ -83,7 +83,7 @@ final class SessionBootstrapper {
|
||||
/// 将缓存快照恢复到账号上下文。
|
||||
private func restoreCachedSnapshot(to accountContext: AccountContext) -> AccountSnapshot? {
|
||||
guard let snapshot = snapshotStore.load() else { return nil }
|
||||
accountContext.applyLogin(profile: snapshot.profile)
|
||||
accountContext.applyLogin(profile: snapshot.profile, accountType: snapshot.accountType)
|
||||
accountContext.replaceScopes(
|
||||
scenic: snapshot.scenicScopes,
|
||||
stores: snapshot.storeScopes,
|
||||
@ -99,7 +99,7 @@ final class SessionBootstrapper {
|
||||
snapshotStore.save(
|
||||
AccountSnapshot(
|
||||
profile: accountContext.profile,
|
||||
accountType: existing?.accountType,
|
||||
accountType: accountContext.accountType ?? existing?.accountType,
|
||||
businessUserId: existing?.businessUserId,
|
||||
currentRoleCode: permissionContext.currentAppRole?.rawValue ?? existing?.currentRoleCode,
|
||||
scenicScopes: accountContext.scenicScopes,
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "icon_1024.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
@ -12,6 +13,7 @@
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "icon_1024.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
@ -23,6 +25,7 @@
|
||||
"value" : "tinted"
|
||||
}
|
||||
],
|
||||
"filename" : "icon_1024.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"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 success = Color(hex: 0x14964A)
|
||||
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 {
|
||||
|
||||
@ -132,7 +132,7 @@ final class PushNotificationManager: NSObject, UNUserNotificationCenterDelegate
|
||||
case .order:
|
||||
router.selectOrders(entry: .storeOrders)
|
||||
case .verificationOrder:
|
||||
router.selectOrders(entry: .verificationOrders)
|
||||
router.openWriteOffList()
|
||||
case .task:
|
||||
navigateHomeRoute(.taskManagement)
|
||||
case .queue:
|
||||
|
||||
@ -115,7 +115,7 @@ final class AccountSnapshotStore {
|
||||
save(
|
||||
AccountSnapshot(
|
||||
profile: accountContext.profile ?? existing?.profile,
|
||||
accountType: existing?.accountType,
|
||||
accountType: accountContext.accountType ?? existing?.accountType,
|
||||
businessUserId: existing?.businessUserId,
|
||||
currentRoleCode: currentRoleCode ?? existing?.currentRoleCode,
|
||||
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 `Constants.menuList` 白名单过滤;iOS 已接入但 Android 首页未登记的 URI 不进入首页常用应用。
|
||||
- 读取时按当前角色权限过滤不可用 URI。
|
||||
- 添加和移除时按同义 URI 去重。
|
||||
- 不同角色的常用应用互不影响。
|
||||
|
||||
@ -72,6 +72,7 @@ enum HomeRoute: Hashable {
|
||||
enum HomeMenuResolvedRoute: Equatable {
|
||||
case tab(AppTab)
|
||||
case orders(OrdersEntry)
|
||||
case orderPush(OrdersRoute)
|
||||
case destination(HomeRoute)
|
||||
case unsupported(uri: String, title: String, reason: String)
|
||||
case placeholder(uri: String, title: String)
|
||||
|
||||
@ -73,7 +73,7 @@ enum HomeMenuRouter {
|
||||
case "photographer_orders", "/scenic-order-manage":
|
||||
return .orders(.storeOrders)
|
||||
case "verification_order":
|
||||
return .orders(.verificationOrders)
|
||||
return .orderPush(.writeOffList)
|
||||
case "photographer_stats":
|
||||
return .tab(.statistics)
|
||||
case "space_settings", "basic_info":
|
||||
@ -330,7 +330,7 @@ enum HomePermissionRouteAuditor {
|
||||
return HomePermissionRouteAudit(
|
||||
routable: entries.filter { entry in
|
||||
switch entry.route {
|
||||
case .tab, .orders, .destination:
|
||||
case .tab, .orders, .orderPush, .destination:
|
||||
return true
|
||||
case .unsupported, .placeholder:
|
||||
return false
|
||||
|
||||
@ -10,6 +10,37 @@ import Foundation
|
||||
/// 首页常用应用存储服务,负责按当前角色权限过滤和持久化常用 URI。
|
||||
struct HomeCommonMenuStore {
|
||||
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
|
||||
|
||||
@ -27,7 +58,7 @@ struct HomeCommonMenuStore {
|
||||
let trimmed = uri.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
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)
|
||||
if deduplicated.count > 4 {
|
||||
@ -40,23 +71,24 @@ struct HomeCommonMenuStore {
|
||||
func load(
|
||||
menuItems: [HomeMenuItem],
|
||||
roleCode: String?,
|
||||
accountScope: String? = nil,
|
||||
legacyRoleId: Int? = nil,
|
||||
topLevelPermissionURIs: [String]
|
||||
) -> [String] {
|
||||
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 key = storageKey(for: roleCode)
|
||||
let key = Self.storageKey(roleCode: roleCode, accountScope: accountScope)
|
||||
let saved = defaults.stringArray(forKey: key)
|
||||
|
||||
if let saved, !saved.isEmpty {
|
||||
let filtered = Self.unique(saved.compactMap { uri -> String? in
|
||||
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 {
|
||||
save(filtered, roleCode: roleCode)
|
||||
save(filtered, roleCode: roleCode, accountScope: accountScope)
|
||||
return filtered
|
||||
}
|
||||
}
|
||||
@ -65,54 +97,63 @@ struct HomeCommonMenuStore {
|
||||
orderedTopLevelURIs: topLevelPermissionURIs,
|
||||
availableURIs: availableURIs
|
||||
)
|
||||
save(defaultURIs, roleCode: roleCode)
|
||||
save(defaultURIs, roleCode: roleCode, accountScope: accountScope)
|
||||
return defaultURIs
|
||||
}
|
||||
|
||||
/// 将指定 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 }
|
||||
let availableURIs = Set(menuItems.map(\.uri))
|
||||
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 {
|
||||
return current
|
||||
}
|
||||
let next = current + [resolved]
|
||||
save(next, roleCode: roleCode)
|
||||
save(next, roleCode: roleCode, accountScope: accountScope)
|
||||
return next
|
||||
}
|
||||
|
||||
/// 从常用应用中移除指定 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 }
|
||||
let aliasKey = HomeMenuRouter.menuAliasKey(for: uri)
|
||||
let next = current.filter { HomeMenuRouter.menuAliasKey(for: $0) != aliasKey }
|
||||
save(next, roleCode: roleCode)
|
||||
save(next, roleCode: roleCode, accountScope: accountScope)
|
||||
return next
|
||||
}
|
||||
|
||||
/// 保存指定角色的常用 URI。
|
||||
func save(_ uris: [String], roleCode: String) {
|
||||
defaults.set(Self.unique(uris), forKey: storageKey(for: roleCode))
|
||||
func save(_ uris: [String], roleCode: String, accountScope: String? = nil) {
|
||||
defaults.set(Self.unique(uris), forKey: Self.storageKey(roleCode: roleCode, accountScope: accountScope))
|
||||
}
|
||||
|
||||
/// 若新 key 无数据,则从 legacy role id key 一次性迁移常用菜单。
|
||||
func migrateLegacyStorageIfNeeded(roleCode: String, legacyRoleId: Int?) {
|
||||
let newKey = storageKey(for: roleCode)
|
||||
guard defaults.stringArray(forKey: newKey) == nil,
|
||||
let legacyRoleId,
|
||||
let legacyData = defaults.stringArray(forKey: legacyStorageKey(for: legacyRoleId)),
|
||||
!legacyData.isEmpty else {
|
||||
/// 若账号级 key 无数据,则从旧 roleCode 或 legacy role id key 一次性迁移常用菜单。
|
||||
func migrateLegacyStorageIfNeeded(roleCode: String, accountScope: String? = nil, legacyRoleId: Int?) {
|
||||
let newKey = Self.storageKey(roleCode: roleCode, accountScope: accountScope)
|
||||
guard defaults.stringArray(forKey: newKey) == nil else { return }
|
||||
|
||||
let legacyKeys = [
|
||||
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
|
||||
}
|
||||
defaults.set(legacyData, forKey: newKey)
|
||||
defaults.removeObject(forKey: legacyStorageKey(for: legacyRoleId))
|
||||
}
|
||||
|
||||
/// 生成按 role_code 隔离的 UserDefaults key。
|
||||
private func storageKey(for roleCode: String) -> String {
|
||||
Self.storageKeyPrefix + roleCode
|
||||
/// 生成按账号和 role_code 隔离的 UserDefaults key。
|
||||
static func storageKey(roleCode: String, accountScope: String?) -> String {
|
||||
let normalizedScope = accountScope?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if normalizedScope.isEmpty {
|
||||
return storageKeyPrefix + roleCode
|
||||
}
|
||||
return accountScopedStorageKeyPrefix + normalizedScope + ".role." + roleCode
|
||||
}
|
||||
|
||||
/// 旧版按 numeric role id 存储的 key。
|
||||
@ -120,6 +161,16 @@ struct HomeCommonMenuStore {
|
||||
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。
|
||||
private static func unique(_ uris: [String]) -> [String] {
|
||||
var seen = Set<String>()
|
||||
|
||||
@ -9,6 +9,7 @@ import SwiftUI
|
||||
|
||||
/// 首页全部功能视图,展示常用应用和当前角色拥有的更多功能。
|
||||
struct HomeMoreFunctionsView: View {
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@EnvironmentObject private var permissionContext: PermissionContext
|
||||
@EnvironmentObject private var appRouter: AppRouter
|
||||
@EnvironmentObject private var router: RouterPath
|
||||
@ -124,13 +125,19 @@ struct HomeMoreFunctionsView: View {
|
||||
Button {
|
||||
let roleCode = permissionContext.currentAppRole?.rawValue
|
||||
if isCommon {
|
||||
commonUris = commonMenuStore.remove(item.uri, current: commonUris, roleCode: roleCode)
|
||||
commonUris = commonMenuStore.remove(
|
||||
item.uri,
|
||||
current: commonUris,
|
||||
roleCode: roleCode,
|
||||
accountScope: accountContext.accountCachePrefix
|
||||
)
|
||||
} else {
|
||||
commonUris = commonMenuStore.add(
|
||||
item.uri,
|
||||
current: commonUris,
|
||||
menuItems: viewModel.menuItems,
|
||||
roleCode: roleCode
|
||||
roleCode: roleCode,
|
||||
accountScope: accountContext.accountCachePrefix
|
||||
)
|
||||
}
|
||||
} label: {
|
||||
@ -187,6 +194,9 @@ struct HomeMoreFunctionsView: View {
|
||||
appRouter.select(tab)
|
||||
case .orders(let entry):
|
||||
appRouter.selectOrders(entry: entry)
|
||||
case .orderPush(let route):
|
||||
appRouter.select(.orders)
|
||||
appRouter.router(for: .orders).navigate(to: .orders(route))
|
||||
case .destination(let route):
|
||||
if route == .moreFunctions {
|
||||
return
|
||||
@ -210,6 +220,7 @@ struct HomeMoreFunctionsView: View {
|
||||
commonUris = commonMenuStore.load(
|
||||
menuItems: viewModel.menuItems,
|
||||
roleCode: roleCode,
|
||||
accountScope: accountContext.accountCachePrefix,
|
||||
legacyRoleId: legacyRoleId,
|
||||
topLevelPermissionURIs: permissionContext.topLevelPermissionURIs(for: roleCode)
|
||||
)
|
||||
|
||||
@ -395,6 +395,9 @@ struct HomeView: View {
|
||||
appRouter.select(tab)
|
||||
case .orders(let entry):
|
||||
appRouter.selectOrders(entry: entry)
|
||||
case .orderPush(let route):
|
||||
appRouter.select(.orders)
|
||||
appRouter.router(for: .orders).navigate(to: .orders(route))
|
||||
case .destination(let route):
|
||||
router.navigate(to: .home(route))
|
||||
case .unsupported(let uri, let title, _):
|
||||
@ -415,6 +418,7 @@ struct HomeView: View {
|
||||
commonUris = commonMenuStore.load(
|
||||
menuItems: viewModel.menuItems,
|
||||
roleCode: roleCode,
|
||||
accountScope: accountContext.accountCachePrefix,
|
||||
legacyRoleId: legacyRoleId,
|
||||
topLevelPermissionURIs: permissionContext.topLevelPermissionURIs(for: roleCode)
|
||||
)
|
||||
|
||||
@ -20,7 +20,7 @@ Main 模块负责登录后的主界面 Tab 容器,以及当前尚未迁移页
|
||||
- `statistics`:数据
|
||||
- `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 {
|
||||
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)
|
||||
} catch {
|
||||
pendingWriteOffCount = nil
|
||||
|
||||
@ -14,10 +14,16 @@ struct HomeRootView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 订单根视图,占位承载后续订单模块迁移内容。
|
||||
/// 订单根视图,按角色展示主订单列表或店长订单列表。
|
||||
struct OrdersRootView: View {
|
||||
@EnvironmentObject private var permissionContext: PermissionContext
|
||||
|
||||
var body: some View {
|
||||
OrdersView()
|
||||
if permissionContext.currentAppRole?.isStoreAdmin == true {
|
||||
StoreAdminOrderListView()
|
||||
} else {
|
||||
StoreOrderListView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -25,11 +25,51 @@ protocol OrderServing {
|
||||
) 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 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
|
||||
|
||||
@ -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 = [
|
||||
URLQueryItem(name: "scenic_id", value: "\(scenicId)"),
|
||||
URLQueryItem(name: "page", value: "\(max(page, 1))"),
|
||||
@ -120,6 +170,21 @@ final class OrdersAPI: OrderServing {
|
||||
if let storeId, storeId > 0 {
|
||||
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(
|
||||
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: -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 createdAt: String
|
||||
let photoTravel: OrderPhotoTravel?
|
||||
let multiTravel: OrderListMultiTravel?
|
||||
let isNeedEdit: Bool
|
||||
let isRefined: Int
|
||||
var isRefined: Int
|
||||
let canBindReferral: Int
|
||||
let showOrderSource: Int
|
||||
let referralOrderNo: String
|
||||
@ -92,6 +116,7 @@ struct OrderEntity: Decodable, Identifiable, Equatable, Hashable {
|
||||
case depositPayTime = "deposit_pay_time"
|
||||
case createdAt = "created_at"
|
||||
case photoTravel = "photo_travel"
|
||||
case multiTravel = "multi_travel"
|
||||
case isNeedEdit
|
||||
case isRefined = "is_refined"
|
||||
case canBindReferral = "can_bind_referral"
|
||||
@ -128,6 +153,7 @@ struct OrderEntity: Decodable, Identifiable, Equatable, Hashable {
|
||||
depositPayTime = try container.decodeLossyString(forKey: .depositPayTime)
|
||||
createdAt = try container.decodeLossyString(forKey: .createdAt)
|
||||
photoTravel = try container.decodeIfPresent(OrderPhotoTravel.self, forKey: .photoTravel)
|
||||
multiTravel = try container.decodeIfPresent(OrderListMultiTravel.self, forKey: .multiTravel)
|
||||
isNeedEdit = try container.decodeLossyBool(forKey: .isNeedEdit) ?? true
|
||||
isRefined = try container.decodeLossyInt(forKey: .isRefined) ?? 0
|
||||
canBindReferral = try container.decodeLossyInt(forKey: .canBindReferral) ?? 0
|
||||
@ -145,6 +171,10 @@ struct OrderPhotoTravel: Decodable, Equatable, Hashable {
|
||||
let checkInTime: String
|
||||
let retouchGiftPhotoNum: Int
|
||||
let retouchGiftVideoNum: Int
|
||||
let canCancelOrder: Bool
|
||||
let needCheckIn: Bool
|
||||
let canGiftRetouch: Bool
|
||||
let retouchGiftTime: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case orderPhotoNum = "order_photo_num"
|
||||
@ -152,6 +182,10 @@ struct OrderPhotoTravel: Decodable, Equatable, Hashable {
|
||||
case checkInTime = "check_in_time"
|
||||
case retouchGiftPhotoNum = "retouch_gift_photo_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)
|
||||
retouchGiftPhotoNum = try container.decodeLossyInt(forKey: .retouchGiftPhotoNum) ?? 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 {
|
||||
let orderNumber: String
|
||||
@ -305,6 +470,7 @@ enum OrdersRoute: Hashable {
|
||||
case historicalShooting(orderNumber: String)
|
||||
case multiTravelTaskUpload(orderNumber: 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`。
|
||||
- `OrdersViewModel`:管理订单子入口、筛选条件、分页状态、加载状态和手动核销状态。
|
||||
- `DepositOrderListViewModel`:管理押金订单分页、核销、退款和操作后刷新。
|
||||
- `DepositOrderDetailViewModel`:管理押金订单详情加载,缺少门店时不发起请求。
|
||||
- `DepositOrderShootingInfoViewModel`:管理押金订单单个打卡点的底片、成片和评价。
|
||||
- `OrderRefundViewModel`:管理普通订单退款金额校验和提交保护(详情页接入前保留逻辑层)。
|
||||
- `HistoricalShootingInfoViewModel`:管理多点位订单历史拍摄媒体展示。
|
||||
- `MultiTravelTaskUploadViewModel`:管理多点旅拍任务上传的订单号、已核销打卡点、云盘附件、本地附件、OSS 上传和提交。
|
||||
- `OrdersAPI`:封装订单列表、核销订单列表和订单核销接口。
|
||||
- `OrdersEntry`:表示订单 Tab 内部入口,包含订单管理和核销订单。
|
||||
- `OrdersRoute`:表示订单模块二级页面路由,包含押金详情、历史拍摄、任务上传和尾片入口。
|
||||
- `OrderNumberParser`:负责从扫码内容中解析订单号,支持 URL query、键值文本和纯订单号。
|
||||
- `StoreOrderListView`:主订单 Tab 根视图,组合 `OrderListHeaderView`、`OrderListCardView` 与 `OrdersViewModel`。
|
||||
- `StoreAdminOrderListView`:店长订单 Tab,组合共享头部与店长卡片,使用 `StoreAdminOrderListViewModel`。
|
||||
- `WriteOffListView`:核销管理页,使用 `WriteOffListViewModel`,含横向状态 pill、日期筛选与扫码/手动核销。
|
||||
- `OrderListHeaderView`:白底状态/日期筛选头部;手机号搜索使用系统 `.searchable`。
|
||||
- `OrderListCardView`:主订单卡片,对齐 Android `OrderView` 展示顺序与操作区。
|
||||
- `OrderListComponents`:状态 Chip、信息行、操作按钮、精修单选等共享组件。
|
||||
- `OrderDialogViews`:退款、赠送修图/视频、店长退款、核销确认弹窗。
|
||||
- `OrdersViewModel`:主订单列表分页、筛选、卡片操作(退款/核销/精修/取消/赠送)与订单来源。
|
||||
- `StoreAdminOrderListViewModel`:店长订单列表与 `storeOrderList` / `storeOrderRefund`。
|
||||
- `WriteOffListViewModel`:核销列表筛选、分页与核销提交。
|
||||
- `DepositOrderListViewModel`:押金订单分页、核销、退款(独立押金入口)。
|
||||
- `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 loadingOrderSourceLeadsFor: String?
|
||||
|
||||
@Published var refundingOrder: OrderEntity?
|
||||
@Published var sendGiftContext: SendGiftContext?
|
||||
|
||||
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 orderTypePhotographerFollowOfflineScan = 14
|
||||
|
||||
@ -125,7 +138,17 @@ final class OrdersViewModel: ObservableObject {
|
||||
if showLoading { loading = true }
|
||||
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
|
||||
writeOffTotal = result.total
|
||||
writeOffPage = 1
|
||||
@ -139,7 +162,17 @@ final class OrdersViewModel: ObservableObject {
|
||||
defer { loadingMore = false }
|
||||
|
||||
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)
|
||||
writeOffTotal = result.total
|
||||
writeOffPage = nextPage
|
||||
@ -158,13 +191,82 @@ final class OrdersViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
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
|
||||
writeOffTotal = refreshed.total
|
||||
writeOffPage = 1
|
||||
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 {
|
||||
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)`。
|
||||
2. 下拉刷新时调用 `reloadProfile(showToast: true)`。
|
||||
1. `ProfileView.task` 进入页面时调用 `reloadProfile()`。
|
||||
2. 下拉刷新时同样调用 `reloadProfile()`,成功后不展示 Toast。
|
||||
3. `ProfileViewModel.reload` 并发请求:
|
||||
- `ProfileAPI.userInfo`
|
||||
- `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 pendingAvatarFileName: String?
|
||||
@Published private(set) var avatarUploadProgress: Int?
|
||||
/// 接口返回前使用的本地账号资料缓存,来自 AccountContext / AccountSnapshot。
|
||||
private var cachedProfile: AccountProfile?
|
||||
|
||||
var displayNickname: String {
|
||||
nonEmpty(userInfo?.nickname) ?? "未设置昵称"
|
||||
nonEmpty(userInfo?.nickname)
|
||||
?? nonEmpty(cachedProfile?.displayName)
|
||||
?? "未设置昵称"
|
||||
}
|
||||
|
||||
var displayRealName: String {
|
||||
@ -30,11 +34,11 @@ final class ProfileViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
var displayPhone: String {
|
||||
nonEmpty(userInfo?.phone) ?? "--"
|
||||
nonEmpty(userInfo?.phone) ?? nonEmpty(cachedProfile?.phone) ?? "--"
|
||||
}
|
||||
|
||||
var displayAvatarURL: String {
|
||||
nonEmpty(userInfo?.avatar) ?? ""
|
||||
nonEmpty(userInfo?.avatar) ?? nonEmpty(cachedProfile?.avatarURL) ?? ""
|
||||
}
|
||||
|
||||
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 {
|
||||
guard !isLoading else { return }
|
||||
|
||||
@ -46,6 +46,9 @@ struct DebugHomeMenuPreviewView: View {
|
||||
appRouter.select(tab)
|
||||
case .orders(let entry):
|
||||
appRouter.selectOrders(entry: entry)
|
||||
case .orderPush(let route):
|
||||
appRouter.select(.orders)
|
||||
appRouter.router(for: .orders).navigate(to: .orders(route))
|
||||
case .destination(let homeRoute):
|
||||
router.navigate(to: .home(homeRoute))
|
||||
case .unsupported(_, let title, let reason):
|
||||
@ -102,6 +105,8 @@ private struct DebugHomeMenuRow: View {
|
||||
return "Tab"
|
||||
case .orders:
|
||||
return "订单"
|
||||
case .orderPush:
|
||||
return "订单页"
|
||||
case .destination(let homeRoute):
|
||||
if case .modulePlaceholder = homeRoute {
|
||||
return "占位"
|
||||
@ -119,7 +124,7 @@ private struct DebugHomeMenuRow: View {
|
||||
switch route {
|
||||
case .tab:
|
||||
return "rectangle.grid.1x2"
|
||||
case .orders:
|
||||
case .orders, .orderPush:
|
||||
return "doc.text"
|
||||
case .destination(let homeRoute):
|
||||
if case .modulePlaceholder = homeRoute {
|
||||
@ -136,7 +141,7 @@ private struct DebugHomeMenuRow: View {
|
||||
/// 返回当前路由类型颜色。
|
||||
private var iconColor: Color {
|
||||
switch route {
|
||||
case .tab, .orders, .destination:
|
||||
case .tab, .orders, .orderPush, .destination:
|
||||
AppDesign.primary
|
||||
case .unsupported:
|
||||
.red
|
||||
|
||||
@ -51,10 +51,11 @@ struct ProfileView: View {
|
||||
.navigationTitle("个人信息")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task {
|
||||
await reloadProfile(showToast: false)
|
||||
viewModel.applyCachedProfile(accountContext.profile)
|
||||
await reloadProfile()
|
||||
}
|
||||
.refreshable {
|
||||
await reloadProfile(showToast: true)
|
||||
await reloadProfile()
|
||||
}
|
||||
.sheet(isPresented: $showsPasswordSheet) {
|
||||
PasswordUpdateSheet { password in
|
||||
@ -517,9 +518,9 @@ struct ProfileView: View {
|
||||
}
|
||||
|
||||
/// 重新拉取个人资料,并同步更新全局账号资料。
|
||||
private func reloadProfile(showToast: Bool) async {
|
||||
private func reloadProfile() async {
|
||||
do {
|
||||
try await globalLoading.withOptionalLoading(!showToast && viewModel.userInfo == nil, message: "加载中...") {
|
||||
try await globalLoading.withOptionalLoading(viewModel.userInfo == nil, message: "加载中...") {
|
||||
try await viewModel.reload(api: profileAPI)
|
||||
}
|
||||
if let userInfo = viewModel.userInfo {
|
||||
@ -527,9 +528,6 @@ struct ProfileView: View {
|
||||
} else {
|
||||
accountContext.replaceProfile(viewModel.accountProfileFallback(accountContext.profile))
|
||||
}
|
||||
if showToast {
|
||||
toastCenter.show("个人信息已刷新")
|
||||
}
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
|
||||
Reference in New Issue
Block a user