Files
suixinkan_ios_new/suixinkan/Features/Tasks/Views/TaskManagementView.swift
汉秋 ffb16eca29 新增任务模块,并接入首页任务管理路由
实现任务列表、详情与创建页面,对接 API,支持云端/本地附件,并添加单元测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 15:26:13 +08:00

273 lines
10 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// TaskManagementView.swift
// suixinkan
//
// Created by Codex on 2026/6/23.
//
import SwiftUI
///
struct TaskManagementView: View {
@Environment(AccountContext.self) private var accountContext
@Environment(RouterPath.self) private var router
@Environment(TaskAPI.self) private var taskAPI
@Environment(ToastCenter.self) private var toastCenter
@Environment(\.globalLoading) private var globalLoading
@State private var viewModel = TaskManagementViewModel()
var body: some View {
ScrollView {
LazyVStack(spacing: AppMetrics.Spacing.medium) {
filterSection
contentSection
}
.padding(.horizontal, AppMetrics.Spacing.pageHorizontal)
.padding(.vertical, AppMetrics.Spacing.medium)
}
.background(Color(hex: 0xF5F7FA))
.navigationTitle("任务管理")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
router.navigate(to: .home(.taskCreate))
} label: {
Image(systemName: "plus")
}
.accessibilityLabel("发布任务")
}
}
.refreshable { await reload(showLoading: false) }
.task {
guard viewModel.tasks.isEmpty else { return }
await reload(showLoading: true)
}
}
///
private var filterSection: some View {
VStack(spacing: AppMetrics.Spacing.small) {
Picker("任务状态", selection: $viewModel.selectedStatus) {
ForEach(TaskStatusFilter.allCases) { status in
Text(status.title).tag(status)
}
}
.pickerStyle(.segmented)
.onChange(of: viewModel.selectedStatus) { _, _ in
Task { await reload(showLoading: true) }
}
HStack(spacing: AppMetrics.Spacing.small) {
Image(systemName: "magnifyingglass")
.foregroundStyle(AppDesign.placeholder)
TextField("搜索任务名称", text: $viewModel.searchText)
.textInputAutocapitalization(.never)
.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)
}
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight)
VStack(spacing: AppMetrics.Spacing.xSmall) {
TaskDatePickerRow(title: "开始日期", date: $viewModel.startDate)
TaskDatePickerRow(title: "结束日期", date: $viewModel.endDate)
HStack {
Button("清除日期") {
viewModel.startDate = nil
viewModel.endDate = nil
Task { await reload(showLoading: true) }
}
.foregroundStyle(AppDesign.textSecondary)
Spacer()
Button("应用日期") {
Task { await reload(showLoading: true) }
}
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
.foregroundStyle(AppDesign.primary)
}
.font(.system(size: AppMetrics.FontSize.subheadline))
}
.padding(AppMetrics.Spacing.medium)
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
}
}
///
@ViewBuilder
private var contentSection: some View {
if accountContext.currentScenic?.id == nil {
TaskEmptyState(title: "缺少景区上下文", message: "请先在首页选择景区后再查看任务。")
} else if viewModel.tasks.isEmpty {
TaskEmptyState(title: "暂无任务", message: viewModel.errorMessage ?? "当前筛选条件下没有任务。")
} else {
ForEach(viewModel.tasks) { task in
Button {
router.navigate(to: .home(.taskDetail(id: task.id, summary: task)))
} label: {
TaskCardView(task: task)
}
.buttonStyle(.plain)
.onAppear {
guard task.id == viewModel.tasks.last?.id else { return }
Task { await loadMore() }
}
}
if viewModel.isLoadingMore {
Text("加载更多...")
.font(.system(size: AppMetrics.FontSize.footnote))
.foregroundStyle(AppDesign.textSecondary)
.padding(.vertical, AppMetrics.Spacing.small)
} else if viewModel.hasMore {
Button("加载更多") {
Task { await loadMore() }
}
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
.foregroundStyle(AppDesign.primary)
.padding(.vertical, AppMetrics.Spacing.small)
}
}
}
///
private func reload(showLoading: Bool) async {
do {
try await globalLoading.withOptionalLoading(showLoading && accountContext.currentScenic?.id != nil) {
try await viewModel.reload(api: taskAPI, scenicId: accountContext.currentScenic?.id)
}
} catch {
toastCenter.show(error.localizedDescription)
}
}
///
private func loadMore() async {
do {
try await viewModel.loadMore(api: taskAPI, scenicId: accountContext.currentScenic?.id)
} catch {
toastCenter.show(error.localizedDescription)
}
}
}
///
private struct TaskCardView: View {
let task: PhotographerTaskItem
var body: some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
HStack(alignment: .top, spacing: AppMetrics.Spacing.small) {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
Text(task.name.isEmpty ? "未命名任务" : task.name)
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
.foregroundStyle(AppDesign.textPrimary)
.lineLimit(2)
if !task.orderNumber.isEmpty {
Text("订单号:\(task.orderNumber)")
.font(.system(size: AppMetrics.FontSize.footnote))
.foregroundStyle(AppDesign.textSecondary)
}
}
Spacer(minLength: AppMetrics.Spacing.small)
Text(task.statusName.isEmpty ? "\(task.taskStatus)" : task.statusName)
.font(.system(size: AppMetrics.FontSize.caption, weight: .semibold))
.foregroundStyle(statusColor)
.padding(.horizontal, AppMetrics.Spacing.small)
.padding(.vertical, AppMetrics.Spacing.xxSmall)
.background(statusColor.opacity(0.12), in: Capsule())
}
if !task.photogRemark.isEmpty {
Text(task.photogRemark)
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.textSecondary)
.lineLimit(2)
}
HStack(spacing: AppMetrics.Spacing.small) {
Label(task.editorName.isEmpty ? "暂无编辑" : task.editorName, systemImage: "person.crop.circle")
Spacer()
Text(task.createdAt.isEmpty ? task.updatedAt : task.createdAt)
}
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.placeholder)
}
.padding(AppMetrics.Spacing.medium)
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
}
///
private var statusColor: Color {
switch task.taskStatus {
case 3:
AppDesign.success
case 4:
Color(hex: 0xEF4444)
case 1, 2:
AppDesign.warning
default:
AppDesign.primary
}
}
}
///
private struct TaskDatePickerRow: View {
let title: String
@Binding var date: Date?
var body: some View {
HStack {
Text(title)
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.textSecondary)
Spacer()
DatePicker(
title,
selection: Binding(
get: { date ?? Date() },
set: { date = $0 }
),
displayedComponents: .date
)
.labelsHidden()
}
}
}
///
private struct TaskEmptyState: View {
let title: String
let message: String
var body: some View {
VStack(spacing: AppMetrics.Spacing.small) {
Image(systemName: "tray")
.font(.system(size: 36, weight: .semibold))
.foregroundStyle(AppDesign.placeholder)
Text(title)
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
.foregroundStyle(AppDesign.textPrimary)
Text(message)
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.textSecondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding(.vertical, AppMetrics.Spacing.xxLarge)
.padding(.horizontal, AppMetrics.Spacing.large)
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
}
}