Lower deployment target to iOS 16, replace @Observable with ObservableObject, add navigation and UI compatibility shims, and include login smoke UI tests. Co-authored-by: Cursor <cursoragent@cursor.com>
187 lines
7.3 KiB
Swift
187 lines
7.3 KiB
Swift
//
|
||
// TaskDetailView.swift
|
||
// suixinkan
|
||
//
|
||
// Created by Codex on 2026/6/23.
|
||
//
|
||
|
||
import SwiftUI
|
||
|
||
/// 任务详情页面,展示任务基础信息、关联订单和任务结果。
|
||
struct TaskDetailView: View {
|
||
@Environment(\.taskAPI) private var taskAPI
|
||
@EnvironmentObject private var toastCenter: ToastCenter
|
||
@Environment(\.globalLoading) private var globalLoading
|
||
|
||
let taskId: Int
|
||
let summary: PhotographerTaskItem?
|
||
|
||
@StateObject 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
|
||
}
|
||
}
|