实现任务列表、详情与创建页面,对接 API,支持云端/本地附件,并添加单元测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
282 lines
12 KiB
Swift
282 lines
12 KiB
Swift
//
|
||
// TaskCreateView.swift
|
||
// suixinkan
|
||
//
|
||
// Created by Codex on 2026/6/23.
|
||
//
|
||
|
||
import PhotosUI
|
||
import SwiftUI
|
||
import UniformTypeIdentifiers
|
||
|
||
/// 发布任务页面,负责填写任务信息、选择附件并提交任务。
|
||
struct TaskCreateView: View {
|
||
@Environment(AccountContext.self) private var accountContext
|
||
@Environment(TaskAPI.self) private var taskAPI
|
||
@Environment(OSSUploadService.self) private var ossUploadService
|
||
@Environment(ToastCenter.self) private var toastCenter
|
||
@Environment(\.dismiss) private var dismiss
|
||
@Environment(\.globalLoading) private var globalLoading
|
||
|
||
@State private var viewModel = TaskCreateViewModel()
|
||
@State private var showOrderSelection = false
|
||
@State private var showCloudSelection = false
|
||
@State private var pickerItems: [PhotosPickerItem] = []
|
||
|
||
var body: some View {
|
||
ScrollView {
|
||
VStack(spacing: AppMetrics.Spacing.medium) {
|
||
basicFormSection
|
||
orderSection
|
||
cloudFileSection
|
||
localFileSection
|
||
submitButton
|
||
}
|
||
.padding(AppMetrics.Spacing.pageHorizontal)
|
||
}
|
||
.background(Color(hex: 0xF5F7FA))
|
||
.navigationTitle("发布任务")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.task {
|
||
await viewModel.loadAvailableOrders(api: taskAPI, scenicId: accountContext.currentScenic?.id)
|
||
}
|
||
.sheet(isPresented: $showOrderSelection) {
|
||
NavigationStack {
|
||
TaskOrderSelectionView(
|
||
orders: viewModel.availableOrders,
|
||
selectedOrder: viewModel.selectedOrder,
|
||
onSelect: { order in
|
||
viewModel.selectOrder(order)
|
||
showOrderSelection = false
|
||
}
|
||
)
|
||
}
|
||
}
|
||
.sheet(isPresented: $showCloudSelection) {
|
||
NavigationStack {
|
||
TaskCloudFileSelectionView { items in
|
||
viewModel.mergeCloudFiles(items)
|
||
showCloudSelection = false
|
||
}
|
||
}
|
||
}
|
||
.onChange(of: pickerItems) { _, items in
|
||
Task { await importPickerItems(items) }
|
||
}
|
||
}
|
||
|
||
/// 基础表单区域。
|
||
private var basicFormSection: some View {
|
||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
|
||
Text("任务信息")
|
||
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||
.foregroundStyle(AppDesign.textPrimary)
|
||
|
||
TextField("任务名称", text: $viewModel.taskName)
|
||
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight)
|
||
|
||
TextField("紧急小时", text: $viewModel.urgentHourText)
|
||
.keyboardType(.numberPad)
|
||
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight)
|
||
|
||
TextField("任务备注", text: $viewModel.remark, axis: .vertical)
|
||
.lineLimit(4, reservesSpace: true)
|
||
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: 104)
|
||
}
|
||
.padding(AppMetrics.Spacing.medium)
|
||
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||
}
|
||
|
||
/// 关联订单区域。
|
||
private var orderSection: some View {
|
||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
|
||
HStack {
|
||
Text("关联订单")
|
||
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||
.foregroundStyle(AppDesign.textPrimary)
|
||
Spacer()
|
||
Button(viewModel.selectedOrder == nil ? "选择订单" : "更换") {
|
||
showOrderSelection = true
|
||
}
|
||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||
.foregroundStyle(AppDesign.primary)
|
||
}
|
||
|
||
if let order = viewModel.selectedOrder {
|
||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
|
||
Text(order.projectName.isEmpty ? "未命名项目" : order.projectName)
|
||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||
.foregroundStyle(AppDesign.textPrimary)
|
||
Text("订单号:\(order.orderNumber)")
|
||
Text("手机号:\(order.userPhone.isEmpty ? "暂无" : order.userPhone)")
|
||
}
|
||
.font(.system(size: AppMetrics.FontSize.footnote))
|
||
.foregroundStyle(AppDesign.textSecondary)
|
||
Button("清除关联订单") {
|
||
viewModel.selectOrder(nil)
|
||
}
|
||
.font(.system(size: AppMetrics.FontSize.footnote))
|
||
.foregroundStyle(Color(hex: 0xEF4444))
|
||
} else {
|
||
Text("可不选择订单,直接发布普通任务。")
|
||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||
.foregroundStyle(AppDesign.textSecondary)
|
||
}
|
||
}
|
||
.padding(AppMetrics.Spacing.medium)
|
||
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||
}
|
||
|
||
/// 云盘附件区域。
|
||
private var cloudFileSection: some View {
|
||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
|
||
HStack {
|
||
Text("云盘附件")
|
||
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||
.foregroundStyle(AppDesign.textPrimary)
|
||
Spacer()
|
||
Button("选择云盘文件") {
|
||
showCloudSelection = true
|
||
}
|
||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||
.foregroundStyle(AppDesign.primary)
|
||
}
|
||
|
||
if viewModel.selectedCloudFiles.isEmpty {
|
||
Text("未选择云盘文件")
|
||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||
.foregroundStyle(AppDesign.textSecondary)
|
||
} else {
|
||
ForEach(viewModel.selectedCloudFiles) { file in
|
||
TaskAttachmentRow(title: file.fileName, subtitle: "云盘文件") {
|
||
viewModel.removeCloudFile(id: file.id)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.padding(AppMetrics.Spacing.medium)
|
||
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||
}
|
||
|
||
/// 本地附件区域。
|
||
private var localFileSection: some View {
|
||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
|
||
HStack {
|
||
Text("本地附件")
|
||
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||
.foregroundStyle(AppDesign.textPrimary)
|
||
Spacer()
|
||
PhotosPicker(selection: $pickerItems, maxSelectionCount: 9, matching: .any(of: [.images, .videos])) {
|
||
Text("选择图片/视频")
|
||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||
.foregroundStyle(AppDesign.primary)
|
||
}
|
||
}
|
||
|
||
if viewModel.selectedLocalFiles.isEmpty {
|
||
Text("未选择本地文件")
|
||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||
.foregroundStyle(AppDesign.textSecondary)
|
||
} else {
|
||
ForEach(viewModel.selectedLocalFiles) { file in
|
||
TaskAttachmentRow(title: file.fileName, subtitle: file.statusText) {
|
||
viewModel.removeLocalFile(id: file.id)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.padding(AppMetrics.Spacing.medium)
|
||
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||
}
|
||
|
||
/// 提交按钮。
|
||
private var submitButton: some View {
|
||
Button {
|
||
Task { await submit() }
|
||
} label: {
|
||
Text(viewModel.isSubmitting ? "提交中..." : "提交任务")
|
||
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||
.foregroundStyle(.white)
|
||
.frame(maxWidth: .infinity)
|
||
.frame(height: AppMetrics.ControlSize.primaryButtonHeight)
|
||
.background(AppDesign.primary, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.button))
|
||
}
|
||
.disabled(viewModel.isSubmitting)
|
||
.opacity(viewModel.isSubmitting ? 0.6 : 1)
|
||
}
|
||
|
||
/// 导入 PhotosPicker 选择的本地文件。
|
||
private func importPickerItems(_ items: [PhotosPickerItem]) async {
|
||
for item in items {
|
||
do {
|
||
guard let data = try await item.loadTransferable(type: Data.self) else { continue }
|
||
let fileName = Self.fileName(for: item)
|
||
viewModel.addLocalFile(data: data, fileName: fileName)
|
||
} catch {
|
||
toastCenter.show(error.localizedDescription)
|
||
}
|
||
}
|
||
pickerItems = []
|
||
}
|
||
|
||
/// 提交发布任务表单。
|
||
private func submit() async {
|
||
do {
|
||
let success = try await globalLoading.withLoading {
|
||
try await viewModel.submit(api: taskAPI, uploadService: ossUploadService, scenicId: accountContext.currentScenic?.id)
|
||
}
|
||
if success {
|
||
toastCenter.show("任务发布成功")
|
||
dismiss()
|
||
} else if let message = viewModel.errorMessage {
|
||
toastCenter.show(message)
|
||
}
|
||
} catch {
|
||
toastCenter.show(viewModel.errorMessage ?? error.localizedDescription)
|
||
}
|
||
}
|
||
|
||
/// 根据选择项类型生成本地附件文件名。
|
||
private static func fileName(for item: PhotosPickerItem) -> String {
|
||
let isVideo = item.supportedContentTypes.contains { $0.conforms(to: .movie) || $0.conforms(to: .video) }
|
||
return "\(UUID().uuidString).\(isVideo ? "mp4" : "jpg")"
|
||
}
|
||
}
|
||
|
||
/// 任务附件行,展示文件名、状态和删除按钮。
|
||
private struct TaskAttachmentRow: View {
|
||
let title: String
|
||
let subtitle: String
|
||
let onDelete: () -> Void
|
||
|
||
var body: some View {
|
||
HStack(spacing: AppMetrics.Spacing.small) {
|
||
Image(systemName: "paperclip")
|
||
.foregroundStyle(AppDesign.primary)
|
||
.frame(width: AppMetrics.ControlSize.iconTapArea, height: AppMetrics.ControlSize.iconTapArea)
|
||
.background(AppDesign.primarySoft, in: Circle())
|
||
|
||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxSmall) {
|
||
Text(title)
|
||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||
.foregroundStyle(AppDesign.textPrimary)
|
||
.lineLimit(1)
|
||
Text(subtitle)
|
||
.font(.system(size: AppMetrics.FontSize.caption))
|
||
.foregroundStyle(AppDesign.textSecondary)
|
||
}
|
||
|
||
Spacer()
|
||
|
||
Button(action: onDelete) {
|
||
Image(systemName: "xmark.circle.fill")
|
||
.foregroundStyle(AppDesign.placeholder)
|
||
.frame(width: AppMetrics.ControlSize.iconTapArea, height: AppMetrics.ControlSize.iconTapArea)
|
||
}
|
||
.accessibilityLabel("删除附件")
|
||
}
|
||
.padding(AppMetrics.Spacing.small)
|
||
.background(Color(hex: 0xF8FAFC), in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input))
|
||
}
|
||
}
|