Add Tasks module with home routing for task management flows.

Implement task list, detail, and create pages with API integration, cloud/local attachment support, and unit tests.

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

View File

@ -0,0 +1,186 @@
//
// TaskDetailView.swift
// suixinkan
//
// Created by Codex on 2026/6/23.
//
import SwiftUI
///
struct TaskDetailView: View {
@Environment(TaskAPI.self) private var taskAPI
@Environment(ToastCenter.self) private var toastCenter
@Environment(\.globalLoading) private var globalLoading
let taskId: Int
let summary: PhotographerTaskItem?
@State private var viewModel = TaskDetailViewModel()
var body: some View {
ScrollView {
VStack(spacing: AppMetrics.Spacing.medium) {
headerSection
orderSection
resultSection
}
.padding(AppMetrics.Spacing.pageHorizontal)
}
.background(Color(hex: 0xF5F7FA))
.navigationTitle("任务详情")
.navigationBarTitleDisplayMode(.inline)
.task {
await globalLoading.withOptionalLoading(viewModel.detail == nil) {
await viewModel.load(api: taskAPI, taskId: taskId)
}
if let message = viewModel.errorMessage {
toastCenter.show(message)
}
}
}
///
private var headerSection: some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
HStack(alignment: .top) {
Text(displayName)
.font(.system(size: AppMetrics.FontSize.title3, weight: .semibold))
.foregroundStyle(AppDesign.textPrimary)
.lineLimit(2)
Spacer()
Text(displayStatus)
.font(.system(size: AppMetrics.FontSize.caption, weight: .semibold))
.foregroundStyle(AppDesign.primary)
.padding(.horizontal, AppMetrics.Spacing.small)
.padding(.vertical, AppMetrics.Spacing.xxSmall)
.background(AppDesign.primarySoft, in: Capsule())
}
TaskInfoRow(title: "任务备注", value: detail?.photogRemark ?? summary?.photogRemark ?? "暂无")
TaskInfoRow(title: "创建时间", value: detail?.createdAt ?? summary?.createdAt ?? "暂无")
TaskInfoRow(title: "紧急小时", value: detail.map { "\($0.urgentHour) 小时" } ?? "暂无")
TaskInfoRow(title: "编辑人员", value: detail?.editorName.nonEmpty ?? summary?.editorName.nonEmpty ?? "暂无")
TaskInfoRow(title: "编辑电话", value: detail?.editorPhone.nonEmpty ?? "暂无")
}
.padding(AppMetrics.Spacing.medium)
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
}
///
private var orderSection: some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
Text("关联订单")
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
.foregroundStyle(AppDesign.textPrimary)
TaskInfoRow(title: "项目名称", value: detail?.order?.projectName.nonEmpty ?? summary?.orderUserNickname.nonEmpty ?? "暂无")
TaskInfoRow(title: "订单号", value: detail?.order?.orderNumber.nonEmpty ?? summary?.orderNumber.nonEmpty ?? "暂无")
TaskInfoRow(title: "手机号", value: detail?.order?.userPhone.nonEmpty ?? summary?.orderUserPhone.nonEmpty ?? "暂无")
TaskInfoRow(title: "支付时间", value: detail?.order?.payTime.nonEmpty ?? "暂无")
}
.padding(AppMetrics.Spacing.medium)
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
}
///
private var resultSection: some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
Text("任务结果")
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
.foregroundStyle(AppDesign.textPrimary)
if mediaItems.isEmpty {
Text("暂无任务结果文件")
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.textSecondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, AppMetrics.Spacing.small)
} else {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 92), spacing: AppMetrics.Spacing.small)], spacing: AppMetrics.Spacing.small) {
ForEach(mediaItems) { media in
TaskMediaThumbView(media: media)
}
}
}
}
.padding(AppMetrics.Spacing.medium)
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
}
///
private var detail: TaskDetailResponse? {
viewModel.detail
}
///
private var displayName: String {
detail?.name.nonEmpty ?? summary?.name.nonEmpty ?? "未命名任务"
}
///
private var displayStatus: String {
detail?.taskStatusLabel.nonEmpty ?? summary?.statusName.nonEmpty ?? "未知"
}
///
private var mediaItems: [TaskMediaItem] {
if let detail {
return detail.taskResult
}
return summary?.media ?? []
}
}
///
private struct TaskInfoRow: View {
let title: String
let value: String
var body: some View {
HStack(alignment: .top, spacing: AppMetrics.Spacing.medium) {
Text(title)
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.textSecondary)
.frame(width: 72, alignment: .leading)
Text(value.isEmpty ? "暂无" : value)
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.textPrimary)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
/// 使 Kingfisher
private struct TaskMediaThumbView: View {
let media: TaskMediaItem
var body: some View {
ZStack(alignment: .bottomLeading) {
RemoteImage(urlString: media.displayURL) {
Image(systemName: media.isVideo ? "video.fill" : "photo")
.font(.system(size: 28, weight: .semibold))
.foregroundStyle(AppDesign.placeholder)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(hex: 0xEEF2F7))
}
.frame(height: 92)
.clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input))
if media.isVideo {
Image(systemName: "play.circle.fill")
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(.white)
.padding(AppMetrics.Spacing.xSmall)
}
}
}
}
private extension String {
///
var nonEmpty: String? {
let text = trimmingCharacters(in: .whitespacesAndNewlines)
return text.isEmpty ? nil : text
}
}