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:
2026-06-26 10:16:35 +08:00
parent c32a610ee0
commit 26f4d0e671
127 changed files with 2320 additions and 1465 deletions

View File

@ -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) {

View File

@ -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 }

View File

@ -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
}