Add Projects, Schedule, and Invite modules with home routing.
Migrate project management, schedule management, and photographer invitation flows from placeholders, including project image OSS upload and shared business models. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
776
suixinkan/Features/Projects/Views/ProjectViews.swift
Normal file
776
suixinkan/Features/Projects/Views/ProjectViews.swift
Normal file
@ -0,0 +1,776 @@
|
||||
//
|
||||
// ProjectViews.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/24.
|
||||
//
|
||||
|
||||
import PhotosUI
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
/// 摄影师项目管理页面,展示项目列表并提供新建、详情和删除入口。
|
||||
struct ProjectManagementView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(AccountSnapshotStore.self) private var snapshotStore
|
||||
@Environment(ProjectAPI.self) private var projectAPI
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
@State private var viewModel = ProjectManagementViewModel()
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
filterBar
|
||||
if accountContext.currentScenic?.id == nil {
|
||||
ContentUnavailableView("缺少景区", 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 {
|
||||
ContentUnavailableView("暂无项目", 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.self) private var snapshotStore
|
||||
@Environment(ProjectAPI.self) private var projectAPI
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
@State 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 {
|
||||
ContentUnavailableView("暂无店铺项目", 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.self) 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 {
|
||||
ContentUnavailableView("加载失败", 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
|
||||
@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
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
let projectId: Int?
|
||||
@State 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 = ProjectEditorViewModel(mode: .edit(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.self) private var snapshotStore
|
||||
@Environment(ProjectAPI.self) private var projectAPI
|
||||
@Environment(OSSUploadService.self) private var uploadService
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
let projectId: Int?
|
||||
@State 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 = StoreProjectEditorViewModel(mode: .edit(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
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user