新增任务模块,并接入首页任务管理路由
实现任务列表、详情与创建页面,对接 API,支持云端/本地附件,并添加单元测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
281
suixinkan/Features/Tasks/Views/TaskCreateView.swift
Normal file
281
suixinkan/Features/Tasks/Views/TaskCreateView.swift
Normal file
@ -0,0 +1,281 @@
|
||||
//
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user