Merge branch 'dev_1_2_1' into xh_test
This commit is contained in:
@@ -70,10 +70,10 @@ enum AuthSessionHelper {
|
||||
AppStore.shared.session.userId = String(user.businessUserId)
|
||||
AppStore.shared.session.accountType = .scenicUser
|
||||
AppStore.shared.session.accountDisplayName = firstNonEmpty(
|
||||
user.scenicName, user.nickname, user.realName, user.username
|
||||
user.scenicName, user.nickname, user.realName
|
||||
)
|
||||
AppStore.shared.session.userName = firstNonEmpty(user.nickname, user.username, user.realName)
|
||||
AppStore.shared.session.realName = firstNonEmpty(user.realName, user.nickname, user.username)
|
||||
AppStore.shared.session.userName = firstNonEmpty(user.nickname, user.realName)
|
||||
AppStore.shared.session.realName = user.realName
|
||||
AppStore.shared.session.phone = user.phone
|
||||
AppStore.shared.session.currentScenicId = user.scenicId
|
||||
AppStore.shared.session.currentScenicName = user.scenicName
|
||||
@@ -90,10 +90,10 @@ enum AuthSessionHelper {
|
||||
AppStore.shared.session.userId = String(user.businessUserId)
|
||||
AppStore.shared.session.accountType = .storeUser
|
||||
AppStore.shared.session.accountDisplayName = firstNonEmpty(
|
||||
user.storeName, user.scenicName, user.realName, user.userName, user.username
|
||||
user.storeName, user.scenicName, user.realName
|
||||
)
|
||||
AppStore.shared.session.userName = firstNonEmpty(user.userName, user.username, user.realName)
|
||||
AppStore.shared.session.realName = firstNonEmpty(user.realName, user.userName, user.username)
|
||||
AppStore.shared.session.userName = user.realName
|
||||
AppStore.shared.session.realName = user.realName
|
||||
AppStore.shared.session.phone = user.phone
|
||||
AppStore.shared.session.avatar = user.avatar
|
||||
AppStore.shared.session.currentScenicId = user.scenicId
|
||||
|
||||
@@ -196,7 +196,6 @@ struct V9ScenicUser: Decodable, Equatable {
|
||||
let userId: Int
|
||||
let scenicUserId: Int
|
||||
let ssUserId: Int
|
||||
let username: String
|
||||
let realName: String
|
||||
let nickname: String
|
||||
let phone: String
|
||||
@@ -212,7 +211,7 @@ struct V9ScenicUser: Decodable, Equatable {
|
||||
}
|
||||
|
||||
var displayName: String {
|
||||
scenicName.nonEmpty ?? nickname.nonEmpty ?? realName.nonEmpty ?? username
|
||||
scenicName.nonEmpty ?? nickname.nonEmpty ?? realName
|
||||
}
|
||||
|
||||
func toAccountSwitchAccount() -> AccountSwitchAccount {
|
||||
@@ -238,8 +237,6 @@ struct V9ScenicUser: Decodable, Equatable {
|
||||
case userId = "user_id"
|
||||
case scenicUserId = "scenic_user_id"
|
||||
case ssUserId = "ss_user_id"
|
||||
case username = "user_name"
|
||||
case legacyUsername = "username"
|
||||
case realName = "real_name"
|
||||
case nickname
|
||||
case phone
|
||||
@@ -258,9 +255,6 @@ struct V9ScenicUser: Decodable, Equatable {
|
||||
userId = try container.decodeLossyInt(forKey: .userId) ?? 0
|
||||
scenicUserId = try container.decodeLossyInt(forKey: .scenicUserId) ?? 0
|
||||
ssUserId = try container.decodeLossyInt(forKey: .ssUserId) ?? 0
|
||||
let preferredUsername = try container.decodeLossyString(forKey: .username)
|
||||
let legacyUsername = try container.decodeLossyString(forKey: .legacyUsername)
|
||||
username = preferredUsername.nonEmpty ?? legacyUsername
|
||||
realName = try container.decodeLossyString(forKey: .realName)
|
||||
nickname = try container.decodeLossyString(forKey: .nickname)
|
||||
phone = try container.decodeLossyString(forKey: .phone)
|
||||
@@ -281,8 +275,6 @@ struct V9StoreUser: Decodable, Equatable {
|
||||
let id: Int
|
||||
let userId: Int
|
||||
let storeUserId: Int
|
||||
let username: String
|
||||
let userName: String
|
||||
let realName: String
|
||||
let phone: String
|
||||
let avatar: String
|
||||
@@ -300,14 +292,14 @@ struct V9StoreUser: Decodable, Equatable {
|
||||
}
|
||||
|
||||
var displayName: String {
|
||||
storeName.nonEmpty ?? scenicName.nonEmpty ?? realName.nonEmpty ?? userName.nonEmpty ?? username
|
||||
storeName.nonEmpty ?? scenicName.nonEmpty ?? realName
|
||||
}
|
||||
|
||||
func toAccountSwitchAccount() -> AccountSwitchAccount {
|
||||
AccountSwitchAccount(
|
||||
accountType: accountType.nonEmpty ?? Self.accountTypeValue,
|
||||
businessUserId: businessUserId,
|
||||
title: storeName.nonEmpty ?? scenicName.nonEmpty ?? realName.nonEmpty ?? userName.nonEmpty ?? "门店账号",
|
||||
title: storeName.nonEmpty ?? scenicName.nonEmpty ?? realName.nonEmpty ?? "门店账号",
|
||||
subtitle: joinAccountSubtitle(scenicName, roleName),
|
||||
phone: phone,
|
||||
realName: realName,
|
||||
@@ -325,8 +317,6 @@ struct V9StoreUser: Decodable, Equatable {
|
||||
case id
|
||||
case userId = "user_id"
|
||||
case storeUserId = "store_user_id"
|
||||
case username
|
||||
case userName = "user_name"
|
||||
case realName = "real_name"
|
||||
case phone
|
||||
case avatar
|
||||
@@ -346,8 +336,6 @@ struct V9StoreUser: Decodable, Equatable {
|
||||
id = try container.decodeLossyInt(forKey: .id) ?? 0
|
||||
userId = try container.decodeLossyInt(forKey: .userId) ?? 0
|
||||
storeUserId = try container.decodeLossyInt(forKey: .storeUserId) ?? 0
|
||||
username = try container.decodeLossyString(forKey: .username)
|
||||
userName = try container.decodeLossyString(forKey: .userName)
|
||||
realName = try container.decodeLossyString(forKey: .realName)
|
||||
phone = try container.decodeLossyString(forKey: .phone)
|
||||
avatar = try container.decodeLossyString(forKey: .avatar)
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
//
|
||||
// CommissionRateLogViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 获客员分成比例修改记录 ViewModel,负责刷新、分页与重复项合并。
|
||||
final class CommissionRateLogViewModel {
|
||||
private enum Constants {
|
||||
static let pageSize = 10
|
||||
}
|
||||
|
||||
private(set) var items: [CommissionRateLogEntity] = []
|
||||
private(set) var isRefreshing = false
|
||||
private(set) var isLoadingMore = false
|
||||
private(set) var initialLoading = true
|
||||
private(set) var canLoadMore = false
|
||||
|
||||
var onStateChange: (() -> Void)?
|
||||
var onShowMessage: ((String) -> Void)?
|
||||
|
||||
private let saleUserId: Int
|
||||
private var total = 0
|
||||
private var lastLoadedPage = 0
|
||||
private var isLoading = false
|
||||
|
||||
init(saleUserId: Int) {
|
||||
self.saleUserId = saleUserId
|
||||
}
|
||||
|
||||
/// 重新加载第一页修改记录。
|
||||
func refresh(api: OrderAPI) async {
|
||||
await load(api: api, page: 1, append: false)
|
||||
}
|
||||
|
||||
/// 在存在下一页时加载更多修改记录。
|
||||
func loadMore(api: OrderAPI) async {
|
||||
guard canLoadMore, !isRefreshing, !isLoadingMore else { return }
|
||||
await load(api: api, page: lastLoadedPage + 1, append: true)
|
||||
}
|
||||
|
||||
private func load(api: OrderAPI, page: Int, append: Bool) async {
|
||||
guard saleUserId > 0 else {
|
||||
initialLoading = false
|
||||
onShowMessage?("获客员信息无效")
|
||||
notifyStateChange()
|
||||
return
|
||||
}
|
||||
guard !isLoading else { return }
|
||||
isLoading = true
|
||||
if append {
|
||||
isLoadingMore = true
|
||||
} else {
|
||||
isRefreshing = true
|
||||
}
|
||||
notifyStateChange()
|
||||
|
||||
defer {
|
||||
isLoading = false
|
||||
initialLoading = false
|
||||
if append {
|
||||
isLoadingMore = false
|
||||
} else {
|
||||
isRefreshing = false
|
||||
}
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
do {
|
||||
let response = try await api.saleUserCommissionRateLogs(
|
||||
saleUserId: saleUserId,
|
||||
page: page,
|
||||
pageSize: Constants.pageSize
|
||||
)
|
||||
if append {
|
||||
let existingIDs = Set(items.map(\.id))
|
||||
items.append(contentsOf: response.list.filter { !existingIDs.contains($0.id) })
|
||||
} else {
|
||||
var seenIDs = Set<Int>()
|
||||
items = response.list.filter { seenIDs.insert($0.id).inserted }
|
||||
}
|
||||
total = max(0, response.total)
|
||||
lastLoadedPage = page
|
||||
canLoadMore = items.count < total && !response.list.isEmpty
|
||||
} catch {
|
||||
onShowMessage?(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func notifyStateChange() {
|
||||
onStateChange?()
|
||||
}
|
||||
}
|
||||
@@ -16,21 +16,24 @@ enum HomeMenuIconFactory {
|
||||
|
||||
private static let imageCache = NSCache<NSString, UIImage>()
|
||||
|
||||
/// 优先加载 Assets 资源图;否则加载 SF Symbol,并统一渲染到固定画布。
|
||||
/// 优先加载 Assets 资源图并归一化画布;SF Symbol 保留系统符号表示与分层渲染。
|
||||
static func image(named iconName: String) -> UIImage? {
|
||||
let cacheKey = iconName as NSString
|
||||
if let cachedImage = imageCache.object(forKey: cacheKey) {
|
||||
return cachedImage
|
||||
}
|
||||
|
||||
guard let sourceImage = UIImage(named: iconName)
|
||||
?? UIImage(systemName: iconName, withConfiguration: symbolConfiguration) else {
|
||||
return nil
|
||||
let image: UIImage?
|
||||
if let assetImage = UIImage(named: iconName) {
|
||||
image = renderOnFixedCanvas(assetImage)
|
||||
} else {
|
||||
image = UIImage(systemName: iconName, withConfiguration: symbolConfiguration)
|
||||
}
|
||||
|
||||
let normalizedImage = renderOnFixedCanvas(sourceImage)
|
||||
imageCache.setObject(normalizedImage, forKey: cacheKey)
|
||||
return normalizedImage
|
||||
if let image {
|
||||
imageCache.setObject(image, forKey: cacheKey)
|
||||
}
|
||||
return image
|
||||
}
|
||||
|
||||
private static func renderOnFixedCanvas(_ image: UIImage) -> UIImage {
|
||||
|
||||
@@ -20,6 +20,7 @@ enum AMapBootstrap {
|
||||
defer { lock.unlock() }
|
||||
guard !isConfigured else { return true }
|
||||
|
||||
#if !targetEnvironment(simulator)
|
||||
AMapLocationManager.updatePrivacyShow(.didShow, privacyInfo: .didContain)
|
||||
AMapLocationManager.updatePrivacyAgree(.didAgree)
|
||||
MAMapView.updatePrivacyShow(.didShow, privacyInfo: .didContain)
|
||||
@@ -29,6 +30,7 @@ enum AMapBootstrap {
|
||||
|
||||
AMapServices.shared().apiKey = AMapConfig.apiKey
|
||||
AMapServices.shared().enableHTTPS = true
|
||||
#endif
|
||||
isConfigured = true
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -3,16 +3,28 @@
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import CoreLocation
|
||||
import Foundation
|
||||
|
||||
/// 高德逆地理编码封装,对齐 Android `GeocodeSearch`。
|
||||
/// 地址逆地理编码能力,供平台定位实现注入。
|
||||
@MainActor
|
||||
final class LocationGeocoder: NSObject, AMapSearchDelegate {
|
||||
protocol LocationAddressGeocoding: AnyObject {
|
||||
/// 将坐标解析为可读地址,失败时返回空字符串。
|
||||
func reverseGeocode(latitude: Double, longitude: Double) async -> String
|
||||
}
|
||||
|
||||
/// 平台逆地理编码封装;真机使用高德,模拟器使用 Core Location。
|
||||
@MainActor
|
||||
final class LocationGeocoder: NSObject, LocationAddressGeocoding {
|
||||
|
||||
nonisolated static let shared = LocationGeocoder()
|
||||
|
||||
#if targetEnvironment(simulator)
|
||||
private let geocoder = CLGeocoder()
|
||||
#else
|
||||
private var searchAPI: AMapSearchAPI?
|
||||
private var continuation: CheckedContinuation<String, Never>?
|
||||
#endif
|
||||
|
||||
nonisolated private override init() {
|
||||
super.init()
|
||||
@@ -20,6 +32,26 @@ final class LocationGeocoder: NSObject, AMapSearchDelegate {
|
||||
|
||||
/// 将坐标解析为可读地址。
|
||||
func reverseGeocode(latitude: Double, longitude: Double) async -> String {
|
||||
#if targetEnvironment(simulator)
|
||||
let location = CLLocation(latitude: latitude, longitude: longitude)
|
||||
guard let placemark = try? await geocoder.reverseGeocodeLocation(location).first else {
|
||||
return ""
|
||||
}
|
||||
if let name = placemark.name?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!name.isEmpty {
|
||||
return name
|
||||
}
|
||||
return [
|
||||
placemark.administrativeArea,
|
||||
placemark.locality,
|
||||
placemark.subLocality,
|
||||
placemark.thoroughfare,
|
||||
placemark.subThoroughfare,
|
||||
]
|
||||
.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
.joined()
|
||||
#else
|
||||
do {
|
||||
try AMapBootstrap.requireConfigured()
|
||||
} catch {
|
||||
@@ -40,8 +72,10 @@ final class LocationGeocoder: NSObject, AMapSearchDelegate {
|
||||
request.radius = 200
|
||||
api.aMapReGoecodeSearch(request)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if !targetEnvironment(simulator)
|
||||
func onReGeocodeSearchDone(_ request: AMapReGeocodeSearchRequest!, response: AMapReGeocodeSearchResponse!) {
|
||||
let address = response?.regeocode?.formattedAddress ?? ""
|
||||
continuation?.resume(returning: address)
|
||||
@@ -62,4 +96,9 @@ final class LocationGeocoder: NSObject, AMapSearchDelegate {
|
||||
searchAPI = api
|
||||
return api
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if !targetEnvironment(simulator)
|
||||
extension LocationGeocoder: AMapSearchDelegate {}
|
||||
#endif
|
||||
|
||||
@@ -6,7 +6,165 @@
|
||||
import CoreLocation
|
||||
import Foundation
|
||||
|
||||
/// 统一定位服务,封装高德 `AMapLocationManager`,对齐 Android `LocationProvider`。
|
||||
/// Core Location 管理器最小接口,便于模拟器定位实现注入测试替身。
|
||||
@MainActor
|
||||
protocol CoreLocationManaging: AnyObject {
|
||||
var authorizationStatus: CLAuthorizationStatus { get }
|
||||
var desiredAccuracy: CLLocationAccuracy { get set }
|
||||
var delegate: CLLocationManagerDelegate? { get set }
|
||||
|
||||
/// 请求使用期间定位权限。
|
||||
func requestWhenInUseAuthorization()
|
||||
/// 请求一次当前位置。
|
||||
func requestLocation()
|
||||
}
|
||||
|
||||
extension CLLocationManager: CoreLocationManaging {}
|
||||
|
||||
/// Core Location 定位实现,供模拟器运行并支持依赖注入测试。
|
||||
@MainActor
|
||||
final class CoreLocationProvider: NSObject, LocationProviding, CLLocationManagerDelegate {
|
||||
|
||||
nonisolated static let shared = CoreLocationProvider()
|
||||
|
||||
private var appStore: AppStore = .shared
|
||||
private var manager: any CoreLocationManaging = CLLocationManager()
|
||||
private var geocoder: any LocationAddressGeocoding = LocationGeocoder.shared
|
||||
private var permissionContinuation: CheckedContinuation<Void, Error>?
|
||||
private var locationContinuation: CheckedContinuation<CLLocationCoordinate2D, Error>?
|
||||
|
||||
nonisolated override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
/// 使用可控依赖创建 Core Location 定位服务。
|
||||
init(
|
||||
appStore: AppStore,
|
||||
manager: any CoreLocationManaging,
|
||||
geocoder: any LocationAddressGeocoding
|
||||
) {
|
||||
self.appStore = appStore
|
||||
self.manager = manager
|
||||
self.geocoder = geocoder
|
||||
super.init()
|
||||
manager.delegate = self
|
||||
}
|
||||
|
||||
/// 请求坐标及逆地理编码地址。
|
||||
func requestSnapshot(
|
||||
desiredAccuracy: CLLocationAccuracy = kCLLocationAccuracyBest
|
||||
) async throws -> HomeLocationSnapshot {
|
||||
let coordinate = try await requestCoordinate(desiredAccuracy: desiredAccuracy)
|
||||
let address = await geocoder.reverseGeocode(
|
||||
latitude: coordinate.latitude,
|
||||
longitude: coordinate.longitude
|
||||
)
|
||||
return HomeLocationSnapshot(
|
||||
latitude: coordinate.latitude,
|
||||
longitude: coordinate.longitude,
|
||||
address: address
|
||||
)
|
||||
}
|
||||
|
||||
/// 请求一次当前位置坐标。
|
||||
func requestCoordinate(
|
||||
desiredAccuracy: CLLocationAccuracy
|
||||
) async throws -> CLLocationCoordinate2D {
|
||||
try requirePrivacyAgreement()
|
||||
try await ensureLocationPermission()
|
||||
guard locationContinuation == nil else {
|
||||
throw LocationProviderError.locationFailed
|
||||
}
|
||||
|
||||
manager.delegate = self
|
||||
manager.desiredAccuracy = desiredAccuracy
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
locationContinuation = continuation
|
||||
manager.requestLocation()
|
||||
}
|
||||
}
|
||||
|
||||
/// 使用系统服务将坐标解析为地址。
|
||||
func reverseGeocode(latitude: Double, longitude: Double) async -> String {
|
||||
guard appStore.session.privacyAgreementAccepted else { return "" }
|
||||
return await geocoder.reverseGeocode(latitude: latitude, longitude: longitude)
|
||||
}
|
||||
|
||||
/// 请求系统定位权限,等待用户完成首次授权。
|
||||
func ensureLocationPermission() async throws {
|
||||
manager.delegate = self
|
||||
switch manager.authorizationStatus {
|
||||
case .authorizedAlways, .authorizedWhenInUse:
|
||||
return
|
||||
case .denied, .restricted:
|
||||
throw LocationProviderError.permissionDenied
|
||||
case .notDetermined:
|
||||
guard permissionContinuation == nil else {
|
||||
throw LocationProviderError.locationFailed
|
||||
}
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
permissionContinuation = continuation
|
||||
manager.requestWhenInUseAuthorization()
|
||||
}
|
||||
@unknown default:
|
||||
throw LocationProviderError.permissionDenied
|
||||
}
|
||||
}
|
||||
|
||||
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||
resolvePermission(status: self.manager.authorizationStatus)
|
||||
}
|
||||
|
||||
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
||||
guard let continuation = locationContinuation else { return }
|
||||
locationContinuation = nil
|
||||
guard let coordinate = locations.last?.coordinate,
|
||||
CLLocationCoordinate2DIsValid(coordinate),
|
||||
coordinate.latitude != 0 || coordinate.longitude != 0 else {
|
||||
continuation.resume(throwing: LocationProviderError.locationFailed)
|
||||
return
|
||||
}
|
||||
continuation.resume(returning: coordinate)
|
||||
}
|
||||
|
||||
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
|
||||
guard let continuation = locationContinuation else { return }
|
||||
locationContinuation = nil
|
||||
continuation.resume(throwing: LocationProviderError.locationFailed)
|
||||
}
|
||||
|
||||
private func requirePrivacyAgreement() throws {
|
||||
guard appStore.session.privacyAgreementAccepted else {
|
||||
throw LocationProviderError.privacyNotAccepted
|
||||
}
|
||||
}
|
||||
|
||||
private func resolvePermission(status: CLAuthorizationStatus) {
|
||||
guard let continuation = permissionContinuation else { return }
|
||||
switch status {
|
||||
case .authorizedAlways, .authorizedWhenInUse:
|
||||
permissionContinuation = nil
|
||||
continuation.resume()
|
||||
case .denied, .restricted:
|
||||
permissionContinuation = nil
|
||||
continuation.resume(throwing: LocationProviderError.permissionDenied)
|
||||
case .notDetermined:
|
||||
break
|
||||
@unknown default:
|
||||
permissionContinuation = nil
|
||||
continuation.resume(throwing: LocationProviderError.permissionDenied)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if targetEnvironment(simulator)
|
||||
|
||||
/// 模拟器默认定位服务,使用 Core Location 与系统逆地理编码。
|
||||
typealias LocationProvider = CoreLocationProvider
|
||||
|
||||
#else
|
||||
|
||||
/// 真机统一定位服务,封装高德 `AMapLocationManager`,对齐 Android `LocationProvider`。
|
||||
@MainActor
|
||||
final class LocationProvider: NSObject, LocationProviding {
|
||||
|
||||
@@ -19,7 +177,7 @@ final class LocationProvider: NSObject, LocationProviding {
|
||||
super.init()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 请求坐标及高德逆地理编码地址。
|
||||
func requestSnapshot(desiredAccuracy: CLLocationAccuracy = kCLLocationAccuracyBest) async throws -> HomeLocationSnapshot {
|
||||
try AMapBootstrap.requireConfigured()
|
||||
try await ensureLocationPermission()
|
||||
@@ -51,7 +209,7 @@ final class LocationProvider: NSObject, LocationProviding {
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 请求一次当前位置坐标。
|
||||
func requestCoordinate(desiredAccuracy: CLLocationAccuracy) async throws -> CLLocationCoordinate2D {
|
||||
try AMapBootstrap.requireConfigured()
|
||||
try await ensureLocationPermission()
|
||||
@@ -78,7 +236,7 @@ final class LocationProvider: NSObject, LocationProviding {
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 使用高德服务将坐标解析为地址。
|
||||
func reverseGeocode(latitude: Double, longitude: Double) async -> String {
|
||||
await geocoder.reverseGeocode(latitude: latitude, longitude: longitude)
|
||||
}
|
||||
@@ -110,3 +268,5 @@ final class LocationProvider: NSObject, LocationProviding {
|
||||
return manager
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
@@ -16,20 +16,25 @@ struct HomeLocationSnapshot: Sendable, Equatable {
|
||||
/// 定位服务协议,便于 ViewModel 注入与单元测试 mock。
|
||||
protocol LocationProviding: Sendable {
|
||||
/// 按指定精度请求当前坐标与地址。
|
||||
@MainActor
|
||||
func requestSnapshot(desiredAccuracy: CLLocationAccuracy) async throws -> HomeLocationSnapshot
|
||||
/// 按指定精度请求当前坐标。
|
||||
@MainActor
|
||||
func requestCoordinate(desiredAccuracy: CLLocationAccuracy) async throws -> CLLocationCoordinate2D
|
||||
/// 逆地理编码。
|
||||
@MainActor
|
||||
func reverseGeocode(latitude: Double, longitude: Double) async -> String
|
||||
}
|
||||
|
||||
extension LocationProviding {
|
||||
/// 请求当前坐标与地址,默认使用最高精度以保持既有业务行为。
|
||||
@MainActor
|
||||
func requestSnapshot() async throws -> HomeLocationSnapshot {
|
||||
try await requestSnapshot(desiredAccuracy: kCLLocationAccuracyBest)
|
||||
}
|
||||
|
||||
/// 请求当前坐标,默认使用最高精度以保持既有业务行为。
|
||||
@MainActor
|
||||
func requestCoordinate() async throws -> CLLocationCoordinate2D {
|
||||
try await requestCoordinate(desiredAccuracy: kCLLocationAccuracyBest)
|
||||
}
|
||||
|
||||
@@ -293,6 +293,28 @@ final class OrderAPI {
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取指定获客员的分成比例修改记录。
|
||||
func saleUserCommissionRateLogs(
|
||||
saleUserId: Int,
|
||||
page: Int = 1,
|
||||
pageSize: Int = 10
|
||||
) async throws -> CommissionRateLogListResponse {
|
||||
guard saleUserId > 0 else {
|
||||
throw APIError.networkFailed("获客员信息无效")
|
||||
}
|
||||
return try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/yf-handset-app/photog/sale-user/commission-rate/logs",
|
||||
queryItems: [
|
||||
URLQueryItem(name: "sale_user_id", value: String(saleUserId)),
|
||||
URLQueryItem(name: "page", value: String(max(1, page))),
|
||||
URLQueryItem(name: "page_size", value: String(min(max(1, pageSize), 50))),
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func shootHistory(orderNumber: String) async throws -> MultiTravelShootHistoryResponse {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
|
||||
@@ -875,6 +875,93 @@ struct CooperativeSalerEntity: Decodable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 获客员分成比例修改记录分页响应。
|
||||
struct CommissionRateLogListResponse: Decodable, Equatable {
|
||||
let list: [CommissionRateLogEntity]
|
||||
let total: Int
|
||||
let page: Int
|
||||
let pageSize: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case list
|
||||
case total
|
||||
case page
|
||||
case pageSize = "page_size"
|
||||
}
|
||||
}
|
||||
|
||||
/// 单条获客员分成比例修改记录。
|
||||
struct CommissionRateLogEntity: Decodable, Equatable, Hashable {
|
||||
let id: Int
|
||||
let bindingId: Int
|
||||
let saleUserId: Int
|
||||
let storeUserId: Int
|
||||
let beforeRate: Int
|
||||
let beforeRateLabel: String
|
||||
let afterRate: Int
|
||||
let afterRateLabel: String
|
||||
let operatorType: String
|
||||
let operatorId: Int
|
||||
let operatorName: String
|
||||
let remark: String
|
||||
let createdAt: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case bindingId = "binding_id"
|
||||
case saleUserId = "sale_user_id"
|
||||
case storeUserId = "store_user_id"
|
||||
case beforeRate = "before_rate"
|
||||
case beforeRateLabel = "before_rate_label"
|
||||
case afterRate = "after_rate"
|
||||
case afterRateLabel = "after_rate_label"
|
||||
case operatorType = "operator_type"
|
||||
case operatorId = "operator_id"
|
||||
case operatorName = "operator_name"
|
||||
case remark
|
||||
case createdAt = "created_at"
|
||||
}
|
||||
|
||||
/// 修改前比例展示文本,优先使用服务端 label。
|
||||
var displayBeforeRate: String {
|
||||
let label = beforeRateLabel.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return label.isEmpty ? "\(beforeRate)%" : label
|
||||
}
|
||||
|
||||
/// 修改后比例展示文本,优先使用服务端 label。
|
||||
var displayAfterRate: String {
|
||||
let label = afterRateLabel.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return label.isEmpty ? "\(afterRate)%" : label
|
||||
}
|
||||
|
||||
/// 修改时间展示文本。
|
||||
var metadataLine: String {
|
||||
formattedCreatedAt
|
||||
}
|
||||
|
||||
/// 去除首尾空白后的备注;空备注不展示。
|
||||
var displayRemark: String? {
|
||||
let value = remark.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return value.isEmpty ? nil : value
|
||||
}
|
||||
|
||||
private var formattedCreatedAt: String {
|
||||
let value = createdAt.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !value.isEmpty else { return "—" }
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.timeZone = TimeZone(identifier: "Asia/Shanghai")
|
||||
for format in ["yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd HH:mm"] {
|
||||
formatter.dateFormat = format
|
||||
if let date = formatter.date(from: value) {
|
||||
formatter.dateFormat = "yyyy-MM-dd HH:mm"
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
struct ReferralOrderListResponse: Decodable {
|
||||
let total: Int
|
||||
let list: [ReferralOrderEntity]
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import CoreLocation
|
||||
import UIKit
|
||||
|
||||
/// 主 Tab 中间扫码按钮结果处理,对齐 Android `MainTabViewModel.signIn`。
|
||||
|
||||
@@ -69,9 +69,9 @@ enum PayPageBrandingPolicy {
|
||||
|
||||
/// 收款页当前账号信息的展示文本格式化规则。
|
||||
enum PaymentAccountDisplayFormatter {
|
||||
/// 解析当前登录摄影师名称,昵称为空时使用实名,仍为空时显示占位符。
|
||||
static func photographerName(userName: String, realName: String) -> String {
|
||||
userName.trimmedNonEmpty ?? realName.trimmedNonEmpty ?? "-"
|
||||
/// 解析当前登录摄影师的真实姓名,空值显示占位符。
|
||||
static func photographerName(realName: String) -> String {
|
||||
realName.trimmedNonEmpty ?? "-"
|
||||
}
|
||||
|
||||
/// 去除空值与重复项后,合并当前账号的全部店铺名称。
|
||||
|
||||
@@ -28,6 +28,7 @@ final class PaymentCollectionDetailsViewModel {
|
||||
|
||||
private var staticPayURL = ""
|
||||
private var dynamicPayURL = ""
|
||||
private var hasCompletedPayCodeRequest = false
|
||||
private let appStore: AppStore
|
||||
private let payPageConfigCache: PayPageConfigCaching
|
||||
|
||||
@@ -50,6 +51,11 @@ final class PaymentCollectionDetailsViewModel {
|
||||
qrImage != nil && !staticPayURL.isEmpty
|
||||
}
|
||||
|
||||
/// 是否应在二维码区域展示加载状态,首次请求开始前也视为加载中,避免短暂闪现错误占位。
|
||||
var isPayCodeLoading: Bool {
|
||||
loading || !hasCompletedPayCodeRequest
|
||||
}
|
||||
|
||||
var displayScenicName: String {
|
||||
scenicName.isEmpty ? "暂无景区" : scenicName
|
||||
}
|
||||
@@ -60,7 +66,6 @@ final class PaymentCollectionDetailsViewModel {
|
||||
|
||||
var displayPhotographerName: String {
|
||||
PaymentAccountDisplayFormatter.photographerName(
|
||||
userName: appStore.session.userName,
|
||||
realName: appStore.session.realName
|
||||
)
|
||||
}
|
||||
@@ -85,11 +90,11 @@ final class PaymentCollectionDetailsViewModel {
|
||||
|
||||
func loadPayCode(api: PaymentAPI) async {
|
||||
guard !loading else { return }
|
||||
refreshLocalState()
|
||||
loading = true
|
||||
notifyStateChange()
|
||||
refreshLocalState()
|
||||
defer {
|
||||
loading = false
|
||||
hasCompletedPayCodeRequest = true
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
|
||||
@@ -17,11 +17,11 @@ final class ProfileViewModel {
|
||||
var onStateChange: (() -> Void)?
|
||||
|
||||
var displayNickname: String {
|
||||
nonEmpty(userInfo?.nickname) ?? "未设置昵称"
|
||||
nonEmpty(userInfo?.nickname) ?? AppStore.shared.session.userName.nonEmpty ?? "未设置昵称"
|
||||
}
|
||||
|
||||
var displayRealName: String {
|
||||
nonEmpty(userInfo?.realName) ?? "--"
|
||||
nonEmpty(userInfo?.realName) ?? AppStore.shared.session.realName.nonEmpty ?? "--"
|
||||
}
|
||||
|
||||
var displayPhone: String {
|
||||
@@ -37,6 +37,17 @@ final class ProfileViewModel {
|
||||
return uid.isEmpty ? "--" : uid
|
||||
}
|
||||
|
||||
/// 本地是否已有可供冷启动首屏展示的基本资料。
|
||||
var hasCachedBasicInfo: Bool {
|
||||
[
|
||||
AppStore.shared.session.userName,
|
||||
AppStore.shared.session.realName,
|
||||
AppStore.shared.session.phone,
|
||||
AppStore.shared.session.avatar,
|
||||
AppStore.shared.session.userId,
|
||||
].contains { nonEmpty($0) != nil }
|
||||
}
|
||||
|
||||
var accountDisplayName: String {
|
||||
let name = AppStore.shared.session.accountDisplayName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return name.isEmpty ? "--" : name
|
||||
@@ -112,6 +123,7 @@ final class ProfileViewModel {
|
||||
if !info.roleName.isEmpty {
|
||||
AppStore.shared.session.roleName = info.roleName
|
||||
}
|
||||
notifyStateChange()
|
||||
|
||||
if showPhotographerFields {
|
||||
async let realName = api.realNameInfo()
|
||||
|
||||
Reference in New Issue
Block a user