修复离线路径误弹位置上报超时窗,并优化提醒设置与定位 Loading 展示。
离线或倒计时未进入提醒窗口时不再弹出超时提醒;全局 Loading 支持按需展示定位文案,提前提醒选项抽成共用组件。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -71,7 +71,7 @@ Core 模块提供跨业务复用的基础能力,包括网络请求、缓存存
|
|||||||
|
|
||||||
Loading 的可观察状态只在 `GlobalLoadingOverlayHost` 内部订阅,并由 `RootView` 挂载到应用根部。这样切换 Loading 显隐时,只会刷新根部 Overlay,不会让当前页面、Tab 根视图或业务子视图形成观察依赖。
|
Loading 的可观察状态只在 `GlobalLoadingOverlayHost` 内部订阅,并由 `RootView` 挂载到应用根部。这样切换 Loading 显隐时,只会刷新根部 Overlay,不会让当前页面、Tab 根视图或业务子视图形成观察依赖。
|
||||||
|
|
||||||
当前全局 Loading UI 不展示文案;`message` 字段保留为内部展示状态,便于后续需要时恢复文案展示。
|
当前全局 Loading 默认只展示动画;调用方传入 `showsMessage: true` 时,才会在动画下方展示 `message` 文案(如位置上报的「获取定位中」)。
|
||||||
|
|
||||||
全局 Loading 只用于阻塞型等待,例如冷启动恢复、登录、首屏加载、提交表单和核销。列表加载更多、上传进度、按钮内局部反馈继续保留在页面局部状态中。
|
全局 Loading 只用于阻塞型等待,例如冷启动恢复、登录、首屏加载、提交表单和核销。列表加载更多、上传进度、按钮内局部反馈继续保留在页面局部状态中。
|
||||||
|
|
||||||
|
|||||||
@ -16,10 +16,12 @@ final class GlobalLoadingCenter {
|
|||||||
fileprivate let state = GlobalLoadingState()
|
fileprivate let state = GlobalLoadingState()
|
||||||
|
|
||||||
/// 显示全局 Loading;多次调用会通过引用计数叠加。
|
/// 显示全局 Loading;多次调用会通过引用计数叠加。
|
||||||
func show(message: String = "") {
|
func show(message: String = "", showsMessage: Bool = false) {
|
||||||
if !message.isEmpty {
|
if !message.isEmpty {
|
||||||
state.message = message
|
state.message = message
|
||||||
}
|
}
|
||||||
|
state.messageDisplayLevels.append(showsMessage)
|
||||||
|
state.showsMessage = state.messageDisplayLevels.contains(true)
|
||||||
state.activeCount += 1
|
state.activeCount += 1
|
||||||
state.isVisible = true
|
state.isVisible = true
|
||||||
}
|
}
|
||||||
@ -27,9 +29,15 @@ final class GlobalLoadingCenter {
|
|||||||
/// 隐藏一次全局 Loading;所有调用方都结束后才真正关闭。
|
/// 隐藏一次全局 Loading;所有调用方都结束后才真正关闭。
|
||||||
func hide() {
|
func hide() {
|
||||||
state.activeCount = max(0, state.activeCount - 1)
|
state.activeCount = max(0, state.activeCount - 1)
|
||||||
|
if !state.messageDisplayLevels.isEmpty {
|
||||||
|
state.messageDisplayLevels.removeLast()
|
||||||
|
}
|
||||||
|
state.showsMessage = state.messageDisplayLevels.contains(true)
|
||||||
guard state.activeCount == 0 else { return }
|
guard state.activeCount == 0 else { return }
|
||||||
state.isVisible = false
|
state.isVisible = false
|
||||||
state.message = ""
|
state.message = ""
|
||||||
|
state.messageDisplayLevels = []
|
||||||
|
state.showsMessage = false
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 更新当前 Loading 文案,仅在 Loading 可见且文案非空时生效。
|
/// 更新当前 Loading 文案,仅在 Loading 可见且文案非空时生效。
|
||||||
@ -41,9 +49,10 @@ final class GlobalLoadingCenter {
|
|||||||
/// 包裹一个异步操作,操作结束或抛错时自动关闭 Loading。
|
/// 包裹一个异步操作,操作结束或抛错时自动关闭 Loading。
|
||||||
func withLoading<T>(
|
func withLoading<T>(
|
||||||
message: String = "",
|
message: String = "",
|
||||||
|
showsMessage: Bool = false,
|
||||||
operation: () async throws -> T
|
operation: () async throws -> T
|
||||||
) async rethrows -> T {
|
) async rethrows -> T {
|
||||||
show(message: message)
|
show(message: message, showsMessage: showsMessage)
|
||||||
defer { hide() }
|
defer { hide() }
|
||||||
return try await operation()
|
return try await operation()
|
||||||
}
|
}
|
||||||
@ -52,10 +61,11 @@ final class GlobalLoadingCenter {
|
|||||||
func withOptionalLoading<T>(
|
func withOptionalLoading<T>(
|
||||||
_ enabled: Bool,
|
_ enabled: Bool,
|
||||||
message: String = "",
|
message: String = "",
|
||||||
|
showsMessage: Bool = false,
|
||||||
operation: () async throws -> T
|
operation: () async throws -> T
|
||||||
) async rethrows -> T {
|
) async rethrows -> T {
|
||||||
if enabled {
|
if enabled {
|
||||||
return try await withLoading(message: message, operation: operation)
|
return try await withLoading(message: message, showsMessage: showsMessage, operation: operation)
|
||||||
}
|
}
|
||||||
return try await operation()
|
return try await operation()
|
||||||
}
|
}
|
||||||
@ -66,7 +76,8 @@ final class GlobalLoadingCenter {
|
|||||||
GlobalLoadingSnapshot(
|
GlobalLoadingSnapshot(
|
||||||
isVisible: state.isVisible,
|
isVisible: state.isVisible,
|
||||||
message: state.message,
|
message: state.message,
|
||||||
activeCount: state.activeCount
|
activeCount: state.activeCount,
|
||||||
|
showsMessage: state.showsMessage
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
@ -77,7 +88,9 @@ final class GlobalLoadingCenter {
|
|||||||
fileprivate final class GlobalLoadingState: ObservableObject {
|
fileprivate final class GlobalLoadingState: ObservableObject {
|
||||||
@Published fileprivate var isVisible = false
|
@Published fileprivate var isVisible = false
|
||||||
@Published fileprivate var message = ""
|
@Published fileprivate var message = ""
|
||||||
|
@Published fileprivate var showsMessage = false
|
||||||
@Published fileprivate var activeCount = 0
|
@Published fileprivate var activeCount = 0
|
||||||
|
fileprivate var messageDisplayLevels: [Bool] = []
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lottie Loading 动画容器,负责播放主包中的 loading.json。
|
/// Lottie Loading 动画容器,负责播放主包中的 loading.json。
|
||||||
@ -146,6 +159,14 @@ private struct GlobalLoadingOverlayHost: View {
|
|||||||
|
|
||||||
VStack(spacing: 14) {
|
VStack(spacing: 14) {
|
||||||
loadingAnimation
|
loadingAnimation
|
||||||
|
if state.showsMessage, !state.message.isEmpty {
|
||||||
|
Text(state.message)
|
||||||
|
.font(.system(size: 15, weight: .medium))
|
||||||
|
.foregroundStyle(AppDesign.textPrimary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.bottom, 6)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 10)
|
.padding(.horizontal, 10)
|
||||||
.padding(.vertical, 10)
|
.padding(.vertical, 10)
|
||||||
@ -155,10 +176,12 @@ private struct GlobalLoadingOverlayHost: View {
|
|||||||
.transition(.opacity)
|
.transition(.opacity)
|
||||||
.zIndex(10_000)
|
.zIndex(10_000)
|
||||||
.accessibilityElement(children: .combine)
|
.accessibilityElement(children: .combine)
|
||||||
.accessibilityLabel("加载中")
|
.accessibilityLabel(state.showsMessage && !state.message.isEmpty ? state.message : "加载中")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.animation(.easeInOut(duration: 0.2), value: state.isVisible)
|
.animation(.easeInOut(duration: 0.2), value: state.isVisible)
|
||||||
|
.animation(.easeInOut(duration: 0.2), value: state.showsMessage)
|
||||||
|
.animation(.easeInOut(duration: 0.2), value: state.message)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@ -200,6 +223,7 @@ struct GlobalLoadingSnapshot: Equatable {
|
|||||||
let isVisible: Bool
|
let isVisible: Bool
|
||||||
let message: String
|
let message: String
|
||||||
let activeCount: Int
|
let activeCount: Int
|
||||||
|
let showsMessage: Bool
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
|||||||
29
suixinkan/Core/Design/ReminderTimeOptionsMenu.swift
Normal file
29
suixinkan/Core/Design/ReminderTimeOptionsMenu.swift
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
//
|
||||||
|
// ReminderTimeOptionsMenu.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
// Created by Codex on 2026/6/29.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// 提前提醒时间选择控件。
|
||||||
|
/// 使用 `Menu` 而非 `confirmationDialog`,避免 ScrollView 内 popover 箭头锚点偏移。
|
||||||
|
struct ReminderTimeOptionsMenu<Label: View>: View {
|
||||||
|
let onSelect: (Int) -> Void
|
||||||
|
@ViewBuilder let label: () -> Label
|
||||||
|
|
||||||
|
private let options = [0, 5, 10, 15, 30]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Menu {
|
||||||
|
ForEach(options, id: \.self) { minute in
|
||||||
|
Button(minute == 0 ? "不提醒" : "\(minute)分钟") {
|
||||||
|
onSelect(minute)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
label()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -44,6 +44,7 @@ Home 模块负责登录后的首页工作台,包括当前景区展示、工作
|
|||||||
首页工作状态区域由 `HomeLocationViewModel` 驱动,与 `LocationReport` 详情页共享同一实例(在 `RootView` 注入)。
|
首页工作状态区域由 `HomeLocationViewModel` 驱动,与 `LocationReport` 详情页共享同一实例(在 `RootView` 注入)。
|
||||||
|
|
||||||
- 在线/离线切换、立即上报、2 小时倒计时和提前提醒均走真实 API。
|
- 在线/离线切换、立即上报、2 小时倒计时和提前提醒均走真实 API。
|
||||||
|
- 「位置上报超时」弹窗仅在本地在线且倒计时进入提前提醒窗口时弹出;离线状态不弹窗。
|
||||||
- 本地状态通过 `LocationStateStore` 持久化在线标志、过期时间戳、上次上报时间和提醒分钟数。
|
- 本地状态通过 `LocationStateStore` 持久化在线标志、过期时间戳、上次上报时间和提醒分钟数。
|
||||||
- 首页大按钮直接触发 GPS 上报;菜单 `location_report` 仍进入详情页。
|
- 首页大按钮直接触发 GPS 上报;菜单 `location_report` 仍进入详情页。
|
||||||
- 非 `homeMinimalTopRoles` 角色才展示工作状态卡片。
|
- 非 `homeMinimalTopRoles` 角色才展示工作状态卡片。
|
||||||
|
|||||||
@ -61,6 +61,8 @@ final class HomeLocationViewModel: ObservableObject {
|
|||||||
isOnline = false
|
isOnline = false
|
||||||
secondsUntilReport = 0
|
secondsUntilReport = 0
|
||||||
expireTimestamp = nil
|
expireTimestamp = nil
|
||||||
|
showTimeoutDialog = false
|
||||||
|
timeoutDialogTriggered = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -156,31 +158,13 @@ final class HomeLocationViewModel: ObservableObject {
|
|||||||
await reportLocation(type: .immediate, staffId: staffId!, scenicId: scenicId!, toast: toast)
|
await reportLocation(type: .immediate, staffId: staffId!, scenicId: scenicId!, toast: toast)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 调用 detail 接口判断是否需要弹出超时提醒。
|
/// 根据本地在线状态和倒计时判断是否需要弹出超时提醒。
|
||||||
func checkLocationTimeout(staffId: Int?) async {
|
func checkLocationTimeout(staffId: Int?) async {
|
||||||
guard reminderMinutes > 0 else {
|
guard isOnline, reminderMinutes > 0, secondsUntilReport > 0 else {
|
||||||
showTimeoutDialog = false
|
showTimeoutDialog = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard let staffId, staffId > 0 else {
|
evaluateReminderThreshold(remainingSeconds: secondsUntilReport, forceDialog: true)
|
||||||
showTimeoutDialog = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
do {
|
|
||||||
let detail = try await api.locationDetail(staffId: staffId)
|
|
||||||
if detail.expireTime <= 0 {
|
|
||||||
showTimeoutDialog = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let currentMillis = Date().timeIntervalSince1970 * 1000
|
|
||||||
let expireMillis = Double(detail.expireTime) * 1000
|
|
||||||
let thresholdMillis = expireMillis - Double(reminderMinutes * 60 * 1000)
|
|
||||||
showTimeoutDialog = currentMillis >= thresholdMillis
|
|
||||||
} catch {
|
|
||||||
showTimeoutDialog = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 关闭超时对话框并将用户置为离线。
|
/// 关闭超时对话框并将用户置为离线。
|
||||||
@ -313,6 +297,8 @@ final class HomeLocationViewModel: ObservableObject {
|
|||||||
secondsUntilReport = 0
|
secondsUntilReport = 0
|
||||||
lastReportAnchorTime = nil
|
lastReportAnchorTime = nil
|
||||||
expireTimestamp = nil
|
expireTimestamp = nil
|
||||||
|
showTimeoutDialog = false
|
||||||
|
timeoutDialogTriggered = false
|
||||||
if clearPersisted {
|
if clearPersisted {
|
||||||
store.saveOnlineStatus(false)
|
store.saveOnlineStatus(false)
|
||||||
store.clearLastReportTime()
|
store.clearLastReportTime()
|
||||||
@ -344,9 +330,9 @@ final class HomeLocationViewModel: ObservableObject {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 判断是否到达提前提醒阈值。
|
/// 判断是否到达提前提醒阈值(仅在线且倒计时进入提醒窗口时触发)。
|
||||||
private func evaluateReminderThreshold(remainingSeconds: Int, forceDialog: Bool) {
|
private func evaluateReminderThreshold(remainingSeconds: Int, forceDialog: Bool) {
|
||||||
guard reminderMinutes > 0 else { return }
|
guard isOnline, reminderMinutes > 0 else { return }
|
||||||
let thresholdSeconds = reminderMinutes * 60
|
let thresholdSeconds = reminderMinutes * 60
|
||||||
guard remainingSeconds <= thresholdSeconds else { return }
|
guard remainingSeconds <= thresholdSeconds else { return }
|
||||||
|
|
||||||
|
|||||||
@ -24,7 +24,6 @@ struct HomeView: View {
|
|||||||
@StateObject private var viewModel = HomeViewModel()
|
@StateObject private var viewModel = HomeViewModel()
|
||||||
@State private var commonUris: [String] = []
|
@State private var commonUris: [String] = []
|
||||||
@State private var showOnlineDialog = false
|
@State private var showOnlineDialog = false
|
||||||
@State private var showReminderDialog = false
|
|
||||||
|
|
||||||
private let commonMenuStore = HomeCommonMenuStore()
|
private let commonMenuStore = HomeCommonMenuStore()
|
||||||
private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
|
private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
|
||||||
@ -134,14 +133,6 @@ struct HomeView: View {
|
|||||||
} message: {
|
} message: {
|
||||||
Text(homeLocationViewModel.isOnline ? "是否确认切换为离线状态?离线后将暂停位置上报。" : "是否确认切换为在线状态?在线后将开始位置上报和计时。")
|
Text(homeLocationViewModel.isOnline ? "是否确认切换为离线状态?离线后将暂停位置上报。" : "是否确认切换为在线状态?在线后将开始位置上报和计时。")
|
||||||
}
|
}
|
||||||
.confirmationDialog("提前提醒时间", isPresented: $showReminderDialog, titleVisibility: .visible) {
|
|
||||||
ForEach([0, 5, 10, 15, 30], id: \.self) { minute in
|
|
||||||
Button(minute == 0 ? "不提醒" : "\(minute)分钟") {
|
|
||||||
homeLocationViewModel.updateReminderMinutes(minute)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Button("取消", role: .cancel) {}
|
|
||||||
}
|
|
||||||
.alert("位置上报提醒", isPresented: $homeLocationViewModel.showReminderDialog) {
|
.alert("位置上报提醒", isPresented: $homeLocationViewModel.showReminderDialog) {
|
||||||
Button("立即上报") {
|
Button("立即上报") {
|
||||||
homeLocationViewModel.dismissReminderDialog()
|
homeLocationViewModel.dismissReminderDialog()
|
||||||
@ -232,9 +223,7 @@ struct HomeView: View {
|
|||||||
|
|
||||||
Spacer(minLength: AppMetrics.Spacing.xxSmall)
|
Spacer(minLength: AppMetrics.Spacing.xxSmall)
|
||||||
|
|
||||||
Button {
|
ReminderTimeOptionsMenu(onSelect: { homeLocationViewModel.updateReminderMinutes($0) }) {
|
||||||
showReminderDialog = true
|
|
||||||
} label: {
|
|
||||||
Label {
|
Label {
|
||||||
Text(reminderText)
|
Text(reminderText)
|
||||||
.font(.system(size: AppMetrics.FontSize.body, weight: .medium))
|
.font(.system(size: AppMetrics.FontSize.body, weight: .medium))
|
||||||
@ -472,7 +461,7 @@ struct HomeView: View {
|
|||||||
/// 包裹需要 GPS 定位的位置上报操作,定位期间展示全局 Loading。
|
/// 包裹需要 GPS 定位的位置上报操作,定位期间展示全局 Loading。
|
||||||
private func runLocationReportTask(_ body: @escaping () async -> Void) {
|
private func runLocationReportTask(_ body: @escaping () async -> Void) {
|
||||||
Task {
|
Task {
|
||||||
await globalLoading.withLoading(message: "正在定位...") {
|
await globalLoading.withLoading(message: "获取定位中", showsMessage: true) {
|
||||||
await body()
|
await body()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,7 +21,6 @@ struct LocationReportView: View {
|
|||||||
@StateObject private var viewModel = LocationReportViewModel()
|
@StateObject private var viewModel = LocationReportViewModel()
|
||||||
@State private var locationProvider = ForegroundLocationProvider()
|
@State private var locationProvider = ForegroundLocationProvider()
|
||||||
@State private var showOnlineDialog = false
|
@State private var showOnlineDialog = false
|
||||||
@State private var showReminderDialog = false
|
|
||||||
private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
|
private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@ -59,7 +58,7 @@ struct LocationReportView: View {
|
|||||||
Button("取消", role: .cancel) {}
|
Button("取消", role: .cancel) {}
|
||||||
Button("确定") {
|
Button("确定") {
|
||||||
Task {
|
Task {
|
||||||
await globalLoading.withLoading(message: "正在定位...") {
|
await globalLoading.withLoading(message: "获取定位中", showsMessage: true) {
|
||||||
await homeLocationViewModel.confirmOnlineToggle(
|
await homeLocationViewModel.confirmOnlineToggle(
|
||||||
staffId: staffId,
|
staffId: staffId,
|
||||||
scenicId: scenicId,
|
scenicId: scenicId,
|
||||||
@ -71,14 +70,6 @@ struct LocationReportView: View {
|
|||||||
} message: {
|
} message: {
|
||||||
Text(homeLocationViewModel.isOnline ? "是否确认切换为离线状态?离线后将暂停位置上报。" : "是否确认切换为在线状态?在线后将开始位置上报和计时。")
|
Text(homeLocationViewModel.isOnline ? "是否确认切换为离线状态?离线后将暂停位置上报。" : "是否确认切换为在线状态?在线后将开始位置上报和计时。")
|
||||||
}
|
}
|
||||||
.confirmationDialog("提前提醒时间", isPresented: $showReminderDialog, titleVisibility: .visible) {
|
|
||||||
ForEach([0, 5, 10, 15, 30], id: \.self) { minute in
|
|
||||||
Button(minute == 0 ? "不提醒" : "\(minute)分钟") {
|
|
||||||
homeLocationViewModel.updateReminderMinutes(minute)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Button("取消", role: .cancel) {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 在线状态和倒计时区域。
|
/// 在线状态和倒计时区域。
|
||||||
@ -188,9 +179,7 @@ struct LocationReportView: View {
|
|||||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
|
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
|
||||||
Text("提醒设置")
|
Text("提醒设置")
|
||||||
.font(.system(size: AppMetrics.FontSize.title3, weight: .semibold))
|
.font(.system(size: AppMetrics.FontSize.title3, weight: .semibold))
|
||||||
Button {
|
ReminderTimeOptionsMenu(onSelect: { homeLocationViewModel.updateReminderMinutes($0) }) {
|
||||||
showReminderDialog = true
|
|
||||||
} label: {
|
|
||||||
Text(homeLocationViewModel.reminderMinutes == 0 ? "不提醒" : "提前 \(homeLocationViewModel.reminderMinutes) 分钟提醒")
|
Text(homeLocationViewModel.reminderMinutes == 0 ? "不提醒" : "提前 \(homeLocationViewModel.reminderMinutes) 分钟提醒")
|
||||||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||||
.foregroundStyle(AppDesign.primary)
|
.foregroundStyle(AppDesign.primary)
|
||||||
|
|||||||
@ -80,6 +80,9 @@ struct SourcePersonRow: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct SourceLeadContent: View {
|
struct SourceLeadContent: View {
|
||||||
|
/// 带客单缩略图固定三列等宽网格。
|
||||||
|
private static let leadImageColumns = Array(repeating: GridItem(.flexible()), count: 3)
|
||||||
|
|
||||||
let lead: OrderSourceLead
|
let lead: OrderSourceLead
|
||||||
var compact = false
|
var compact = false
|
||||||
var enablePhoneCall = true
|
var enablePhoneCall = true
|
||||||
@ -123,11 +126,11 @@ struct SourceLeadContent: View {
|
|||||||
.background(OrderSourceColors.imageEmptyBg, in: RoundedRectangle(cornerRadius: 6))
|
.background(OrderSourceColors.imageEmptyBg, in: RoundedRectangle(cornerRadius: 6))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
HStack(spacing: 8) {
|
LazyVGrid(columns: Self.leadImageColumns, spacing: 8) {
|
||||||
ForEach(Array(lead.images.prefix(3).enumerated()), id: \.offset) { index, imageUrl in
|
ForEach(0..<3, id: \.self) { index in
|
||||||
leadThumbnail(imageUrl: imageUrl, index: index)
|
if index < min(lead.images.count, 3) {
|
||||||
}
|
leadThumbnail(imageUrl: lead.images[index], index: index)
|
||||||
ForEach(0..<max(0, 3 - min(lead.images.count, 3)), id: \.self) { _ in
|
} else {
|
||||||
Color.clear
|
Color.clear
|
||||||
.aspectRatio(1, contentMode: .fit)
|
.aspectRatio(1, contentMode: .fit)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
@ -136,25 +139,33 @@ struct SourceLeadContent: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func leadThumbnail(imageUrl: String, index: Int) -> some View {
|
private func leadThumbnail(imageUrl: String, index: Int) -> some View {
|
||||||
Group {
|
Color.clear
|
||||||
if let url = URL(string: imageUrl), !imageUrl.isEmpty {
|
|
||||||
KFImage(url)
|
|
||||||
.resizable()
|
|
||||||
.scaledToFill()
|
|
||||||
} else {
|
|
||||||
OrderSourceColors.imageEmptyBg
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.aspectRatio(1, contentMode: .fit)
|
.aspectRatio(1, contentMode: .fit)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.overlay {
|
||||||
|
leadThumbnailImage(imageUrl: imageUrl)
|
||||||
|
}
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
onImageTap(lead.images, index)
|
onImageTap(lead.images, index)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func leadThumbnailImage(imageUrl: String) -> some View {
|
||||||
|
if let url = URL(string: imageUrl), !imageUrl.isEmpty {
|
||||||
|
KFImage(url)
|
||||||
|
.placeholder { OrderSourceColors.imageEmptyBg }
|
||||||
|
.resizable()
|
||||||
|
.scaledToFill()
|
||||||
|
} else {
|
||||||
|
OrderSourceColors.imageEmptyBg
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SourceLeadCard: View {
|
struct SourceLeadCard: View {
|
||||||
|
|||||||
@ -62,19 +62,6 @@ struct ProfileView: View {
|
|||||||
await updatePassword(password)
|
await updatePassword(password)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.confirmationDialog("确认退出当前账号?", isPresented: $showsLogoutConfirm, titleVisibility: .visible) {
|
|
||||||
Button("退出登录", role: .destructive) {
|
|
||||||
authSessionCoordinator.logout(
|
|
||||||
appSession: appSession,
|
|
||||||
accountContext: accountContext,
|
|
||||||
permissionContext: permissionContext,
|
|
||||||
scenicSpotContext: scenicSpotContext,
|
|
||||||
appRouter: appRouter,
|
|
||||||
toastCenter: toastCenter
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Button("取消", role: .cancel) {}
|
|
||||||
}
|
|
||||||
.onChange(of: viewModel.isEditingProfile) { isEditing in
|
.onChange(of: viewModel.isEditingProfile) { isEditing in
|
||||||
guard isEditing else { return }
|
guard isEditing else { return }
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
|
||||||
@ -371,6 +358,19 @@ struct ProfileView: View {
|
|||||||
.shadow(color: Color(hex: 0xC8D7EA, alpha: 0.24), radius: 14, x: 0, y: 7)
|
.shadow(color: Color(hex: 0xC8D7EA, alpha: 0.24), radius: 14, x: 0, y: 7)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
.confirmationDialog("确认退出当前账号?", isPresented: $showsLogoutConfirm, titleVisibility: .visible) {
|
||||||
|
Button("退出登录", role: .destructive) {
|
||||||
|
authSessionCoordinator.logout(
|
||||||
|
appSession: appSession,
|
||||||
|
accountContext: accountContext,
|
||||||
|
permissionContext: permissionContext,
|
||||||
|
scenicSpotContext: scenicSpotContext,
|
||||||
|
appRouter: appRouter,
|
||||||
|
toastCenter: toastCenter
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Button("取消", role: .cancel) {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 构造资料信息行,统一左右布局和点击区域。
|
/// 构造资料信息行,统一左右布局和点击区域。
|
||||||
|
|||||||
@ -18,13 +18,13 @@ final class GlobalLoadingCenterTests: XCTestCase {
|
|||||||
center.show(message: "加载账号")
|
center.show(message: "加载账号")
|
||||||
center.show(message: "加载权限")
|
center.show(message: "加载权限")
|
||||||
|
|
||||||
XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: true, message: "加载权限", activeCount: 2))
|
XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: true, message: "加载权限", activeCount: 2, showsMessage: false))
|
||||||
|
|
||||||
center.hide()
|
center.hide()
|
||||||
XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: true, message: "加载权限", activeCount: 1))
|
XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: true, message: "加载权限", activeCount: 1, showsMessage: false))
|
||||||
|
|
||||||
center.hide()
|
center.hide()
|
||||||
XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0))
|
XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0, showsMessage: false))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 测试 hide 调用次数超过 show 时不会产生负数计数。
|
/// 测试 hide 调用次数超过 show 时不会产生负数计数。
|
||||||
@ -33,7 +33,7 @@ final class GlobalLoadingCenterTests: XCTestCase {
|
|||||||
|
|
||||||
center.hide()
|
center.hide()
|
||||||
|
|
||||||
XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0))
|
XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0, showsMessage: false))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 测试更新文案只影响当前展示内容,不改变引用计数。
|
/// 测试更新文案只影响当前展示内容,不改变引用计数。
|
||||||
@ -41,12 +41,12 @@ final class GlobalLoadingCenterTests: XCTestCase {
|
|||||||
let center = GlobalLoadingCenter()
|
let center = GlobalLoadingCenter()
|
||||||
|
|
||||||
center.updateMessage("不会展示")
|
center.updateMessage("不会展示")
|
||||||
XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0))
|
XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0, showsMessage: false))
|
||||||
|
|
||||||
center.show(message: "加载中")
|
center.show(message: "加载中")
|
||||||
center.updateMessage("即将完成")
|
center.updateMessage("即将完成")
|
||||||
|
|
||||||
XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: true, message: "即将完成", activeCount: 1))
|
XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: true, message: "即将完成", activeCount: 1, showsMessage: false))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 测试 withLoading 成功完成后自动隐藏 Loading。
|
/// 测试 withLoading 成功完成后自动隐藏 Loading。
|
||||||
@ -54,12 +54,12 @@ final class GlobalLoadingCenterTests: XCTestCase {
|
|||||||
let center = GlobalLoadingCenter()
|
let center = GlobalLoadingCenter()
|
||||||
|
|
||||||
let value = try await center.withLoading(message: "提交中") {
|
let value = try await center.withLoading(message: "提交中") {
|
||||||
XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: true, message: "提交中", activeCount: 1))
|
XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: true, message: "提交中", activeCount: 1, showsMessage: false))
|
||||||
return 42
|
return 42
|
||||||
}
|
}
|
||||||
|
|
||||||
XCTAssertEqual(value, 42)
|
XCTAssertEqual(value, 42)
|
||||||
XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0))
|
XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0, showsMessage: false))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 测试 withLoading 抛错时也会自动隐藏 Loading。
|
/// 测试 withLoading 抛错时也会自动隐藏 Loading。
|
||||||
@ -75,7 +75,7 @@ final class GlobalLoadingCenterTests: XCTestCase {
|
|||||||
XCTAssertEqual(error as? TestFailure, .expected)
|
XCTAssertEqual(error as? TestFailure, .expected)
|
||||||
}
|
}
|
||||||
|
|
||||||
XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0))
|
XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0, showsMessage: false))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 测试 withOptionalLoading 关闭时不会改变展示状态。
|
/// 测试 withOptionalLoading 关闭时不会改变展示状态。
|
||||||
@ -83,12 +83,28 @@ final class GlobalLoadingCenterTests: XCTestCase {
|
|||||||
let center = GlobalLoadingCenter()
|
let center = GlobalLoadingCenter()
|
||||||
|
|
||||||
let value = try await center.withOptionalLoading(false, message: "不展示") {
|
let value = try await center.withOptionalLoading(false, message: "不展示") {
|
||||||
XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0))
|
XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0, showsMessage: false))
|
||||||
return "done"
|
return "done"
|
||||||
}
|
}
|
||||||
|
|
||||||
XCTAssertEqual(value, "done")
|
XCTAssertEqual(value, "done")
|
||||||
XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0))
|
XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0, showsMessage: false))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试默认不展示文案,仅在显式开启时记录展示状态。
|
||||||
|
func testShowsMessageOnlyWhenEnabled() {
|
||||||
|
let center = GlobalLoadingCenter()
|
||||||
|
|
||||||
|
center.show(message: "加载中")
|
||||||
|
XCTAssertEqual(center.snapshotForTests.message, "加载中")
|
||||||
|
XCTAssertFalse(center.snapshotForTests.showsMessage)
|
||||||
|
|
||||||
|
center.hide()
|
||||||
|
center.show(message: "获取定位中", showsMessage: true)
|
||||||
|
XCTAssertTrue(center.snapshotForTests.showsMessage)
|
||||||
|
|
||||||
|
center.hide()
|
||||||
|
XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0, showsMessage: false))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 测试使用 Loading 不需要读取可观察展示状态,业务侧只能拿到命令中心类型。
|
/// 测试使用 Loading 不需要读取可观察展示状态,业务侧只能拿到命令中心类型。
|
||||||
@ -97,7 +113,7 @@ final class GlobalLoadingCenterTests: XCTestCase {
|
|||||||
|
|
||||||
issueLoadingCommand(center)
|
issueLoadingCommand(center)
|
||||||
|
|
||||||
XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: true, message: "加载中", activeCount: 1))
|
XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: true, message: "加载中", activeCount: 1, showsMessage: false))
|
||||||
center.hide()
|
center.hide()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -331,19 +331,54 @@ final class HomeLocationViewModelTests: XCTestCase {
|
|||||||
XCTAssertEqual(viewModel.reminderMinutes, 10)
|
XCTAssertEqual(viewModel.reminderMinutes, 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 测试 detail 接口触发超时对话框。
|
/// 测试在线且倒计时进入提醒窗口时弹出超时对话框。
|
||||||
func testCheckLocationTimeoutShowsDialogWhenNearExpiry() async {
|
func testCheckLocationTimeoutShowsDialogWhenOnlineAndWithinReminderWindow() async {
|
||||||
let api = MockLocationReportService()
|
let store = makeIsolatedStore()
|
||||||
let expireTime = Int(Date().timeIntervalSince1970) + 120
|
let expireTime = Int(Date().timeIntervalSince1970) + 120
|
||||||
api.detailResponse = LocationDetailResponse(status: 1, needReport: true, expireTime: expireTime)
|
store.saveOnlineStatus(true)
|
||||||
let viewModel = HomeLocationViewModel(api: api, store: makeIsolatedStore())
|
store.saveExpireTime(expireTime)
|
||||||
viewModel.updateReminderMinutes(5)
|
store.saveReminderMinutes(5)
|
||||||
|
|
||||||
|
let viewModel = HomeLocationViewModel(api: MockLocationReportService(), store: store)
|
||||||
|
viewModel.restoreState()
|
||||||
|
|
||||||
await viewModel.checkLocationTimeout(staffId: 77)
|
await viewModel.checkLocationTimeout(staffId: 77)
|
||||||
|
|
||||||
|
XCTAssertTrue(viewModel.isOnline)
|
||||||
XCTAssertTrue(viewModel.showTimeoutDialog)
|
XCTAssertTrue(viewModel.showTimeoutDialog)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 测试离线状态不弹出超时对话框。
|
||||||
|
func testCheckLocationTimeoutDoesNotShowDialogWhenOffline() async {
|
||||||
|
let store = makeIsolatedStore()
|
||||||
|
store.saveOnlineStatus(false)
|
||||||
|
store.saveReminderMinutes(5)
|
||||||
|
|
||||||
|
let viewModel = HomeLocationViewModel(api: MockLocationReportService(), store: store)
|
||||||
|
viewModel.restoreState()
|
||||||
|
|
||||||
|
await viewModel.checkLocationTimeout(staffId: 77)
|
||||||
|
|
||||||
|
XCTAssertFalse(viewModel.showTimeoutDialog)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试在线但倒计时未进入提醒窗口时不弹窗。
|
||||||
|
func testCheckLocationTimeoutDoesNotShowDialogWhenOutsideReminderWindow() async {
|
||||||
|
let store = makeIsolatedStore()
|
||||||
|
let expireTime = Int(Date().timeIntervalSince1970) + 3_600
|
||||||
|
store.saveOnlineStatus(true)
|
||||||
|
store.saveExpireTime(expireTime)
|
||||||
|
store.saveReminderMinutes(5)
|
||||||
|
|
||||||
|
let viewModel = HomeLocationViewModel(api: MockLocationReportService(), store: store)
|
||||||
|
viewModel.restoreState()
|
||||||
|
|
||||||
|
await viewModel.checkLocationTimeout(staffId: 77)
|
||||||
|
|
||||||
|
XCTAssertTrue(viewModel.isOnline)
|
||||||
|
XCTAssertFalse(viewModel.showTimeoutDialog)
|
||||||
|
}
|
||||||
|
|
||||||
private func makeIsolatedStore() -> LocationStateStore {
|
private func makeIsolatedStore() -> LocationStateStore {
|
||||||
let suiteName = "HomeLocationViewModelTests.\(UUID().uuidString)"
|
let suiteName = "HomeLocationViewModelTests.\(UUID().uuidString)"
|
||||||
let defaults = UserDefaults(suiteName: suiteName)!
|
let defaults = UserDefaults(suiteName: suiteName)!
|
||||||
|
|||||||
Reference in New Issue
Block a user