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>
777 lines
33 KiB
Swift
777 lines
33 KiB
Swift
//
|
||
// ProjectViews.swift
|
||
// suixinkan
|
||
//
|
||
// Created by Codex on 2026/6/24.
|
||
//
|
||
|
||
import PhotosUI
|
||
import SwiftUI
|
||
import UniformTypeIdentifiers
|
||
|
||
/// 摄影师项目管理页面,展示项目列表并提供新建、详情和删除入口。
|
||
struct ProjectManagementView: View {
|
||
@EnvironmentObject private var accountContext: AccountContext
|
||
@Environment(\.accountSnapshotStore) private var snapshotStore
|
||
@Environment(\.projectAPI) private var projectAPI
|
||
@Environment(\.globalLoading) private var globalLoading
|
||
@StateObject private var viewModel = ProjectManagementViewModel()
|
||
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
filterBar
|
||
if accountContext.currentScenic?.id == nil {
|
||
AppContentUnavailableView("缺少景区", systemImage: "map", description: Text("请选择景区后再管理项目。"))
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
} else {
|
||
projectList
|
||
}
|
||
}
|
||
.navigationTitle("项目管理")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .topBarTrailing) {
|
||
NavigationLink(value: HomeRoute.projectEditor(id: nil, storeMode: false)) {
|
||
Image(systemName: "plus")
|
||
}
|
||
.accessibilityLabel("新建项目")
|
||
}
|
||
}
|
||
.task(id: accountContext.currentScenic?.id) {
|
||
await reload(showLoading: true)
|
||
}
|
||
}
|
||
|
||
private var filterBar: some View {
|
||
HStack(spacing: AppMetrics.Spacing.small) {
|
||
Image(systemName: "magnifyingglass")
|
||
.foregroundStyle(AppDesign.textSecondary)
|
||
TextField("搜索项目名称", text: $viewModel.searchText)
|
||
.textInputAutocapitalization(.never)
|
||
.autocorrectionDisabled(true)
|
||
.submitLabel(.search)
|
||
.onSubmit { Task { await reload(showLoading: true) } }
|
||
Button("搜索") {
|
||
Task { await reload(showLoading: true) }
|
||
}
|
||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||
.foregroundStyle(AppDesign.primary)
|
||
}
|
||
.padding(.horizontal, AppMetrics.Spacing.medium)
|
||
.frame(height: 52)
|
||
.background(.white)
|
||
}
|
||
|
||
private var projectList: some View {
|
||
ScrollView {
|
||
LazyVStack(spacing: AppMetrics.Spacing.medium) {
|
||
if viewModel.items.isEmpty && !viewModel.loading {
|
||
AppContentUnavailableView("暂无项目", systemImage: "folder", description: Text("可点击右上角创建项目。"))
|
||
.frame(maxWidth: .infinity, minHeight: 260)
|
||
}
|
||
ForEach(viewModel.items) { item in
|
||
NavigationLink(value: HomeRoute.projectDetail(id: item.id, storeMode: false)) {
|
||
ProjectCardView(item: item)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.contextMenu {
|
||
Button(role: .destructive) {
|
||
Task { await viewModel.delete(id: item.id, api: projectAPI) }
|
||
} label: {
|
||
Label("删除", systemImage: "trash")
|
||
}
|
||
}
|
||
}
|
||
if viewModel.hasMore {
|
||
ProgressView()
|
||
.padding(.vertical, AppMetrics.Spacing.medium)
|
||
.onAppear {
|
||
Task { await viewModel.loadMore(api: projectAPI, scenicId: accountContext.currentScenic?.id) }
|
||
}
|
||
} else if !viewModel.items.isEmpty {
|
||
Text("没有更多")
|
||
.font(.system(size: AppMetrics.FontSize.caption))
|
||
.foregroundStyle(AppDesign.textSecondary)
|
||
.padding(.vertical, AppMetrics.Spacing.medium)
|
||
}
|
||
}
|
||
.padding(AppMetrics.Spacing.medium)
|
||
}
|
||
.background(Color(hex: 0xF5F7FA))
|
||
.refreshable { await reload(showLoading: false) }
|
||
}
|
||
|
||
private func reload(showLoading: Bool) async {
|
||
await globalLoading.withOptionalLoading(showLoading && accountContext.currentScenic?.id != nil) {
|
||
await viewModel.reload(api: projectAPI, scenicId: accountContext.currentScenic?.id)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 店铺项目管理页面,展示店铺项目列表、筛选和新建入口。
|
||
struct StoreProjectManagementView: View {
|
||
@Environment(\.accountSnapshotStore) private var snapshotStore
|
||
@Environment(\.projectAPI) private var projectAPI
|
||
@Environment(\.globalLoading) private var globalLoading
|
||
@StateObject private var viewModel = StoreProjectManagementViewModel()
|
||
|
||
var body: some View {
|
||
ScrollView {
|
||
VStack(spacing: AppMetrics.Spacing.medium) {
|
||
storeFilterCard
|
||
storeSummaryCard
|
||
ForEach(viewModel.filteredItems) { item in
|
||
NavigationLink(value: HomeRoute.projectDetail(id: item.id, storeMode: true)) {
|
||
ProjectCompactCardView(item: item)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
if viewModel.filteredItems.isEmpty && !viewModel.loading {
|
||
AppContentUnavailableView("暂无店铺项目", systemImage: "folder", description: Text("可点击右上角新建店铺项目。"))
|
||
.frame(minHeight: 260)
|
||
}
|
||
}
|
||
.padding(AppMetrics.Spacing.medium)
|
||
}
|
||
.background(Color(hex: 0xF5F7FA))
|
||
.navigationTitle("店铺项目管理")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .topBarTrailing) {
|
||
NavigationLink(value: HomeRoute.projectEditor(id: nil, storeMode: true)) {
|
||
Image(systemName: "plus")
|
||
}
|
||
.accessibilityLabel("新建店铺项目")
|
||
}
|
||
}
|
||
.task { await reload(showLoading: true) }
|
||
.refreshable { await reload(showLoading: false) }
|
||
}
|
||
|
||
private var storeFilterCard: some View {
|
||
VStack(spacing: AppMetrics.Spacing.small) {
|
||
Picker("项目类型", selection: $viewModel.selectedType) {
|
||
Text("全部").tag(0)
|
||
Text("多点位").tag(19)
|
||
Text("押金").tag(4)
|
||
}
|
||
.pickerStyle(.segmented)
|
||
TextField("搜索项目名称", text: $viewModel.searchText)
|
||
.textInputAutocapitalization(.never)
|
||
.autocorrectionDisabled(true)
|
||
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight)
|
||
}
|
||
.padding(AppMetrics.Spacing.medium)
|
||
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||
}
|
||
|
||
private var storeSummaryCard: some View {
|
||
HStack(spacing: AppMetrics.Spacing.small) {
|
||
stat("总数", "\(viewModel.filteredItems.count)")
|
||
stat("启用", "\(viewModel.filteredItems.filter { $0.status == 1 }.count)")
|
||
stat("押金", "\(viewModel.filteredItems.filter { $0.type == 4 }.count)")
|
||
}
|
||
}
|
||
|
||
private func stat(_ title: String, _ value: String) -> some View {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text(title)
|
||
.font(.system(size: AppMetrics.FontSize.caption))
|
||
.foregroundStyle(AppDesign.textSecondary)
|
||
Text(value)
|
||
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||
.foregroundStyle(AppDesign.textPrimary)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(AppMetrics.Spacing.small)
|
||
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||
}
|
||
|
||
private func reload(showLoading: Bool) async {
|
||
await globalLoading.withOptionalLoading(showLoading) {
|
||
await viewModel.reload(api: projectAPI, userId: snapshotStore.load()?.profile?.userId ?? snapshotStore.load()?.businessUserId.map(String.init))
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 项目详情页面,展示项目基础信息、媒体、打卡点和摄影师摘要。
|
||
struct ProjectDetailView: View {
|
||
@Environment(\.projectAPI) private var projectAPI
|
||
@Environment(\.globalLoading) private var globalLoading
|
||
let projectId: Int
|
||
let storeMode: Bool
|
||
@State private var detail: PhotographerProjectDetailResponse?
|
||
@State private var errorMessage: String?
|
||
|
||
var body: some View {
|
||
ScrollView {
|
||
VStack(spacing: AppMetrics.Spacing.medium) {
|
||
if let detail {
|
||
ProjectDetailContentView(detail: detail)
|
||
} else if let errorMessage {
|
||
AppContentUnavailableView("加载失败", systemImage: "exclamationmark.triangle", description: Text(errorMessage))
|
||
.frame(minHeight: 360)
|
||
} else {
|
||
ProgressView()
|
||
.frame(maxWidth: .infinity, minHeight: 360)
|
||
}
|
||
}
|
||
.padding(AppMetrics.Spacing.medium)
|
||
}
|
||
.background(Color(hex: 0xF5F7FA))
|
||
.navigationTitle("项目详情")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
if detail != nil {
|
||
ToolbarItem(placement: .topBarTrailing) {
|
||
NavigationLink(value: HomeRoute.projectEditor(id: projectId, storeMode: storeMode)) {
|
||
Text("编辑")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.task { await load() }
|
||
}
|
||
|
||
private func load() async {
|
||
await globalLoading.withLoading {
|
||
do {
|
||
detail = storeMode ? try await projectAPI.storeManagerProjectDetail(id: projectId) : try await projectAPI.projectDetail(id: projectId)
|
||
} catch {
|
||
errorMessage = error.localizedDescription
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 摄影师项目编辑页面,支持新建和编辑项目。
|
||
struct ProjectEditorView: View {
|
||
@Environment(\.dismiss) private var dismiss
|
||
@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?
|
||
@StateObject private var viewModel = ProjectEditorViewModel(mode: .create)
|
||
@State private var coverItem: PhotosPickerItem?
|
||
@State private var carouselItems: [PhotosPickerItem] = []
|
||
@State private var loadingDetail = false
|
||
|
||
var body: some View {
|
||
ScrollView {
|
||
VStack(spacing: AppMetrics.Spacing.medium) {
|
||
basicForm
|
||
projectImageForm
|
||
countForm
|
||
spotForm
|
||
if let error = viewModel.errorMessage {
|
||
Text(error)
|
||
.font(.system(size: AppMetrics.FontSize.caption))
|
||
.foregroundStyle(Color(hex: 0xE5484D))
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(AppMetrics.Spacing.medium)
|
||
.background(Color(hex: 0xE5484D).opacity(0.1), in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||
}
|
||
}
|
||
.padding(AppMetrics.Spacing.medium)
|
||
}
|
||
.background(Color(hex: 0xF5F7FA))
|
||
.navigationTitle(projectId == nil ? "新建项目" : "编辑项目")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .topBarTrailing) {
|
||
Button(viewModel.submitting ? "保存中..." : "保存") {
|
||
Task { await submit() }
|
||
}
|
||
.disabled(viewModel.submitting || loadingDetail)
|
||
}
|
||
}
|
||
.task {
|
||
await prepare()
|
||
}
|
||
.onChange(of: coverItem) { newValue in
|
||
Task { await loadCover(newValue) }
|
||
}
|
||
.onChange(of: carouselItems) { newValue in
|
||
Task { await loadCarousel(newValue) }
|
||
}
|
||
}
|
||
|
||
private var basicForm: some View {
|
||
ProjectFormCard(title: "基础信息") {
|
||
TextField("项目名称", text: $viewModel.name)
|
||
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight)
|
||
TextField("项目简介", text: $viewModel.descriptionText, axis: .vertical)
|
||
.lineLimit(3...5)
|
||
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: 96)
|
||
HStack(spacing: AppMetrics.Spacing.small) {
|
||
TextField("优惠价", text: $viewModel.price)
|
||
.keyboardType(.decimalPad)
|
||
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight)
|
||
TextField("原价", text: $viewModel.otPrice)
|
||
.keyboardType(.decimalPad)
|
||
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight)
|
||
}
|
||
TextField("押金", text: $viewModel.deposit)
|
||
.keyboardType(.decimalPad)
|
||
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight)
|
||
TextField("标签,多个用逗号分隔", text: $viewModel.attrLabelText)
|
||
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight)
|
||
}
|
||
}
|
||
|
||
private var projectImageForm: some View {
|
||
ProjectFormCard(title: "项目图片") {
|
||
PhotosPicker(selection: $coverItem, matching: .images) {
|
||
ProjectImagePickerLabel(title: "项目封面", url: viewModel.coverImage?.previewURL ?? viewModel.existingCoverURL)
|
||
}
|
||
PhotosPicker(selection: $carouselItems, maxSelectionCount: 8, matching: .images) {
|
||
Label("选择轮播图(\(viewModel.carouselImages.count + viewModel.existingCarouselURLs.count))", systemImage: "photo.stack")
|
||
.frame(maxWidth: .infinity)
|
||
.frame(height: 44)
|
||
.background(AppDesign.primarySoft, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input))
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
|
||
private var countForm: some View {
|
||
ProjectFormCard(title: "交付数量") {
|
||
HStack(spacing: AppMetrics.Spacing.small) {
|
||
TextField("底片", text: $viewModel.materialNum)
|
||
TextField("精修", text: $viewModel.photoNum)
|
||
TextField("视频", text: $viewModel.videoNum)
|
||
}
|
||
.keyboardType(.numberPad)
|
||
.textFieldStyle(.roundedBorder)
|
||
}
|
||
}
|
||
|
||
private var spotForm: some View {
|
||
ProjectFormCard(title: "打卡点") {
|
||
if scenicSpotContext.spots.isEmpty {
|
||
Text("暂无打卡点数据")
|
||
.font(.system(size: AppMetrics.FontSize.caption))
|
||
.foregroundStyle(AppDesign.textSecondary)
|
||
} else {
|
||
ForEach(scenicSpotContext.spots) { spot in
|
||
Button {
|
||
if viewModel.selectedSpotIds.contains(spot.id) {
|
||
viewModel.selectedSpotIds.remove(spot.id)
|
||
} else {
|
||
viewModel.selectedSpotIds.insert(spot.id)
|
||
}
|
||
} label: {
|
||
HStack {
|
||
Text(spot.name)
|
||
Spacer()
|
||
Image(systemName: viewModel.selectedSpotIds.contains(spot.id) ? "checkmark.circle.fill" : "circle")
|
||
.foregroundStyle(viewModel.selectedSpotIds.contains(spot.id) ? AppDesign.primary : AppDesign.textSecondary)
|
||
}
|
||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||
.foregroundStyle(AppDesign.textPrimary)
|
||
.padding(AppMetrics.Spacing.small)
|
||
.background(Color(hex: 0xF8FAFC), in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input))
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func prepare() async {
|
||
guard let projectId else { return }
|
||
loadingDetail = true
|
||
defer { loadingDetail = false }
|
||
do {
|
||
let detail = try await projectAPI.projectDetail(id: projectId)
|
||
viewModel.apply(detail)
|
||
} catch {
|
||
viewModel.errorMessage = error.localizedDescription
|
||
}
|
||
}
|
||
|
||
private func submit() async {
|
||
await globalLoading.withLoading {
|
||
let success = await viewModel.submit(
|
||
scenicId: accountContext.currentScenic?.id,
|
||
userId: snapshotStore.load()?.businessUserId ?? Int(snapshotStore.load()?.profile?.userId ?? ""),
|
||
api: projectAPI,
|
||
uploadService: uploadService
|
||
)
|
||
if success { dismiss() }
|
||
}
|
||
}
|
||
|
||
private func loadCover(_ item: PhotosPickerItem?) async {
|
||
guard let file = await ProjectPickerLoader.loadImage(from: item) else { return }
|
||
viewModel.coverImage = file
|
||
}
|
||
|
||
private func loadCarousel(_ items: [PhotosPickerItem]) async {
|
||
viewModel.carouselImages = await ProjectPickerLoader.loadImages(from: items)
|
||
}
|
||
}
|
||
|
||
/// 店铺项目编辑页面,支持新建和编辑店铺项目。
|
||
struct StoreProjectEditorView: View {
|
||
@Environment(\.dismiss) private var dismiss
|
||
@Environment(\.accountSnapshotStore) private var snapshotStore
|
||
@Environment(\.projectAPI) private var projectAPI
|
||
@Environment(\.ossUploadService) private var uploadService
|
||
@Environment(\.globalLoading) private var globalLoading
|
||
let projectId: Int?
|
||
@StateObject private var viewModel = StoreProjectEditorViewModel(mode: .create)
|
||
@State private var coverItem: PhotosPickerItem?
|
||
@State private var carouselItems: [PhotosPickerItem] = []
|
||
|
||
var body: some View {
|
||
ScrollView {
|
||
VStack(spacing: AppMetrics.Spacing.medium) {
|
||
storeBasicForm
|
||
storeImageForm
|
||
storeScenicForm
|
||
storePriceForm
|
||
if let error = viewModel.errorMessage {
|
||
Text(error)
|
||
.font(.system(size: AppMetrics.FontSize.caption))
|
||
.foregroundStyle(Color(hex: 0xE5484D))
|
||
}
|
||
}
|
||
.padding(AppMetrics.Spacing.medium)
|
||
}
|
||
.background(Color(hex: 0xF5F7FA))
|
||
.navigationTitle(projectId == nil ? "新建店铺项目" : "编辑店铺项目")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .topBarTrailing) {
|
||
Button(viewModel.submitting ? "保存中..." : "保存") {
|
||
Task { await submit() }
|
||
}
|
||
.disabled(viewModel.submitting)
|
||
}
|
||
}
|
||
.task { await prepare() }
|
||
.onChange(of: coverItem) { newValue in
|
||
Task { viewModel.coverImage = await ProjectPickerLoader.loadImage(from: newValue) }
|
||
}
|
||
.onChange(of: carouselItems) { newValue in
|
||
Task { viewModel.carouselImages = await ProjectPickerLoader.loadImages(from: newValue) }
|
||
}
|
||
}
|
||
|
||
private var storeBasicForm: some View {
|
||
ProjectFormCard(title: "基础信息") {
|
||
Picker("类型", selection: $viewModel.projectType) {
|
||
Text("多点位").tag(19)
|
||
Text("押金").tag(4)
|
||
}
|
||
.pickerStyle(.segmented)
|
||
TextField("项目名称", text: $viewModel.name)
|
||
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight)
|
||
TextField("项目简介", text: $viewModel.descriptionText, axis: .vertical)
|
||
.lineLimit(3...5)
|
||
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: 96)
|
||
}
|
||
}
|
||
|
||
private var storeImageForm: some View {
|
||
ProjectFormCard(title: "项目图片") {
|
||
PhotosPicker(selection: $coverItem, matching: .images) {
|
||
ProjectImagePickerLabel(title: "项目封面", url: viewModel.coverImage?.previewURL ?? viewModel.existingCoverURL)
|
||
}
|
||
PhotosPicker(selection: $carouselItems, maxSelectionCount: 8, matching: .images) {
|
||
Label("选择轮播图(\(viewModel.carouselImages.count + viewModel.existingCarouselURLs.count))", systemImage: "photo.stack")
|
||
.frame(maxWidth: .infinity)
|
||
.frame(height: 44)
|
||
.background(AppDesign.primarySoft, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input))
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
|
||
private var storeScenicForm: some View {
|
||
ProjectFormCard(title: viewModel.projectType == 4 ? "景区与门店" : "景区") {
|
||
ForEach(viewModel.scenicList) { scenic in
|
||
Button {
|
||
viewModel.toggleScenic(scenic.id)
|
||
Task { await viewModel.loadStoreList(api: projectAPI) }
|
||
} label: {
|
||
HStack {
|
||
Text(scenic.name)
|
||
Spacer()
|
||
Image(systemName: viewModel.selectedScenicIds.contains(scenic.id) ? "checkmark.circle.fill" : "circle")
|
||
}
|
||
.foregroundStyle(viewModel.selectedScenicIds.contains(scenic.id) ? AppDesign.primary : AppDesign.textPrimary)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
if viewModel.projectType == 4 {
|
||
Picker("门店", selection: Binding(get: { viewModel.selectedStoreId ?? 0 }, set: { viewModel.selectedStoreId = $0 == 0 ? nil : $0 })) {
|
||
Text("请选择").tag(0)
|
||
ForEach(viewModel.storeList) { store in
|
||
Text(store.name).tag(store.id)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private var storePriceForm: some View {
|
||
ProjectFormCard(title: "价格与数量") {
|
||
if viewModel.projectType == 4 {
|
||
TextField("押金金额", text: $viewModel.priceDeposit)
|
||
.keyboardType(.decimalPad)
|
||
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight)
|
||
} else {
|
||
TextField("项目价格", text: $viewModel.projectPrice)
|
||
.keyboardType(.decimalPad)
|
||
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight)
|
||
HStack {
|
||
TextField("底片价", text: $viewModel.priceMaterial)
|
||
TextField("精修价", text: $viewModel.pricePhoto)
|
||
TextField("视频价", text: $viewModel.priceVideo)
|
||
}
|
||
.keyboardType(.decimalPad)
|
||
.textFieldStyle(.roundedBorder)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func prepare() async {
|
||
let userId = snapshotStore.load()?.businessUserId ?? Int(snapshotStore.load()?.profile?.userId ?? "") ?? 0
|
||
if let projectId {
|
||
do {
|
||
let detail = try await projectAPI.storeManagerProjectDetail(id: projectId)
|
||
viewModel.apply(detail)
|
||
} catch {
|
||
viewModel.errorMessage = error.localizedDescription
|
||
}
|
||
}
|
||
await viewModel.loadScenicList(api: projectAPI, userId: userId)
|
||
await viewModel.loadStoreList(api: projectAPI)
|
||
}
|
||
|
||
private func submit() async {
|
||
let userId = snapshotStore.load()?.businessUserId ?? Int(snapshotStore.load()?.profile?.userId ?? "") ?? 0
|
||
await globalLoading.withLoading {
|
||
let success = await viewModel.submit(userId: userId, api: projectAPI, uploadService: uploadService)
|
||
if success { dismiss() }
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 项目卡片视图,展示封面、名称、状态和价格。
|
||
private struct ProjectCardView: View {
|
||
let item: PhotographerProjectItem
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
RemoteImage(urlString: item.coverProject.isEmpty ? item.coverVideo : item.coverProject, contentMode: .fill) {
|
||
Image(systemName: "photo")
|
||
.font(.system(size: 30))
|
||
.foregroundStyle(AppDesign.primary)
|
||
}
|
||
.frame(height: 180)
|
||
.clipped()
|
||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
|
||
HStack {
|
||
Text(item.name.isEmpty ? "未命名项目" : item.name)
|
||
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||
.foregroundStyle(AppDesign.textPrimary)
|
||
Spacer()
|
||
Text(item.statusName.isEmpty ? "状态\(item.status)" : item.statusName)
|
||
.font(.system(size: AppMetrics.FontSize.caption))
|
||
.foregroundStyle(AppDesign.primary)
|
||
}
|
||
Text("¥\(item.price.isEmpty ? "0.00" : item.price)")
|
||
.font(.system(size: AppMetrics.FontSize.title3, weight: .bold))
|
||
.foregroundStyle(AppDesign.textPrimary)
|
||
}
|
||
.padding(AppMetrics.Spacing.medium)
|
||
}
|
||
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||
.clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||
}
|
||
}
|
||
|
||
/// 紧凑项目卡片视图,展示店铺项目摘要。
|
||
private struct ProjectCompactCardView: View {
|
||
let item: PhotographerProjectItem
|
||
|
||
var body: some View {
|
||
HStack(spacing: AppMetrics.Spacing.medium) {
|
||
RemoteImage(urlString: item.coverProject.isEmpty ? item.coverVideo : item.coverProject, contentMode: .fill) {
|
||
Image(systemName: "photo")
|
||
.foregroundStyle(AppDesign.primary)
|
||
}
|
||
.frame(width: 76, height: 76)
|
||
.clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input))
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
Text(item.name.isEmpty ? "未命名项目" : item.name)
|
||
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||
.foregroundStyle(AppDesign.textPrimary)
|
||
Text(item.typeName.isEmpty ? "类型\(item.type)" : item.typeName)
|
||
.font(.system(size: AppMetrics.FontSize.caption))
|
||
.foregroundStyle(AppDesign.textSecondary)
|
||
Text("¥\(item.price.isEmpty ? item.priceDeposit : item.price)")
|
||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||
.foregroundStyle(AppDesign.primary)
|
||
}
|
||
Spacer()
|
||
}
|
||
.padding(AppMetrics.Spacing.medium)
|
||
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||
}
|
||
}
|
||
|
||
/// 项目详情内容视图。
|
||
private struct ProjectDetailContentView: View {
|
||
let detail: PhotographerProjectDetailResponse
|
||
|
||
var body: some View {
|
||
VStack(spacing: AppMetrics.Spacing.medium) {
|
||
ProjectCardView(item: detail.asListItem)
|
||
ProjectFormCard(title: "服务内容") {
|
||
HStack {
|
||
service("底片", "\(detail.materialNum)")
|
||
service("精修", "\(detail.photoNum)")
|
||
service("视频", "\(detail.videoNum)")
|
||
}
|
||
}
|
||
ProjectFormCard(title: "基础信息") {
|
||
row("项目类型", detail.typeName)
|
||
row("状态", detail.statusName)
|
||
row("创建人", detail.creatorName)
|
||
row("简介", detail.description)
|
||
if !detail.auditRemark.isEmpty {
|
||
row("审核备注", detail.auditRemark)
|
||
}
|
||
}
|
||
if !detail.scenicList.isEmpty {
|
||
ProjectFormCard(title: "打卡点") {
|
||
Text(detail.scenicList.map(\.name).joined(separator: "、"))
|
||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||
.foregroundStyle(AppDesign.textPrimary)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func service(_ title: String, _ value: String) -> some View {
|
||
VStack(spacing: 4) {
|
||
Text(value)
|
||
.font(.system(size: AppMetrics.FontSize.title3, weight: .bold))
|
||
.foregroundStyle(AppDesign.primary)
|
||
Text(title)
|
||
.font(.system(size: AppMetrics.FontSize.caption))
|
||
.foregroundStyle(AppDesign.textSecondary)
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
|
||
private func row(_ title: String, _ value: String) -> some View {
|
||
HStack(alignment: .top) {
|
||
Text(title)
|
||
.foregroundStyle(AppDesign.textSecondary)
|
||
Spacer()
|
||
Text(value.isEmpty ? "--" : value)
|
||
.multilineTextAlignment(.trailing)
|
||
.foregroundStyle(AppDesign.textPrimary)
|
||
}
|
||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||
}
|
||
}
|
||
|
||
/// 项目表单通用卡片。
|
||
private struct ProjectFormCard<Content: View>: View {
|
||
let title: String
|
||
@ViewBuilder let content: () -> Content
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
|
||
Text(title)
|
||
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||
.foregroundStyle(AppDesign.textPrimary)
|
||
content()
|
||
}
|
||
.padding(AppMetrics.Spacing.medium)
|
||
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||
}
|
||
}
|
||
|
||
/// 项目图片选择标签。
|
||
private struct ProjectImagePickerLabel: View {
|
||
let title: String
|
||
let url: String
|
||
|
||
var body: some View {
|
||
ZStack {
|
||
RemoteImage(urlString: url, contentMode: .fill) {
|
||
VStack(spacing: 8) {
|
||
Image(systemName: "photo.badge.plus")
|
||
Text(title)
|
||
}
|
||
.foregroundStyle(AppDesign.primary)
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
.frame(height: 160)
|
||
.background(AppDesign.primarySoft, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input))
|
||
.clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input))
|
||
}
|
||
}
|
||
|
||
/// PhotosPicker 项目图片加载器。
|
||
private enum ProjectPickerLoader {
|
||
/// 从单个 PhotosPickerItem 读取项目图片。
|
||
static func loadImage(from item: PhotosPickerItem?) async -> ProjectLocalImage? {
|
||
guard let item, let data = try? await item.loadTransferable(type: Data.self) else { return nil }
|
||
let fileName = fileName(for: item)
|
||
return ProjectLocalImage(data: data, fileName: fileName, previewURL: dataURL(data: data, fileName: fileName))
|
||
}
|
||
|
||
/// 从多个 PhotosPickerItem 读取项目图片。
|
||
static func loadImages(from items: [PhotosPickerItem]) async -> [ProjectLocalImage] {
|
||
var result: [ProjectLocalImage] = []
|
||
for item in items {
|
||
if let image = await loadImage(from: item) {
|
||
result.append(image)
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
private static func fileName(for item: PhotosPickerItem) -> String {
|
||
let ext = item.supportedContentTypes.first?.preferredFilenameExtension ?? "jpg"
|
||
return "project_\(UUID().uuidString).\(ext)"
|
||
}
|
||
|
||
private static func dataURL(data: Data, fileName: String) -> String? {
|
||
let mime = UTType(filenameExtension: URL(fileURLWithPath: fileName).pathExtension)?.preferredMIMEType ?? "image/jpeg"
|
||
return "data:\(mime);base64,\(data.base64EncodedString())"
|
||
}
|
||
}
|
||
|
||
private extension PhotographerProjectDetailResponse {
|
||
/// 将详情转换为列表卡片展示项。
|
||
var asListItem: PhotographerProjectItem {
|
||
PhotographerProjectItem(
|
||
id: id,
|
||
type: type,
|
||
typeName: typeName,
|
||
status: status,
|
||
statusName: statusName,
|
||
name: name,
|
||
coverProject: coverProject,
|
||
coverVideo: cover,
|
||
price: price,
|
||
otPrice: otPrice,
|
||
priceDeposit: priceDeposit,
|
||
attrLabel: attrLabel
|
||
)
|
||
}
|
||
}
|