新增任务模块,并接入首页任务管理路由

实现任务列表、详情与创建页面,对接 API,支持云端/本地附件,并添加单元测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-23 15:26:13 +08:00
parent 09922f70fe
commit ffb16eca29
23 changed files with 2795 additions and 8 deletions

View File

@ -0,0 +1,272 @@
//
// 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))
}
}