Migrate from iOS 17 Observation to iOS 16-compatible Combine architecture.
Lower deployment target to iOS 16, replace @Observable with ObservableObject, add navigation and UI compatibility shims, and include login smoke UI tests. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -6,7 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
/// 账号上下文服务协议,抽象权限、景区、门店和景点读取能力以便测试替换。
|
||||
@ -25,10 +25,9 @@ protocol AccountContextServing {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 账号上下文 API,封装角色权限、景区、门店和景点读取接口。
|
||||
final class AccountContextAPI: AccountContextServing {
|
||||
@ObservationIgnored private let client: APIClient
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化账号上下文 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
/// 资产服务协议,抽象云盘、媒体库和相册接口以便 ViewModel 测试替换。
|
||||
@MainActor
|
||||
@ -83,9 +83,8 @@ protocol AssetsServing {
|
||||
|
||||
/// 资产 API,负责封装相册云盘和素材管理相关接口。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class AssetsAPI: AssetsServing {
|
||||
@ObservationIgnored private let client: APIClient
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化资产 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
|
||||
@ -6,15 +6,14 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
/// 云盘传输记录仓库,保存本次 App 会话内的上传和下载进度。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class CloudTransferStore {
|
||||
final class CloudTransferStore: ObservableObject {
|
||||
static let shared = CloudTransferStore()
|
||||
|
||||
private(set) var records: [CloudTransferItem] = []
|
||||
@Published private(set) var records: [CloudTransferItem] = []
|
||||
|
||||
/// 新增一条传输记录并返回记录 ID。
|
||||
@discardableResult
|
||||
@ -56,19 +55,18 @@ final class CloudTransferStore {
|
||||
|
||||
/// 云盘 ViewModel,负责文件列表、筛选、分页、文件操作和上传闭环。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class CloudStorageViewModel {
|
||||
var path: [CloudDriveFile] = [CloudDriveFile(id: 0, name: "云盘")]
|
||||
var files: [CloudDriveFile] = []
|
||||
var total = 0
|
||||
var page = 1
|
||||
var searchText = ""
|
||||
var selectedFilter: CloudDriveFilter = .all
|
||||
var selectedSort: CloudDriveSort = .updatedDesc
|
||||
var isLoading = false
|
||||
var isLoadingMore = false
|
||||
var isMutating = false
|
||||
var errorMessage: String?
|
||||
final class CloudStorageViewModel: ObservableObject {
|
||||
@Published var path: [CloudDriveFile] = [CloudDriveFile(id: 0, name: "云盘")]
|
||||
@Published var files: [CloudDriveFile] = []
|
||||
@Published var total = 0
|
||||
@Published var page = 1
|
||||
@Published var searchText = ""
|
||||
@Published var selectedFilter: CloudDriveFilter = .all
|
||||
@Published var selectedSort: CloudDriveSort = .updatedDesc
|
||||
@Published var isLoading = false
|
||||
@Published var isLoadingMore = false
|
||||
@Published var isMutating = false
|
||||
@Published var errorMessage: String?
|
||||
|
||||
private let pageSize = 20
|
||||
|
||||
@ -260,20 +258,19 @@ final class CloudStorageViewModel {
|
||||
|
||||
/// 媒体库 ViewModel,负责素材或样片列表、筛选、分页、详情和上下架。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class MediaLibraryViewModel {
|
||||
final class MediaLibraryViewModel: ObservableObject {
|
||||
let kind: MediaLibraryKind
|
||||
var items: [MediaLibraryItem] = []
|
||||
var selectedDetail: MediaLibraryDetail?
|
||||
var orderInfo: MediaLibraryOrderInfo?
|
||||
var total = 0
|
||||
var page = 1
|
||||
var keyword = ""
|
||||
var selectedAuditFilter: MediaLibraryAuditFilter = .all
|
||||
var isLoading = false
|
||||
var isLoadingMore = false
|
||||
var isLoadingDetail = false
|
||||
var errorMessage: String?
|
||||
@Published var items: [MediaLibraryItem] = []
|
||||
@Published var selectedDetail: MediaLibraryDetail?
|
||||
@Published var orderInfo: MediaLibraryOrderInfo?
|
||||
@Published var total = 0
|
||||
@Published var page = 1
|
||||
@Published var keyword = ""
|
||||
@Published var selectedAuditFilter: MediaLibraryAuditFilter = .all
|
||||
@Published var isLoading = false
|
||||
@Published var isLoadingMore = false
|
||||
@Published var isLoadingDetail = false
|
||||
@Published var errorMessage: String?
|
||||
|
||||
private let pageSize = 10
|
||||
|
||||
@ -396,20 +393,19 @@ final class MediaLibraryViewModel {
|
||||
|
||||
/// 媒体库上传编辑 ViewModel,负责表单校验、OSS 上传和提交素材或样片。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class MediaLibraryEditorViewModel {
|
||||
var name = ""
|
||||
var description = ""
|
||||
var selectedSpotId: Int?
|
||||
var selectedProjectId: Int?
|
||||
var projects: [PhotographerProjectItem] = []
|
||||
var tagsText = ""
|
||||
var coverFile: MediaLocalUploadFile?
|
||||
var mediaFiles: [MediaLocalUploadFile] = []
|
||||
var isSubmitting = false
|
||||
var isLoadingProjects = false
|
||||
var errorMessage: String?
|
||||
var didSubmitSuccessfully = false
|
||||
final class MediaLibraryEditorViewModel: ObservableObject {
|
||||
@Published var name = ""
|
||||
@Published var description = ""
|
||||
@Published var selectedSpotId: Int?
|
||||
@Published var selectedProjectId: Int?
|
||||
@Published var projects: [PhotographerProjectItem] = []
|
||||
@Published var tagsText = ""
|
||||
@Published var coverFile: MediaLocalUploadFile?
|
||||
@Published var mediaFiles: [MediaLocalUploadFile] = []
|
||||
@Published var isSubmitting = false
|
||||
@Published var isLoadingProjects = false
|
||||
@Published var errorMessage: String?
|
||||
@Published var didSubmitSuccessfully = false
|
||||
|
||||
private let editingId: Int?
|
||||
|
||||
@ -611,18 +607,17 @@ final class MediaLibraryEditorViewModel {
|
||||
|
||||
/// 相册列表 ViewModel,负责相册文件夹搜索、日期筛选、分页和新建相册。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class AlbumListViewModel {
|
||||
var folders: [AlbumFolderItem] = []
|
||||
var total = 0
|
||||
var page = 1
|
||||
var searchText = ""
|
||||
var startTime = ""
|
||||
var endTime = ""
|
||||
var isLoading = false
|
||||
var isLoadingMore = false
|
||||
var isMutating = false
|
||||
var errorMessage: String?
|
||||
final class AlbumListViewModel: ObservableObject {
|
||||
@Published var folders: [AlbumFolderItem] = []
|
||||
@Published var total = 0
|
||||
@Published var page = 1
|
||||
@Published var searchText = ""
|
||||
@Published var startTime = ""
|
||||
@Published var endTime = ""
|
||||
@Published var isLoading = false
|
||||
@Published var isLoadingMore = false
|
||||
@Published var isMutating = false
|
||||
@Published var errorMessage: String?
|
||||
|
||||
private let pageSize = 10
|
||||
|
||||
@ -716,18 +711,17 @@ final class AlbumListViewModel {
|
||||
|
||||
/// 相册详情 ViewModel,负责相册信息、图片/视频列表和文件操作。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class AlbumDetailViewModel {
|
||||
final class AlbumDetailViewModel: ObservableObject {
|
||||
let folderId: Int
|
||||
var folder: AlbumFolderItem?
|
||||
var files: [AlbumFileItem] = []
|
||||
var selectedTab: AlbumFileTab = .image
|
||||
var total = 0
|
||||
var page = 1
|
||||
var isLoading = false
|
||||
var isLoadingMore = false
|
||||
var isMutating = false
|
||||
var errorMessage: String?
|
||||
@Published var folder: AlbumFolderItem?
|
||||
@Published var files: [AlbumFileItem] = []
|
||||
@Published var selectedTab: AlbumFileTab = .image
|
||||
@Published var total = 0
|
||||
@Published var page = 1
|
||||
@Published var isLoading = false
|
||||
@Published var isLoadingMore = false
|
||||
@Published var isMutating = false
|
||||
@Published var errorMessage: String?
|
||||
|
||||
private let pageSize = 20
|
||||
|
||||
@ -863,16 +857,15 @@ final class AlbumDetailViewModel {
|
||||
|
||||
/// 相册预览上传 ViewModel,负责选择相册、本地文件上传和相册入库。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class AlbumTrailerViewModel {
|
||||
var folders: [AlbumFolderItem] = []
|
||||
var selectedFolderId: Int?
|
||||
var localFiles: [AlbumLocalUploadFile] = []
|
||||
var uploadProgress = 0
|
||||
var isLoadingFolders = false
|
||||
var isSubmitting = false
|
||||
var errorMessage: String?
|
||||
var didUploadSuccessfully = false
|
||||
final class AlbumTrailerViewModel: ObservableObject {
|
||||
@Published var folders: [AlbumFolderItem] = []
|
||||
@Published var selectedFolderId: Int?
|
||||
@Published var localFiles: [AlbumLocalUploadFile] = []
|
||||
@Published var uploadProgress = 0
|
||||
@Published var isLoadingFolders = false
|
||||
@Published var isSubmitting = false
|
||||
@Published var errorMessage: String?
|
||||
@Published var didUploadSuccessfully = false
|
||||
|
||||
/// 加载可上传的相册列表。
|
||||
func loadFolders(api: any AssetsServing, scenicId: Int?) async {
|
||||
|
||||
@ -12,12 +12,12 @@ import UniformTypeIdentifiers
|
||||
|
||||
/// 相册管理页面,展示相册列表、筛选和新建相册入口。
|
||||
struct AlbumListView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(AssetsAPI.self) private var assetsAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@Environment(\.assetsAPI) private var assetsAPI
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
|
||||
@State private var viewModel = AlbumListViewModel()
|
||||
@StateObject private var viewModel = AlbumListViewModel()
|
||||
@State private var showCreateSheet = false
|
||||
@State private var newAlbumName = ""
|
||||
@State private var newAlbumRemark = ""
|
||||
@ -158,12 +158,12 @@ struct AlbumListView: View {
|
||||
|
||||
/// 相册详情页面,展示图片/视频列表并支持封面、删除和编辑。
|
||||
struct AlbumDetailView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(AssetsAPI.self) private var assetsAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@Environment(\.assetsAPI) private var assetsAPI
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
|
||||
@State private var viewModel: AlbumDetailViewModel
|
||||
@StateObject private var viewModel: AlbumDetailViewModel
|
||||
@State private var actionFile: AlbumFileItem?
|
||||
@State private var previewFile: AlbumFileItem?
|
||||
@State private var showRenameSheet = false
|
||||
@ -174,7 +174,7 @@ struct AlbumDetailView: View {
|
||||
|
||||
/// 初始化相册详情页面。
|
||||
init(folderId: Int, summary: AlbumFolderItem? = nil) {
|
||||
_viewModel = State(initialValue: AlbumDetailViewModel(folderId: folderId, summary: summary))
|
||||
_viewModel = StateObject(wrappedValue: AlbumDetailViewModel(folderId: folderId, summary: summary))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@ -282,7 +282,7 @@ struct AlbumDetailView: View {
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.onChange(of: viewModel.selectedTab) { _, tab in
|
||||
.onChange(of: viewModel.selectedTab) { tab in
|
||||
Task { await viewModel.selectTab(tab, api: assetsAPI, scenicId: accountContext.currentScenic?.id) }
|
||||
}
|
||||
}
|
||||
@ -378,14 +378,14 @@ struct AlbumTrailerEntryView: View {
|
||||
|
||||
/// 相册预览上传页面,负责选择相册、本地文件和提交上传。
|
||||
struct AlbumTrailerUploadView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(AssetsAPI.self) private var assetsAPI
|
||||
@Environment(OSSUploadService.self) private var uploadService
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@Environment(\.assetsAPI) private var assetsAPI
|
||||
@Environment(\.ossUploadService) private var uploadService
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
|
||||
@State private var viewModel = AlbumTrailerViewModel()
|
||||
@StateObject private var viewModel = AlbumTrailerViewModel()
|
||||
@State private var selectedItems: [PhotosPickerItem] = []
|
||||
|
||||
let initialFolderId: Int?
|
||||
@ -412,7 +412,7 @@ struct AlbumTrailerUploadView: View {
|
||||
toastCenter.show(message)
|
||||
}
|
||||
}
|
||||
.onChange(of: selectedItems) { _, items in
|
||||
.onChange(of: selectedItems) { items in
|
||||
Task { await loadPickedFiles(items) }
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,14 +14,14 @@ import UIKit
|
||||
|
||||
/// 相册云盘页面,展示文件夹浏览、筛选、上传、预览和文件操作。
|
||||
struct CloudStorageView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(AssetsAPI.self) private var assetsAPI
|
||||
@Environment(OSSUploadService.self) private var uploadService
|
||||
@Environment(RouterPath.self) private var router
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@Environment(\.assetsAPI) private var assetsAPI
|
||||
@Environment(\.ossUploadService) private var uploadService
|
||||
@EnvironmentObject private var router: RouterPath
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
|
||||
@State private var viewModel = CloudStorageViewModel()
|
||||
@StateObject private var viewModel = CloudStorageViewModel()
|
||||
@State private var transferStore = CloudTransferStore.shared
|
||||
@State private var selectedItems: [PhotosPickerItem] = []
|
||||
@State private var actionItem: CloudDriveFile?
|
||||
@ -70,7 +70,7 @@ struct CloudStorageView: View {
|
||||
guard viewModel.files.isEmpty else { return }
|
||||
await reload(showLoading: true)
|
||||
}
|
||||
.onChange(of: selectedItems) { _, items in
|
||||
.onChange(of: selectedItems) { items in
|
||||
Task { await upload(items) }
|
||||
}
|
||||
.confirmationDialog("文件操作", isPresented: actionDialogBinding, presenting: actionItem) { item in
|
||||
@ -176,7 +176,7 @@ struct CloudStorageView: View {
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.onChange(of: viewModel.selectedFilter) { _, _ in
|
||||
.onChange(of: viewModel.selectedFilter) { _ in
|
||||
Task { await reload(showLoading: true) }
|
||||
}
|
||||
|
||||
@ -186,7 +186,7 @@ struct CloudStorageView: View {
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.onChange(of: viewModel.selectedSort) { _, _ in
|
||||
.onChange(of: viewModel.selectedSort) { _ in
|
||||
Task { await reload(showLoading: true) }
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,18 +12,18 @@ import SwiftUI
|
||||
struct MediaLibraryView: View {
|
||||
let kind: MediaLibraryKind
|
||||
|
||||
@Environment(AssetsAPI.self) private var assetsAPI
|
||||
@Environment(RouterPath.self) private var router
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@Environment(\.assetsAPI) private var assetsAPI
|
||||
@EnvironmentObject private var router: RouterPath
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
|
||||
@State private var viewModel: MediaLibraryViewModel
|
||||
@StateObject private var viewModel: MediaLibraryViewModel
|
||||
@State private var selectedItem: MediaLibraryItem?
|
||||
|
||||
/// 初始化媒体库页面。
|
||||
init(kind: MediaLibraryKind = .material) {
|
||||
self.kind = kind
|
||||
_viewModel = State(initialValue: MediaLibraryViewModel(kind: kind))
|
||||
_viewModel = StateObject(wrappedValue: MediaLibraryViewModel(kind: kind))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@ -54,7 +54,7 @@ struct MediaLibraryView: View {
|
||||
guard viewModel.items.isEmpty else { return }
|
||||
await reload(showLoading: true)
|
||||
}
|
||||
.navigationDestination(item: $selectedItem) { item in
|
||||
.appNavigationDestination(item: $selectedItem) { item in
|
||||
MediaLibraryDetailView(item: item, listViewModel: viewModel)
|
||||
}
|
||||
}
|
||||
@ -82,7 +82,7 @@ struct MediaLibraryView: View {
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.onChange(of: viewModel.selectedAuditFilter) { _, _ in
|
||||
.onChange(of: viewModel.selectedAuditFilter) { _ in
|
||||
Task { await reload(showLoading: true) }
|
||||
}
|
||||
}
|
||||
@ -235,8 +235,8 @@ struct MediaLibraryDetailView: View {
|
||||
let item: MediaLibraryItem
|
||||
let listViewModel: MediaLibraryViewModel
|
||||
|
||||
@Environment(AssetsAPI.self) private var assetsAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@Environment(\.assetsAPI) private var assetsAPI
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
|
||||
@ -415,15 +415,15 @@ struct MediaLibraryUploadView: View {
|
||||
let kind: MediaLibraryKind
|
||||
let editingDetail: MediaLibraryDetail?
|
||||
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(ScenicSpotContext.self) private var scenicSpotContext
|
||||
@Environment(AssetsAPI.self) private var assetsAPI
|
||||
@Environment(OSSUploadService.self) private var uploadService
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@EnvironmentObject private var scenicSpotContext: ScenicSpotContext
|
||||
@Environment(\.assetsAPI) private var assetsAPI
|
||||
@Environment(\.ossUploadService) private var uploadService
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
|
||||
@State private var viewModel: MediaLibraryEditorViewModel
|
||||
@StateObject private var viewModel: MediaLibraryEditorViewModel
|
||||
@State private var coverSelection: PhotosPickerItem?
|
||||
@State private var mediaSelection: [PhotosPickerItem] = []
|
||||
|
||||
@ -431,7 +431,7 @@ struct MediaLibraryUploadView: View {
|
||||
init(kind: MediaLibraryKind = .material, editingDetail: MediaLibraryDetail? = nil) {
|
||||
self.kind = kind
|
||||
self.editingDetail = editingDetail
|
||||
_viewModel = State(initialValue: MediaLibraryEditorViewModel(detail: editingDetail))
|
||||
_viewModel = StateObject(wrappedValue: MediaLibraryEditorViewModel(detail: editingDetail))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@ -457,10 +457,10 @@ struct MediaLibraryUploadView: View {
|
||||
toastCenter.show(message)
|
||||
}
|
||||
}
|
||||
.onChange(of: coverSelection) { _, item in
|
||||
.onChange(of: coverSelection) { item in
|
||||
Task { await loadCover(item) }
|
||||
}
|
||||
.onChange(of: mediaSelection) { _, items in
|
||||
.onChange(of: mediaSelection) { items in
|
||||
Task { await loadMedia(items) }
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,13 +6,12 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 登录认证 API,封装 v9 登录和账号选择相关接口。
|
||||
final class AuthAPI {
|
||||
@ObservationIgnored private let client: APIClient
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化登录 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
/// 登录输入焦点实体,标识当前需要聚焦的输入框。
|
||||
enum LoginField: Hashable {
|
||||
@ -68,17 +68,16 @@ enum LoginFlowError: LocalizedError {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 登录页 ViewModel,负责表单状态、登录请求和多账号选择流程。
|
||||
final class LoginViewModel {
|
||||
var username = "18651857230"
|
||||
var password = "zhifly666"
|
||||
var privacyChecked = false
|
||||
var isLoading = false
|
||||
var isSelectingAccount = false
|
||||
var showsPassword = false
|
||||
var showsAgreementSheet = false
|
||||
var pendingAccountSelection: AccountSelectionPayload?
|
||||
final class LoginViewModel: ObservableObject {
|
||||
@Published var username = "18651857230"
|
||||
@Published var password = "zhifly666"
|
||||
@Published var privacyChecked = false
|
||||
@Published var isLoading = false
|
||||
@Published var isSelectingAccount = false
|
||||
@Published var showsPassword = false
|
||||
@Published var showsAgreementSheet = false
|
||||
@Published var pendingAccountSelection: AccountSelectionPayload?
|
||||
|
||||
var canSubmit: Bool {
|
||||
isValidPhone && !trimmedPassword.isEmpty
|
||||
|
||||
@ -44,7 +44,7 @@ struct AccountSelectionView: View {
|
||||
@ViewBuilder
|
||||
private var accountList: some View {
|
||||
if payload.accounts.isEmpty {
|
||||
ContentUnavailableView(
|
||||
AppContentUnavailableView(
|
||||
"暂无可用账号",
|
||||
systemImage: "person.crop.circle.badge.exclamationmark",
|
||||
description: Text("当前账号没有可用的景区账号或门店账号,请联系管理员。")
|
||||
|
||||
@ -9,17 +9,17 @@ import SwiftUI
|
||||
|
||||
/// 登录页视图,负责展示登录表单、协议确认和多账号选择入口。
|
||||
struct LoginView: View {
|
||||
@Environment(AppSession.self) private var appSession
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(PermissionContext.self) private var permissionContext
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@Environment(AuthAPI.self) private var authAPI
|
||||
@Environment(ProfileAPI.self) private var profileAPI
|
||||
@Environment(AccountContextAPI.self) private var accountContextAPI
|
||||
@Environment(AuthSessionCoordinator.self) private var authSessionCoordinator
|
||||
@EnvironmentObject private var appSession: AppSession
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@EnvironmentObject private var permissionContext: PermissionContext
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.authAPI) private var authAPI
|
||||
@Environment(\.profileAPI) private var profileAPI
|
||||
@Environment(\.accountContextAPI) private var accountContextAPI
|
||||
@Environment(\.authSessionCoordinator) private var authSessionCoordinator
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
@State private var viewModel = LoginViewModel()
|
||||
@StateObject private var viewModel = LoginViewModel()
|
||||
@FocusState private var focusedField: LoginField?
|
||||
|
||||
private var contentMaxWidth: CGFloat {
|
||||
@ -76,11 +76,11 @@ struct LoginView: View {
|
||||
)
|
||||
}
|
||||
}
|
||||
.onChange(of: viewModel.username) { _, _ in
|
||||
.onChange(of: viewModel.username) { _ in
|
||||
viewModel.normalizeUsernameCountryCodeIfNeeded()
|
||||
dismissLoginToastIfNeeded()
|
||||
}
|
||||
.onChange(of: viewModel.password) { _, _ in
|
||||
.onChange(of: viewModel.password) { _ in
|
||||
dismissLoginToastIfNeeded()
|
||||
}
|
||||
.task {
|
||||
@ -462,12 +462,12 @@ private struct LoginAgreementConsentSheet: View {
|
||||
|
||||
#Preview {
|
||||
LoginView()
|
||||
.environment(AppSession())
|
||||
.environment(AccountContext())
|
||||
.environment(PermissionContext())
|
||||
.environment(ToastCenter())
|
||||
.environment(AuthAPI(client: APIClient()))
|
||||
.environment(ProfileAPI(client: APIClient()))
|
||||
.environment(AccountContextAPI(client: APIClient()))
|
||||
.environment(AuthSessionCoordinator())
|
||||
.environmentObject(AppSession())
|
||||
.environmentObject(AccountContext())
|
||||
.environmentObject(PermissionContext())
|
||||
.environmentObject(ToastCenter())
|
||||
.environment(\.authAPI, AuthAPI(client: APIClient()))
|
||||
.environment(\.profileAPI, ProfileAPI(client: APIClient()))
|
||||
.environment(\.accountContextAPI, AccountContextAPI(client: APIClient()))
|
||||
.environment(\.authSessionCoordinator, AuthSessionCoordinator())
|
||||
}
|
||||
|
||||
@ -6,13 +6,12 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 首页 ViewModel,根据当前角色权限构建首页菜单列表。
|
||||
final class HomeViewModel {
|
||||
private(set) var menuItems: [HomeMenuItem] = []
|
||||
final class HomeViewModel: ObservableObject {
|
||||
@Published private(set) var menuItems: [HomeMenuItem] = []
|
||||
|
||||
/// 菜单排序权重,与旧工程和 Android 端保持一致。
|
||||
private let preferredOrder: [String] = [
|
||||
|
||||
@ -9,12 +9,12 @@ import SwiftUI
|
||||
|
||||
/// 首页全部功能视图,展示常用应用和当前角色拥有的更多功能。
|
||||
struct HomeMoreFunctionsView: View {
|
||||
@Environment(PermissionContext.self) private var permissionContext
|
||||
@Environment(AppRouter.self) private var appRouter
|
||||
@Environment(RouterPath.self) private var router
|
||||
@EnvironmentObject private var permissionContext: PermissionContext
|
||||
@EnvironmentObject private var appRouter: AppRouter
|
||||
@EnvironmentObject private var router: RouterPath
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var viewModel = HomeViewModel()
|
||||
@StateObject private var viewModel = HomeViewModel()
|
||||
@State private var commonUris: [String] = []
|
||||
|
||||
private let commonMenuStore = HomeCommonMenuStore()
|
||||
@ -49,10 +49,10 @@ struct HomeMoreFunctionsView: View {
|
||||
.task {
|
||||
rebuildMenus()
|
||||
}
|
||||
.onChange(of: permissionContext.currentRole?.id) { _, _ in
|
||||
.onChange(of: permissionContext.currentRole?.id) { _ in
|
||||
rebuildMenus()
|
||||
}
|
||||
.onChange(of: permissionContext.rolePermissions.count) { _, _ in
|
||||
.onChange(of: permissionContext.rolePermissions.count) { _ in
|
||||
rebuildMenus()
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,13 +10,13 @@ import SwiftUI
|
||||
|
||||
/// 首页主视图,展示景区、工作状态、快捷操作和权限菜单入口。
|
||||
struct HomeView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(PermissionContext.self) private var permissionContext
|
||||
@Environment(AppRouter.self) private var appRouter
|
||||
@Environment(RouterPath.self) private var router
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@EnvironmentObject private var permissionContext: PermissionContext
|
||||
@EnvironmentObject private var appRouter: AppRouter
|
||||
@EnvironmentObject private var router: RouterPath
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
@State private var viewModel = HomeViewModel()
|
||||
@StateObject private var viewModel = HomeViewModel()
|
||||
@State private var commonUris: [String] = []
|
||||
@State private var isOnline = false
|
||||
@State private var reminderMinutes = 0
|
||||
@ -113,10 +113,10 @@ struct HomeView: View {
|
||||
guard isOnline, secondsUntilReport > 0 else { return }
|
||||
secondsUntilReport -= 1
|
||||
}
|
||||
.onChange(of: permissionContext.currentRole?.id) { _, _ in
|
||||
.onChange(of: permissionContext.currentRole?.id) { _ in
|
||||
rebuildMenusFromCurrentContext()
|
||||
}
|
||||
.onChange(of: permissionContext.rolePermissions.count) { _, _ in
|
||||
.onChange(of: permissionContext.rolePermissions.count) { _ in
|
||||
rebuildMenusFromCurrentContext()
|
||||
}
|
||||
.alert("切换在线状态", isPresented: $showOnlineDialog) {
|
||||
@ -423,9 +423,9 @@ struct HomeView: View {
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
HomeView()
|
||||
.environment(AccountContext())
|
||||
.environment(PermissionContext())
|
||||
.environment(AppRouter())
|
||||
.environment(RouterPath())
|
||||
.environmentObject(AccountContext())
|
||||
.environmentObject(PermissionContext())
|
||||
.environmentObject(AppRouter())
|
||||
.environmentObject(RouterPath())
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
/// 邀请服务协议,抽象邀请信息和邀请记录接口。
|
||||
@MainActor
|
||||
@ -20,9 +20,8 @@ protocol InviteServing {
|
||||
|
||||
/// 邀请 API,封装摄影师邀请相关接口。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class InviteAPI: InviteServing {
|
||||
@ObservationIgnored private let client: APIClient
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化邀请 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
|
||||
@ -7,22 +7,21 @@
|
||||
|
||||
import CoreImage.CIFilterBuiltins
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
import UIKit
|
||||
|
||||
/// 邀请页 ViewModel,负责加载邀请信息、生成二维码和复制内容。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class PhotographerInviteViewModel {
|
||||
private(set) var loading = false
|
||||
private(set) var inviteCode = ""
|
||||
private(set) var inviteUrl = ""
|
||||
private(set) var rules: [String] = []
|
||||
private(set) var qrImage: UIImage?
|
||||
var errorMessage: String?
|
||||
final class PhotographerInviteViewModel: ObservableObject {
|
||||
@Published private(set) var loading = false
|
||||
@Published private(set) var inviteCode = ""
|
||||
@Published private(set) var inviteUrl = ""
|
||||
@Published private(set) var rules: [String] = []
|
||||
@Published private(set) var qrImage: UIImage?
|
||||
@Published var errorMessage: String?
|
||||
|
||||
@ObservationIgnored private let qrContext = CIContext()
|
||||
@ObservationIgnored private let qrFilter = CIFilter.qrCodeGenerator()
|
||||
private let qrContext = CIContext()
|
||||
private let qrFilter = CIFilter.qrCodeGenerator()
|
||||
|
||||
/// 重新加载邀请信息。
|
||||
func reload(api: any InviteServing) async {
|
||||
@ -72,21 +71,20 @@ final class PhotographerInviteViewModel {
|
||||
|
||||
/// 邀请记录 ViewModel,负责邀请用户、奖励明细和钱包汇总分页。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class InviteRecordViewModel {
|
||||
var tab: InviteRecordTab = .invite
|
||||
private(set) var totalRewardText = "¥ 0.00"
|
||||
private(set) var withdrawableText = "¥ 0.00"
|
||||
private(set) var displayRows: [InviteDisplayRow] = []
|
||||
private(set) var loading = false
|
||||
private(set) var loadingMore = false
|
||||
private(set) var hasMore = false
|
||||
var errorMessage: String?
|
||||
final class InviteRecordViewModel: ObservableObject {
|
||||
@Published var tab: InviteRecordTab = .invite
|
||||
@Published private(set) var totalRewardText = "¥ 0.00"
|
||||
@Published private(set) var withdrawableText = "¥ 0.00"
|
||||
@Published private(set) var displayRows: [InviteDisplayRow] = []
|
||||
@Published private(set) var loading = false
|
||||
@Published private(set) var loadingMore = false
|
||||
@Published private(set) var hasMore = false
|
||||
@Published var errorMessage: String?
|
||||
|
||||
private var inviteRows: [InviteDisplayRow] = []
|
||||
private var rewardRows: [InviteDisplayRow] = []
|
||||
private var invitePage = 1
|
||||
private var rewardPage = 1
|
||||
@Published private var inviteRows: [InviteDisplayRow] = []
|
||||
@Published private var rewardRows: [InviteDisplayRow] = []
|
||||
@Published private var invitePage = 1
|
||||
@Published private var rewardPage = 1
|
||||
private let invitePageSize = 20
|
||||
private let rewardPageSize = 10
|
||||
|
||||
|
||||
@ -9,10 +9,10 @@ import SwiftUI
|
||||
|
||||
/// 摄影师邀请页面,展示邀请二维码、邀请码、规则和邀请记录入口。
|
||||
struct PhotographerInviteView: View {
|
||||
@Environment(InviteAPI.self) private var inviteAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@Environment(\.inviteAPI) private var inviteAPI
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
@State private var viewModel = PhotographerInviteViewModel()
|
||||
@StateObject private var viewModel = PhotographerInviteViewModel()
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
@ -140,10 +140,10 @@ struct PhotographerInviteView: View {
|
||||
|
||||
/// 邀请记录页面,展示邀请用户和奖励记录。
|
||||
struct InviteRecordView: View {
|
||||
@Environment(InviteAPI.self) private var inviteAPI
|
||||
@Environment(WalletAPI.self) private var walletAPI
|
||||
@Environment(\.inviteAPI) private var inviteAPI
|
||||
@Environment(\.walletAPI) private var walletAPI
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
@State private var viewModel = InviteRecordViewModel()
|
||||
@StateObject private var viewModel = InviteRecordViewModel()
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
@ -155,7 +155,7 @@ struct InviteRecordView: View {
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.onChange(of: viewModel.tab) { _, newValue in
|
||||
.onChange(of: viewModel.tab) { newValue in
|
||||
Task { await viewModel.selectTab(newValue, inviteAPI: inviteAPI, walletAPI: walletAPI) }
|
||||
}
|
||||
recordList
|
||||
@ -205,7 +205,7 @@ struct InviteRecordView: View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
if viewModel.displayRows.isEmpty && !viewModel.loading {
|
||||
ContentUnavailableView("暂无记录", systemImage: "tray")
|
||||
AppContentUnavailableView("暂无记录", systemImage: "tray")
|
||||
.frame(minHeight: 280)
|
||||
}
|
||||
ForEach(viewModel.displayRows) { row in
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
/// 直播服务协议,定义直播管理和直播相册接口能力。
|
||||
@MainActor
|
||||
@ -26,10 +26,9 @@ protocol LiveServing {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 直播 API,封装手动直播和直播相册网络请求。
|
||||
final class LiveAPI {
|
||||
@ObservationIgnored private let client: APIClient
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化直播 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
/// 直播播放地址解析器,避免把 RTMP 推流地址误当作播放地址。
|
||||
enum LivePlaybackURLResolver {
|
||||
@ -44,13 +44,12 @@ enum LivePlaybackState: Equatable {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 直播播放器 ViewModel,管理系统播放器的 URL、播放和释放状态。
|
||||
final class LivePlaybackViewModel {
|
||||
var state: LivePlaybackState = .empty
|
||||
var errorMessage: String?
|
||||
final class LivePlaybackViewModel: ObservableObject {
|
||||
@Published var state: LivePlaybackState = .empty
|
||||
@Published var errorMessage: String?
|
||||
|
||||
@ObservationIgnored private(set) var player: AVPlayer?
|
||||
@Published private(set) var player: AVPlayer?
|
||||
|
||||
var playableURL: URL? {
|
||||
switch state {
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import Network
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
/// 直播推流权限状态。
|
||||
enum LivePushPermissionState: Equatable {
|
||||
@ -158,23 +158,22 @@ final class SystemLiveNetworkMonitor: LiveNetworkMonitoring {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 推流准备 ViewModel,检查权限、网络和默认 SDK 可用性。
|
||||
final class LivePushReadinessViewModel {
|
||||
var cameraPermission: LivePushPermissionState = .unknown
|
||||
var microphonePermission: LivePushPermissionState = .unknown
|
||||
var networkState: LivePushNetworkState = .unknown
|
||||
var prepared = false
|
||||
var running = false
|
||||
var errorMessage: String?
|
||||
final class LivePushReadinessViewModel: ObservableObject {
|
||||
@Published var cameraPermission: LivePushPermissionState = .unknown
|
||||
@Published var microphonePermission: LivePushPermissionState = .unknown
|
||||
@Published var networkState: LivePushNetworkState = .unknown
|
||||
@Published var prepared = false
|
||||
@Published var running = false
|
||||
@Published var errorMessage: String?
|
||||
|
||||
let adapterName: String
|
||||
let sdkStatusText: String
|
||||
|
||||
@ObservationIgnored private let permissionProvider: any LivePermissionProviding
|
||||
@ObservationIgnored private let networkMonitor: any LiveNetworkMonitoring
|
||||
@ObservationIgnored private let adapter: any LivePushAdapter
|
||||
@ObservationIgnored private var pushURL: URL?
|
||||
private let permissionProvider: any LivePermissionProviding
|
||||
private let networkMonitor: any LiveNetworkMonitoring
|
||||
private let adapter: any LivePushAdapter
|
||||
@Published private var pushURL: URL?
|
||||
|
||||
init(
|
||||
permissionProvider: any LivePermissionProviding = SystemLivePermissionProvider(),
|
||||
|
||||
@ -6,22 +6,21 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 直播管理 ViewModel,负责直播列表、详情、创建和控制动作。
|
||||
final class LiveManagementViewModel {
|
||||
var items: [LiveEntity] = []
|
||||
var detail: LiveEntity?
|
||||
var loading = false
|
||||
var loadingMore = false
|
||||
var hasMore = false
|
||||
var page = 1
|
||||
var errorMessage: String?
|
||||
final class LiveManagementViewModel: ObservableObject {
|
||||
@Published var items: [LiveEntity] = []
|
||||
@Published var detail: LiveEntity?
|
||||
@Published var loading = false
|
||||
@Published var loadingMore = false
|
||||
@Published var hasMore = false
|
||||
@Published var page = 1
|
||||
@Published var errorMessage: String?
|
||||
|
||||
@ObservationIgnored private let pageSize = 10
|
||||
@ObservationIgnored private var total = 0
|
||||
private let pageSize = 10
|
||||
@Published private var total = 0
|
||||
|
||||
/// 进行中的直播数量。
|
||||
var liveRunningCount: Int {
|
||||
@ -136,13 +135,12 @@ final class LiveManagementViewModel {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 直播详情 ViewModel,负责直播详情页动作后的详情刷新。
|
||||
final class LiveDetailViewModel {
|
||||
var detail: LiveEntity
|
||||
var loading = false
|
||||
var actionInFlight = false
|
||||
var errorMessage: String?
|
||||
final class LiveDetailViewModel: ObservableObject {
|
||||
@Published var detail: LiveEntity
|
||||
@Published var loading = false
|
||||
@Published var actionInFlight = false
|
||||
@Published var errorMessage: String?
|
||||
|
||||
init(detail: LiveEntity) {
|
||||
self.detail = detail
|
||||
@ -192,20 +190,19 @@ final class LiveDetailViewModel {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 直播相册 ViewModel,负责相册列表、筛选、新建和删除。
|
||||
final class LiveAlbumViewModel {
|
||||
var folders: [LiveAlbumFolderItem] = []
|
||||
var startDate: Date?
|
||||
var endDate: Date?
|
||||
var loading = false
|
||||
var loadingMore = false
|
||||
var hasMore = false
|
||||
var page = 1
|
||||
var errorMessage: String?
|
||||
final class LiveAlbumViewModel: ObservableObject {
|
||||
@Published var folders: [LiveAlbumFolderItem] = []
|
||||
@Published var startDate: Date?
|
||||
@Published var endDate: Date?
|
||||
@Published var loading = false
|
||||
@Published var loadingMore = false
|
||||
@Published var hasMore = false
|
||||
@Published var page = 1
|
||||
@Published var errorMessage: String?
|
||||
|
||||
@ObservationIgnored private let pageSize = 10
|
||||
@ObservationIgnored private var total = 0
|
||||
private let pageSize = 10
|
||||
@Published private var total = 0
|
||||
|
||||
/// 设置开始时间并校验时间顺序。
|
||||
func setStartDate(_ date: Date) throws {
|
||||
@ -301,14 +298,13 @@ final class LiveAlbumViewModel {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 新建直播相册 ViewModel,负责本地素材上传和创建相册。
|
||||
final class LiveAlbumCreateViewModel {
|
||||
var name = ""
|
||||
var localFiles: [LiveAlbumLocalUploadFile] = []
|
||||
var submitting = false
|
||||
var uploadProgress = 0
|
||||
var errorMessage: String?
|
||||
final class LiveAlbumCreateViewModel: ObservableObject {
|
||||
@Published var name = ""
|
||||
@Published var localFiles: [LiveAlbumLocalUploadFile] = []
|
||||
@Published var submitting = false
|
||||
@Published var uploadProgress = 0
|
||||
@Published var errorMessage: String?
|
||||
|
||||
/// 添加本地待上传素材。
|
||||
func addLocalFiles(_ files: [LiveAlbumLocalUploadFile]) {
|
||||
@ -368,15 +364,14 @@ final class LiveAlbumCreateViewModel {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 直播相册预览 ViewModel,负责加载相册详情和删除当前素材。
|
||||
final class LiveAlbumPreviewViewModel {
|
||||
final class LiveAlbumPreviewViewModel: ObservableObject {
|
||||
let folderId: Int
|
||||
var folder: LiveAlbumFolderItem?
|
||||
var files: [LiveAlbumFileItem] = []
|
||||
var currentIndex: Int
|
||||
var loading = false
|
||||
var errorMessage: String?
|
||||
@Published var folder: LiveAlbumFolderItem?
|
||||
@Published var files: [LiveAlbumFileItem] = []
|
||||
@Published var currentIndex: Int
|
||||
@Published var loading = false
|
||||
@Published var errorMessage: String?
|
||||
|
||||
init(folderId: Int, startIndex: Int = 0, summary: LiveAlbumFolderItem? = nil) {
|
||||
self.folderId = folderId
|
||||
|
||||
@ -12,12 +12,12 @@ import UniformTypeIdentifiers
|
||||
|
||||
/// 直播相册首页,展示相册列表、筛选和上传入口。
|
||||
struct LiveAlbumView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(LiveAPI.self) private var liveAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@Environment(\.liveAPI) private var liveAPI
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
|
||||
@State private var viewModel = LiveAlbumViewModel()
|
||||
@StateObject private var viewModel = LiveAlbumViewModel()
|
||||
@State private var showStartPicker = false
|
||||
@State private var showEndPicker = false
|
||||
@State private var showCreatePage = false
|
||||
@ -29,7 +29,7 @@ struct LiveAlbumView: View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: AppMetrics.Spacing.medium) {
|
||||
if viewModel.folders.isEmpty {
|
||||
ContentUnavailableView("暂无素材", systemImage: "photo.stack")
|
||||
AppContentUnavailableView("暂无素材", systemImage: "photo.stack")
|
||||
.frame(maxWidth: .infinity, minHeight: 300)
|
||||
} else {
|
||||
ForEach(viewModel.folders) { folder in
|
||||
@ -286,13 +286,13 @@ private struct LiveDatePickerSheet: View {
|
||||
|
||||
/// 新建直播相册页面,支持选择本地图片/视频并上传。
|
||||
struct LiveAlbumCreateView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(LiveAPI.self) private var liveAPI
|
||||
@Environment(OSSUploadService.self) private var uploadService
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@Environment(\.liveAPI) private var liveAPI
|
||||
@Environment(\.ossUploadService) private var uploadService
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var viewModel = LiveAlbumCreateViewModel()
|
||||
@StateObject private var viewModel = LiveAlbumCreateViewModel()
|
||||
@State private var pickerItems: [PhotosPickerItem] = []
|
||||
let onCreated: () -> Void
|
||||
|
||||
@ -330,7 +330,7 @@ struct LiveAlbumCreateView: View {
|
||||
.background(Color(hex: 0xF5F7FA))
|
||||
.navigationTitle("上传素材")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onChange(of: pickerItems) { _, newItems in
|
||||
.onChange(of: pickerItems) { newItems in
|
||||
Task { await importPickerItems(newItems) }
|
||||
}
|
||||
}
|
||||
@ -338,7 +338,7 @@ struct LiveAlbumCreateView: View {
|
||||
@ViewBuilder
|
||||
private var localFileGrid: some View {
|
||||
if viewModel.localFiles.isEmpty {
|
||||
ContentUnavailableView("未选择素材", systemImage: "photo")
|
||||
AppContentUnavailableView("未选择素材", systemImage: "photo")
|
||||
.frame(maxWidth: .infinity, minHeight: 180)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||||
} else {
|
||||
@ -392,21 +392,21 @@ struct LiveAlbumCreateView: View {
|
||||
|
||||
/// 直播相册预览页面。
|
||||
struct LiveAlbumPreviewView: View {
|
||||
@Environment(LiveAPI.self) private var liveAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@Environment(\.liveAPI) private var liveAPI
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var viewModel: LiveAlbumPreviewViewModel
|
||||
@StateObject private var viewModel: LiveAlbumPreviewViewModel
|
||||
@State private var showDeleteConfirm = false
|
||||
|
||||
init(folderId: Int, startIndex: Int, summary: LiveAlbumFolderItem?) {
|
||||
_viewModel = State(initialValue: LiveAlbumPreviewViewModel(folderId: folderId, startIndex: startIndex, summary: summary))
|
||||
_viewModel = StateObject(wrappedValue: LiveAlbumPreviewViewModel(folderId: folderId, startIndex: startIndex, summary: summary))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
if viewModel.files.isEmpty {
|
||||
ContentUnavailableView("没有可预览的素材", systemImage: "photo")
|
||||
AppContentUnavailableView("没有可预览的素材", systemImage: "photo")
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color.black)
|
||||
} else {
|
||||
@ -459,12 +459,12 @@ struct LiveAlbumPreviewView: View {
|
||||
|
||||
private struct LiveAlbumPreviewPage: View {
|
||||
let file: LiveAlbumFileItem
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@State private var playbackViewModel: LivePlaybackViewModel
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@StateObject private var playbackViewModel: LivePlaybackViewModel
|
||||
|
||||
init(file: LiveAlbumFileItem) {
|
||||
self.file = file
|
||||
_playbackViewModel = State(initialValue: LivePlaybackViewModel(urlString: file.url))
|
||||
_playbackViewModel = StateObject(wrappedValue: LivePlaybackViewModel(urlString: file.url))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
|
||||
@ -10,12 +10,12 @@ import SwiftUI
|
||||
|
||||
/// 直播管理首页,展示直播列表和控制入口。
|
||||
struct LiveManagementView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(LiveAPI.self) private var liveAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@Environment(\.liveAPI) private var liveAPI
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
|
||||
@State private var viewModel = LiveManagementViewModel()
|
||||
@StateObject private var viewModel = LiveManagementViewModel()
|
||||
@State private var showAddPage = false
|
||||
|
||||
var body: some View {
|
||||
@ -24,7 +24,7 @@ struct LiveManagementView: View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: AppMetrics.Spacing.medium) {
|
||||
if viewModel.items.isEmpty {
|
||||
ContentUnavailableView("暂无直播", systemImage: "dot.radiowaves.left.and.right")
|
||||
AppContentUnavailableView("暂无直播", systemImage: "dot.radiowaves.left.and.right")
|
||||
.frame(maxWidth: .infinity, minHeight: 280)
|
||||
} else {
|
||||
ForEach(viewModel.items) { item in
|
||||
@ -231,9 +231,9 @@ private struct LiveSmallAction: View {
|
||||
|
||||
/// 添加直播页面。
|
||||
struct LiveAddView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(LiveAPI.self) private var liveAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@Environment(\.liveAPI) private var liveAPI
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var title = ""
|
||||
@ -351,18 +351,18 @@ struct LiveAddView: View {
|
||||
|
||||
/// 直播详情页面,展示推流信息和控制动作。
|
||||
struct LiveDetailView: View {
|
||||
@Environment(LiveAPI.self) private var liveAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@Environment(\.liveAPI) private var liveAPI
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
@State private var viewModel: LiveDetailViewModel
|
||||
@State private var playbackViewModel: LivePlaybackViewModel
|
||||
@State private var pushReadinessViewModel = LivePushReadinessViewModel()
|
||||
@StateObject private var viewModel: LiveDetailViewModel
|
||||
@StateObject private var playbackViewModel: LivePlaybackViewModel
|
||||
@StateObject private var pushReadinessViewModel = LivePushReadinessViewModel()
|
||||
@State private var copied = false
|
||||
let onChanged: () -> Void
|
||||
|
||||
init(initialDetail: LiveEntity, onChanged: @escaping () -> Void) {
|
||||
_viewModel = State(initialValue: LiveDetailViewModel(detail: initialDetail))
|
||||
_playbackViewModel = State(initialValue: LivePlaybackViewModel(live: initialDetail))
|
||||
_viewModel = StateObject(wrappedValue: LiveDetailViewModel(detail: initialDetail))
|
||||
_playbackViewModel = StateObject(wrappedValue: LivePlaybackViewModel(live: initialDetail))
|
||||
self.onChanged = onChanged
|
||||
}
|
||||
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
/// 位置上报服务协议,抽象上报和历史接口以便 ViewModel 测试替换。
|
||||
@MainActor
|
||||
@ -20,9 +20,8 @@ protocol LocationReportServing {
|
||||
|
||||
/// 位置上报 API,负责封装旧工程定位上报相关接口。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class LocationReportAPI: LocationReportServing {
|
||||
@ObservationIgnored private let client: APIClient
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化位置上报 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
|
||||
@ -6,22 +6,21 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
/// 位置上报 ViewModel,负责页面内在线状态、坐标、倒计时和提交动作。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class LocationReportViewModel {
|
||||
var isOnline = false
|
||||
var reminderMinutes = 30
|
||||
var secondsUntilReport = 0
|
||||
var currentCoordinate: LocationCoordinate?
|
||||
var markedCoordinate: LocationCoordinate?
|
||||
var currentAddress = ""
|
||||
var markedAddress = ""
|
||||
var lastReportText = ""
|
||||
var errorMessage: String?
|
||||
var isSubmitting = false
|
||||
final class LocationReportViewModel: ObservableObject {
|
||||
@Published var isOnline = false
|
||||
@Published var reminderMinutes = 30
|
||||
@Published var secondsUntilReport = 0
|
||||
@Published var currentCoordinate: LocationCoordinate?
|
||||
@Published var markedCoordinate: LocationCoordinate?
|
||||
@Published var currentAddress = ""
|
||||
@Published var markedAddress = ""
|
||||
@Published var lastReportText = ""
|
||||
@Published var errorMessage: String?
|
||||
@Published var isSubmitting = false
|
||||
|
||||
/// 倒计时展示文案。
|
||||
var countdownText: String {
|
||||
@ -130,18 +129,17 @@ final class LocationReportViewModel {
|
||||
|
||||
/// 位置上报历史 ViewModel,负责筛选、日期和分页加载。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class LocationReportHistoryViewModel {
|
||||
var selectedType: LocationReportType = .all
|
||||
var startDate: Date?
|
||||
var endDate: Date?
|
||||
var items: [LocationReportHistoryItem] = []
|
||||
var errorMessage: String?
|
||||
var isLoading = false
|
||||
var isLoadingMore = false
|
||||
var total = 0
|
||||
final class LocationReportHistoryViewModel: ObservableObject {
|
||||
@Published var selectedType: LocationReportType = .all
|
||||
@Published var startDate: Date?
|
||||
@Published var endDate: Date?
|
||||
@Published var items: [LocationReportHistoryItem] = []
|
||||
@Published var errorMessage: String?
|
||||
@Published var isLoading = false
|
||||
@Published var isLoadingMore = false
|
||||
@Published var total = 0
|
||||
|
||||
private var page = 1
|
||||
@Published private var page = 1
|
||||
private let pageSize = 20
|
||||
|
||||
/// 是否还有下一页历史记录。
|
||||
|
||||
@ -10,14 +10,14 @@ import SwiftUI
|
||||
|
||||
/// 位置上报页面,支持当前位置上报、标记点上报、在线状态和提醒设置。
|
||||
struct LocationReportView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(AccountSnapshotStore.self) private var snapshotStore
|
||||
@Environment(LocationReportAPI.self) private var locationReportAPI
|
||||
@Environment(RouterPath.self) private var router
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@Environment(\.accountSnapshotStore) private var snapshotStore
|
||||
@Environment(\.locationReportAPI) private var locationReportAPI
|
||||
@EnvironmentObject private var router: RouterPath
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
|
||||
@State private var viewModel = LocationReportViewModel()
|
||||
@StateObject private var viewModel = LocationReportViewModel()
|
||||
@State private var locationProvider = ForegroundLocationProvider()
|
||||
private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
|
||||
|
||||
@ -242,12 +242,12 @@ struct LocationReportView: View {
|
||||
|
||||
/// 位置上报历史页面,展示历史记录筛选和分页。
|
||||
struct LocationReportHistoryView: View {
|
||||
@Environment(AccountSnapshotStore.self) private var snapshotStore
|
||||
@Environment(LocationReportAPI.self) private var locationReportAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@Environment(\.accountSnapshotStore) private var snapshotStore
|
||||
@Environment(\.locationReportAPI) private var locationReportAPI
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
|
||||
@State private var viewModel = LocationReportHistoryViewModel()
|
||||
@StateObject private var viewModel = LocationReportHistoryViewModel()
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
@ -277,7 +277,7 @@ struct LocationReportHistoryView: View {
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.onChange(of: viewModel.selectedType) { _, _ in
|
||||
.onChange(of: viewModel.selectedType) { _ in
|
||||
Task { await reload(showLoading: true) }
|
||||
}
|
||||
|
||||
|
||||
@ -6,13 +6,12 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 主 Tab 角标 ViewModel,负责获取订单 Tab 待核销数量。
|
||||
final class MainTabBadgeViewModel {
|
||||
private(set) var pendingWriteOffCount: Int?
|
||||
final class MainTabBadgeViewModel: ObservableObject {
|
||||
@Published private(set) var pendingWriteOffCount: Int?
|
||||
|
||||
/// 刷新待核销订单数量,缺少景区或接口失败时清空角标。
|
||||
func refreshPendingWriteOffCount(api: OrderServing, scenicId: Int?, storeId: Int?) async {
|
||||
|
||||
@ -32,11 +32,9 @@ struct MainTabsView: View {
|
||||
|
||||
/// 系统 TabView 外壳,保留原生 TabBar 行为以便后续切换回系统实现。
|
||||
private struct SystemMainTabsView: View {
|
||||
@Environment(AppRouter.self) private var appRouter
|
||||
@EnvironmentObject private var appRouter: AppRouter
|
||||
|
||||
var body: some View {
|
||||
@Bindable var appRouter = appRouter
|
||||
|
||||
TabView(selection: $appRouter.selectedTab) {
|
||||
ForEach(AppTab.allCases) { tab in
|
||||
TabNavigationStackHost(tab: tab)
|
||||
@ -49,12 +47,12 @@ private struct SystemMainTabsView: View {
|
||||
|
||||
/// 自定义 TabBar 外壳,复用每个 Tab 独立 NavigationStack 并保留已访问页面状态。
|
||||
private struct CustomMainTabsView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(AppRouter.self) private var appRouter
|
||||
@Environment(OrdersAPI.self) private var ordersAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@EnvironmentObject private var appRouter: AppRouter
|
||||
@Environment(\.ordersAPI) private var ordersAPI
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
|
||||
@State private var badgeViewModel = MainTabBadgeViewModel()
|
||||
@StateObject private var badgeViewModel = MainTabBadgeViewModel()
|
||||
@State private var showScanner = false
|
||||
@State private var loadedTabs: Set<AppTab> = [.home]
|
||||
|
||||
@ -66,8 +64,6 @@ private struct CustomMainTabsView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@Bindable var appRouter = appRouter
|
||||
|
||||
ZStack {
|
||||
ForEach(AppTab.allCases) { tab in
|
||||
PreservedTabPage(
|
||||
@ -102,15 +98,15 @@ private struct CustomMainTabsView: View {
|
||||
loadedTabs.insert(appRouter.selectedTab)
|
||||
await refreshOrderBadge()
|
||||
}
|
||||
.onChange(of: appRouter.selectedTab) { _, selectedTab in
|
||||
.onChange(of: appRouter.selectedTab) { selectedTab in
|
||||
loadedTabs.insert(selectedTab)
|
||||
guard selectedTab == .orders else { return }
|
||||
Task { await refreshOrderBadge() }
|
||||
}
|
||||
.onChange(of: accountContext.currentScenic?.id) { _, _ in
|
||||
.onChange(of: accountContext.currentScenic?.id) { _ in
|
||||
Task { await refreshOrderBadge() }
|
||||
}
|
||||
.onChange(of: accountContext.currentStore?.id) { _, _ in
|
||||
.onChange(of: accountContext.currentStore?.id) { _ in
|
||||
Task { await refreshOrderBadge() }
|
||||
}
|
||||
}
|
||||
@ -127,7 +123,7 @@ private struct CustomMainTabsView: View {
|
||||
|
||||
/// 自定义 TabBar 模式下的单个 Tab 导航栈,TabBar 只属于根页面内容。
|
||||
private struct CustomTabNavigationStackHost: View {
|
||||
@Environment(AppRouter.self) private var appRouter
|
||||
@EnvironmentObject private var appRouter: AppRouter
|
||||
|
||||
let tab: AppTab
|
||||
@Binding var selectedTab: AppTab
|
||||
@ -153,13 +149,13 @@ private struct CustomTabNavigationStackHost: View {
|
||||
.toolbar(route.hidesTabBarWhenPushed ? .hidden : .visible, for: .tabBar)
|
||||
}
|
||||
}
|
||||
.environment(appRouter.router(for: tab))
|
||||
.environmentObject(appRouter.router(for: tab))
|
||||
}
|
||||
}
|
||||
|
||||
/// 单个 Tab 对应的导航栈,供系统和自定义 TabBar 外壳共同复用。
|
||||
private struct TabNavigationStackHost: View {
|
||||
@Environment(AppRouter.self) private var appRouter
|
||||
@EnvironmentObject private var appRouter: AppRouter
|
||||
|
||||
let tab: AppTab
|
||||
|
||||
@ -172,7 +168,7 @@ private struct TabNavigationStackHost: View {
|
||||
.toolbar(route.hidesTabBarWhenPushed ? .hidden : .visible, for: .tabBar)
|
||||
}
|
||||
}
|
||||
.environment(appRouter.router(for: tab))
|
||||
.environmentObject(appRouter.router(for: tab))
|
||||
}
|
||||
}
|
||||
|
||||
@ -197,8 +193,8 @@ private struct PreservedTabPage<Content: View>: View {
|
||||
|
||||
#Preview {
|
||||
MainTabsView()
|
||||
.environment(AppRouter())
|
||||
.environment(AccountContext())
|
||||
.environment(OrdersAPI(client: APIClient()))
|
||||
.environment(ToastCenter())
|
||||
.environmentObject(AppRouter())
|
||||
.environmentObject(AccountContext())
|
||||
.environment(\.ordersAPI, OrdersAPI(client: APIClient()))
|
||||
.environmentObject(ToastCenter())
|
||||
}
|
||||
|
||||
@ -37,7 +37,7 @@ struct ProfileRootView: View {
|
||||
|
||||
/// 通用 Tab 占位视图,用于未迁移模块的临时入口。
|
||||
private struct PlaceholderTabRootView: View {
|
||||
@Environment(RouterPath.self) private var router
|
||||
@EnvironmentObject private var router: RouterPath
|
||||
|
||||
let title: String
|
||||
let systemImage: String
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
/// 消息中心服务协议,定义消息列表、已读和删除能力。
|
||||
@MainActor
|
||||
@ -22,10 +22,9 @@ protocol MessageCenterServing {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 消息中心 API,封装消息相关网络请求。
|
||||
final class MessageCenterAPI {
|
||||
@ObservationIgnored private let client: APIClient
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化消息中心 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
|
||||
@ -6,22 +6,21 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 消息中心 ViewModel,负责消息分页、筛选、已读和删除。
|
||||
final class MessageCenterViewModel {
|
||||
var messages: [MessageItem] = []
|
||||
var selectedFilter: MessageFilter = .all
|
||||
var isLoading = false
|
||||
var isLoadingMore = false
|
||||
var loadFailed = false
|
||||
var loadFailureReason: String?
|
||||
var message: String?
|
||||
final class MessageCenterViewModel: ObservableObject {
|
||||
@Published var messages: [MessageItem] = []
|
||||
@Published var selectedFilter: MessageFilter = .all
|
||||
@Published var isLoading = false
|
||||
@Published var isLoadingMore = false
|
||||
@Published var loadFailed = false
|
||||
@Published var loadFailureReason: String?
|
||||
@Published var message: String?
|
||||
|
||||
private(set) var hasMoreMessages = false
|
||||
private(set) var lastId = 0
|
||||
@Published private(set) var hasMoreMessages = false
|
||||
@Published private(set) var lastId = 0
|
||||
private let pageSize = 20
|
||||
|
||||
/// 未读消息数量。
|
||||
|
||||
@ -9,9 +9,9 @@ import SwiftUI
|
||||
|
||||
/// 消息中心页面,展示消息列表、筛选、已读和删除入口。
|
||||
struct MessageCenterView: View {
|
||||
@Environment(MessageCenterAPI.self) private var messageAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@State private var viewModel = MessageCenterViewModel()
|
||||
@Environment(\.messageCenterAPI) private var messageAPI
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@StateObject private var viewModel = MessageCenterViewModel()
|
||||
@State private var selectedMessage: MessageItem?
|
||||
@State private var processingMessageId: String?
|
||||
|
||||
@ -80,7 +80,7 @@ struct MessageCenterView: View {
|
||||
ProgressView("加载中...")
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if viewModel.loadFailed {
|
||||
ContentUnavailableView {
|
||||
AppContentUnavailableView {
|
||||
Label("消息加载失败", systemImage: "exclamationmark.triangle")
|
||||
} description: {
|
||||
Text(viewModel.loadFailureReason ?? "请稍后重试")
|
||||
@ -92,7 +92,7 @@ struct MessageCenterView: View {
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if viewModel.messages.isEmpty {
|
||||
ContentUnavailableView("暂无消息", systemImage: "bell", description: Text("暂无可查看的系统消息"))
|
||||
AppContentUnavailableView("暂无消息", systemImage: "bell", description: Text("暂无可查看的系统消息"))
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
ScrollView {
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
/// 运营区域服务协议,定义店铺和景区管理员两类围栏数据接口。
|
||||
@MainActor
|
||||
@ -16,10 +16,9 @@ protocol OperatingAreaServing {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 运营区域 API,封装运营区域只读围栏接口。
|
||||
final class OperatingAreaAPI {
|
||||
@ObservationIgnored private let client: APIClient
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化运营区域 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
|
||||
@ -6,18 +6,17 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 运营区域 ViewModel,负责角色分流、围栏加载、解析和错误状态管理。
|
||||
final class OperatingAreaViewModel {
|
||||
private(set) var mode: OperatingAreaEntryMode?
|
||||
private(set) var loading = false
|
||||
private(set) var items: [OperatingAreaItem] = []
|
||||
private(set) var fenceRings: [OperatingFenceRing] = []
|
||||
private(set) var blockReason: OperatingAreaBlockReason?
|
||||
private(set) var errorMessage: String?
|
||||
final class OperatingAreaViewModel: ObservableObject {
|
||||
@Published private(set) var mode: OperatingAreaEntryMode?
|
||||
@Published private(set) var loading = false
|
||||
@Published private(set) var items: [OperatingAreaItem] = []
|
||||
@Published private(set) var fenceRings: [OperatingFenceRing] = []
|
||||
@Published private(set) var blockReason: OperatingAreaBlockReason?
|
||||
@Published private(set) var errorMessage: String?
|
||||
|
||||
/// 按当前账号和角色刷新运营区域。
|
||||
func reload(
|
||||
|
||||
@ -14,13 +14,13 @@ import MAMapKit
|
||||
|
||||
/// 运营区域首页,按当前角色展示店铺或景区运营围栏。
|
||||
struct OperatingAreaView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(PermissionContext.self) private var permissionContext
|
||||
@Environment(OperatingAreaAPI.self) private var operatingAreaAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@EnvironmentObject private var permissionContext: PermissionContext
|
||||
@Environment(\.operatingAreaAPI) private var operatingAreaAPI
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
|
||||
@State private var viewModel = OperatingAreaViewModel()
|
||||
@StateObject private var viewModel = OperatingAreaViewModel()
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
@ -87,7 +87,7 @@ struct OperatingAreaView: View {
|
||||
}
|
||||
|
||||
if let reason = viewModel.blockReason {
|
||||
ContentUnavailableView(reason.message, systemImage: "mappin.slash")
|
||||
AppContentUnavailableView(reason.message, systemImage: "mappin.slash")
|
||||
.frame(maxWidth: .infinity, minHeight: 260)
|
||||
.background(Color(hex: 0xF8FAFC), in: RoundedRectangle(cornerRadius: 8))
|
||||
.overlay(alignment: .bottom) {
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
/// 订单服务协议,抽象订单列表、核销列表和核销操作以便测试替换。
|
||||
@ -59,10 +59,9 @@ protocol OrderServing {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 订单 API,封装订单管理、核销订单和订单核销接口。
|
||||
final class OrdersAPI: OrderServing {
|
||||
@ObservationIgnored private let client: APIClient
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化订单 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
|
||||
@ -6,16 +6,15 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 订单详情 ViewModel,负责加载门店订单详情并保留列表摘要兜底展示。
|
||||
final class OrderDetailViewModel {
|
||||
private(set) var detail: StoreOrderDetailResponse?
|
||||
private(set) var loading = false
|
||||
private(set) var errorMessage: String?
|
||||
private(set) var contextMessage: String?
|
||||
final class OrderDetailViewModel: ObservableObject {
|
||||
@Published private(set) var detail: StoreOrderDetailResponse?
|
||||
@Published private(set) var loading = false
|
||||
@Published private(set) var errorMessage: String?
|
||||
@Published private(set) var contextMessage: String?
|
||||
|
||||
/// 当前页面用于展示的订单信息,详情接口成功时优先使用详情数据。
|
||||
var display: StoreOrderDetailDisplay {
|
||||
|
||||
@ -6,20 +6,19 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 押金订单列表 ViewModel,负责分页、核销、退款和操作后刷新。
|
||||
final class DepositOrderListViewModel {
|
||||
private(set) var orders: [DepositOrderListItem] = []
|
||||
private(set) var total = 0
|
||||
private(set) var page = 1
|
||||
private(set) var hasMore = false
|
||||
private(set) var loading = false
|
||||
private(set) var loadingMore = false
|
||||
private(set) var operatingOrderNumber: String?
|
||||
var errorMessage: String?
|
||||
final class DepositOrderListViewModel: ObservableObject {
|
||||
@Published private(set) var orders: [DepositOrderListItem] = []
|
||||
@Published private(set) var total = 0
|
||||
@Published private(set) var page = 1
|
||||
@Published private(set) var hasMore = false
|
||||
@Published private(set) var loading = false
|
||||
@Published private(set) var loadingMore = false
|
||||
@Published private(set) var operatingOrderNumber: String?
|
||||
@Published var errorMessage: String?
|
||||
|
||||
private let pageSize = 10
|
||||
|
||||
@ -134,12 +133,11 @@ final class DepositOrderListViewModel {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 押金订单详情 ViewModel,负责按门店和订单号加载详情。
|
||||
final class DepositOrderDetailViewModel {
|
||||
private(set) var detail: StoreOrderDetailResponse?
|
||||
private(set) var loading = false
|
||||
var errorMessage: String?
|
||||
final class DepositOrderDetailViewModel: ObservableObject {
|
||||
@Published private(set) var detail: StoreOrderDetailResponse?
|
||||
@Published private(set) var loading = false
|
||||
@Published var errorMessage: String?
|
||||
|
||||
/// 加载押金订单详情,缺少门店或订单号时不请求接口。
|
||||
func load(api: OrderServing, storeId: Int?, orderNumber: String) async {
|
||||
@ -179,12 +177,11 @@ final class DepositOrderDetailViewModel {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 押金拍摄信息 ViewModel,负责加载单个打卡点的媒体和评分。
|
||||
final class DepositOrderShootingInfoViewModel {
|
||||
private(set) var detail: StoreOrderShootingDetailResponse?
|
||||
private(set) var loading = false
|
||||
var errorMessage: String?
|
||||
final class DepositOrderShootingInfoViewModel: ObservableObject {
|
||||
@Published private(set) var detail: StoreOrderShootingDetailResponse?
|
||||
@Published private(set) var loading = false
|
||||
@Published var errorMessage: String?
|
||||
|
||||
/// 加载押金订单拍摄信息,缺少门店或订单号时不请求接口。
|
||||
func load(api: OrderServing, storeId: Int?, orderNumber: String, scenicSpotId: Int, photogUid: Int) async {
|
||||
@ -229,14 +226,13 @@ final class DepositOrderShootingInfoViewModel {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 普通订单退款 ViewModel,负责退款入口判断、金额校验和提交保护。
|
||||
final class OrderRefundViewModel {
|
||||
var mode: OrderRefundMode = .full
|
||||
var amount = ""
|
||||
var reason = ""
|
||||
private(set) var submitting = false
|
||||
var errorMessage: String?
|
||||
final class OrderRefundViewModel: ObservableObject {
|
||||
@Published var mode: OrderRefundMode = .full
|
||||
@Published var amount = ""
|
||||
@Published var reason = ""
|
||||
@Published private(set) var submitting = false
|
||||
@Published var errorMessage: String?
|
||||
|
||||
/// 判断订单是否允许展示普通退款入口。
|
||||
func canRefund(_ item: OrderEntity) -> Bool {
|
||||
@ -344,14 +340,13 @@ final class OrderRefundViewModel {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 历史拍摄 ViewModel,负责按订单号加载多点位历史拍摄媒体。
|
||||
final class HistoricalShootingInfoViewModel {
|
||||
private(set) var projectName = ""
|
||||
private(set) var projectTypeName = ""
|
||||
private(set) var spots: [PhotogSpotItem] = []
|
||||
private(set) var loading = false
|
||||
var errorMessage: String?
|
||||
final class HistoricalShootingInfoViewModel: ObservableObject {
|
||||
@Published private(set) var projectName = ""
|
||||
@Published private(set) var projectTypeName = ""
|
||||
@Published private(set) var spots: [PhotogSpotItem] = []
|
||||
@Published private(set) var loading = false
|
||||
@Published var errorMessage: String?
|
||||
|
||||
/// 加载历史拍摄信息,订单号为空时清空旧数据且不请求接口。
|
||||
func load(api: OrderServing, orderNumber: String) async {
|
||||
@ -390,20 +385,19 @@ final class HistoricalShootingInfoViewModel {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 多点旅拍任务上传 ViewModel,负责订单号、打卡点、云盘附件、本地附件和素材提交。
|
||||
final class MultiTravelTaskUploadViewModel {
|
||||
var orderNumber = ""
|
||||
var selectedSpotId: Int?
|
||||
private(set) var spots: [MultiTravelVerifiedScenicSpotItem] = []
|
||||
var selectedCloudFiles: [TaskCloudSelectionItem] = []
|
||||
var selectedLocalFiles: [TaskLocalUploadItem] = []
|
||||
private(set) var isLoadingSpots = false
|
||||
private(set) var isSubmitting = false
|
||||
private(set) var didSubmitSuccessfully = false
|
||||
var errorMessage: String?
|
||||
final class MultiTravelTaskUploadViewModel: ObservableObject {
|
||||
@Published var orderNumber = ""
|
||||
@Published var selectedSpotId: Int?
|
||||
@Published private(set) var spots: [MultiTravelVerifiedScenicSpotItem] = []
|
||||
@Published var selectedCloudFiles: [TaskCloudSelectionItem] = []
|
||||
@Published var selectedLocalFiles: [TaskLocalUploadItem] = []
|
||||
@Published private(set) var isLoadingSpots = false
|
||||
@Published private(set) var isSubmitting = false
|
||||
@Published private(set) var didSubmitSuccessfully = false
|
||||
@Published var errorMessage: String?
|
||||
|
||||
private var loadedSpotOrderNumber = ""
|
||||
@Published private var loadedSpotOrderNumber = ""
|
||||
|
||||
/// 当前选中打卡点展示名。
|
||||
var selectedSpotName: String {
|
||||
|
||||
@ -6,31 +6,30 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 订单页面 ViewModel,管理订单列表、核销列表、筛选条件和手动核销流程。
|
||||
final class OrdersViewModel {
|
||||
var selectedEntry: OrdersEntry = .storeOrders
|
||||
var selectedStatus = 0
|
||||
var searchPhone = ""
|
||||
var filterStartDate: Date?
|
||||
var filterEndDate: Date?
|
||||
private(set) var loading = false
|
||||
private(set) var loadingMore = false
|
||||
private(set) var isVerifying = false
|
||||
private(set) var currentVerifyingOrderNumber: String?
|
||||
final class OrdersViewModel: ObservableObject {
|
||||
@Published var selectedEntry: OrdersEntry = .storeOrders
|
||||
@Published var selectedStatus = 0
|
||||
@Published var searchPhone = ""
|
||||
@Published var filterStartDate: Date?
|
||||
@Published var filterEndDate: Date?
|
||||
@Published private(set) var loading = false
|
||||
@Published private(set) var loadingMore = false
|
||||
@Published private(set) var isVerifying = false
|
||||
@Published private(set) var currentVerifyingOrderNumber: String?
|
||||
|
||||
private(set) var storeOrders: [OrderEntity] = []
|
||||
private(set) var storeTotal = 0
|
||||
private(set) var storePage = 1
|
||||
private(set) var storeHasMore = false
|
||||
@Published private(set) var storeOrders: [OrderEntity] = []
|
||||
@Published private(set) var storeTotal = 0
|
||||
@Published private(set) var storePage = 1
|
||||
@Published private(set) var storeHasMore = false
|
||||
|
||||
private(set) var writeOffOrders: [WriteOffOrderItem] = []
|
||||
private(set) var writeOffTotal = 0
|
||||
private(set) var writeOffPage = 1
|
||||
private(set) var writeOffHasMore = false
|
||||
@Published private(set) var writeOffOrders: [WriteOffOrderItem] = []
|
||||
@Published private(set) var writeOffTotal = 0
|
||||
@Published private(set) var writeOffPage = 1
|
||||
@Published private(set) var writeOffHasMore = false
|
||||
|
||||
/// 返回去除首尾空白后的手机号搜索值。
|
||||
var normalizedPhone: String? {
|
||||
|
||||
@ -9,13 +9,13 @@ import SwiftUI
|
||||
|
||||
/// 押金订单入口页,支持订单号查询、列表分页、核销和退款。
|
||||
struct DepositOrderEntryView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(OrdersAPI.self) private var ordersAPI
|
||||
@Environment(RouterPath.self) private var router
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@Environment(\.ordersAPI) private var ordersAPI
|
||||
@EnvironmentObject private var router: RouterPath
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
|
||||
@State private var viewModel = DepositOrderListViewModel()
|
||||
@StateObject private var viewModel = DepositOrderListViewModel()
|
||||
@State private var orderNumber = ""
|
||||
@State private var pendingWriteOff: DepositOrderListItem?
|
||||
@State private var pendingRefund: DepositOrderListItem?
|
||||
@ -36,7 +36,7 @@ struct DepositOrderEntryView: View {
|
||||
|
||||
Section {
|
||||
if viewModel.orders.isEmpty {
|
||||
ContentUnavailableView("暂无押金订单", systemImage: "doc.text.magnifyingglass")
|
||||
AppContentUnavailableView("暂无押金订单", systemImage: "doc.text.magnifyingglass")
|
||||
.frame(maxWidth: .infinity, minHeight: 180)
|
||||
} else {
|
||||
ForEach(viewModel.orders) { item in
|
||||
@ -239,13 +239,13 @@ private struct DepositOrderRow: View {
|
||||
|
||||
/// 押金订单详情页,展示订单基础信息和拍摄点列表。
|
||||
struct DepositOrderDetailView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(OrdersAPI.self) private var ordersAPI
|
||||
@Environment(RouterPath.self) private var router
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@Environment(\.ordersAPI) private var ordersAPI
|
||||
@EnvironmentObject private var router: RouterPath
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
|
||||
let orderNumber: String
|
||||
@State private var viewModel = DepositOrderDetailViewModel()
|
||||
@StateObject private var viewModel = DepositOrderDetailViewModel()
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
@ -275,7 +275,7 @@ struct DepositOrderDetailView: View {
|
||||
Section("拍摄详情") {
|
||||
let shootingList = detail.multiTravel?.shootingList ?? []
|
||||
if shootingList.isEmpty {
|
||||
ContentUnavailableView("暂无拍摄记录", systemImage: "photo.stack")
|
||||
AppContentUnavailableView("暂无拍摄记录", systemImage: "photo.stack")
|
||||
} else {
|
||||
ForEach(shootingList) { item in
|
||||
Button {
|
||||
@ -296,7 +296,7 @@ struct DepositOrderDetailView: View {
|
||||
}
|
||||
} else {
|
||||
Section {
|
||||
ContentUnavailableView("暂无订单详情", systemImage: "doc.text.magnifyingglass")
|
||||
AppContentUnavailableView("暂无订单详情", systemImage: "doc.text.magnifyingglass")
|
||||
.frame(maxWidth: .infinity, minHeight: 220)
|
||||
}
|
||||
}
|
||||
@ -363,15 +363,15 @@ private struct DepositShootingPointRow: View {
|
||||
|
||||
/// 押金拍摄信息页,展示评价、底片和成片。
|
||||
struct DepositOrderShootingInfoView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(OrdersAPI.self) private var ordersAPI
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@Environment(\.ordersAPI) private var ordersAPI
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
|
||||
let orderNumber: String
|
||||
let scenicSpotId: Int
|
||||
let photogUid: Int
|
||||
|
||||
@State private var viewModel = DepositOrderShootingInfoViewModel()
|
||||
@StateObject private var viewModel = DepositOrderShootingInfoViewModel()
|
||||
@State private var selectedTab: DepositShootingMediaTab = .negative
|
||||
|
||||
var body: some View {
|
||||
@ -393,7 +393,7 @@ struct DepositOrderShootingInfoView: View {
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
} else {
|
||||
ContentUnavailableView("暂无评价信息", systemImage: "star")
|
||||
AppContentUnavailableView("暂无评价信息", systemImage: "star")
|
||||
}
|
||||
}
|
||||
|
||||
@ -410,7 +410,7 @@ struct DepositOrderShootingInfoView: View {
|
||||
}
|
||||
} else {
|
||||
Section {
|
||||
ContentUnavailableView("暂无拍摄信息", systemImage: "photo.stack")
|
||||
AppContentUnavailableView("暂无拍摄信息", systemImage: "photo.stack")
|
||||
.frame(maxWidth: .infinity, minHeight: 220)
|
||||
}
|
||||
}
|
||||
@ -448,11 +448,11 @@ struct DepositOrderShootingInfoView: View {
|
||||
|
||||
/// 历史拍摄信息页,展示多点位拍摄媒体。
|
||||
struct HistoricalShootingInfoView: View {
|
||||
@Environment(OrdersAPI.self) private var ordersAPI
|
||||
@Environment(\.ordersAPI) private var ordersAPI
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
|
||||
let orderNumber: String
|
||||
@State private var viewModel = HistoricalShootingInfoViewModel()
|
||||
@StateObject private var viewModel = HistoricalShootingInfoViewModel()
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
@ -464,7 +464,7 @@ struct HistoricalShootingInfoView: View {
|
||||
|
||||
Section("历史拍摄") {
|
||||
if viewModel.spots.isEmpty {
|
||||
ContentUnavailableView("暂无历史拍摄信息", systemImage: "photo.on.rectangle")
|
||||
AppContentUnavailableView("暂无历史拍摄信息", systemImage: "photo.on.rectangle")
|
||||
.frame(maxWidth: .infinity, minHeight: 220)
|
||||
} else {
|
||||
ForEach(viewModel.spots) { spot in
|
||||
@ -565,7 +565,7 @@ private struct OrderMediaGrid: View {
|
||||
|
||||
var body: some View {
|
||||
if files.isEmpty {
|
||||
ContentUnavailableView("暂无媒体文件", systemImage: "photo")
|
||||
AppContentUnavailableView("暂无媒体文件", systemImage: "photo")
|
||||
.frame(maxWidth: .infinity, minHeight: 140)
|
||||
} else {
|
||||
LazyVGrid(columns: columns, spacing: AppMetrics.Spacing.small) {
|
||||
|
||||
@ -9,21 +9,21 @@ import SwiftUI
|
||||
|
||||
/// 门店订单详情页,展示订单接口补全后的支付、客户、项目和拍摄点信息。
|
||||
struct StoreOrderDetailView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(OrdersAPI.self) private var ordersAPI
|
||||
@Environment(RouterPath.self) private var router
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@Environment(\.ordersAPI) private var ordersAPI
|
||||
@EnvironmentObject private var router: RouterPath
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
|
||||
let item: OrderEntity
|
||||
@State private var viewModel: OrderDetailViewModel
|
||||
@State private var refundViewModel = OrderRefundViewModel()
|
||||
@StateObject private var viewModel: OrderDetailViewModel
|
||||
@StateObject private var refundViewModel = OrderRefundViewModel()
|
||||
@State private var showRefundSheet = false
|
||||
|
||||
/// 使用列表订单初始化详情页,并创建详情 ViewModel。
|
||||
init(item: OrderEntity) {
|
||||
self.item = item
|
||||
_viewModel = State(initialValue: OrderDetailViewModel(item: item))
|
||||
_viewModel = StateObject(wrappedValue: OrderDetailViewModel(item: item))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@ -204,7 +204,7 @@ struct StoreOrderDetailView: View {
|
||||
/// 普通订单退款表单,负责收集退款模式、金额和原因。
|
||||
private struct OrderRefundSheet: View {
|
||||
let item: OrderEntity
|
||||
@Bindable var viewModel: OrderRefundViewModel
|
||||
@ObservedObject var viewModel: OrderRefundViewModel
|
||||
let onCancel: () -> Void
|
||||
let onSubmit: () -> Void
|
||||
@FocusState private var focusedField: RefundInputField?
|
||||
@ -276,9 +276,9 @@ private enum RefundInputField: Hashable {
|
||||
|
||||
/// 核销订单详情页,展示核销列表项信息并支持再次发起核销。
|
||||
struct WriteOffOrderDetailView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(OrdersAPI.self) private var ordersAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@Environment(\.ordersAPI) private var ordersAPI
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
|
||||
let item: WriteOffOrderItem
|
||||
@State private var verifying = false
|
||||
|
||||
@ -11,21 +11,21 @@ import UniformTypeIdentifiers
|
||||
|
||||
/// 多点旅拍任务上传页面,负责选择打卡点和素材后提交到订单接口。
|
||||
struct MultiTravelTaskUploadView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(OrdersAPI.self) private var ordersAPI
|
||||
@Environment(OSSUploadService.self) private var uploadService
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@Environment(\.ordersAPI) private var ordersAPI
|
||||
@Environment(\.ossUploadService) private var uploadService
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
|
||||
@State private var viewModel: MultiTravelTaskUploadViewModel
|
||||
@StateObject private var viewModel: MultiTravelTaskUploadViewModel
|
||||
@State private var showSpotPicker = false
|
||||
@State private var showCloudSelection = false
|
||||
@State private var pickerItems: [PhotosPickerItem] = []
|
||||
|
||||
/// 使用订单号初始化任务上传页面。
|
||||
init(initialOrderNumber: String) {
|
||||
_viewModel = State(initialValue: MultiTravelTaskUploadViewModel(initialOrderNumber: initialOrderNumber))
|
||||
_viewModel = StateObject(wrappedValue: MultiTravelTaskUploadViewModel(initialOrderNumber: initialOrderNumber))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@ -61,7 +61,7 @@ struct MultiTravelTaskUploadView: View {
|
||||
}
|
||||
Button("取消", role: .cancel) {}
|
||||
}
|
||||
.onChange(of: pickerItems) { _, items in
|
||||
.onChange(of: pickerItems) { items in
|
||||
Task { await importPickerItems(items) }
|
||||
}
|
||||
.alert("提示", isPresented: errorBinding) {
|
||||
|
||||
@ -9,16 +9,16 @@ import SwiftUI
|
||||
|
||||
/// 订单 Tab 根视图,展示订单管理和核销订单两个业务入口。
|
||||
struct OrdersView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(PermissionContext.self) private var permissionContext
|
||||
@Environment(AppRouter.self) private var appRouter
|
||||
@Environment(RouterPath.self) private var router
|
||||
@Environment(OrdersAPI.self) private var ordersAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@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
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
@State private var viewModel = OrdersViewModel()
|
||||
@StateObject private var viewModel = OrdersViewModel()
|
||||
@State private var manualOrderNumber = ""
|
||||
@State private var showDateFilterSheet = false
|
||||
@State private var showScanner = false
|
||||
@ -62,7 +62,7 @@ struct OrdersView: View {
|
||||
entrySection
|
||||
|
||||
if currentScenicId == nil {
|
||||
ContentUnavailableView(
|
||||
AppContentUnavailableView(
|
||||
"缺少经营上下文",
|
||||
systemImage: "mountain.2",
|
||||
description: Text("请先在首页选择景区后查看订单。")
|
||||
@ -80,7 +80,7 @@ struct OrdersView: View {
|
||||
.padding(.bottom, AppMetrics.Spacing.pageVertical)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.onChange(of: matchedScannedOrderNumber) { _, orderNumber in
|
||||
.onChange(of: matchedScannedOrderNumber) { orderNumber in
|
||||
guard let orderNumber else { return }
|
||||
withAnimation(.easeInOut(duration: 0.25)) {
|
||||
scrollProxy.scrollTo(orderNumber, anchor: .center)
|
||||
@ -96,15 +96,15 @@ struct OrdersView: View {
|
||||
await reload()
|
||||
await consumePendingScanCodeIfNeeded()
|
||||
}
|
||||
.onChange(of: appRouter.selectedOrdersEntry) { _, entry in
|
||||
.onChange(of: appRouter.selectedOrdersEntry) { entry in
|
||||
guard viewModel.selectedEntry != entry else { return }
|
||||
viewModel.selectedEntry = entry
|
||||
Task { await reload() }
|
||||
}
|
||||
.onChange(of: appRouter.pendingOrderScanCode) { _, _ in
|
||||
.onChange(of: appRouter.pendingOrderScanCode) { _ in
|
||||
Task { await consumePendingScanCodeIfNeeded() }
|
||||
}
|
||||
.onChange(of: accountContext.currentScenic?.id) { _, _ in
|
||||
.onChange(of: accountContext.currentScenic?.id) { _ in
|
||||
Task { await reload() }
|
||||
}
|
||||
.confirmationDialog("确认核销该订单?", isPresented: $showVerifyConfirmation, titleVisibility: .visible) {
|
||||
@ -233,7 +233,7 @@ struct OrdersView: View {
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(minHeight: 260)
|
||||
} else if viewModel.storeOrders.isEmpty {
|
||||
ContentUnavailableView("暂无订单", systemImage: "tray", description: Text("可切换筛选条件或下拉刷新。"))
|
||||
AppContentUnavailableView("暂无订单", systemImage: "tray", description: Text("可切换筛选条件或下拉刷新。"))
|
||||
.frame(minHeight: 260)
|
||||
} else {
|
||||
ForEach(viewModel.storeOrders) { item in
|
||||
@ -306,7 +306,7 @@ struct OrdersView: View {
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(minHeight: 260)
|
||||
} else if viewModel.writeOffOrders.isEmpty {
|
||||
ContentUnavailableView("暂无核销订单", systemImage: "tray", description: Text("可下拉刷新或切换景区查看。"))
|
||||
AppContentUnavailableView("暂无核销订单", systemImage: "tray", description: Text("可下拉刷新或切换景区查看。"))
|
||||
.frame(minHeight: 260)
|
||||
} else {
|
||||
ForEach(viewModel.writeOffOrders) { item in
|
||||
@ -735,11 +735,11 @@ private struct OrdersDateFilterSheet: View {
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
OrdersView()
|
||||
.environment(AccountContext())
|
||||
.environment(PermissionContext())
|
||||
.environment(AppRouter())
|
||||
.environment(RouterPath())
|
||||
.environment(OrdersAPI(client: APIClient()))
|
||||
.environment(ToastCenter())
|
||||
.environmentObject(AccountContext())
|
||||
.environmentObject(PermissionContext())
|
||||
.environmentObject(AppRouter())
|
||||
.environmentObject(RouterPath())
|
||||
.environment(\.ordersAPI, OrdersAPI(client: APIClient()))
|
||||
.environmentObject(ToastCenter())
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
/// 收款模块服务协议,定义收款码和收款记录接口能力。
|
||||
@MainActor
|
||||
@ -19,10 +19,9 @@ protocol PaymentServing {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 收款 API,封装首页“收款”模块需要的网络请求。
|
||||
final class PaymentAPI {
|
||||
@ObservationIgnored private let client: APIClient
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化收款 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
|
||||
@ -8,25 +8,24 @@
|
||||
import CoreImage
|
||||
import CoreImage.CIFilterBuiltins
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 收款页 ViewModel,管理收款码加载、动态金额二维码和到账轮询。
|
||||
final class PaymentCollectionViewModel {
|
||||
var staticPayUrl = ""
|
||||
var dynamicPayUrl = ""
|
||||
var amountText = ""
|
||||
var remarkText = ""
|
||||
var currentPayUrl = ""
|
||||
var qrImage: UIImage?
|
||||
var isLoading = false
|
||||
var isPolling = false
|
||||
var errorMessage: String?
|
||||
var status: PaymentCollectionStatus = .idle
|
||||
final class PaymentCollectionViewModel: ObservableObject {
|
||||
@Published var staticPayUrl = ""
|
||||
@Published var dynamicPayUrl = ""
|
||||
@Published var amountText = ""
|
||||
@Published var remarkText = ""
|
||||
@Published var currentPayUrl = ""
|
||||
@Published var qrImage: UIImage?
|
||||
@Published var isLoading = false
|
||||
@Published var isPolling = false
|
||||
@Published var errorMessage: String?
|
||||
@Published var status: PaymentCollectionStatus = .idle
|
||||
|
||||
private var knownRecordIDs = Set<String>()
|
||||
@Published private var knownRecordIDs = Set<String>()
|
||||
private let context = CIContext()
|
||||
private let qrFilter = CIFilter.qrCodeGenerator()
|
||||
|
||||
@ -191,12 +190,11 @@ private extension String {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 收款记录 ViewModel,管理收款记录加载、日期分组和汇总兜底。
|
||||
final class PaymentCollectionRecordViewModel {
|
||||
var groups: [PaymentCollectionRecordGroup] = []
|
||||
var isLoading = false
|
||||
var errorMessage: String?
|
||||
final class PaymentCollectionRecordViewModel: ObservableObject {
|
||||
@Published var groups: [PaymentCollectionRecordGroup] = []
|
||||
@Published var isLoading = false
|
||||
@Published var errorMessage: String?
|
||||
|
||||
/// 加载当前景区收款记录,无景区时不请求接口。
|
||||
func load(api: PaymentServing, scenicId: Int?) async {
|
||||
|
||||
@ -11,12 +11,12 @@ import SwiftUI
|
||||
|
||||
/// 收款页,展示静态收款码、动态金额收款和收款记录入口。
|
||||
struct PaymentCollectionView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(PaymentAPI.self) private var paymentAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@Environment(\.paymentAPI) private var paymentAPI
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
|
||||
@State private var viewModel = PaymentCollectionViewModel()
|
||||
@StateObject private var viewModel = PaymentCollectionViewModel()
|
||||
@State private var showingAmountSheet = false
|
||||
@State private var pollingTask: Task<Void, Never>?
|
||||
@State private var speaker = PaymentVoiceSpeaker()
|
||||
@ -56,12 +56,12 @@ struct PaymentCollectionView: View {
|
||||
.onDisappear {
|
||||
pollingTask?.cancel()
|
||||
}
|
||||
.onChange(of: viewModel.errorMessage) { _, message in
|
||||
.onChange(of: viewModel.errorMessage) { message in
|
||||
if let message {
|
||||
toastCenter.show(message)
|
||||
}
|
||||
}
|
||||
.onChange(of: viewModel.status) { _, status in
|
||||
.onChange(of: viewModel.status) { status in
|
||||
if case .success(let item) = status {
|
||||
speaker.speak("收款到账\(item.orderAmount)元")
|
||||
}
|
||||
@ -256,11 +256,11 @@ struct PaymentCollectionView: View {
|
||||
|
||||
/// 收款记录页,按日期展示扫码收款明细。
|
||||
struct PaymentCollectionRecordView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(PaymentAPI.self) private var paymentAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@Environment(\.paymentAPI) private var paymentAPI
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
@State private var viewModel = PaymentCollectionRecordViewModel()
|
||||
@StateObject private var viewModel = PaymentCollectionRecordViewModel()
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
@ -304,7 +304,7 @@ struct PaymentCollectionRecordView: View {
|
||||
await viewModel.load(api: paymentAPI, scenicId: accountContext.currentScenic?.id)
|
||||
}
|
||||
}
|
||||
.onChange(of: viewModel.errorMessage) { _, message in
|
||||
.onChange(of: viewModel.errorMessage) { message in
|
||||
if let message {
|
||||
toastCenter.show(message)
|
||||
}
|
||||
@ -314,9 +314,8 @@ struct PaymentCollectionRecordView: View {
|
||||
|
||||
/// 收款语音播报服务,使用系统 TTS 播报到账提示。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class PaymentVoiceSpeaker {
|
||||
@ObservationIgnored private let synthesizer = AVSpeechSynthesizer()
|
||||
private let synthesizer = AVSpeechSynthesizer()
|
||||
|
||||
/// 播报指定中文文案。
|
||||
func speak(_ text: String) {
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
/// 飞手认证服务协议,定义认证详情、短信、提交和编辑接口。
|
||||
@MainActor
|
||||
@ -18,10 +18,9 @@ protocol PilotCertificationServing {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 飞手认证 API,封装 `/api/app/flyer` 认证相关接口。
|
||||
final class PilotCertificationAPI {
|
||||
@ObservationIgnored private let client: APIClient
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化飞手认证 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
/// 飞手认证实名认证读取协议,便于 ViewModel 单测替换。
|
||||
@ -17,29 +17,28 @@ protocol PilotRealNameServing {
|
||||
extension ProfileAPI: PilotRealNameServing {}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 飞手认证 ViewModel,管理审核状态、表单、验证码、证件图上传和提交。
|
||||
final class PilotCertificationViewModel {
|
||||
private(set) var flyer: FlyerDetailResponse?
|
||||
private(set) var realNameInfo: RealNameInfo?
|
||||
var name = ""
|
||||
var certType: PilotCertType = .caac
|
||||
var certNo = ""
|
||||
var certImageUrl = ""
|
||||
var startDate = Date()
|
||||
var endDate = Calendar.current.date(byAdding: .year, value: 1, to: Date()) ?? Date()
|
||||
var droneModel = ""
|
||||
var droneSerialNo = ""
|
||||
var phone = ""
|
||||
var verifyCode = ""
|
||||
private(set) var pendingCertificateImageData: Data?
|
||||
private(set) var pendingCertificateFileName: String?
|
||||
private(set) var uploadProgress: Int?
|
||||
private(set) var loading = false
|
||||
private(set) var sendingCode = false
|
||||
private(set) var submitting = false
|
||||
private(set) var countdown = 0
|
||||
var statusMessage: String?
|
||||
final class PilotCertificationViewModel: ObservableObject {
|
||||
@Published private(set) var flyer: FlyerDetailResponse?
|
||||
@Published private(set) var realNameInfo: RealNameInfo?
|
||||
@Published var name = ""
|
||||
@Published var certType: PilotCertType = .caac
|
||||
@Published var certNo = ""
|
||||
@Published var certImageUrl = ""
|
||||
@Published var startDate = Date()
|
||||
@Published var endDate = Calendar.current.date(byAdding: .year, value: 1, to: Date()) ?? Date()
|
||||
@Published var droneModel = ""
|
||||
@Published var droneSerialNo = ""
|
||||
@Published var phone = ""
|
||||
@Published var verifyCode = ""
|
||||
@Published private(set) var pendingCertificateImageData: Data?
|
||||
@Published private(set) var pendingCertificateFileName: String?
|
||||
@Published private(set) var uploadProgress: Int?
|
||||
@Published private(set) var loading = false
|
||||
@Published private(set) var sendingCode = false
|
||||
@Published private(set) var submitting = false
|
||||
@Published private(set) var countdown = 0
|
||||
@Published var statusMessage: String?
|
||||
|
||||
/// 加载实名状态和飞手认证详情,单通道失败不清空另一通道数据。
|
||||
func load(api: any PilotCertificationServing, realNameAPI: any PilotRealNameServing) async {
|
||||
|
||||
@ -12,14 +12,14 @@ import UIKit
|
||||
|
||||
/// 飞手认证页面,展示认证审核状态并支持提交/驳回后编辑。
|
||||
struct PilotCertificationView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(ProfileAPI.self) private var profileAPI
|
||||
@Environment(PilotCertificationAPI.self) private var pilotAPI
|
||||
@Environment(OSSUploadService.self) private var ossUploadService
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@Environment(\.profileAPI) private var profileAPI
|
||||
@Environment(\.pilotCertificationAPI) private var pilotAPI
|
||||
@Environment(\.ossUploadService) private var ossUploadService
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
|
||||
@State private var viewModel = PilotCertificationViewModel()
|
||||
@StateObject private var viewModel = PilotCertificationViewModel()
|
||||
@State private var selectedImageItem: PhotosPickerItem?
|
||||
private let countdownTimer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
|
||||
|
||||
@ -55,7 +55,7 @@ struct PilotCertificationView: View {
|
||||
.onReceive(countdownTimer) { _ in
|
||||
viewModel.tickCountdown()
|
||||
}
|
||||
.onChange(of: selectedImageItem) { _, item in
|
||||
.onChange(of: selectedImageItem) { item in
|
||||
guard let item else { return }
|
||||
Task { await loadImage(item) }
|
||||
}
|
||||
|
||||
@ -6,13 +6,12 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 个人信息 API,封装“我的”页面需要的用户资料和资料更新接口。
|
||||
final class ProfileAPI {
|
||||
@ObservationIgnored private let client: APIClient
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化个人信息 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
|
||||
@ -6,17 +6,16 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 账号切换 ViewModel,管理可切换账号列表、选择状态和提交状态。
|
||||
final class AccountSwitchViewModel {
|
||||
private(set) var accounts: [AccountSwitchAccount] = []
|
||||
var selectedAccountId: String?
|
||||
private(set) var loading = false
|
||||
private(set) var switching = false
|
||||
private var didLoad = false
|
||||
final class AccountSwitchViewModel: ObservableObject {
|
||||
@Published private(set) var accounts: [AccountSwitchAccount] = []
|
||||
@Published var selectedAccountId: String?
|
||||
@Published private(set) var loading = false
|
||||
@Published private(set) var switching = false
|
||||
@Published private var didLoad = false
|
||||
|
||||
/// 当前选中的账号。
|
||||
var selectedAccount: AccountSwitchAccount? {
|
||||
|
||||
@ -6,21 +6,20 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 个人信息页 ViewModel,负责资料加载、编辑状态和提交更新。
|
||||
final class ProfileViewModel {
|
||||
var userInfo: UserInfoResponse?
|
||||
var realNameInfo: RealNameInfo?
|
||||
var isLoading = false
|
||||
var isSaving = false
|
||||
var isEditingProfile = false
|
||||
var editingNickname = ""
|
||||
private(set) var pendingAvatarData: Data?
|
||||
private(set) var pendingAvatarFileName: String?
|
||||
private(set) var avatarUploadProgress: Int?
|
||||
final class ProfileViewModel: ObservableObject {
|
||||
@Published var userInfo: UserInfoResponse?
|
||||
@Published var realNameInfo: RealNameInfo?
|
||||
@Published var isLoading = false
|
||||
@Published var isSaving = false
|
||||
@Published var isEditingProfile = false
|
||||
@Published var editingNickname = ""
|
||||
@Published private(set) var pendingAvatarData: Data?
|
||||
@Published private(set) var pendingAvatarFileName: String?
|
||||
@Published private(set) var avatarUploadProgress: Int?
|
||||
|
||||
var displayNickname: String {
|
||||
nonEmpty(userInfo?.nickname) ?? "未设置昵称"
|
||||
|
||||
@ -6,31 +6,30 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 实名认证 ViewModel,管理认证资料表单、审核状态、短信验证码和提交状态。
|
||||
final class RealNameAuthViewModel {
|
||||
private(set) var info: RealNameInfo?
|
||||
var realName = ""
|
||||
var idCardNo = ""
|
||||
var smsCode = ""
|
||||
var startDate = Date()
|
||||
var endDate = Calendar.current.date(byAdding: .year, value: 10, to: Date()) ?? Date()
|
||||
var isLongValid = false
|
||||
var frontUrl = ""
|
||||
var backUrl = ""
|
||||
private(set) var pendingFrontImageData: Data?
|
||||
private(set) var pendingBackImageData: Data?
|
||||
private(set) var frontUploadProgress: Int?
|
||||
private(set) var backUploadProgress: Int?
|
||||
private var pendingFrontFileName: String?
|
||||
private var pendingBackFileName: String?
|
||||
private(set) var loading = false
|
||||
private(set) var sendingCode = false
|
||||
private(set) var submitting = false
|
||||
var statusMessage: String?
|
||||
final class RealNameAuthViewModel: ObservableObject {
|
||||
@Published private(set) var info: RealNameInfo?
|
||||
@Published var realName = ""
|
||||
@Published var idCardNo = ""
|
||||
@Published var smsCode = ""
|
||||
@Published var startDate = Date()
|
||||
@Published var endDate = Calendar.current.date(byAdding: .year, value: 10, to: Date()) ?? Date()
|
||||
@Published var isLongValid = false
|
||||
@Published var frontUrl = ""
|
||||
@Published var backUrl = ""
|
||||
@Published private(set) var pendingFrontImageData: Data?
|
||||
@Published private(set) var pendingBackImageData: Data?
|
||||
@Published private(set) var frontUploadProgress: Int?
|
||||
@Published private(set) var backUploadProgress: Int?
|
||||
@Published private var pendingFrontFileName: String?
|
||||
@Published private var pendingBackFileName: String?
|
||||
@Published private(set) var loading = false
|
||||
@Published private(set) var sendingCode = false
|
||||
@Published private(set) var submitting = false
|
||||
@Published var statusMessage: String?
|
||||
|
||||
/// 拉取实名认证信息,并回填表单。
|
||||
func load(api: ProfileAPI) async throws {
|
||||
|
||||
@ -9,19 +9,19 @@ import SwiftUI
|
||||
|
||||
/// 账号切换页面,展示当前登录主体下可进入的景区账号和门店账号。
|
||||
struct AccountSwitchView: View {
|
||||
@Environment(AppSession.self) private var appSession
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(PermissionContext.self) private var permissionContext
|
||||
@Environment(AppRouter.self) private var appRouter
|
||||
@Environment(ProfileAPI.self) private var profileAPI
|
||||
@Environment(AuthAPI.self) private var authAPI
|
||||
@Environment(AccountContextAPI.self) private var accountContextAPI
|
||||
@Environment(AuthSessionCoordinator.self) private var authSessionCoordinator
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@EnvironmentObject private var appSession: AppSession
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@EnvironmentObject private var permissionContext: PermissionContext
|
||||
@EnvironmentObject private var appRouter: AppRouter
|
||||
@Environment(\.profileAPI) private var profileAPI
|
||||
@Environment(\.authAPI) private var authAPI
|
||||
@Environment(\.accountContextAPI) private var accountContextAPI
|
||||
@Environment(\.authSessionCoordinator) private var authSessionCoordinator
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var viewModel = AccountSwitchViewModel()
|
||||
@StateObject private var viewModel = AccountSwitchViewModel()
|
||||
|
||||
private var selectedAccount: AccountSwitchAccount? {
|
||||
viewModel.selectedAccount
|
||||
@ -43,7 +43,7 @@ struct AccountSwitchView: View {
|
||||
@ViewBuilder
|
||||
private var accountList: some View {
|
||||
if viewModel.accounts.isEmpty && !viewModel.loading {
|
||||
ContentUnavailableView(
|
||||
AppContentUnavailableView(
|
||||
"暂无可切换账号",
|
||||
systemImage: "person.crop.circle.badge.exclamationmark",
|
||||
description: Text("当前登录账号下没有其他可切换账号。")
|
||||
|
||||
@ -10,9 +10,9 @@ import SwiftUI
|
||||
|
||||
/// 首页菜单调试预览页,展示所有已知菜单入口并跳过权限过滤。
|
||||
struct DebugHomeMenuPreviewView: View {
|
||||
@Environment(AppRouter.self) private var appRouter
|
||||
@Environment(RouterPath.self) private var router
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@EnvironmentObject private var appRouter: AppRouter
|
||||
@EnvironmentObject private var router: RouterPath
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
|
||||
private let items = HomeMenuRouter.debugAllMenuItems()
|
||||
|
||||
|
||||
@ -11,20 +11,20 @@ import UIKit
|
||||
|
||||
/// 个人信息页视图,展示用户资料、账号状态、实名认证状态和退出登录入口。
|
||||
struct ProfileView: View {
|
||||
@Environment(AppSession.self) private var appSession
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(PermissionContext.self) private var permissionContext
|
||||
@Environment(ScenicSpotContext.self) private var scenicSpotContext
|
||||
@Environment(AppRouter.self) private var appRouter
|
||||
@Environment(RouterPath.self) private var router
|
||||
@Environment(ProfileAPI.self) private var profileAPI
|
||||
@Environment(OSSUploadService.self) private var ossUploadService
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@Environment(AuthSessionCoordinator.self) private var authSessionCoordinator
|
||||
@EnvironmentObject private var appSession: AppSession
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@EnvironmentObject private var permissionContext: PermissionContext
|
||||
@EnvironmentObject private var scenicSpotContext: ScenicSpotContext
|
||||
@EnvironmentObject private var appRouter: AppRouter
|
||||
@EnvironmentObject private var router: RouterPath
|
||||
@Environment(\.profileAPI) private var profileAPI
|
||||
@Environment(\.ossUploadService) private var ossUploadService
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.authSessionCoordinator) private var authSessionCoordinator
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
@State private var viewModel = ProfileViewModel()
|
||||
@StateObject private var viewModel = ProfileViewModel()
|
||||
@State private var showsPasswordSheet = false
|
||||
@State private var showsLogoutConfirm = false
|
||||
@State private var pickedAvatarItem: PhotosPickerItem?
|
||||
@ -74,13 +74,13 @@ struct ProfileView: View {
|
||||
}
|
||||
Button("取消", role: .cancel) {}
|
||||
}
|
||||
.onChange(of: viewModel.isEditingProfile) { _, isEditing in
|
||||
.onChange(of: viewModel.isEditingProfile) { isEditing in
|
||||
guard isEditing else { return }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
|
||||
nicknameFocused = true
|
||||
}
|
||||
}
|
||||
.onChange(of: pickedAvatarItem) { _, item in
|
||||
.onChange(of: pickedAvatarItem) { item in
|
||||
Task { await prepareAvatarImage(from: item) }
|
||||
}
|
||||
}
|
||||
@ -662,14 +662,14 @@ private struct PasswordUpdateSheet: View {
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
ProfileView()
|
||||
.environment(AppSession())
|
||||
.environment(AccountContext())
|
||||
.environment(PermissionContext())
|
||||
.environment(ScenicSpotContext())
|
||||
.environment(AppRouter())
|
||||
.environment(ToastCenter())
|
||||
.environment(ProfileAPI(client: APIClient()))
|
||||
.environment(OSSUploadService(configService: UploadAPI(client: APIClient())))
|
||||
.environment(AuthSessionCoordinator())
|
||||
.environmentObject(AppSession())
|
||||
.environmentObject(AccountContext())
|
||||
.environmentObject(PermissionContext())
|
||||
.environmentObject(ScenicSpotContext())
|
||||
.environmentObject(AppRouter())
|
||||
.environmentObject(ToastCenter())
|
||||
.environment(\.profileAPI, ProfileAPI(client: APIClient()))
|
||||
.environment(\.ossUploadService, OSSUploadService(configService: UploadAPI(client: APIClient())))
|
||||
.environment(\.authSessionCoordinator, AuthSessionCoordinator())
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,12 +11,12 @@ import UIKit
|
||||
|
||||
/// 实名认证页面,展示审核状态并允许提交基础实名资料。
|
||||
struct RealNameAuthView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(ProfileAPI.self) private var profileAPI
|
||||
@Environment(OSSUploadService.self) private var ossUploadService
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@Environment(\.profileAPI) private var profileAPI
|
||||
@Environment(\.ossUploadService) private var ossUploadService
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
@State private var viewModel = RealNameAuthViewModel()
|
||||
@StateObject private var viewModel = RealNameAuthViewModel()
|
||||
@State private var pickedFrontItem: PhotosPickerItem?
|
||||
@State private var pickedBackItem: PhotosPickerItem?
|
||||
|
||||
@ -38,10 +38,10 @@ struct RealNameAuthView: View {
|
||||
.task {
|
||||
await loadInfo()
|
||||
}
|
||||
.onChange(of: pickedFrontItem) { _, item in
|
||||
.onChange(of: pickedFrontItem) { item in
|
||||
Task { await prepareIdentityImage(from: item, side: .front) }
|
||||
}
|
||||
.onChange(of: pickedBackItem) { _, item in
|
||||
.onChange(of: pickedBackItem) { item in
|
||||
Task { await prepareIdentityImage(from: item, side: .back) }
|
||||
}
|
||||
}
|
||||
|
||||
@ -53,7 +53,7 @@ enum SettingsDisplayPolicy {
|
||||
|
||||
/// 设置中心页面,展示关于我们、版本、下载链接和协议入口。
|
||||
struct SettingsCenterView: View {
|
||||
@Environment(RouterPath.self) private var router
|
||||
@EnvironmentObject private var router: RouterPath
|
||||
@State private var copiedDownloadLink = false
|
||||
|
||||
private var versionText: String {
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
/// 项目服务协议,抽象摄影师项目和店铺项目接口以便 ViewModel 测试替换。
|
||||
@MainActor
|
||||
@ -56,9 +56,8 @@ protocol ProjectServing {
|
||||
|
||||
/// 项目 API,封装项目管理、店铺项目管理相关接口。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class ProjectAPI: ProjectServing {
|
||||
@ObservationIgnored private let client: APIClient
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化项目 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
/// 项目表单模式,区分新建和编辑。
|
||||
enum ProjectEditorMode: Equatable {
|
||||
@ -48,18 +48,17 @@ enum ProjectEditorError: LocalizedError, Equatable {
|
||||
|
||||
/// 摄影师项目管理 ViewModel,负责项目列表、分页、详情和删除。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class ProjectManagementViewModel {
|
||||
private(set) var items: [PhotographerProjectItem] = []
|
||||
private(set) var loading = false
|
||||
private(set) var loadingMore = false
|
||||
private(set) var hasMore = false
|
||||
private(set) var selectedDetail: PhotographerProjectDetailResponse?
|
||||
var searchText = ""
|
||||
var errorMessage: String?
|
||||
final class ProjectManagementViewModel: ObservableObject {
|
||||
@Published private(set) var items: [PhotographerProjectItem] = []
|
||||
@Published private(set) var loading = false
|
||||
@Published private(set) var loadingMore = false
|
||||
@Published private(set) var hasMore = false
|
||||
@Published private(set) var selectedDetail: PhotographerProjectDetailResponse?
|
||||
@Published var searchText = ""
|
||||
@Published var errorMessage: String?
|
||||
|
||||
private var page = 1
|
||||
private var total = 0
|
||||
@Published private var page = 1
|
||||
@Published private var total = 0
|
||||
private let pageSize = 10
|
||||
|
||||
/// 重新加载摄影师项目列表。
|
||||
@ -156,47 +155,54 @@ final class ProjectManagementViewModel {
|
||||
|
||||
/// 项目编辑 ViewModel,负责摄影师项目表单、图片上传和提交。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class ProjectEditorViewModel {
|
||||
var name = ""
|
||||
var descriptionText = ""
|
||||
var price = ""
|
||||
var otPrice = ""
|
||||
var deposit = ""
|
||||
var attrLabelText = ""
|
||||
var materialNum = "1"
|
||||
var photoNum = "1"
|
||||
var videoNum = "1"
|
||||
var selectedSpotIds: Set<Int> = []
|
||||
var coverImage: ProjectLocalImage?
|
||||
var carouselImages: [ProjectLocalImage] = []
|
||||
var existingCoverURL = ""
|
||||
var existingCarouselURLs: [String] = []
|
||||
private(set) var submitting = false
|
||||
private(set) var uploadProgress = 0
|
||||
var errorMessage: String?
|
||||
final class ProjectEditorViewModel: ObservableObject {
|
||||
@Published var name = ""
|
||||
@Published var descriptionText = ""
|
||||
@Published var price = ""
|
||||
@Published var otPrice = ""
|
||||
@Published var deposit = ""
|
||||
@Published var attrLabelText = ""
|
||||
@Published var materialNum = "1"
|
||||
@Published var photoNum = "1"
|
||||
@Published var videoNum = "1"
|
||||
@Published var selectedSpotIds: Set<Int> = []
|
||||
@Published var coverImage: ProjectLocalImage?
|
||||
@Published var carouselImages: [ProjectLocalImage] = []
|
||||
@Published var existingCoverURL = ""
|
||||
@Published var existingCarouselURLs: [String] = []
|
||||
@Published private(set) var submitting = false
|
||||
@Published private(set) var uploadProgress = 0
|
||||
@Published var errorMessage: String?
|
||||
|
||||
private let mode: ProjectEditorMode
|
||||
private var mode: ProjectEditorMode
|
||||
|
||||
/// 初始化摄影师项目表单,并在编辑模式下回填详情。
|
||||
init(mode: ProjectEditorMode) {
|
||||
self.mode = mode
|
||||
if case let .edit(detail) = mode {
|
||||
name = detail.name
|
||||
descriptionText = detail.description
|
||||
price = detail.price
|
||||
otPrice = detail.otPrice
|
||||
deposit = detail.priceDeposit
|
||||
attrLabelText = detail.attrLabel.joined(separator: ",")
|
||||
materialNum = "\(max(detail.materialNum, 0))"
|
||||
photoNum = "\(max(detail.photoNum, 0))"
|
||||
videoNum = "\(max(detail.videoNum, 0))"
|
||||
selectedSpotIds = Set(detail.scenicList.map(\.id))
|
||||
existingCoverURL = detail.coverProject
|
||||
existingCarouselURLs = detail.coverCarousel
|
||||
apply(detail)
|
||||
}
|
||||
}
|
||||
|
||||
/// 回填编辑详情,保留同一个 ObservableObject 实例。
|
||||
func apply(_ detail: PhotographerProjectDetailResponse) {
|
||||
mode = .edit(detail)
|
||||
name = detail.name
|
||||
descriptionText = detail.description
|
||||
price = detail.price
|
||||
otPrice = detail.otPrice
|
||||
deposit = detail.priceDeposit
|
||||
attrLabelText = detail.attrLabel.joined(separator: ",")
|
||||
materialNum = "\(max(detail.materialNum, 0))"
|
||||
photoNum = "\(max(detail.photoNum, 0))"
|
||||
videoNum = "\(max(detail.videoNum, 0))"
|
||||
selectedSpotIds = Set(detail.scenicList.map(\.id))
|
||||
existingCoverURL = detail.coverProject
|
||||
existingCarouselURLs = detail.coverCarousel
|
||||
coverImage = nil
|
||||
carouselImages = []
|
||||
}
|
||||
|
||||
/// 提交摄影师项目,必要时先上传封面和轮播图。
|
||||
func submit(
|
||||
scenicId: Int?,
|
||||
@ -304,14 +310,13 @@ final class ProjectEditorViewModel {
|
||||
|
||||
/// 店铺项目管理 ViewModel,负责店铺项目列表、筛选、详情和删除。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class StoreProjectManagementViewModel {
|
||||
private(set) var items: [PhotographerProjectItem] = []
|
||||
private(set) var loading = false
|
||||
private(set) var selectedDetail: PhotographerProjectDetailResponse?
|
||||
var searchText = ""
|
||||
var selectedType = 0
|
||||
var errorMessage: String?
|
||||
final class StoreProjectManagementViewModel: ObservableObject {
|
||||
@Published private(set) var items: [PhotographerProjectItem] = []
|
||||
@Published private(set) var loading = false
|
||||
@Published private(set) var selectedDetail: PhotographerProjectDetailResponse?
|
||||
@Published var searchText = ""
|
||||
@Published var selectedType = 0
|
||||
@Published var errorMessage: String?
|
||||
|
||||
/// 根据本地筛选条件返回展示项目。
|
||||
var filteredItems: [PhotographerProjectItem] {
|
||||
@ -364,51 +369,58 @@ final class StoreProjectManagementViewModel {
|
||||
|
||||
/// 店铺项目编辑 ViewModel,负责多点位和押金项目表单提交。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class StoreProjectEditorViewModel {
|
||||
var projectType = 19
|
||||
var scenicList: [StoreManagerScenicItem] = []
|
||||
var storeList: [StoreItem] = []
|
||||
var selectedScenicIds: Set<Int> = []
|
||||
var selectedStoreId: Int?
|
||||
var name = ""
|
||||
var descriptionText = ""
|
||||
var coverImage: ProjectLocalImage?
|
||||
var carouselImages: [ProjectLocalImage] = []
|
||||
var existingCoverURL = ""
|
||||
var existingCarouselURLs: [String] = []
|
||||
var settleSpotNum = "1"
|
||||
var projectPrice = ""
|
||||
var priceMaterial = ""
|
||||
var pricePhoto = ""
|
||||
var priceVideo = ""
|
||||
var priceDeposit = ""
|
||||
var materialNum = "1"
|
||||
var photoNum = "1"
|
||||
var videoNum = "1"
|
||||
private(set) var submitting = false
|
||||
var errorMessage: String?
|
||||
final class StoreProjectEditorViewModel: ObservableObject {
|
||||
@Published var projectType = 19
|
||||
@Published var scenicList: [StoreManagerScenicItem] = []
|
||||
@Published var storeList: [StoreItem] = []
|
||||
@Published var selectedScenicIds: Set<Int> = []
|
||||
@Published var selectedStoreId: Int?
|
||||
@Published var name = ""
|
||||
@Published var descriptionText = ""
|
||||
@Published var coverImage: ProjectLocalImage?
|
||||
@Published var carouselImages: [ProjectLocalImage] = []
|
||||
@Published var existingCoverURL = ""
|
||||
@Published var existingCarouselURLs: [String] = []
|
||||
@Published var settleSpotNum = "1"
|
||||
@Published var projectPrice = ""
|
||||
@Published var priceMaterial = ""
|
||||
@Published var pricePhoto = ""
|
||||
@Published var priceVideo = ""
|
||||
@Published var priceDeposit = ""
|
||||
@Published var materialNum = "1"
|
||||
@Published var photoNum = "1"
|
||||
@Published var videoNum = "1"
|
||||
@Published private(set) var submitting = false
|
||||
@Published var errorMessage: String?
|
||||
|
||||
private let mode: StoreProjectEditorMode
|
||||
private var mode: StoreProjectEditorMode
|
||||
|
||||
/// 初始化店铺项目表单,并在编辑模式下回填详情。
|
||||
init(mode: StoreProjectEditorMode) {
|
||||
self.mode = mode
|
||||
if case let .edit(detail) = mode {
|
||||
projectType = detail.type == 4 ? 4 : 19
|
||||
name = detail.name
|
||||
descriptionText = detail.description
|
||||
existingCoverURL = detail.coverProject
|
||||
existingCarouselURLs = detail.coverCarousel
|
||||
selectedScenicIds = Set(detail.scenicList.map(\.id))
|
||||
projectPrice = detail.price
|
||||
priceDeposit = detail.priceDeposit
|
||||
materialNum = "\(max(detail.materialNum, 0))"
|
||||
photoNum = "\(max(detail.photoNum, 0))"
|
||||
videoNum = "\(max(detail.videoNum, 0))"
|
||||
apply(detail)
|
||||
}
|
||||
}
|
||||
|
||||
/// 回填编辑详情,保留同一个 ObservableObject 实例。
|
||||
func apply(_ detail: PhotographerProjectDetailResponse) {
|
||||
mode = .edit(detail)
|
||||
projectType = detail.type == 4 ? 4 : 19
|
||||
name = detail.name
|
||||
descriptionText = detail.description
|
||||
existingCoverURL = detail.coverProject
|
||||
existingCarouselURLs = detail.coverCarousel
|
||||
selectedScenicIds = Set(detail.scenicList.map(\.id))
|
||||
projectPrice = detail.price
|
||||
priceDeposit = detail.priceDeposit
|
||||
materialNum = "\(max(detail.materialNum, 0))"
|
||||
photoNum = "\(max(detail.photoNum, 0))"
|
||||
videoNum = "\(max(detail.videoNum, 0))"
|
||||
coverImage = nil
|
||||
carouselImages = []
|
||||
}
|
||||
|
||||
/// 加载店铺项目可管理景区。
|
||||
func loadScenicList(api: any ProjectServing, userId: Int) async {
|
||||
guard scenicList.isEmpty, userId > 0 else { return }
|
||||
|
||||
@ -11,17 +11,17 @@ import UniformTypeIdentifiers
|
||||
|
||||
/// 摄影师项目管理页面,展示项目列表并提供新建、详情和删除入口。
|
||||
struct ProjectManagementView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(AccountSnapshotStore.self) private var snapshotStore
|
||||
@Environment(ProjectAPI.self) private var projectAPI
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@Environment(\.accountSnapshotStore) private var snapshotStore
|
||||
@Environment(\.projectAPI) private var projectAPI
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
@State private var viewModel = ProjectManagementViewModel()
|
||||
@StateObject private var viewModel = ProjectManagementViewModel()
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
filterBar
|
||||
if accountContext.currentScenic?.id == nil {
|
||||
ContentUnavailableView("缺少景区", systemImage: "map", description: Text("请选择景区后再管理项目。"))
|
||||
AppContentUnavailableView("缺少景区", systemImage: "map", description: Text("请选择景区后再管理项目。"))
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
projectList
|
||||
@ -66,7 +66,7 @@ struct ProjectManagementView: View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: AppMetrics.Spacing.medium) {
|
||||
if viewModel.items.isEmpty && !viewModel.loading {
|
||||
ContentUnavailableView("暂无项目", systemImage: "folder", description: Text("可点击右上角创建项目。"))
|
||||
AppContentUnavailableView("暂无项目", systemImage: "folder", description: Text("可点击右上角创建项目。"))
|
||||
.frame(maxWidth: .infinity, minHeight: 260)
|
||||
}
|
||||
ForEach(viewModel.items) { item in
|
||||
@ -110,10 +110,10 @@ struct ProjectManagementView: View {
|
||||
|
||||
/// 店铺项目管理页面,展示店铺项目列表、筛选和新建入口。
|
||||
struct StoreProjectManagementView: View {
|
||||
@Environment(AccountSnapshotStore.self) private var snapshotStore
|
||||
@Environment(ProjectAPI.self) private var projectAPI
|
||||
@Environment(\.accountSnapshotStore) private var snapshotStore
|
||||
@Environment(\.projectAPI) private var projectAPI
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
@State private var viewModel = StoreProjectManagementViewModel()
|
||||
@StateObject private var viewModel = StoreProjectManagementViewModel()
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
@ -127,7 +127,7 @@ struct StoreProjectManagementView: View {
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
if viewModel.filteredItems.isEmpty && !viewModel.loading {
|
||||
ContentUnavailableView("暂无店铺项目", systemImage: "folder", description: Text("可点击右上角新建店铺项目。"))
|
||||
AppContentUnavailableView("暂无店铺项目", systemImage: "folder", description: Text("可点击右上角新建店铺项目。"))
|
||||
.frame(minHeight: 260)
|
||||
}
|
||||
}
|
||||
@ -196,7 +196,7 @@ struct StoreProjectManagementView: View {
|
||||
|
||||
/// 项目详情页面,展示项目基础信息、媒体、打卡点和摄影师摘要。
|
||||
struct ProjectDetailView: View {
|
||||
@Environment(ProjectAPI.self) private var projectAPI
|
||||
@Environment(\.projectAPI) private var projectAPI
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
let projectId: Int
|
||||
let storeMode: Bool
|
||||
@ -209,7 +209,7 @@ struct ProjectDetailView: View {
|
||||
if let detail {
|
||||
ProjectDetailContentView(detail: detail)
|
||||
} else if let errorMessage {
|
||||
ContentUnavailableView("加载失败", systemImage: "exclamationmark.triangle", description: Text(errorMessage))
|
||||
AppContentUnavailableView("加载失败", systemImage: "exclamationmark.triangle", description: Text(errorMessage))
|
||||
.frame(minHeight: 360)
|
||||
} else {
|
||||
ProgressView()
|
||||
@ -247,14 +247,14 @@ struct ProjectDetailView: View {
|
||||
/// 摄影师项目编辑页面,支持新建和编辑项目。
|
||||
struct ProjectEditorView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(AccountSnapshotStore.self) private var snapshotStore
|
||||
@Environment(ScenicSpotContext.self) private var scenicSpotContext
|
||||
@Environment(ProjectAPI.self) private var projectAPI
|
||||
@Environment(OSSUploadService.self) private var uploadService
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@Environment(\.accountSnapshotStore) private var snapshotStore
|
||||
@EnvironmentObject private var scenicSpotContext: ScenicSpotContext
|
||||
@Environment(\.projectAPI) private var projectAPI
|
||||
@Environment(\.ossUploadService) private var uploadService
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
let projectId: Int?
|
||||
@State private var viewModel = ProjectEditorViewModel(mode: .create)
|
||||
@StateObject private var viewModel = ProjectEditorViewModel(mode: .create)
|
||||
@State private var coverItem: PhotosPickerItem?
|
||||
@State private var carouselItems: [PhotosPickerItem] = []
|
||||
@State private var loadingDetail = false
|
||||
@ -291,10 +291,10 @@ struct ProjectEditorView: View {
|
||||
.task {
|
||||
await prepare()
|
||||
}
|
||||
.onChange(of: coverItem) { _, newValue in
|
||||
.onChange(of: coverItem) { newValue in
|
||||
Task { await loadCover(newValue) }
|
||||
}
|
||||
.onChange(of: carouselItems) { _, newValue in
|
||||
.onChange(of: carouselItems) { newValue in
|
||||
Task { await loadCarousel(newValue) }
|
||||
}
|
||||
}
|
||||
@ -387,7 +387,7 @@ struct ProjectEditorView: View {
|
||||
defer { loadingDetail = false }
|
||||
do {
|
||||
let detail = try await projectAPI.projectDetail(id: projectId)
|
||||
viewModel = ProjectEditorViewModel(mode: .edit(detail))
|
||||
viewModel.apply(detail)
|
||||
} catch {
|
||||
viewModel.errorMessage = error.localizedDescription
|
||||
}
|
||||
@ -418,12 +418,12 @@ struct ProjectEditorView: View {
|
||||
/// 店铺项目编辑页面,支持新建和编辑店铺项目。
|
||||
struct StoreProjectEditorView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(AccountSnapshotStore.self) private var snapshotStore
|
||||
@Environment(ProjectAPI.self) private var projectAPI
|
||||
@Environment(OSSUploadService.self) private var uploadService
|
||||
@Environment(\.accountSnapshotStore) private var snapshotStore
|
||||
@Environment(\.projectAPI) private var projectAPI
|
||||
@Environment(\.ossUploadService) private var uploadService
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
let projectId: Int?
|
||||
@State private var viewModel = StoreProjectEditorViewModel(mode: .create)
|
||||
@StateObject private var viewModel = StoreProjectEditorViewModel(mode: .create)
|
||||
@State private var coverItem: PhotosPickerItem?
|
||||
@State private var carouselItems: [PhotosPickerItem] = []
|
||||
|
||||
@ -454,10 +454,10 @@ struct StoreProjectEditorView: View {
|
||||
}
|
||||
}
|
||||
.task { await prepare() }
|
||||
.onChange(of: coverItem) { _, newValue in
|
||||
.onChange(of: coverItem) { newValue in
|
||||
Task { viewModel.coverImage = await ProjectPickerLoader.loadImage(from: newValue) }
|
||||
}
|
||||
.onChange(of: carouselItems) { _, newValue in
|
||||
.onChange(of: carouselItems) { newValue in
|
||||
Task { viewModel.carouselImages = await ProjectPickerLoader.loadImages(from: newValue) }
|
||||
}
|
||||
}
|
||||
@ -545,7 +545,7 @@ struct StoreProjectEditorView: View {
|
||||
if let projectId {
|
||||
do {
|
||||
let detail = try await projectAPI.storeManagerProjectDetail(id: projectId)
|
||||
viewModel = StoreProjectEditorViewModel(mode: .edit(detail))
|
||||
viewModel.apply(detail)
|
||||
} catch {
|
||||
viewModel.errorMessage = error.localizedDescription
|
||||
}
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
/// 打卡点服务协议,抽象列表、详情和增删改接口以便单元测试替换。
|
||||
@MainActor
|
||||
@ -29,9 +29,8 @@ protocol PunchPointServing {
|
||||
|
||||
/// 打卡点 API,负责封装旧工程打卡点管理相关接口。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class PunchPointAPI: PunchPointServing {
|
||||
@ObservationIgnored private let client: APIClient
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化打卡点 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
|
||||
@ -6,21 +6,20 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
/// 打卡点列表 ViewModel,负责筛选、分页、详情加载和删除刷新。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class PunchPointListViewModel {
|
||||
var selectedFilter: PunchPointFilter = .all
|
||||
var items: [PunchPointItem] = []
|
||||
var selectedDetail: PunchPointItem?
|
||||
var errorMessage: String?
|
||||
var isLoading = false
|
||||
var isLoadingMore = false
|
||||
var total = 0
|
||||
final class PunchPointListViewModel: ObservableObject {
|
||||
@Published var selectedFilter: PunchPointFilter = .all
|
||||
@Published var items: [PunchPointItem] = []
|
||||
@Published var selectedDetail: PunchPointItem?
|
||||
@Published var errorMessage: String?
|
||||
@Published var isLoading = false
|
||||
@Published var isLoadingMore = false
|
||||
@Published var total = 0
|
||||
|
||||
private var page = 1
|
||||
@Published private var page = 1
|
||||
private let pageSize = 20
|
||||
|
||||
/// 是否还存在下一页数据。
|
||||
@ -121,35 +120,41 @@ final class PunchPointListViewModel {
|
||||
|
||||
/// 打卡点编辑 ViewModel,负责新增、编辑、表单校验和 OSS 图片上传。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class PunchPointEditorViewModel {
|
||||
var name = ""
|
||||
var description = ""
|
||||
var address = ""
|
||||
var latitudeText = ""
|
||||
var longitudeText = ""
|
||||
var scenicSpotText = ""
|
||||
var remoteImages: [String] = []
|
||||
var localImages: [PunchPointLocalImage] = []
|
||||
var errorMessage: String?
|
||||
var isSubmitting = false
|
||||
final class PunchPointEditorViewModel: ObservableObject {
|
||||
@Published var name = ""
|
||||
@Published var description = ""
|
||||
@Published var address = ""
|
||||
@Published var latitudeText = ""
|
||||
@Published var longitudeText = ""
|
||||
@Published var scenicSpotText = ""
|
||||
@Published var remoteImages: [String] = []
|
||||
@Published var localImages: [PunchPointLocalImage] = []
|
||||
@Published var errorMessage: String?
|
||||
@Published var isSubmitting = false
|
||||
|
||||
let editingItem: PunchPointItem?
|
||||
private(set) var editingItem: PunchPointItem?
|
||||
|
||||
/// 初始化编辑 ViewModel,可传入已有打卡点作为编辑表单初值。
|
||||
init(item: PunchPointItem? = nil) {
|
||||
editingItem = item
|
||||
if let item {
|
||||
name = item.name
|
||||
description = item.description
|
||||
address = item.region?.address ?? ""
|
||||
latitudeText = item.region.map { String($0.lat) } ?? ""
|
||||
longitudeText = item.region.map { String($0.lot) } ?? ""
|
||||
scenicSpotText = item.scenicSpotStr
|
||||
remoteImages = item.guideImages
|
||||
apply(item)
|
||||
}
|
||||
}
|
||||
|
||||
/// 回填编辑详情,保留同一个 ObservableObject 实例。
|
||||
func apply(_ item: PunchPointItem) {
|
||||
editingItem = item
|
||||
name = item.name
|
||||
description = item.description
|
||||
address = item.region?.address ?? ""
|
||||
latitudeText = item.region.map { String($0.lat) } ?? ""
|
||||
longitudeText = item.region.map { String($0.lot) } ?? ""
|
||||
scenicSpotText = item.scenicSpotStr
|
||||
remoteImages = item.guideImages
|
||||
localImages = []
|
||||
}
|
||||
|
||||
/// 设置经纬度和地址,通常由定位或地图选点回填。
|
||||
func applyLocation(latitude: Double, longitude: Double, address: String) {
|
||||
latitudeText = String(format: "%.6f", latitude)
|
||||
|
||||
@ -11,15 +11,15 @@ import UIKit
|
||||
|
||||
/// 打卡点列表页面,展示当前景区下的打卡点并提供新增入口。
|
||||
struct PunchPointListView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(AccountContextAPI.self) private var accountContextAPI
|
||||
@Environment(PunchPointAPI.self) private var punchPointAPI
|
||||
@Environment(RouterPath.self) private var router
|
||||
@Environment(ScenicSpotContext.self) private var scenicSpotContext
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@Environment(\.accountContextAPI) private var accountContextAPI
|
||||
@Environment(\.punchPointAPI) private var punchPointAPI
|
||||
@EnvironmentObject private var router: RouterPath
|
||||
@EnvironmentObject private var scenicSpotContext: ScenicSpotContext
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
|
||||
@State private var viewModel = PunchPointListViewModel()
|
||||
@StateObject private var viewModel = PunchPointListViewModel()
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
@ -58,7 +58,7 @@ struct PunchPointListView: View {
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.onChange(of: viewModel.selectedFilter) { _, newValue in
|
||||
.onChange(of: viewModel.selectedFilter) { newValue in
|
||||
Task {
|
||||
await globalLoading.withOptionalLoading(currentScenicId != nil) {
|
||||
await viewModel.selectFilter(newValue, scenicId: currentScenicId, api: punchPointAPI)
|
||||
@ -116,15 +116,15 @@ struct PunchPointDetailView: View {
|
||||
let punchPointId: Int
|
||||
let summary: PunchPointItem?
|
||||
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(AccountContextAPI.self) private var accountContextAPI
|
||||
@Environment(PunchPointAPI.self) private var punchPointAPI
|
||||
@Environment(RouterPath.self) private var router
|
||||
@Environment(ScenicSpotContext.self) private var scenicSpotContext
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@Environment(\.accountContextAPI) private var accountContextAPI
|
||||
@Environment(\.punchPointAPI) private var punchPointAPI
|
||||
@EnvironmentObject private var router: RouterPath
|
||||
@EnvironmentObject private var scenicSpotContext: ScenicSpotContext
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
|
||||
@State private var viewModel = PunchPointListViewModel()
|
||||
@StateObject private var viewModel = PunchPointListViewModel()
|
||||
@State private var showDeleteConfirm = false
|
||||
|
||||
var item: PunchPointItem? {
|
||||
@ -275,17 +275,17 @@ struct PunchPointDetailView: View {
|
||||
struct PunchPointEditorView: View {
|
||||
let punchPointId: Int?
|
||||
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(AccountContextAPI.self) private var accountContextAPI
|
||||
@Environment(PunchPointAPI.self) private var punchPointAPI
|
||||
@Environment(OSSUploadService.self) private var uploadService
|
||||
@Environment(ScenicSpotContext.self) private var scenicSpotContext
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@Environment(\.accountContextAPI) private var accountContextAPI
|
||||
@Environment(\.punchPointAPI) private var punchPointAPI
|
||||
@Environment(\.ossUploadService) private var uploadService
|
||||
@EnvironmentObject private var scenicSpotContext: ScenicSpotContext
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
|
||||
@State private var detailLoader = PunchPointListViewModel()
|
||||
@State private var viewModel = PunchPointEditorViewModel()
|
||||
@StateObject private var detailLoader = PunchPointListViewModel()
|
||||
@StateObject private var viewModel = PunchPointEditorViewModel()
|
||||
@State private var locationProvider = ForegroundLocationProvider()
|
||||
@State private var selectedItems: [PhotosPickerItem] = []
|
||||
|
||||
@ -308,11 +308,11 @@ struct PunchPointEditorView: View {
|
||||
await globalLoading.withOptionalLoading(detailLoader.selectedDetail == nil) {
|
||||
await detailLoader.loadDetail(id: punchPointId, api: punchPointAPI)
|
||||
if let detail = detailLoader.selectedDetail {
|
||||
viewModel = PunchPointEditorViewModel(item: detail)
|
||||
viewModel.apply(detail)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: selectedItems) { _, items in
|
||||
.onChange(of: selectedItems) { items in
|
||||
Task { await loadPickedImages(items) }
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
/// 排队管理服务协议,定义列表、动作、设置、二维码和实时 token 能力。
|
||||
@MainActor
|
||||
@ -26,10 +26,9 @@ protocol ScenicQueueServing: AnyObject {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 排队管理 API,封装 `/api/app/scenic-queue` 下的排队接口。
|
||||
final class ScenicQueueAPI {
|
||||
@ObservationIgnored private let client: APIClient
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化排队管理 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
|
||||
@ -6,44 +6,43 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
import Photos
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 排队管理列表 ViewModel,负责打卡点门禁、列表分页、动作和实时事件。
|
||||
final class QueueManagementViewModel {
|
||||
var loading = false
|
||||
var loadingMore = false
|
||||
var items: [QueueItem] = []
|
||||
var scenicSpots: [ScenicSpotItem] = []
|
||||
var selectedSpotId: Int?
|
||||
var selectedListType: QueueListType = .queueing
|
||||
var queueCount = 0
|
||||
var avgWaitMin = 0.0
|
||||
var totalCount = 0
|
||||
var hasMore = false
|
||||
var page = 1
|
||||
var lastSyncTimeText = "--"
|
||||
var queueGatePassed = false
|
||||
var selectedSpotName = "--"
|
||||
var showStartShootingButton = true
|
||||
var autoCallAheadCount = 0
|
||||
var quickCallButtonEnabled = false
|
||||
var prepareCallButtonEnabled = false
|
||||
var remoteCallAnnouncement: ScenicQueueRemoteCallAnnouncement?
|
||||
var errorMessage: String?
|
||||
final class QueueManagementViewModel: ObservableObject {
|
||||
@Published var loading = false
|
||||
@Published var loadingMore = false
|
||||
@Published var items: [QueueItem] = []
|
||||
@Published var scenicSpots: [ScenicSpotItem] = []
|
||||
@Published var selectedSpotId: Int?
|
||||
@Published var selectedListType: QueueListType = .queueing
|
||||
@Published var queueCount = 0
|
||||
@Published var avgWaitMin = 0.0
|
||||
@Published var totalCount = 0
|
||||
@Published var hasMore = false
|
||||
@Published var page = 1
|
||||
@Published var lastSyncTimeText = "--"
|
||||
@Published var queueGatePassed = false
|
||||
@Published var selectedSpotName = "--"
|
||||
@Published var showStartShootingButton = true
|
||||
@Published var autoCallAheadCount = 0
|
||||
@Published var quickCallButtonEnabled = false
|
||||
@Published var prepareCallButtonEnabled = false
|
||||
@Published var remoteCallAnnouncement: ScenicQueueRemoteCallAnnouncement?
|
||||
@Published var errorMessage: String?
|
||||
|
||||
@ObservationIgnored private let pageSize = 10
|
||||
@ObservationIgnored private let socketClient = ScenicQueueSocketClient()
|
||||
@ObservationIgnored private var realtimeSpotId: Int?
|
||||
@ObservationIgnored private var realtimeScenicId: Int?
|
||||
@ObservationIgnored private var currentUserId: String?
|
||||
@ObservationIgnored private var currentScenicId: Int?
|
||||
@ObservationIgnored private var pendingRemoteCalledRecordIds = Set<Int64>()
|
||||
@ObservationIgnored private var handledRemoteCallEventIds = Set<String>()
|
||||
private let pageSize = 10
|
||||
private let socketClient = ScenicQueueSocketClient()
|
||||
@Published private var realtimeSpotId: Int?
|
||||
@Published private var realtimeScenicId: Int?
|
||||
@Published private var currentUserId: String?
|
||||
@Published private var currentScenicId: Int?
|
||||
@Published private var pendingRemoteCalledRecordIds = Set<Int64>()
|
||||
@Published private var handledRemoteCallEventIds = Set<String>()
|
||||
|
||||
/// 待处理排队数量。
|
||||
var pendingCount: Int {
|
||||
@ -417,41 +416,40 @@ final class QueueManagementViewModel {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 排队设置 ViewModel,负责设置读取、校验、保存、二维码和日志。
|
||||
final class ScenicQueueSettingsViewModel {
|
||||
var loading = false
|
||||
var message: String?
|
||||
var scenicSpots: [ScenicSpotItem] = []
|
||||
var selectedSpotId: Int?
|
||||
var photoEstimateMin = "0"
|
||||
var photoEstimateSec = "30"
|
||||
var firstAhead = "0"
|
||||
var firstSms = false
|
||||
var firstPhone = false
|
||||
var secondAhead = "0"
|
||||
var secondSms = false
|
||||
var secondPhone = false
|
||||
var broadcastIntervalSec = "50"
|
||||
var countdownThresholdSec = "15"
|
||||
var maxQueueRangeKm = "0.00"
|
||||
var localQueueLimit = "0"
|
||||
var localPassDelay = "1"
|
||||
var showStartShootingButton = true
|
||||
var autoCallAheadCount = "0"
|
||||
var queueOpen = true
|
||||
var businessStartTime = ScenicQueueSettingsViewModel.businessTime(hour: 10, minute: 0)
|
||||
var businessEndTime = ScenicQueueSettingsViewModel.businessTime(hour: 20, minute: 0)
|
||||
var customTtsText = ""
|
||||
var presetVoices: [String] = []
|
||||
var quickCallButtonEnabled = false
|
||||
var prepareCallButtonEnabled = false
|
||||
var logs: [ScenicQueueSettingChangeLogItem] = []
|
||||
var qrcodeURL = ""
|
||||
final class ScenicQueueSettingsViewModel: ObservableObject {
|
||||
@Published var loading = false
|
||||
@Published var message: String?
|
||||
@Published var scenicSpots: [ScenicSpotItem] = []
|
||||
@Published var selectedSpotId: Int?
|
||||
@Published var photoEstimateMin = "0"
|
||||
@Published var photoEstimateSec = "30"
|
||||
@Published var firstAhead = "0"
|
||||
@Published var firstSms = false
|
||||
@Published var firstPhone = false
|
||||
@Published var secondAhead = "0"
|
||||
@Published var secondSms = false
|
||||
@Published var secondPhone = false
|
||||
@Published var broadcastIntervalSec = "50"
|
||||
@Published var countdownThresholdSec = "15"
|
||||
@Published var maxQueueRangeKm = "0.00"
|
||||
@Published var localQueueLimit = "0"
|
||||
@Published var localPassDelay = "1"
|
||||
@Published var showStartShootingButton = true
|
||||
@Published var autoCallAheadCount = "0"
|
||||
@Published var queueOpen = true
|
||||
@Published var businessStartTime = ScenicQueueSettingsViewModel.businessTime(hour: 10, minute: 0)
|
||||
@Published var businessEndTime = ScenicQueueSettingsViewModel.businessTime(hour: 20, minute: 0)
|
||||
@Published var customTtsText = ""
|
||||
@Published var presetVoices: [String] = []
|
||||
@Published var quickCallButtonEnabled = false
|
||||
@Published var prepareCallButtonEnabled = false
|
||||
@Published var logs: [ScenicQueueSettingChangeLogItem] = []
|
||||
@Published var qrcodeURL = ""
|
||||
|
||||
@ObservationIgnored private let speaker = ScenicQueueSpeechService()
|
||||
@ObservationIgnored private var currentUserId: String?
|
||||
@ObservationIgnored private var currentScenicId: Int?
|
||||
private let speaker = ScenicQueueSpeechService()
|
||||
@Published private var currentUserId: String?
|
||||
@Published private var currentScenicId: Int?
|
||||
|
||||
/// 加载设置。设置页允许自动选中第一个打卡点。
|
||||
func load(api: any ScenicQueueServing, scenicId: Int?, userId: String?, spots: [ScenicSpotItem]) async {
|
||||
|
||||
@ -9,12 +9,12 @@ import SwiftUI
|
||||
|
||||
/// 排队管理首页,展示当前打卡点队列和操作入口。
|
||||
struct QueueManagementView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(ScenicSpotContext.self) private var scenicSpotContext
|
||||
@Environment(ScenicQueueAPI.self) private var queueAPI
|
||||
@Environment(ScenicQueueRuntime.self) private var queueRuntime
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@State private var viewModel = QueueManagementViewModel()
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@EnvironmentObject private var scenicSpotContext: ScenicSpotContext
|
||||
@Environment(\.scenicQueueAPI) private var queueAPI
|
||||
@EnvironmentObject private var queueRuntime: ScenicQueueRuntime
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@StateObject private var viewModel = QueueManagementViewModel()
|
||||
@State private var pendingAction: QueueActionConfirmation?
|
||||
@State private var markTarget: QueueItem?
|
||||
@State private var processingId: Int64?
|
||||
@ -27,7 +27,7 @@ struct QueueManagementView: View {
|
||||
VStack(spacing: AppMetrics.Spacing.medium) {
|
||||
header
|
||||
if currentScenicId == nil {
|
||||
ContentUnavailableView("请先选择景区", systemImage: "map", description: Text("排队管理需要当前景区上下文"))
|
||||
AppContentUnavailableView("请先选择景区", systemImage: "map", description: Text("排队管理需要当前景区上下文"))
|
||||
.frame(minHeight: 260)
|
||||
} else if !viewModel.queueGatePassed {
|
||||
gateCard
|
||||
@ -79,12 +79,12 @@ struct QueueManagementView: View {
|
||||
.refreshable {
|
||||
await reload()
|
||||
}
|
||||
.onChange(of: viewModel.remoteCallAnnouncement) { _, announcement in
|
||||
.onChange(of: viewModel.remoteCallAnnouncement) { announcement in
|
||||
if let announcement {
|
||||
toastCenter.show("远端已叫号 \(announcement.queueCode.isEmpty ? "当前游客" : announcement.queueCode)")
|
||||
}
|
||||
}
|
||||
.onChange(of: viewModel.errorMessage) { _, message in
|
||||
.onChange(of: viewModel.errorMessage) { message in
|
||||
if let message { toastCenter.show(message) }
|
||||
}
|
||||
}
|
||||
@ -163,7 +163,7 @@ struct QueueManagementView: View {
|
||||
ProgressView("加载中...")
|
||||
.frame(maxWidth: .infinity, minHeight: 240)
|
||||
} else if viewModel.items.isEmpty {
|
||||
ContentUnavailableView(viewModel.selectedListType.emptyText, systemImage: "person.crop.circle.badge.questionmark")
|
||||
AppContentUnavailableView(viewModel.selectedListType.emptyText, systemImage: "person.crop.circle.badge.questionmark")
|
||||
.frame(maxWidth: .infinity, minHeight: 240)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||||
} else {
|
||||
|
||||
@ -9,12 +9,12 @@ import SwiftUI
|
||||
|
||||
/// 排队设置页面。
|
||||
struct ScenicQueueSettingsView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(ScenicSpotContext.self) private var scenicSpotContext
|
||||
@Environment(ScenicQueueAPI.self) private var queueAPI
|
||||
@Environment(ScenicQueueRuntime.self) private var queueRuntime
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@State private var viewModel = ScenicQueueSettingsViewModel()
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@EnvironmentObject private var scenicSpotContext: ScenicSpotContext
|
||||
@Environment(\.scenicQueueAPI) private var queueAPI
|
||||
@EnvironmentObject private var queueRuntime: ScenicQueueRuntime
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@StateObject private var viewModel = ScenicQueueSettingsViewModel()
|
||||
@State private var selectedTab: ScenicQueueSettingsTab = .basic
|
||||
@State private var qrcodeURL: String?
|
||||
@State private var saving = false
|
||||
@ -75,7 +75,7 @@ struct ScenicQueueSettingsView: View {
|
||||
await viewModel.load(api: queueAPI, scenicId: currentScenicId, userId: currentUserId, spots: scenicSpotContext.spots)
|
||||
await viewModel.loadSettingChangeLogs(api: queueAPI, scenicId: currentScenicId)
|
||||
}
|
||||
.onChange(of: viewModel.message) { _, message in
|
||||
.onChange(of: viewModel.message) { message in
|
||||
if let message { toastCenter.show(message) }
|
||||
}
|
||||
}
|
||||
@ -106,7 +106,7 @@ struct ScenicQueueSettingsView: View {
|
||||
DatePicker("营业开始", selection: $viewModel.businessStartTime, displayedComponents: .hourAndMinute)
|
||||
DatePicker("营业结束", selection: $viewModel.businessEndTime, displayedComponents: .hourAndMinute)
|
||||
field("最大排队范围", text: $viewModel.maxQueueRangeKm, keyboard: .decimalPad, unit: "公里")
|
||||
.onChange(of: viewModel.maxQueueRangeKm) { _, value in viewModel.updateMaxQueueRange(value) }
|
||||
.onChange(of: viewModel.maxQueueRangeKm) { value in viewModel.updateMaxQueueRange(value) }
|
||||
field("排队次数限制", text: $viewModel.localQueueLimit, keyboard: .numberPad, unit: "次")
|
||||
field("过号顺延", text: $viewModel.localPassDelay, keyboard: .numberPad, unit: "位")
|
||||
field("拍摄分钟", text: $viewModel.photoEstimateMin, keyboard: .numberPad, unit: "分")
|
||||
@ -192,7 +192,7 @@ struct ScenicQueueSettingsView: View {
|
||||
}
|
||||
}
|
||||
if viewModel.logs.isEmpty {
|
||||
ContentUnavailableView("暂无配置日志", systemImage: "clock.arrow.circlepath")
|
||||
AppContentUnavailableView("暂无配置日志", systemImage: "clock.arrow.circlepath")
|
||||
.frame(maxWidth: .infinity, minHeight: 180)
|
||||
} else {
|
||||
ForEach(viewModel.logs) { log in
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
/// 景区权限模块服务协议,定义景区选择、权限申请和景区申请所需接口。
|
||||
@MainActor
|
||||
@ -34,10 +34,9 @@ protocol ScenicPermissionServing {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 景区权限 API,封装景区选择和申请相关网络请求。
|
||||
final class ScenicPermissionAPI {
|
||||
@ObservationIgnored private let client: APIClient
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化景区权限 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
/// 权限申请角色选项实体,表示可申请的一个业务角色。
|
||||
struct PermissionRoleOption: Equatable, Identifiable {
|
||||
@ -31,21 +31,20 @@ struct ExistingRolePermissionInfo: Equatable, Identifiable {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 权限申请 ViewModel,负责角色/景区选择和提交审核。
|
||||
final class PermissionApplyViewModel {
|
||||
var roleOptions: [PermissionRoleOption] = []
|
||||
var selectedRoleId: Int?
|
||||
var scenicOptions: [PermissionScenicOption] = []
|
||||
var loadingScenics = false
|
||||
var scenicLoadFailed = false
|
||||
var scenicLoadFailureReason: String?
|
||||
var submitting = false
|
||||
var message: String?
|
||||
final class PermissionApplyViewModel: ObservableObject {
|
||||
@Published var roleOptions: [PermissionRoleOption] = []
|
||||
@Published var selectedRoleId: Int?
|
||||
@Published var scenicOptions: [PermissionScenicOption] = []
|
||||
@Published var loadingScenics = false
|
||||
@Published var scenicLoadFailed = false
|
||||
@Published var scenicLoadFailureReason: String?
|
||||
@Published var submitting = false
|
||||
@Published var message: String?
|
||||
|
||||
private let initialPending: RoleApplyPendingResponse?
|
||||
private var rolePermissions: [RolePermissionResponse] = []
|
||||
private var selectedIds: Set<Int> = []
|
||||
@Published private var rolePermissions: [RolePermissionResponse] = []
|
||||
@Published private var selectedIds: Set<Int> = []
|
||||
|
||||
/// 初始化权限申请 ViewModel,可传入驳回申请用于编辑回填。
|
||||
init(initialPending: RoleApplyPendingResponse? = nil) {
|
||||
@ -181,13 +180,12 @@ final class PermissionApplyViewModel {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 权限申请状态 ViewModel,负责读取审核中的或指定编号的权限申请。
|
||||
final class PermissionApplyStatusViewModel {
|
||||
var loading = false
|
||||
var pending: RoleApplyPendingResponse?
|
||||
var loadFailed = false
|
||||
var loadFailureReason: String?
|
||||
final class PermissionApplyStatusViewModel: ObservableObject {
|
||||
@Published var loading = false
|
||||
@Published var pending: RoleApplyPendingResponse?
|
||||
@Published var loadFailed = false
|
||||
@Published var loadFailureReason: String?
|
||||
|
||||
/// 加载权限申请状态;有申请编号时优先匹配编号,否则展示审核中或驳回记录。
|
||||
func load(api: any ScenicPermissionServing, applyCode: String?) async {
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
/// 景区申请本地图片草稿实体,表示尚未或正在上传的图片。
|
||||
struct ScenicApplicationImageDraft: Equatable, Identifiable {
|
||||
@ -29,27 +29,26 @@ struct ScenicApplicationImageDraft: Equatable, Identifiable {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 景区申请 ViewModel,负责新增景区申请表单、图片上传和待审核记录回填。
|
||||
final class ScenicApplicationViewModel {
|
||||
var scenicName = ""
|
||||
var imageURLs = ""
|
||||
var localImages: [ScenicApplicationImageDraft] = []
|
||||
var selectedProvince = ""
|
||||
var selectedCity = ""
|
||||
var coopType = 1
|
||||
var remark = ""
|
||||
var agreed = false
|
||||
var submitting = false
|
||||
var message: String?
|
||||
var provinces: [ScenicAreaNode] = []
|
||||
var cities: [ScenicAreaNode] = []
|
||||
var pending: ScenicApplicationPendingResponse?
|
||||
var loading = false
|
||||
var loadFailed = false
|
||||
var loadFailureReason: String?
|
||||
var pendingLoadFailed = false
|
||||
var pendingLoadFailureReason: String?
|
||||
final class ScenicApplicationViewModel: ObservableObject {
|
||||
@Published var scenicName = ""
|
||||
@Published var imageURLs = ""
|
||||
@Published var localImages: [ScenicApplicationImageDraft] = []
|
||||
@Published var selectedProvince = ""
|
||||
@Published var selectedCity = ""
|
||||
@Published var coopType = 1
|
||||
@Published var remark = ""
|
||||
@Published var agreed = false
|
||||
@Published var submitting = false
|
||||
@Published var message: String?
|
||||
@Published var provinces: [ScenicAreaNode] = []
|
||||
@Published var cities: [ScenicAreaNode] = []
|
||||
@Published var pending: ScenicApplicationPendingResponse?
|
||||
@Published var loading = false
|
||||
@Published var loadFailed = false
|
||||
@Published var loadFailureReason: String?
|
||||
@Published var pendingLoadFailed = false
|
||||
@Published var pendingLoadFailureReason: String?
|
||||
|
||||
/// 返回当前申请是否只读;审核中的申请不可再次编辑提交。
|
||||
var isReadOnly: Bool {
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
|
||||
import CoreLocation
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
/// 景区选择展示实体,表示列表中的一个可切换景区。
|
||||
struct ScenicSelectionItem: Equatable, Identifiable {
|
||||
@ -51,13 +51,12 @@ struct ScenicSelectionItem: Equatable, Identifiable {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 景区选择 ViewModel,负责搜索、定位距离计算和切换景区持久化。
|
||||
final class ScenicSelectionViewModel {
|
||||
var searchQuery = ""
|
||||
var currentLocationText = "定位后获取最近景区"
|
||||
var locationWarning: String?
|
||||
private(set) var items: [ScenicSelectionItem] = []
|
||||
final class ScenicSelectionViewModel: ObservableObject {
|
||||
@Published var searchQuery = ""
|
||||
@Published var currentLocationText = "定位后获取最近景区"
|
||||
@Published var locationWarning: String?
|
||||
@Published private(set) var items: [ScenicSelectionItem] = []
|
||||
|
||||
/// 返回按搜索关键词过滤后的景区列表。
|
||||
var filteredItems: [ScenicSelectionItem] {
|
||||
|
||||
@ -9,8 +9,8 @@ import SwiftUI
|
||||
|
||||
/// 权限申请状态页面,展示审核中、通过或驳回的角色景区权限申请。
|
||||
struct PermissionApplyStatusView: View {
|
||||
@Environment(ScenicPermissionAPI.self) private var scenicPermissionAPI
|
||||
@State private var viewModel = PermissionApplyStatusViewModel()
|
||||
@Environment(\.scenicPermissionAPI) private var scenicPermissionAPI
|
||||
@StateObject private var viewModel = PermissionApplyStatusViewModel()
|
||||
@State private var editingPending: RoleApplyPendingResponse?
|
||||
let applyCode: String?
|
||||
|
||||
@ -27,7 +27,7 @@ struct PermissionApplyStatusView: View {
|
||||
roleCard(pending)
|
||||
scenicCard(pending)
|
||||
} else if viewModel.loadFailed {
|
||||
ContentUnavailableView(
|
||||
AppContentUnavailableView(
|
||||
"申请状态加载失败",
|
||||
systemImage: "exclamationmark.triangle",
|
||||
description: Text(viewModel.loadFailureReason ?? "请检查网络后重试")
|
||||
@ -38,7 +38,7 @@ struct PermissionApplyStatusView: View {
|
||||
.font(.system(size: AppMetrics.FontSize.footnote, weight: .semibold))
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
ContentUnavailableView("暂无审核中的申请", systemImage: "doc.text.magnifyingglass")
|
||||
AppContentUnavailableView("暂无审核中的申请", systemImage: "doc.text.magnifyingglass")
|
||||
}
|
||||
}
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
@ -57,7 +57,7 @@ struct PermissionApplyStatusView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationDestination(item: $editingPending) { pending in
|
||||
.appNavigationDestination(item: $editingPending) { pending in
|
||||
PermissionApplyView(initialPending: pending)
|
||||
}
|
||||
.task {
|
||||
|
||||
@ -9,14 +9,14 @@ import SwiftUI
|
||||
|
||||
/// 权限申请页面,支持申请某角色在更多景区下的权限。
|
||||
struct PermissionApplyView: View {
|
||||
@Environment(PermissionContext.self) private var permissionContext
|
||||
@Environment(ScenicPermissionAPI.self) private var scenicPermissionAPI
|
||||
@State private var viewModel: PermissionApplyViewModel
|
||||
@EnvironmentObject private var permissionContext: PermissionContext
|
||||
@Environment(\.scenicPermissionAPI) private var scenicPermissionAPI
|
||||
@StateObject private var viewModel: PermissionApplyViewModel
|
||||
@State private var activePicker: PermissionPickerSheet?
|
||||
|
||||
/// 初始化权限申请页面,可传入驳回记录用于编辑。
|
||||
init(initialPending: RoleApplyPendingResponse? = nil) {
|
||||
_viewModel = State(initialValue: PermissionApplyViewModel(initialPending: initialPending))
|
||||
_viewModel = StateObject(wrappedValue: PermissionApplyViewModel(initialPending: initialPending))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
|
||||
@ -10,9 +10,9 @@ import SwiftUI
|
||||
|
||||
/// 景区申请页面,支持新增景区入驻资料、图片上传和审核状态回填。
|
||||
struct ScenicApplicationView: View {
|
||||
@Environment(ScenicPermissionAPI.self) private var scenicPermissionAPI
|
||||
@Environment(OSSUploadService.self) private var ossUploadService
|
||||
@State private var viewModel = ScenicApplicationViewModel()
|
||||
@Environment(\.scenicPermissionAPI) private var scenicPermissionAPI
|
||||
@Environment(\.ossUploadService) private var ossUploadService
|
||||
@StateObject private var viewModel = ScenicApplicationViewModel()
|
||||
@State private var pickedImageItems: [PhotosPickerItem] = []
|
||||
@State private var activeLocationPicker: ScenicLocationPicker?
|
||||
|
||||
@ -21,7 +21,7 @@ struct ScenicApplicationView: View {
|
||||
ScrollView {
|
||||
VStack(spacing: AppMetrics.Spacing.medium) {
|
||||
if viewModel.loadFailed {
|
||||
ContentUnavailableView(
|
||||
AppContentUnavailableView(
|
||||
"景区申请初始化失败",
|
||||
systemImage: "exclamationmark.triangle",
|
||||
description: Text(viewModel.loadFailureReason ?? "请检查网络后重试")
|
||||
@ -48,7 +48,7 @@ struct ScenicApplicationView: View {
|
||||
.task {
|
||||
await viewModel.loadInitial(api: scenicPermissionAPI)
|
||||
}
|
||||
.onChange(of: pickedImageItems) { _, items in
|
||||
.onChange(of: pickedImageItems) { items in
|
||||
guard !items.isEmpty else { return }
|
||||
Task {
|
||||
await importPickedImages(items)
|
||||
@ -111,7 +111,7 @@ struct ScenicApplicationView: View {
|
||||
locationSection
|
||||
coopTypeSection
|
||||
inputField("备注信息 (\(viewModel.remark.count)/50)", text: $viewModel.remark, axis: .vertical, disabled: viewModel.isReadOnly)
|
||||
.onChange(of: viewModel.remark) { _, value in
|
||||
.onChange(of: viewModel.remark) { value in
|
||||
if value.count > 50 {
|
||||
viewModel.remark = String(value.prefix(50))
|
||||
}
|
||||
|
||||
@ -10,12 +10,12 @@ import SwiftUI
|
||||
|
||||
/// 景区选择页面,支持搜索、定位距离展示和切换当前景区。
|
||||
struct ScenicSelectionView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(PermissionContext.self) private var permissionContext
|
||||
@Environment(AccountSnapshotStore.self) private var snapshotStore
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@EnvironmentObject private var permissionContext: PermissionContext
|
||||
@Environment(\.accountSnapshotStore) private var snapshotStore
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var viewModel = ScenicSelectionViewModel()
|
||||
@StateObject private var viewModel = ScenicSelectionViewModel()
|
||||
@State private var locationProvider = ScenicSelectionLocationProvider()
|
||||
|
||||
var body: some View {
|
||||
@ -109,7 +109,7 @@ struct ScenicSelectionView: View {
|
||||
private var content: some View {
|
||||
ScrollView {
|
||||
if viewModel.filteredItems.isEmpty {
|
||||
ContentUnavailableView(
|
||||
AppContentUnavailableView(
|
||||
"暂无可选景区",
|
||||
systemImage: "mountain.2",
|
||||
description: Text("可尝试清空搜索关键词或刷新定位。")
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
/// 景区结算服务协议,定义结算申请提交能力。
|
||||
@MainActor
|
||||
@ -16,10 +16,9 @@ protocol ScenicSettlementServing {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 景区结算 API,封装结算申请提交网络请求。
|
||||
final class ScenicSettlementAPI {
|
||||
@ObservationIgnored private let client: APIClient
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化景区结算 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
|
||||
@ -6,24 +6,23 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 景区结算申请 ViewModel,管理可申请景区、多选、金额备注和提交。
|
||||
final class ScenicSettlementViewModel {
|
||||
var existingScenics: [BusinessScope] = []
|
||||
var options: [ScenicSettlementOption] = []
|
||||
var amountText = ""
|
||||
var remarkText = ""
|
||||
var isLoading = false
|
||||
var isSubmitting = false
|
||||
var loadFailed = false
|
||||
var loadFailureReason: String?
|
||||
var message: String?
|
||||
final class ScenicSettlementViewModel: ObservableObject {
|
||||
@Published var existingScenics: [BusinessScope] = []
|
||||
@Published var options: [ScenicSettlementOption] = []
|
||||
@Published var amountText = ""
|
||||
@Published var remarkText = ""
|
||||
@Published var isLoading = false
|
||||
@Published var isSubmitting = false
|
||||
@Published var loadFailed = false
|
||||
@Published var loadFailureReason: String?
|
||||
@Published var message: String?
|
||||
|
||||
private var selectedIds = Set<Int>()
|
||||
@Published private var selectedIds = Set<Int>()
|
||||
|
||||
/// 已选择景区数量。
|
||||
var selectedCount: Int {
|
||||
@ -176,16 +175,15 @@ enum ScenicSettlementAmountValidation: Equatable {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 景区结算审核 ViewModel,聚合景区申请和权限申请审核记录。
|
||||
final class ScenicSettlementReviewViewModel {
|
||||
var scenicApplications: [ScenicApplicationPendingResponse] = []
|
||||
var roleApplications: [RoleApplyPendingResponse] = []
|
||||
var isLoading = false
|
||||
var message: String?
|
||||
var loadFailedAll = false
|
||||
var scenicLoadFailed = false
|
||||
var roleLoadFailed = false
|
||||
final class ScenicSettlementReviewViewModel: ObservableObject {
|
||||
@Published var scenicApplications: [ScenicApplicationPendingResponse] = []
|
||||
@Published var roleApplications: [RoleApplyPendingResponse] = []
|
||||
@Published var isLoading = false
|
||||
@Published var message: String?
|
||||
@Published var loadFailedAll = false
|
||||
@Published var scenicLoadFailed = false
|
||||
@Published var roleLoadFailed = false
|
||||
|
||||
/// 待审核记录数量。
|
||||
var pendingCount: Int {
|
||||
|
||||
@ -9,11 +9,11 @@ import SwiftUI
|
||||
|
||||
/// 景区结算申请页,支持选择未开通结算的景区并提交金额。
|
||||
struct ScenicSettlementView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(ScenicPermissionAPI.self) private var scenicPermissionAPI
|
||||
@Environment(ScenicSettlementAPI.self) private var scenicSettlementAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@State private var viewModel = ScenicSettlementViewModel()
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@Environment(\.scenicPermissionAPI) private var scenicPermissionAPI
|
||||
@Environment(\.scenicSettlementAPI) private var scenicSettlementAPI
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@StateObject private var viewModel = ScenicSettlementViewModel()
|
||||
@State private var showScenicApplication = false
|
||||
|
||||
var body: some View {
|
||||
@ -45,7 +45,7 @@ struct ScenicSettlementView: View {
|
||||
ScenicApplicationView()
|
||||
}
|
||||
}
|
||||
.onChange(of: viewModel.message) { _, message in
|
||||
.onChange(of: viewModel.message) { message in
|
||||
guard let message else { return }
|
||||
toastCenter.show(message)
|
||||
viewModel.message = nil
|
||||
@ -92,7 +92,7 @@ struct ScenicSettlementView: View {
|
||||
.frame(maxWidth: .infinity, minHeight: 120)
|
||||
} else if viewModel.loadFailed {
|
||||
VStack(spacing: AppMetrics.Spacing.xSmall) {
|
||||
ContentUnavailableView(
|
||||
AppContentUnavailableView(
|
||||
"可申请景区加载失败",
|
||||
systemImage: "exclamationmark.triangle",
|
||||
description: Text(viewModel.loadFailureReason ?? "请检查网络后重试")
|
||||
@ -195,9 +195,9 @@ struct ScenicSettlementView: View {
|
||||
|
||||
/// 景区结算审核记录页,展示景区申请和权限申请审核状态。
|
||||
struct ScenicSettlementReviewView: View {
|
||||
@Environment(ScenicPermissionAPI.self) private var scenicPermissionAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@State private var viewModel = ScenicSettlementReviewViewModel()
|
||||
@Environment(\.scenicPermissionAPI) private var scenicPermissionAPI
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@StateObject private var viewModel = ScenicSettlementReviewViewModel()
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
@ -221,7 +221,7 @@ struct ScenicSettlementReviewView: View {
|
||||
.refreshable {
|
||||
await viewModel.load(api: scenicPermissionAPI)
|
||||
}
|
||||
.onChange(of: viewModel.message) { _, message in
|
||||
.onChange(of: viewModel.message) { message in
|
||||
guard let message else { return }
|
||||
toastCenter.show(message)
|
||||
viewModel.message = nil
|
||||
@ -230,7 +230,7 @@ struct ScenicSettlementReviewView: View {
|
||||
|
||||
private var fullFailureView: some View {
|
||||
VStack(spacing: AppMetrics.Spacing.small) {
|
||||
ContentUnavailableView(
|
||||
AppContentUnavailableView(
|
||||
"审核记录加载失败",
|
||||
systemImage: "exclamationmark.triangle",
|
||||
description: Text("景区申请与权限申请均未加载成功,请检查网络后重试")
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
/// 排班服务协议,抽象排班列表、新增、删除和可关联订单接口。
|
||||
@MainActor
|
||||
@ -29,9 +29,8 @@ protocol ScheduleServing {
|
||||
|
||||
/// 排班 API,封装摄影师排班相关接口。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class ScheduleAPI: ScheduleServing {
|
||||
@ObservationIgnored private let client: APIClient
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化排班 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
/// 排班错误实体,表示表单校验失败。
|
||||
enum ScheduleValidationError: LocalizedError, Equatable {
|
||||
@ -26,14 +26,13 @@ enum ScheduleValidationError: LocalizedError, Equatable {
|
||||
|
||||
/// 排班管理 ViewModel,负责月份日期标记、某日列表和删除操作。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class ScheduleManagementViewModel {
|
||||
var monthDate = Date.scheduleFirstDayOfMonth
|
||||
var selectedDate = Date()
|
||||
private(set) var markedDays: Set<String> = []
|
||||
private(set) var items: [ScheduleItem] = []
|
||||
private(set) var loading = false
|
||||
var errorMessage: String?
|
||||
final class ScheduleManagementViewModel: ObservableObject {
|
||||
@Published var monthDate = Date.scheduleFirstDayOfMonth
|
||||
@Published var selectedDate = Date()
|
||||
@Published private(set) var markedDays: Set<String> = []
|
||||
@Published private(set) var items: [ScheduleItem] = []
|
||||
@Published private(set) var loading = false
|
||||
@Published var errorMessage: String?
|
||||
|
||||
/// 切换到上一个月并重新加载。
|
||||
func previousMonth(api: any ScheduleServing, scenicId: Int?) async {
|
||||
@ -109,13 +108,12 @@ final class ScheduleManagementViewModel {
|
||||
|
||||
/// 新增排班 ViewModel,负责可选订单加载、表单校验和提交。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class ScheduleAddViewModel {
|
||||
var draft = ScheduleDraft()
|
||||
private(set) var availableOrders: [AvailableOrderResponse] = []
|
||||
private(set) var loadingOrders = false
|
||||
private(set) var submitting = false
|
||||
var errorMessage: String?
|
||||
final class ScheduleAddViewModel: ObservableObject {
|
||||
@Published var draft = ScheduleDraft()
|
||||
@Published private(set) var availableOrders: [AvailableOrderResponse] = []
|
||||
@Published private(set) var loadingOrders = false
|
||||
@Published private(set) var submitting = false
|
||||
@Published var errorMessage: String?
|
||||
|
||||
/// 加载可关联订单列表。
|
||||
func loadOrders(api: any ScheduleServing, scenicId: Int?) async {
|
||||
|
||||
@ -9,15 +9,15 @@ import SwiftUI
|
||||
|
||||
/// 排班管理页面,展示月视图日期标记和当天排班列表。
|
||||
struct ScheduleManagementView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(ScheduleAPI.self) private var scheduleAPI
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@Environment(\.scheduleAPI) private var scheduleAPI
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
@State private var viewModel = ScheduleManagementViewModel()
|
||||
@StateObject private var viewModel = ScheduleManagementViewModel()
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
if accountContext.currentScenic?.id == nil {
|
||||
ContentUnavailableView("缺少景区", systemImage: "calendar.badge.exclamationmark", description: Text("请选择景区后再查看排班。"))
|
||||
AppContentUnavailableView("缺少景区", systemImage: "calendar.badge.exclamationmark", description: Text("请选择景区后再查看排班。"))
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
ScrollView {
|
||||
@ -96,7 +96,7 @@ struct ScheduleManagementView: View {
|
||||
Text(viewModel.selectedDate.scheduleDayText)
|
||||
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||||
if viewModel.items.isEmpty {
|
||||
ContentUnavailableView("当天暂无日程", systemImage: "calendar")
|
||||
AppContentUnavailableView("当天暂无日程", systemImage: "calendar")
|
||||
.frame(minHeight: 180)
|
||||
} else {
|
||||
ForEach(viewModel.items) { item in
|
||||
@ -126,10 +126,10 @@ struct ScheduleManagementView: View {
|
||||
/// 新增排班页面,支持手动录入和关联订单。
|
||||
struct ScheduleAddView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(ScheduleAPI.self) private var scheduleAPI
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@Environment(\.scheduleAPI) private var scheduleAPI
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
@State private var viewModel = ScheduleAddViewModel()
|
||||
@StateObject private var viewModel = ScheduleAddViewModel()
|
||||
@State private var date = Date()
|
||||
@State private var startTime = Date()
|
||||
@State private var endTime = Calendar.current.date(byAdding: .hour, value: 1, to: Date()) ?? Date()
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
/// 数据统计服务协议,抽象汇总和每日明细读取能力以便测试替换。
|
||||
@ -26,10 +26,9 @@ protocol StatisticsServing {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 数据统计 API,封装摄影师和景区管理员两套统计接口。
|
||||
final class StatisticsAPI: StatisticsServing {
|
||||
@ObservationIgnored private let client: APIClient
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化数据统计 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
|
||||
@ -6,21 +6,20 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 数据统计页面 ViewModel,管理时间段、汇总数据、每日明细和分页状态。
|
||||
final class StatisticsViewModel {
|
||||
var selectedPeriod: StatisticsPeriod = .today
|
||||
private(set) var loading = false
|
||||
private(set) var loadingMore = false
|
||||
private(set) var summary = StatisticsSummaryResponse()
|
||||
private(set) var dailyItems: [StatisticsDailyItem] = []
|
||||
private(set) var totalDailyCount = 0
|
||||
private(set) var lastRefreshAt: Date?
|
||||
final class StatisticsViewModel: ObservableObject {
|
||||
@Published var selectedPeriod: StatisticsPeriod = .today
|
||||
@Published private(set) var loading = false
|
||||
@Published private(set) var loadingMore = false
|
||||
@Published private(set) var summary = StatisticsSummaryResponse()
|
||||
@Published private(set) var dailyItems: [StatisticsDailyItem] = []
|
||||
@Published private(set) var totalDailyCount = 0
|
||||
@Published private(set) var lastRefreshAt: Date?
|
||||
|
||||
private var page = 1
|
||||
@Published private var page = 1
|
||||
private let pageSize = 15
|
||||
|
||||
/// 当前日数据是否还有下一页。
|
||||
|
||||
@ -9,14 +9,14 @@ import SwiftUI
|
||||
|
||||
/// 数据 Tab 根视图,展示订单统计汇总和每日明细。
|
||||
struct StatisticsView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(PermissionContext.self) private var permissionContext
|
||||
@Environment(StatisticsAPI.self) private var statisticsAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@EnvironmentObject private var permissionContext: PermissionContext
|
||||
@Environment(\.statisticsAPI) private var statisticsAPI
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
@State private var viewModel = StatisticsViewModel()
|
||||
@StateObject private var viewModel = StatisticsViewModel()
|
||||
|
||||
private var currentScenicId: Int? {
|
||||
accountContext.currentScenic?.id
|
||||
@ -33,7 +33,7 @@ struct StatisticsView: View {
|
||||
var body: some View {
|
||||
Group {
|
||||
if currentScenicId == nil {
|
||||
ContentUnavailableView(
|
||||
AppContentUnavailableView(
|
||||
"缺少经营上下文",
|
||||
systemImage: "chart.bar.doc.horizontal",
|
||||
description: Text("请先在首页选择景区后查看数据看板。")
|
||||
@ -59,10 +59,10 @@ struct StatisticsView: View {
|
||||
.navigationTitle("数据")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task { await reload() }
|
||||
.onChange(of: accountContext.currentScenic?.id) { _, _ in
|
||||
.onChange(of: accountContext.currentScenic?.id) { _ in
|
||||
Task { await reload() }
|
||||
}
|
||||
.onChange(of: permissionContext.currentRole?.id) { _, _ in
|
||||
.onChange(of: permissionContext.currentRole?.id) { _ in
|
||||
Task { await reload() }
|
||||
}
|
||||
}
|
||||
@ -171,7 +171,7 @@ struct StatisticsView: View {
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(minHeight: 280)
|
||||
} else if viewModel.dailyItems.isEmpty {
|
||||
ContentUnavailableView("暂无数据", systemImage: "tray", description: Text("可切换时间范围或下拉刷新。"))
|
||||
AppContentUnavailableView("暂无数据", systemImage: "tray", description: Text("可切换时间范围或下拉刷新。"))
|
||||
.frame(minHeight: 280)
|
||||
} else {
|
||||
VStack(spacing: 0) {
|
||||
@ -318,9 +318,9 @@ struct StatisticsView: View {
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
StatisticsView()
|
||||
.environment(AccountContext())
|
||||
.environment(PermissionContext())
|
||||
.environment(StatisticsAPI(client: APIClient()))
|
||||
.environment(ToastCenter())
|
||||
.environmentObject(AccountContext())
|
||||
.environmentObject(PermissionContext())
|
||||
.environment(\.statisticsAPI, StatisticsAPI(client: APIClient()))
|
||||
.environmentObject(ToastCenter())
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
/// 任务服务协议,抽象任务列表、详情、发布和云盘选择接口以便测试替换。
|
||||
@MainActor
|
||||
@ -44,9 +44,8 @@ protocol TaskServing {
|
||||
|
||||
/// 任务 API,封装任务管理和发布任务相关接口。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class TaskAPI: TaskServing {
|
||||
@ObservationIgnored private let client: APIClient
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化任务 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
/// 云盘文件类型筛选实体,表示发布任务选择附件时的过滤方式。
|
||||
enum TaskCloudFileFilter: Int, CaseIterable, Identifiable {
|
||||
@ -31,18 +31,17 @@ enum TaskCloudFileFilter: Int, CaseIterable, Identifiable {
|
||||
|
||||
/// 云盘文件选择 ViewModel,负责目录浏览、筛选、分页和附件选择。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class TaskCloudFileSelectionViewModel {
|
||||
var path: [CloudDriveFile] = [CloudDriveFile(id: 0, name: "云盘")]
|
||||
var files: [CloudDriveFile] = []
|
||||
var total = 0
|
||||
var page = 1
|
||||
var searchText = ""
|
||||
var selectedFilter: TaskCloudFileFilter = .all
|
||||
var selectedFiles: [CloudDriveFile] = []
|
||||
var isLoading = false
|
||||
var isLoadingMore = false
|
||||
var errorMessage: String?
|
||||
final class TaskCloudFileSelectionViewModel: ObservableObject {
|
||||
@Published var path: [CloudDriveFile] = [CloudDriveFile(id: 0, name: "云盘")]
|
||||
@Published var files: [CloudDriveFile] = []
|
||||
@Published var total = 0
|
||||
@Published var page = 1
|
||||
@Published var searchText = ""
|
||||
@Published var selectedFilter: TaskCloudFileFilter = .all
|
||||
@Published var selectedFiles: [CloudDriveFile] = []
|
||||
@Published var isLoading = false
|
||||
@Published var isLoadingMore = false
|
||||
@Published var errorMessage: String?
|
||||
|
||||
private let pageSize = 20
|
||||
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
/// 任务云盘选择项实体,表示发布任务表单中的云盘附件。
|
||||
struct TaskCloudSelectionItem: Identifiable, Hashable {
|
||||
@ -43,19 +43,18 @@ struct TaskLocalUploadItem: Identifiable, Hashable {
|
||||
|
||||
/// 发布任务 ViewModel,负责表单、附件上传和提交任务。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class TaskCreateViewModel {
|
||||
var taskName = ""
|
||||
var remark = ""
|
||||
var urgentHourText = "0"
|
||||
var selectedOrder: AvailableOrderResponse?
|
||||
var availableOrders: [AvailableOrderResponse] = []
|
||||
var selectedCloudFiles: [TaskCloudSelectionItem] = []
|
||||
var selectedLocalFiles: [TaskLocalUploadItem] = []
|
||||
var isLoadingOrders = false
|
||||
var isSubmitting = false
|
||||
var errorMessage: String?
|
||||
var didSubmitSuccessfully = false
|
||||
final class TaskCreateViewModel: ObservableObject {
|
||||
@Published var taskName = ""
|
||||
@Published var remark = ""
|
||||
@Published var urgentHourText = "0"
|
||||
@Published var selectedOrder: AvailableOrderResponse?
|
||||
@Published var availableOrders: [AvailableOrderResponse] = []
|
||||
@Published var selectedCloudFiles: [TaskCloudSelectionItem] = []
|
||||
@Published var selectedLocalFiles: [TaskLocalUploadItem] = []
|
||||
@Published var isLoadingOrders = false
|
||||
@Published var isSubmitting = false
|
||||
@Published var errorMessage: String?
|
||||
@Published var didSubmitSuccessfully = false
|
||||
|
||||
/// 判断当前表单是否满足提交按钮启用条件。
|
||||
var canSubmit: Bool {
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
/// 任务状态筛选实体,表示任务管理页支持的任务状态。
|
||||
enum TaskStatusFilter: Int, CaseIterable, Identifiable {
|
||||
@ -37,18 +37,17 @@ enum TaskStatusFilter: Int, CaseIterable, Identifiable {
|
||||
|
||||
/// 任务管理 ViewModel,负责任务列表、筛选、分页和错误状态。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class TaskManagementViewModel {
|
||||
var isLoading = false
|
||||
var isLoadingMore = false
|
||||
var tasks: [PhotographerTaskItem] = []
|
||||
var total = 0
|
||||
var page = 1
|
||||
var selectedStatus: TaskStatusFilter = .all
|
||||
var searchText = ""
|
||||
var startDate: Date?
|
||||
var endDate: Date?
|
||||
var errorMessage: String?
|
||||
final class TaskManagementViewModel: ObservableObject {
|
||||
@Published var isLoading = false
|
||||
@Published var isLoadingMore = false
|
||||
@Published var tasks: [PhotographerTaskItem] = []
|
||||
@Published var total = 0
|
||||
@Published var page = 1
|
||||
@Published var selectedStatus: TaskStatusFilter = .all
|
||||
@Published var searchText = ""
|
||||
@Published var startDate: Date?
|
||||
@Published var endDate: Date?
|
||||
@Published var errorMessage: String?
|
||||
|
||||
private let pageSize = 10
|
||||
|
||||
@ -146,11 +145,10 @@ final class TaskManagementViewModel {
|
||||
|
||||
/// 任务详情 ViewModel,负责详情加载和列表摘要兜底展示。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class TaskDetailViewModel {
|
||||
var isLoading = false
|
||||
var detail: TaskDetailResponse?
|
||||
var errorMessage: String?
|
||||
final class TaskDetailViewModel: ObservableObject {
|
||||
@Published var isLoading = false
|
||||
@Published var detail: TaskDetailResponse?
|
||||
@Published var errorMessage: String?
|
||||
|
||||
/// 加载任务详情;接口失败时保留外部传入的列表摘要。
|
||||
func load(api: any TaskServing, taskId: Int) async {
|
||||
|
||||
@ -11,14 +11,14 @@ import UniformTypeIdentifiers
|
||||
|
||||
/// 发布任务页面,负责填写任务信息、选择附件并提交任务。
|
||||
struct TaskCreateView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(TaskAPI.self) private var taskAPI
|
||||
@Environment(OSSUploadService.self) private var ossUploadService
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@Environment(\.taskAPI) private var taskAPI
|
||||
@Environment(\.ossUploadService) private var ossUploadService
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
|
||||
@State private var viewModel = TaskCreateViewModel()
|
||||
@StateObject private var viewModel = TaskCreateViewModel()
|
||||
@State private var showOrderSelection = false
|
||||
@State private var showCloudSelection = false
|
||||
@State private var pickerItems: [PhotosPickerItem] = []
|
||||
@ -60,7 +60,7 @@ struct TaskCreateView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: pickerItems) { _, items in
|
||||
.onChange(of: pickerItems) { items in
|
||||
Task { await importPickerItems(items) }
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,14 +9,14 @@ import SwiftUI
|
||||
|
||||
/// 任务详情页面,展示任务基础信息、关联订单和任务结果。
|
||||
struct TaskDetailView: View {
|
||||
@Environment(TaskAPI.self) private var taskAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@Environment(\.taskAPI) private var taskAPI
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
|
||||
let taskId: Int
|
||||
let summary: PhotographerTaskItem?
|
||||
|
||||
@State private var viewModel = TaskDetailViewModel()
|
||||
@StateObject private var viewModel = TaskDetailViewModel()
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
|
||||
@ -9,13 +9,13 @@ import SwiftUI
|
||||
|
||||
/// 任务管理页面,展示任务列表、筛选条件和发布任务入口。
|
||||
struct TaskManagementView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(RouterPath.self) private var router
|
||||
@Environment(TaskAPI.self) private var taskAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@EnvironmentObject private var router: RouterPath
|
||||
@Environment(\.taskAPI) private var taskAPI
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
|
||||
@State private var viewModel = TaskManagementViewModel()
|
||||
@StateObject private var viewModel = TaskManagementViewModel()
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
@ -55,7 +55,7 @@ struct TaskManagementView: View {
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.onChange(of: viewModel.selectedStatus) { _, _ in
|
||||
.onChange(of: viewModel.selectedStatus) { _ in
|
||||
Task { await reload(showLoading: true) }
|
||||
}
|
||||
|
||||
|
||||
@ -57,13 +57,13 @@ struct TaskOrderSelectionView: View {
|
||||
|
||||
/// 任务云盘文件选择页面,供发布任务时选择云盘附件。
|
||||
struct TaskCloudFileSelectionView: View {
|
||||
@Environment(TaskAPI.self) private var taskAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@Environment(\.taskAPI) private var taskAPI
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
let onConfirm: ([TaskCloudSelectionItem]) -> Void
|
||||
|
||||
@State private var viewModel = TaskCloudFileSelectionViewModel()
|
||||
@StateObject private var viewModel = TaskCloudFileSelectionViewModel()
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
@ -108,7 +108,7 @@ struct TaskCloudFileSelectionView: View {
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.onChange(of: viewModel.selectedFilter) { _, _ in
|
||||
.onChange(of: viewModel.selectedFilter) { _ in
|
||||
Task { await reload() }
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
/// 钱包模块服务协议,定义钱包、提现、银行卡和积分接口能力。
|
||||
@MainActor
|
||||
@ -55,10 +55,9 @@ protocol WalletServing {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 钱包 API,封装个人钱包、提现、银行卡和积分兑换网络请求。
|
||||
final class WalletAPI {
|
||||
@ObservationIgnored private let client: APIClient
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化钱包 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
/// 钱包实名认证服务协议,抽象实名认证状态读取能力以便测试替换。
|
||||
@ -34,25 +34,24 @@ enum WalletLedgerTab: String, CaseIterable, Identifiable {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 钱包首页 ViewModel,管理钱包汇总、收益明细、提现记录和提现资格流。
|
||||
final class WalletViewModel {
|
||||
var summary: WalletSummaryResponse?
|
||||
var pointsOverview: PointOverviewResponse?
|
||||
var selectedTab: WalletLedgerTab = .earnings
|
||||
var selectedFilter: WalletDateFilter = .last30
|
||||
var earningsGroups: [WalletEarningDetailGroup] = []
|
||||
var withdrawRecords: [WalletWithdrawRecord] = []
|
||||
var isLoadingSummary = false
|
||||
var isLoadingList = false
|
||||
var isCheckingWithdraw = false
|
||||
var errorMessage: String?
|
||||
final class WalletViewModel: ObservableObject {
|
||||
@Published var summary: WalletSummaryResponse?
|
||||
@Published var pointsOverview: PointOverviewResponse?
|
||||
@Published var selectedTab: WalletLedgerTab = .earnings
|
||||
@Published var selectedFilter: WalletDateFilter = .last30
|
||||
@Published var earningsGroups: [WalletEarningDetailGroup] = []
|
||||
@Published var withdrawRecords: [WalletWithdrawRecord] = []
|
||||
@Published var isLoadingSummary = false
|
||||
@Published var isLoadingList = false
|
||||
@Published var isCheckingWithdraw = false
|
||||
@Published var errorMessage: String?
|
||||
|
||||
private let pageSize = 20
|
||||
private var earningsPage = 1
|
||||
private var earningsTotal = 0
|
||||
private var withdrawPage = 1
|
||||
private var withdrawTotal = 0
|
||||
@Published private var earningsPage = 1
|
||||
@Published private var earningsTotal = 0
|
||||
@Published private var withdrawPage = 1
|
||||
@Published private var withdrawTotal = 0
|
||||
|
||||
/// 钱包可提现金额展示文本。
|
||||
var withdrawableText: String {
|
||||
@ -225,16 +224,15 @@ final class WalletViewModel {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 提现申请 ViewModel,管理提现信息、金额校验、短信验证码和提交。
|
||||
final class WithdrawApplyViewModel {
|
||||
var info: WithdrawInfoResponse?
|
||||
var amountText = ""
|
||||
var smsCode = ""
|
||||
var isLoading = false
|
||||
var isSubmitting = false
|
||||
var smsCountdown = 0
|
||||
var errorMessage: String?
|
||||
final class WithdrawApplyViewModel: ObservableObject {
|
||||
@Published var info: WithdrawInfoResponse?
|
||||
@Published var amountText = ""
|
||||
@Published var smsCode = ""
|
||||
@Published var isLoading = false
|
||||
@Published var isSubmitting = false
|
||||
@Published var smsCountdown = 0
|
||||
@Published var errorMessage: String?
|
||||
|
||||
/// 加载提现申请所需信息。
|
||||
func load(api: WalletServing) async {
|
||||
@ -294,28 +292,27 @@ final class WithdrawApplyViewModel {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 银行卡设置 ViewModel,管理银行卡表单、图片上传、短信验证码和提交。
|
||||
final class WithdrawalSettingsViewModel {
|
||||
var bankCard: WalletBankCardInfo?
|
||||
var banks: [String] = []
|
||||
var areas: [AreaNode] = []
|
||||
var realName = ""
|
||||
var cardNumber = ""
|
||||
var bankName = ""
|
||||
var branchName = ""
|
||||
var provinceCode = ""
|
||||
var cityCode = ""
|
||||
var smsCode = ""
|
||||
var frontImageData: Data?
|
||||
var backImageData: Data?
|
||||
var frontImageURL = ""
|
||||
var backImageURL = ""
|
||||
var uploadProgress = 0
|
||||
var isLoading = false
|
||||
var isSubmitting = false
|
||||
var smsCountdown = 0
|
||||
var errorMessage: String?
|
||||
final class WithdrawalSettingsViewModel: ObservableObject {
|
||||
@Published var bankCard: WalletBankCardInfo?
|
||||
@Published var banks: [String] = []
|
||||
@Published var areas: [AreaNode] = []
|
||||
@Published var realName = ""
|
||||
@Published var cardNumber = ""
|
||||
@Published var bankName = ""
|
||||
@Published var branchName = ""
|
||||
@Published var provinceCode = ""
|
||||
@Published var cityCode = ""
|
||||
@Published var smsCode = ""
|
||||
@Published var frontImageData: Data?
|
||||
@Published var backImageData: Data?
|
||||
@Published var frontImageURL = ""
|
||||
@Published var backImageURL = ""
|
||||
@Published var uploadProgress = 0
|
||||
@Published var isLoading = false
|
||||
@Published var isSubmitting = false
|
||||
@Published var smsCountdown = 0
|
||||
@Published var errorMessage: String?
|
||||
|
||||
/// 加载银行卡设置需要的银行、地区和已提交资料。
|
||||
func load(api: WalletServing) async {
|
||||
@ -440,19 +437,18 @@ final class WithdrawalSettingsViewModel {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 积分兑换 ViewModel,管理积分概览、兑换记录、兑换金额校验和提交。
|
||||
final class PointsRedemptionViewModel {
|
||||
var overview = PointOverviewResponse()
|
||||
var records: [PointWithdrawItem] = []
|
||||
var pointsText = ""
|
||||
var isLoading = false
|
||||
var isSubmitting = false
|
||||
var errorMessage: String?
|
||||
final class PointsRedemptionViewModel: ObservableObject {
|
||||
@Published var overview = PointOverviewResponse()
|
||||
@Published var records: [PointWithdrawItem] = []
|
||||
@Published var pointsText = ""
|
||||
@Published var isLoading = false
|
||||
@Published var isSubmitting = false
|
||||
@Published var errorMessage: String?
|
||||
|
||||
private let pageSize = 20
|
||||
private var page = 1
|
||||
private var total = 0
|
||||
@Published private var page = 1
|
||||
@Published private var total = 0
|
||||
|
||||
/// 是否还能继续加载兑换记录。
|
||||
var canLoadMore: Bool {
|
||||
|
||||
@ -11,12 +11,12 @@ import SwiftUI
|
||||
|
||||
/// 钱包首页,展示钱包汇总、收益明细、提现记录和钱包二级入口。
|
||||
struct WalletView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(WalletAPI.self) private var walletAPI
|
||||
@Environment(ProfileAPI.self) private var profileAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@Environment(\.walletAPI) private var walletAPI
|
||||
@Environment(\.profileAPI) private var profileAPI
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
|
||||
@State private var viewModel = WalletViewModel()
|
||||
@StateObject private var viewModel = WalletViewModel()
|
||||
@State private var route: WalletRoute?
|
||||
|
||||
var body: some View {
|
||||
@ -33,7 +33,7 @@ struct WalletView: View {
|
||||
.background(Color(hex: 0xF5F7FA))
|
||||
.navigationTitle("我的钱包")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationDestination(item: $route) { route in
|
||||
.appNavigationDestination(item: $route) { route in
|
||||
switch route {
|
||||
case .withdrawApply:
|
||||
WithdrawApplyView()
|
||||
@ -51,7 +51,7 @@ struct WalletView: View {
|
||||
.refreshable {
|
||||
await viewModel.loadInitial(api: walletAPI, staffId: staffId)
|
||||
}
|
||||
.onChange(of: viewModel.errorMessage) { _, message in
|
||||
.onChange(of: viewModel.errorMessage) { message in
|
||||
if let message {
|
||||
toastCenter.show(message)
|
||||
}
|
||||
@ -256,10 +256,10 @@ struct WalletView: View {
|
||||
|
||||
/// 提现申请页,输入金额和短信验证码后提交提现。
|
||||
struct WithdrawApplyView: View {
|
||||
@Environment(WalletAPI.self) private var walletAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@Environment(\.walletAPI) private var walletAPI
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var viewModel = WithdrawApplyViewModel()
|
||||
@StateObject private var viewModel = WithdrawApplyViewModel()
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
@ -301,7 +301,7 @@ struct WithdrawApplyView: View {
|
||||
viewModel.smsCountdown -= 1
|
||||
}
|
||||
}
|
||||
.onChange(of: viewModel.errorMessage) { _, message in
|
||||
.onChange(of: viewModel.errorMessage) { message in
|
||||
if let message {
|
||||
toastCenter.show(message)
|
||||
}
|
||||
@ -311,13 +311,13 @@ struct WithdrawApplyView: View {
|
||||
|
||||
/// 银行卡设置页,使用图片选择和 OSS 上传提交银行卡资料。
|
||||
struct WithdrawalSettingsView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(WalletAPI.self) private var walletAPI
|
||||
@Environment(OSSUploadService.self) private var ossUploadService
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@Environment(\.walletAPI) private var walletAPI
|
||||
@Environment(\.ossUploadService) private var ossUploadService
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var viewModel = WithdrawalSettingsViewModel()
|
||||
@StateObject private var viewModel = WithdrawalSettingsViewModel()
|
||||
@State private var frontItem: PhotosPickerItem?
|
||||
@State private var backItem: PhotosPickerItem?
|
||||
|
||||
@ -389,10 +389,10 @@ struct WithdrawalSettingsView: View {
|
||||
.navigationTitle("银行卡设置")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task { await viewModel.load(api: walletAPI) }
|
||||
.onChange(of: frontItem) { _, item in
|
||||
.onChange(of: frontItem) { item in
|
||||
Task { viewModel.frontImageData = try? await item?.loadTransferable(type: Data.self) }
|
||||
}
|
||||
.onChange(of: backItem) { _, item in
|
||||
.onChange(of: backItem) { item in
|
||||
Task { viewModel.backImageData = try? await item?.loadTransferable(type: Data.self) }
|
||||
}
|
||||
.onReceive(Timer.publish(every: 1, on: .main, in: .common).autoconnect()) { _ in
|
||||
@ -400,7 +400,7 @@ struct WithdrawalSettingsView: View {
|
||||
viewModel.smsCountdown -= 1
|
||||
}
|
||||
}
|
||||
.onChange(of: viewModel.errorMessage) { _, message in
|
||||
.onChange(of: viewModel.errorMessage) { message in
|
||||
if let message {
|
||||
toastCenter.show(message)
|
||||
}
|
||||
@ -449,10 +449,10 @@ struct WithdrawalSettingsView: View {
|
||||
|
||||
/// 积分兑换页,展示积分概览、提交兑换申请和兑换记录。
|
||||
struct PointsRedemptionView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(WalletAPI.self) private var walletAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@State private var viewModel = PointsRedemptionViewModel()
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@Environment(\.walletAPI) private var walletAPI
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@StateObject private var viewModel = PointsRedemptionViewModel()
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
@ -517,7 +517,7 @@ struct PointsRedemptionView: View {
|
||||
.navigationTitle("积分兑换")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task { await viewModel.load(api: walletAPI, staffId: staffId) }
|
||||
.onChange(of: viewModel.errorMessage) { _, message in
|
||||
.onChange(of: viewModel.errorMessage) { message in
|
||||
if let message {
|
||||
toastCenter.show(message)
|
||||
}
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
/// 提现审核筛选项,按提现记录的状态文案聚合审核视角。
|
||||
enum WithdrawalAuditFilter: String, CaseIterable, Identifiable {
|
||||
@ -27,20 +27,19 @@ enum WithdrawalAuditFilter: String, CaseIterable, Identifiable {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 提现审核 ViewModel,负责提现记录分页、状态筛选和失败状态清理。
|
||||
final class WithdrawalAuditViewModel {
|
||||
var records: [WalletWithdrawRecord] = []
|
||||
var selectedFilter: WithdrawalAuditFilter = .all
|
||||
var isLoading = false
|
||||
var isLoadingMore = false
|
||||
var loadFailed = false
|
||||
var loadFailureReason: String?
|
||||
var errorMessage: String?
|
||||
final class WithdrawalAuditViewModel: ObservableObject {
|
||||
@Published var records: [WalletWithdrawRecord] = []
|
||||
@Published var selectedFilter: WithdrawalAuditFilter = .all
|
||||
@Published var isLoading = false
|
||||
@Published var isLoadingMore = false
|
||||
@Published var loadFailed = false
|
||||
@Published var loadFailureReason: String?
|
||||
@Published var errorMessage: String?
|
||||
|
||||
private let pageSize = 10
|
||||
private var page = 1
|
||||
private var total = 0
|
||||
@Published private var page = 1
|
||||
@Published private var total = 0
|
||||
|
||||
/// 当前筛选下的提现记录。
|
||||
var filteredRecords: [WalletWithdrawRecord] {
|
||||
|
||||
@ -9,9 +9,9 @@ import SwiftUI
|
||||
|
||||
/// 提现审核列表页,以审核视角展示钱包提现记录和处理进度。
|
||||
struct WithdrawalAuditView: View {
|
||||
@Environment(WalletAPI.self) private var walletAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@State private var viewModel = WithdrawalAuditViewModel()
|
||||
@Environment(\.walletAPI) private var walletAPI
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@StateObject private var viewModel = WithdrawalAuditViewModel()
|
||||
@State private var selectedRecord: WalletWithdrawRecord?
|
||||
|
||||
var body: some View {
|
||||
@ -37,7 +37,7 @@ struct WithdrawalAuditView: View {
|
||||
WithdrawalAuditDetailView(record: record)
|
||||
}
|
||||
}
|
||||
.onChange(of: viewModel.errorMessage) { _, message in
|
||||
.onChange(of: viewModel.errorMessage) { message in
|
||||
guard let message else { return }
|
||||
toastCenter.show(message)
|
||||
viewModel.errorMessage = nil
|
||||
@ -82,7 +82,7 @@ struct WithdrawalAuditView: View {
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 12))
|
||||
} else if viewModel.loadFailed && viewModel.records.isEmpty {
|
||||
VStack(spacing: AppMetrics.Spacing.small) {
|
||||
ContentUnavailableView(
|
||||
AppContentUnavailableView(
|
||||
"提现审核记录加载失败",
|
||||
systemImage: "exclamationmark.triangle",
|
||||
description: Text(viewModel.loadFailureReason ?? "请检查网络后重试")
|
||||
@ -100,7 +100,7 @@ struct WithdrawalAuditView: View {
|
||||
.frame(maxWidth: .infinity, minHeight: 220)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 12))
|
||||
} else if viewModel.filteredRecords.isEmpty {
|
||||
ContentUnavailableView("暂无提现审核记录", systemImage: "tray", description: Text("可切换筛选条件或下拉刷新"))
|
||||
AppContentUnavailableView("暂无提现审核记录", systemImage: "tray", description: Text("可切换筛选条件或下拉刷新"))
|
||||
.frame(maxWidth: .infinity, minHeight: 220)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 12))
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user