从 iOS 17 Observation 迁移至 iOS 16 兼容的 Combine 架构
将最低部署版本降至 iOS 16,以 ObservableObject 替换 @Observable,新增导航与 UI 兼容层,并补充登录冒烟 UI 测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user