新增项目、排期与邀请模块,并接入首页路由
将项目管理、排期管理与摄影师邀请流程从占位页迁移为完整实现,包含项目图片 OSS 上传与共享业务模型。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
93
suixinkan/Features/Schedule/API/ScheduleAPI.swift
Normal file
93
suixinkan/Features/Schedule/API/ScheduleAPI.swift
Normal file
@ -0,0 +1,93 @@
|
||||
//
|
||||
// ScheduleAPI.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// 排班服务协议,抽象排班列表、新增、删除和可关联订单接口。
|
||||
@MainActor
|
||||
protocol ScheduleServing {
|
||||
/// 获取指定月份有排班的日期集合。
|
||||
func monthScheduleDays(scenicId: Int, yearMonth: String) async throws -> [String]
|
||||
|
||||
/// 获取指定日期的排班列表。
|
||||
func dayScheduleList(scenicId: Int, date: String) async throws -> [ScheduleItem]
|
||||
|
||||
/// 新增排班。
|
||||
func addSchedule(_ request: AddScheduleRequest) async throws
|
||||
|
||||
/// 删除排班。
|
||||
func deleteSchedule(id: Int) async throws
|
||||
|
||||
/// 获取排班可关联订单。
|
||||
func availableOrderList(scenicId: Int) async throws -> [AvailableOrderResponse]
|
||||
}
|
||||
|
||||
/// 排班 API,封装摄影师排班相关接口。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class ScheduleAPI: ScheduleServing {
|
||||
@ObservationIgnored private let client: APIClient
|
||||
|
||||
/// 初始化排班 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
self.client = client
|
||||
}
|
||||
|
||||
/// 获取指定月份有排班的日期集合。
|
||||
func monthScheduleDays(scenicId: Int, yearMonth: String) async throws -> [String] {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/yf-handset-app/photog/schedule/list-date",
|
||||
queryItems: [
|
||||
URLQueryItem(name: "scenic_id", value: "\(scenicId)"),
|
||||
URLQueryItem(name: "date", value: yearMonth)
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取指定日期的排班列表。
|
||||
func dayScheduleList(scenicId: Int, date: String) async throws -> [ScheduleItem] {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/yf-handset-app/photog/schedule/list",
|
||||
queryItems: [
|
||||
URLQueryItem(name: "scenic_id", value: "\(scenicId)"),
|
||||
URLQueryItem(name: "date", value: date)
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 新增排班。
|
||||
func addSchedule(_ request: AddScheduleRequest) async throws {
|
||||
_ = try await client.send(
|
||||
APIRequest(method: .post, path: "/api/yf-handset-app/photog/schedule/add", body: request)
|
||||
) as EmptyPayload
|
||||
}
|
||||
|
||||
/// 删除排班。
|
||||
func deleteSchedule(id: Int) async throws {
|
||||
_ = try await client.send(
|
||||
APIRequest(method: .post, path: "/api/yf-handset-app/photog/schedule/delete", body: ScheduleDeleteRequest(id: id))
|
||||
) as EmptyPayload
|
||||
}
|
||||
|
||||
/// 获取排班可关联订单。
|
||||
func availableOrderList(scenicId: Int) async throws -> [AvailableOrderResponse] {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/yf-handset-app/photog/task/available-order",
|
||||
queryItems: [URLQueryItem(name: "scenic_id", value: "\(scenicId)")]
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
140
suixinkan/Features/Schedule/Models/ScheduleModels.swift
Normal file
140
suixinkan/Features/Schedule/Models/ScheduleModels.swift
Normal file
@ -0,0 +1,140 @@
|
||||
//
|
||||
// ScheduleModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 排班日程实体,表示某一天的一条工作安排。
|
||||
struct ScheduleItem: Decodable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let remark: String
|
||||
let startTime: String
|
||||
let endTime: String
|
||||
let scheduleDate: String
|
||||
let orderNumber: String?
|
||||
let userNickname: String?
|
||||
let userPhone: String?
|
||||
let orderStatus: Int?
|
||||
let orderStatusName: String?
|
||||
|
||||
/// 字段映射,兼容后端下划线命名。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case name
|
||||
case remark
|
||||
case startTime = "start_time"
|
||||
case endTime = "end_time"
|
||||
case scheduleDate = "schedule_date"
|
||||
case orderNumber = "order_number"
|
||||
case userNickname = "user_nickname"
|
||||
case userPhone = "user_phone"
|
||||
case orderStatus = "order_status"
|
||||
case orderStatusName = "order_status_name"
|
||||
}
|
||||
|
||||
/// 宽松解码排班字段。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decodeScheduleLossyInt(forKey: .id) ?? 0
|
||||
name = try container.decodeScheduleLossyString(forKey: .name)
|
||||
remark = try container.decodeScheduleLossyString(forKey: .remark)
|
||||
startTime = try container.decodeScheduleLossyString(forKey: .startTime)
|
||||
endTime = try container.decodeScheduleLossyString(forKey: .endTime)
|
||||
scheduleDate = try container.decodeScheduleLossyString(forKey: .scheduleDate)
|
||||
orderNumber = container.decodeScheduleLossyOptionalString(forKey: .orderNumber)
|
||||
userNickname = container.decodeScheduleLossyOptionalString(forKey: .userNickname)
|
||||
userPhone = container.decodeScheduleLossyOptionalString(forKey: .userPhone)
|
||||
orderStatus = try container.decodeScheduleLossyInt(forKey: .orderStatus)
|
||||
orderStatusName = container.decodeScheduleLossyOptionalString(forKey: .orderStatusName)
|
||||
}
|
||||
}
|
||||
|
||||
/// 新增排班请求实体。
|
||||
struct AddScheduleRequest: Encodable, Equatable {
|
||||
let scenicId: String
|
||||
let name: String
|
||||
let remark: String
|
||||
let startTime: String
|
||||
let endTime: String
|
||||
let scheduleDate: String
|
||||
let orderNumber: String?
|
||||
|
||||
/// 请求字段映射,兼容后端下划线命名。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case scenicId = "scenic_id"
|
||||
case name
|
||||
case remark
|
||||
case startTime = "start_time"
|
||||
case endTime = "end_time"
|
||||
case scheduleDate = "schedule_date"
|
||||
case orderNumber = "order_number"
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除排班请求实体。
|
||||
struct ScheduleDeleteRequest: Encodable, Equatable {
|
||||
let id: Int
|
||||
}
|
||||
|
||||
/// 排班编辑表单实体,用于 ViewModel 校验和生成请求。
|
||||
struct ScheduleDraft: Equatable {
|
||||
var name = ""
|
||||
var remark = ""
|
||||
var scheduleDate = ""
|
||||
var startTime = ""
|
||||
var endTime = ""
|
||||
var manualOrderNumber = ""
|
||||
var selectedOrder: AvailableOrderResponse?
|
||||
|
||||
/// 计算最终订单号,优先使用选择的订单,手工输入作为兜底。
|
||||
func finalOrderNumber() -> String? {
|
||||
let picked = selectedOrder?.orderNumber.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let manual = manualOrderNumber.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let value = picked.isEmpty ? manual : picked
|
||||
return value.isEmpty ? nil : value
|
||||
}
|
||||
}
|
||||
|
||||
private extension KeyedDecodingContainer {
|
||||
/// 将 String、数字和 Bool 宽松解码为字符串。
|
||||
func decodeScheduleLossyString(forKey key: Key) throws -> String {
|
||||
decodeScheduleLossyOptionalString(forKey: key) ?? ""
|
||||
}
|
||||
|
||||
/// 将 String、数字和 Bool 宽松解码为可选字符串。
|
||||
func decodeScheduleLossyOptionalString(forKey key: Key) -> String? {
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : value
|
||||
}
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||
return String(value)
|
||||
}
|
||||
if let value = try? decodeIfPresent(Double.self, forKey: key) {
|
||||
return String(value)
|
||||
}
|
||||
if let value = try? decodeIfPresent(Bool.self, forKey: key) {
|
||||
return value ? "1" : "0"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 将 String、Double 和 Int 宽松解码为 Int。
|
||||
func decodeScheduleLossyInt(forKey key: Key) throws -> Int? {
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try? decodeIfPresent(Double.self, forKey: key) {
|
||||
return Int(value)
|
||||
}
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return Int(text) ?? Double(text).map(Int.init)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
27
suixinkan/Features/Schedule/Schedule.md
Normal file
27
suixinkan/Features/Schedule/Schedule.md
Normal file
@ -0,0 +1,27 @@
|
||||
# Schedule 模块业务逻辑
|
||||
|
||||
## 模块职责
|
||||
|
||||
Schedule 模块负责首页 `schedule_management` 入口,提供排班日历、某日排班列表、新增排班和删除排班能力。
|
||||
|
||||
模块状态只保存在 `ScheduleManagementViewModel` 和 `ScheduleAddViewModel` 内,不写入全局登录态、账号上下文或首页状态。
|
||||
|
||||
## 排班列表
|
||||
|
||||
`ScheduleManagementViewModel` 按当前月份加载有排班的日期标记,并按选中日期加载日排班列表。切换月份或日期时只刷新当前模块数据。
|
||||
|
||||
缺少当前景区时,ViewModel 会清空月份标记和日程列表,并展示缺少经营上下文的空状态。
|
||||
|
||||
## 新增排班
|
||||
|
||||
`ScheduleAddViewModel` 管理名称、备注、日期、开始/结束时间和关联订单。关联订单可以从接口返回的可选订单中选择,也可以手填订单号兜底。
|
||||
|
||||
提交前会校验名称、景区、日期和时间顺序;重复提交会被忽略。提交成功后返回排班列表并刷新当前日期。
|
||||
|
||||
## 接口边界
|
||||
|
||||
`ScheduleAPI` 封装月排班日期、日排班列表、新增排班、删除排班和可关联订单接口。可关联订单模型放在共享业务模型中,避免 Schedule 直接依赖 Tasks 模块。
|
||||
|
||||
## 缓存边界
|
||||
|
||||
排班表单、关联订单、月标记和日程列表都只保存在内存中,不做本地缓存。
|
||||
225
suixinkan/Features/Schedule/ViewModels/ScheduleViewModels.swift
Normal file
225
suixinkan/Features/Schedule/ViewModels/ScheduleViewModels.swift
Normal file
@ -0,0 +1,225 @@
|
||||
//
|
||||
// ScheduleViewModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// 排班错误实体,表示表单校验失败。
|
||||
enum ScheduleValidationError: LocalizedError, Equatable {
|
||||
case missingScenic
|
||||
case missingName
|
||||
case invalidTime
|
||||
|
||||
/// 错误文案。
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .missingScenic: return "当前缺少景区信息"
|
||||
case .missingName: return "请输入日程名称"
|
||||
case .invalidTime: return "结束时间必须晚于开始时间"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 排班管理 ViewModel,负责月份日期标记、某日列表和删除操作。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class ScheduleManagementViewModel {
|
||||
var monthDate = Date.scheduleFirstDayOfMonth
|
||||
var selectedDate = Date()
|
||||
private(set) var markedDays: Set<String> = []
|
||||
private(set) var items: [ScheduleItem] = []
|
||||
private(set) var loading = false
|
||||
var errorMessage: String?
|
||||
|
||||
/// 切换到上一个月并重新加载。
|
||||
func previousMonth(api: any ScheduleServing, scenicId: Int?) async {
|
||||
monthDate = Calendar.current.date(byAdding: .month, value: -1, to: monthDate) ?? monthDate
|
||||
selectedDate = monthDate
|
||||
await reload(api: api, scenicId: scenicId)
|
||||
}
|
||||
|
||||
/// 切换到下一个月并重新加载。
|
||||
func nextMonth(api: any ScheduleServing, scenicId: Int?) async {
|
||||
monthDate = Calendar.current.date(byAdding: .month, value: 1, to: monthDate) ?? monthDate
|
||||
selectedDate = monthDate
|
||||
await reload(api: api, scenicId: scenicId)
|
||||
}
|
||||
|
||||
/// 选择日期并加载当天排班。
|
||||
func selectDate(_ date: Date, api: any ScheduleServing, scenicId: Int?) async {
|
||||
selectedDate = date
|
||||
await loadDay(api: api, scenicId: scenicId)
|
||||
}
|
||||
|
||||
/// 重新加载月份日期标记和当前日排班。
|
||||
func reload(api: any ScheduleServing, scenicId: Int?) async {
|
||||
guard let scenicId else {
|
||||
reset()
|
||||
return
|
||||
}
|
||||
loading = true
|
||||
errorMessage = nil
|
||||
defer { loading = false }
|
||||
do {
|
||||
async let days = api.monthScheduleDays(scenicId: scenicId, yearMonth: monthDate.scheduleYearMonthText)
|
||||
async let list = api.dayScheduleList(scenicId: scenicId, date: selectedDate.scheduleDayText)
|
||||
markedDays = Set(try await days)
|
||||
items = try await list
|
||||
} catch {
|
||||
items = []
|
||||
markedDays = []
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 只加载当前选中日期的排班列表。
|
||||
func loadDay(api: any ScheduleServing, scenicId: Int?) async {
|
||||
guard let scenicId else {
|
||||
reset()
|
||||
return
|
||||
}
|
||||
do {
|
||||
items = try await api.dayScheduleList(scenicId: scenicId, date: selectedDate.scheduleDayText)
|
||||
} catch {
|
||||
items = []
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除排班后刷新当前日和月份标记。
|
||||
func delete(item: ScheduleItem, api: any ScheduleServing, scenicId: Int?) async {
|
||||
do {
|
||||
try await api.deleteSchedule(id: item.id)
|
||||
await reload(api: api, scenicId: scenicId)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func reset() {
|
||||
markedDays = []
|
||||
items = []
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
/// 新增排班 ViewModel,负责可选订单加载、表单校验和提交。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class ScheduleAddViewModel {
|
||||
var draft = ScheduleDraft()
|
||||
private(set) var availableOrders: [AvailableOrderResponse] = []
|
||||
private(set) var loadingOrders = false
|
||||
private(set) var submitting = false
|
||||
var errorMessage: String?
|
||||
|
||||
/// 加载可关联订单列表。
|
||||
func loadOrders(api: any ScheduleServing, scenicId: Int?) async {
|
||||
guard let scenicId else {
|
||||
availableOrders = []
|
||||
return
|
||||
}
|
||||
loadingOrders = true
|
||||
defer { loadingOrders = false }
|
||||
do {
|
||||
availableOrders = try await api.availableOrderList(scenicId: scenicId)
|
||||
} catch {
|
||||
availableOrders = []
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 提交新增排班请求。
|
||||
func submit(api: any ScheduleServing, scenicId: Int?, startDate: Date, endDate: Date) async -> Bool {
|
||||
guard !submitting else { return false }
|
||||
guard let scenicId else { return fail(.missingScenic) }
|
||||
let normalizedName = draft.name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedName.isEmpty else { return fail(.missingName) }
|
||||
guard endDate > startDate else { return fail(.invalidTime) }
|
||||
|
||||
submitting = true
|
||||
errorMessage = nil
|
||||
defer { submitting = false }
|
||||
do {
|
||||
try await api.addSchedule(
|
||||
AddScheduleRequest(
|
||||
scenicId: "\(scenicId)",
|
||||
name: normalizedName,
|
||||
remark: draft.remark.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? normalizedName : draft.remark.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
startTime: startDate.scheduleHourMinuteText,
|
||||
endTime: endDate.scheduleHourMinuteText,
|
||||
scheduleDate: startDate.scheduleDayText,
|
||||
orderNumber: draft.finalOrderNumber()
|
||||
)
|
||||
)
|
||||
return true
|
||||
} catch {
|
||||
errorMessage = normalizedError(error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据旧后端英文错误归一化为中文。
|
||||
private func normalizedError(_ error: Error) -> String {
|
||||
let text = error.localizedDescription
|
||||
if text.contains("start time") || text.contains("format H:i") {
|
||||
return "开始时间格式不正确,请重新选择开始时间"
|
||||
}
|
||||
if text.contains("end time") {
|
||||
return "结束时间格式不正确,请重新选择结束时间"
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
private func fail(_ error: ScheduleValidationError) -> Bool {
|
||||
errorMessage = error.localizedDescription
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
extension Date {
|
||||
/// 当前月份第一天。
|
||||
static var scheduleFirstDayOfMonth: Date {
|
||||
Calendar.current.date(from: Calendar.current.dateComponents([.year, .month], from: Date())) ?? Date()
|
||||
}
|
||||
|
||||
/// yyyy-MM 格式的月份文本。
|
||||
var scheduleYearMonthText: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "zh_CN")
|
||||
formatter.dateFormat = "yyyy-MM"
|
||||
return formatter.string(from: self)
|
||||
}
|
||||
|
||||
/// yyyy-MM-dd 格式的日期文本。
|
||||
var scheduleDayText: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "zh_CN")
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
return formatter.string(from: self)
|
||||
}
|
||||
|
||||
/// HH:mm 格式的时间文本。
|
||||
var scheduleHourMinuteText: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "zh_CN")
|
||||
formatter.dateFormat = "HH:mm"
|
||||
return formatter.string(from: self)
|
||||
}
|
||||
|
||||
/// 当前月份的全部日期。
|
||||
var scheduleDaysInMonth: [Date] {
|
||||
let calendar = Calendar.current
|
||||
let range = calendar.range(of: .day, in: .month, for: self) ?? 1..<2
|
||||
let components = calendar.dateComponents([.year, .month], from: self)
|
||||
return range.compactMap { day in
|
||||
var next = components
|
||||
next.day = day
|
||||
return calendar.date(from: next)
|
||||
}
|
||||
}
|
||||
}
|
||||
255
suixinkan/Features/Schedule/Views/ScheduleViews.swift
Normal file
255
suixinkan/Features/Schedule/Views/ScheduleViews.swift
Normal file
@ -0,0 +1,255 @@
|
||||
//
|
||||
// ScheduleViews.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/24.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// 排班管理页面,展示月视图日期标记和当天排班列表。
|
||||
struct ScheduleManagementView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(ScheduleAPI.self) private var scheduleAPI
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
@State private var viewModel = ScheduleManagementViewModel()
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
if accountContext.currentScenic?.id == nil {
|
||||
ContentUnavailableView("缺少景区", systemImage: "calendar.badge.exclamationmark", description: Text("请选择景区后再查看排班。"))
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
ScrollView {
|
||||
VStack(spacing: AppMetrics.Spacing.medium) {
|
||||
calendarCard
|
||||
scheduleListCard
|
||||
}
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
}
|
||||
.background(Color(hex: 0xF5F7FA))
|
||||
.refreshable { await reload(showLoading: false) }
|
||||
}
|
||||
}
|
||||
.navigationTitle("日程管理")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
NavigationLink(value: HomeRoute.scheduleAdd) {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
.accessibilityLabel("添加日程")
|
||||
}
|
||||
}
|
||||
.task(id: accountContext.currentScenic?.id) {
|
||||
await reload(showLoading: true)
|
||||
}
|
||||
}
|
||||
|
||||
private var calendarCard: some View {
|
||||
VStack(spacing: AppMetrics.Spacing.medium) {
|
||||
HStack {
|
||||
Button {
|
||||
Task { await viewModel.previousMonth(api: scheduleAPI, scenicId: accountContext.currentScenic?.id) }
|
||||
} label: {
|
||||
Image(systemName: "chevron.left")
|
||||
.frame(width: 36, height: 36)
|
||||
}
|
||||
Spacer()
|
||||
Text(viewModel.monthDate.scheduleYearMonthText)
|
||||
.font(.system(size: AppMetrics.FontSize.title3, weight: .semibold))
|
||||
Spacer()
|
||||
Button {
|
||||
Task { await viewModel.nextMonth(api: scheduleAPI, scenicId: accountContext.currentScenic?.id) }
|
||||
} label: {
|
||||
Image(systemName: "chevron.right")
|
||||
.frame(width: 36, height: 36)
|
||||
}
|
||||
}
|
||||
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 7), spacing: AppMetrics.Spacing.small) {
|
||||
ForEach(viewModel.monthDate.scheduleDaysInMonth, id: \.scheduleDayText) { date in
|
||||
Button {
|
||||
Task { await viewModel.selectDate(date, api: scheduleAPI, scenicId: accountContext.currentScenic?.id) }
|
||||
} label: {
|
||||
VStack(spacing: 4) {
|
||||
Text(dayNumber(date))
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: viewModel.selectedDate.scheduleDayText == date.scheduleDayText ? .bold : .regular))
|
||||
Circle()
|
||||
.fill(viewModel.markedDays.contains(date.scheduleDayText) ? AppDesign.primary : .clear)
|
||||
.frame(width: 5, height: 5)
|
||||
}
|
||||
.foregroundStyle(viewModel.selectedDate.scheduleDayText == date.scheduleDayText ? .white : AppDesign.textPrimary)
|
||||
.frame(height: 42)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(viewModel.selectedDate.scheduleDayText == date.scheduleDayText ? AppDesign.primary : Color.clear, in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||||
}
|
||||
|
||||
private var scheduleListCard: some View {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
|
||||
Text(viewModel.selectedDate.scheduleDayText)
|
||||
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||||
if viewModel.items.isEmpty {
|
||||
ContentUnavailableView("当天暂无日程", systemImage: "calendar")
|
||||
.frame(minHeight: 180)
|
||||
} else {
|
||||
ForEach(viewModel.items) { item in
|
||||
ScheduleItemCard(item: item) {
|
||||
Task { await viewModel.delete(item: item, api: scheduleAPI, scenicId: accountContext.currentScenic?.id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||||
}
|
||||
|
||||
private func reload(showLoading: Bool) async {
|
||||
await globalLoading.withOptionalLoading(showLoading && accountContext.currentScenic?.id != nil) {
|
||||
await viewModel.reload(api: scheduleAPI, scenicId: accountContext.currentScenic?.id)
|
||||
}
|
||||
}
|
||||
|
||||
private func dayNumber(_ date: Date) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "d"
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
}
|
||||
|
||||
/// 新增排班页面,支持手动录入和关联订单。
|
||||
struct ScheduleAddView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(ScheduleAPI.self) private var scheduleAPI
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
@State private var viewModel = ScheduleAddViewModel()
|
||||
@State private var date = Date()
|
||||
@State private var startTime = Date()
|
||||
@State private var endTime = Calendar.current.date(byAdding: .hour, value: 1, to: Date()) ?? Date()
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: AppMetrics.Spacing.medium) {
|
||||
formCard("基础信息") {
|
||||
TextField("日程名称", text: $viewModel.draft.name)
|
||||
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight)
|
||||
TextField("备注", text: $viewModel.draft.remark, axis: .vertical)
|
||||
.lineLimit(2...4)
|
||||
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: 82)
|
||||
Picker("关联订单", selection: Binding(get: { viewModel.draft.selectedOrder?.orderNumber ?? "" }, set: { value in
|
||||
viewModel.draft.selectedOrder = viewModel.availableOrders.first { $0.orderNumber == value }
|
||||
})) {
|
||||
Text("不关联").tag("")
|
||||
ForEach(viewModel.availableOrders) { order in
|
||||
Text("\(order.projectName) \(order.orderNumber)").tag(order.orderNumber)
|
||||
}
|
||||
}
|
||||
TextField("手工输入订单号(兜底)", text: $viewModel.draft.manualOrderNumber)
|
||||
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight)
|
||||
}
|
||||
formCard("时间") {
|
||||
DatePicker("日期", selection: $date, displayedComponents: .date)
|
||||
DatePicker("开始时间", selection: $startTime, displayedComponents: .hourAndMinute)
|
||||
DatePicker("结束时间", selection: $endTime, displayedComponents: .hourAndMinute)
|
||||
}
|
||||
if let error = viewModel.errorMessage {
|
||||
Text(error)
|
||||
.font(.system(size: AppMetrics.FontSize.caption))
|
||||
.foregroundStyle(Color(hex: 0xE5484D))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
}
|
||||
.background(Color(hex: 0xF5F7FA))
|
||||
.navigationTitle("添加日程")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button(viewModel.submitting ? "保存中..." : "保存") {
|
||||
Task { await submit() }
|
||||
}
|
||||
.disabled(viewModel.submitting)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await viewModel.loadOrders(api: scheduleAPI, scenicId: accountContext.currentScenic?.id)
|
||||
}
|
||||
}
|
||||
|
||||
private func formCard<Content: View>(_ title: String, @ViewBuilder content: () -> Content) -> some View {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
|
||||
Text(title)
|
||||
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||||
content()
|
||||
}
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||||
}
|
||||
|
||||
private func submit() async {
|
||||
let start = combinedDate(date: date, time: startTime)
|
||||
let end = combinedDate(date: date, time: endTime)
|
||||
await globalLoading.withLoading {
|
||||
if await viewModel.submit(api: scheduleAPI, scenicId: accountContext.currentScenic?.id, startDate: start, endDate: end) {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func combinedDate(date: Date, time: Date) -> Date {
|
||||
let calendar = Calendar.current
|
||||
var dateComponents = calendar.dateComponents([.year, .month, .day], from: date)
|
||||
let timeComponents = calendar.dateComponents([.hour, .minute], from: time)
|
||||
dateComponents.hour = timeComponents.hour
|
||||
dateComponents.minute = timeComponents.minute
|
||||
return calendar.date(from: dateComponents) ?? date
|
||||
}
|
||||
}
|
||||
|
||||
/// 排班日程卡片。
|
||||
private struct ScheduleItemCard: View {
|
||||
let item: ScheduleItem
|
||||
let onDelete: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
|
||||
HStack {
|
||||
Text(item.name.isEmpty ? "--" : item.name)
|
||||
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||||
Spacer()
|
||||
Button(role: .destructive, action: onDelete) {
|
||||
Image(systemName: "trash")
|
||||
.frame(width: 32, height: 32)
|
||||
}
|
||||
}
|
||||
row("clock", "\(item.startTime) - \(item.endTime)")
|
||||
row("note.text", item.remark.isEmpty ? "无备注" : item.remark)
|
||||
if let orderNumber = item.orderNumber, !orderNumber.isEmpty {
|
||||
row("bag", "订单号:\(orderNumber)")
|
||||
}
|
||||
if let phone = item.userPhone, !phone.isEmpty {
|
||||
row("person", "用户:\(phone)")
|
||||
}
|
||||
}
|
||||
.padding(AppMetrics.Spacing.small)
|
||||
.background(Color(hex: 0xF8FAFC), in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input))
|
||||
}
|
||||
|
||||
private func row(_ icon: String, _ text: String) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: icon)
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
Text(text)
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
}
|
||||
.font(.system(size: AppMetrics.FontSize.caption))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user