// // 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: 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 ) } }