From 311a70d610cb43ad291962de4f100a1b85b861ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=89=E7=A7=8B?= <497055328@qq.com> Date: Wed, 24 Jun 2026 15:57:15 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E9=A1=B9=E7=9B=AE=E3=80=81?= =?UTF-8?q?=E6=8E=92=E6=9C=9F=E4=B8=8E=E9=82=80=E8=AF=B7=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E6=8E=A5=E5=85=A5=E9=A6=96=E9=A1=B5=E8=B7=AF?= =?UTF-8?q?=E7=94=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将项目管理、排期管理与摄影师邀请流程从占位页迁移为完整实现,包含项目图片 OSS 上传与共享业务模型。 Co-authored-by: Cursor --- suixinkan/App/RootView.swift | 9 + .../Core/Models/SharedBusinessModels.swift | 166 ++++ suixinkan/Core/Upload/OSSUploadService.swift | 10 + .../Features/Assets/Models/AssetsModels.swift | 46 -- suixinkan/Features/Home/Home.md | 2 +- .../Features/Home/Models/HomeMenuItem.swift | 8 + .../Home/Routing/HomeMenuRouter.swift | 31 +- .../Home/Views/HomeSupportViews.swift | 20 + suixinkan/Features/Invite/API/InviteAPI.swift | 50 ++ suixinkan/Features/Invite/Invite.md | 30 + .../Features/Invite/Models/InviteModels.swift | 156 ++++ .../Invite/ViewModels/InviteViewModels.swift | 204 +++++ .../Features/Invite/Views/InviteViews.swift | 284 +++++++ .../Features/Projects/API/ProjectAPI.swift | 178 ++++ .../Projects/Models/ProjectModels.swift | 440 ++++++++++ suixinkan/Features/Projects/Projects.md | 31 + .../ViewModels/ProjectViewModels.swift | 609 ++++++++++++++ .../Projects/Views/ProjectViews.swift | 776 ++++++++++++++++++ .../Features/Schedule/API/ScheduleAPI.swift | 93 +++ .../Schedule/Models/ScheduleModels.swift | 140 ++++ suixinkan/Features/Schedule/Schedule.md | 27 + .../ViewModels/ScheduleViewModels.swift | 225 +++++ .../Schedule/Views/ScheduleViews.swift | 255 ++++++ .../Features/Tasks/Models/TaskModels.swift | 32 - .../Features/Wallet/Models/WalletModels.swift | 23 + .../Assets/AssetsViewModelTests.swift | 5 + suixinkanTests/HomeCommonMenuStoreTests.swift | 8 +- suixinkanTests/HomeMenuRouterTests.swift | 25 +- suixinkanTests/HomeViewModelTests.swift | 2 +- suixinkanTests/Invite/InviteTests.swift | 154 ++++ .../ProfileSecondaryPagesTests.swift | 5 + suixinkanTests/Projects/ProjectAPITests.swift | 150 ++++ .../Projects/ProjectViewModelTests.swift | 195 +++++ .../PunchPoint/PunchPointAPITests.swift | 1 + .../ScenicPermissionViewModelTests.swift | 5 + suixinkanTests/Schedule/ScheduleTests.swift | 184 +++++ suixinkanTests/TaskViewModelTests.swift | 3 + suixinkanTests/UploadTests.swift | 4 + suixinkanTests/WalletViewModelTests.swift | 1 + 功能同步Checklist.md | 17 +- 40 files changed, 4496 insertions(+), 108 deletions(-) create mode 100644 suixinkan/Core/Models/SharedBusinessModels.swift create mode 100644 suixinkan/Features/Invite/API/InviteAPI.swift create mode 100644 suixinkan/Features/Invite/Invite.md create mode 100644 suixinkan/Features/Invite/Models/InviteModels.swift create mode 100644 suixinkan/Features/Invite/ViewModels/InviteViewModels.swift create mode 100644 suixinkan/Features/Invite/Views/InviteViews.swift create mode 100644 suixinkan/Features/Projects/API/ProjectAPI.swift create mode 100644 suixinkan/Features/Projects/Models/ProjectModels.swift create mode 100644 suixinkan/Features/Projects/Projects.md create mode 100644 suixinkan/Features/Projects/ViewModels/ProjectViewModels.swift create mode 100644 suixinkan/Features/Projects/Views/ProjectViews.swift create mode 100644 suixinkan/Features/Schedule/API/ScheduleAPI.swift create mode 100644 suixinkan/Features/Schedule/Models/ScheduleModels.swift create mode 100644 suixinkan/Features/Schedule/Schedule.md create mode 100644 suixinkan/Features/Schedule/ViewModels/ScheduleViewModels.swift create mode 100644 suixinkan/Features/Schedule/Views/ScheduleViews.swift create mode 100644 suixinkanTests/Invite/InviteTests.swift create mode 100644 suixinkanTests/Projects/ProjectAPITests.swift create mode 100644 suixinkanTests/Projects/ProjectViewModelTests.swift create mode 100644 suixinkanTests/Schedule/ScheduleTests.swift diff --git a/suixinkan/App/RootView.swift b/suixinkan/App/RootView.swift index 7894018..7bb2efb 100644 --- a/suixinkan/App/RootView.swift +++ b/suixinkan/App/RootView.swift @@ -29,6 +29,9 @@ struct RootView: View { @State private var walletAPI: WalletAPI @State private var scenicPermissionAPI: ScenicPermissionAPI @State private var taskAPI: TaskAPI + @State private var projectAPI: ProjectAPI + @State private var scheduleAPI: ScheduleAPI + @State private var inviteAPI: InviteAPI @State private var assetsAPI: AssetsAPI @State private var punchPointAPI: PunchPointAPI @State private var locationReportAPI: LocationReportAPI @@ -54,6 +57,9 @@ struct RootView: View { _walletAPI = State(initialValue: WalletAPI(client: apiClient)) _scenicPermissionAPI = State(initialValue: ScenicPermissionAPI(client: apiClient)) _taskAPI = State(initialValue: TaskAPI(client: apiClient)) + _projectAPI = State(initialValue: ProjectAPI(client: apiClient)) + _scheduleAPI = State(initialValue: ScheduleAPI(client: apiClient)) + _inviteAPI = State(initialValue: InviteAPI(client: apiClient)) _assetsAPI = State(initialValue: AssetsAPI(client: apiClient)) _punchPointAPI = State(initialValue: PunchPointAPI(client: apiClient)) _locationReportAPI = State(initialValue: LocationReportAPI(client: apiClient)) @@ -96,6 +102,9 @@ struct RootView: View { .environment(walletAPI) .environment(scenicPermissionAPI) .environment(taskAPI) + .environment(projectAPI) + .environment(scheduleAPI) + .environment(inviteAPI) .environment(assetsAPI) .environment(punchPointAPI) .environment(locationReportAPI) diff --git a/suixinkan/Core/Models/SharedBusinessModels.swift b/suixinkan/Core/Models/SharedBusinessModels.swift new file mode 100644 index 0000000..c0096e8 --- /dev/null +++ b/suixinkan/Core/Models/SharedBusinessModels.swift @@ -0,0 +1,166 @@ +// +// SharedBusinessModels.swift +// suixinkan +// +// Created by Codex on 2026/6/24. +// + +import Foundation + +/// 可关联订单实体,表示任务、排班等业务表单中可选择的订单。 +struct AvailableOrderResponse: Decodable, Identifiable, Hashable { + var id: String { orderNumber } + let projectName: String + let orderNumber: String + let orderStatus: Int + let orderStatusLabel: String + let payTime: String + let userPhone: String + + /// 可关联订单字段映射,兼容后端下划线命名。 + enum CodingKeys: String, CodingKey { + case projectName = "project_name" + case orderNumber = "order_number" + case orderStatus = "order_status" + case orderStatusLabel = "order_status_label" + case payTime = "pay_time" + case userPhone = "user_phone" + } + + /// 宽松解码可关联订单字段。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + projectName = try container.decodeSharedLossyString(forKey: .projectName) + orderNumber = try container.decodeSharedLossyString(forKey: .orderNumber) + orderStatus = try container.decodeSharedLossyInt(forKey: .orderStatus) ?? 0 + orderStatusLabel = try container.decodeSharedLossyString(forKey: .orderStatusLabel) + payTime = try container.decodeSharedLossyString(forKey: .payTime) + userPhone = try container.decodeSharedLossyString(forKey: .userPhone) + } +} + +/// 摄影项目列表项实体,表示项目管理列表和样片上传时可关联的项目。 +struct PhotographerProjectItem: Decodable, Identifiable, Hashable { + let id: Int + let type: Int + let typeName: String + let status: Int + let statusName: String + let name: String + let coverProject: String + let coverVideo: String + let price: String + let otPrice: String + let priceDeposit: String + let attrLabel: [String] + let label: String + + /// 创建摄影项目列表项,主要用于详情页摘要和测试替身。 + init( + id: Int, + type: Int = 0, + typeName: String = "", + status: Int = 0, + statusName: String = "", + name: String, + coverProject: String = "", + coverVideo: String = "", + price: String = "", + otPrice: String = "", + priceDeposit: String = "", + attrLabel: [String] = [], + label: String = "" + ) { + self.id = id + self.type = type + self.typeName = typeName + self.status = status + self.statusName = statusName + self.name = name + self.coverProject = coverProject + self.coverVideo = coverVideo + self.price = price + self.otPrice = otPrice + self.priceDeposit = priceDeposit + self.attrLabel = attrLabel + self.label = label + } + + /// 字段映射,兼容后端下划线命名。 + enum CodingKeys: String, CodingKey { + case id + case type + case typeName = "type_name" + case status + case statusName = "status_name" + case name + case coverProject = "cover_project" + case coverVideo = "cover_video" + case price + case otPrice = "ot_price" + case priceDeposit = "price_deposit" + case attrLabel = "attr_label" + case label + } + + /// 宽松解码项目列表项。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeSharedLossyInt(forKey: .id) ?? 0 + type = try container.decodeSharedLossyInt(forKey: .type) ?? 0 + typeName = try container.decodeSharedLossyString(forKey: .typeName) + status = try container.decodeSharedLossyInt(forKey: .status) ?? 0 + statusName = try container.decodeSharedLossyString(forKey: .statusName) + name = try container.decodeSharedLossyString(forKey: .name) + coverProject = try container.decodeSharedLossyString(forKey: .coverProject) + coverVideo = try container.decodeSharedLossyString(forKey: .coverVideo) + price = try container.decodeSharedLossyString(forKey: .price) + otPrice = try container.decodeSharedLossyString(forKey: .otPrice) + priceDeposit = try container.decodeSharedLossyString(forKey: .priceDeposit) + label = try container.decodeSharedLossyString(forKey: .label) + if let labels = try? container.decodeIfPresent([String].self, forKey: .attrLabel) { + attrLabel = labels + } else if let text = try? container.decodeIfPresent(String.self, forKey: .attrLabel) { + attrLabel = text + .components(separatedBy: CharacterSet(charactersIn: ",,")) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + } else { + attrLabel = [] + } + } +} + +private extension KeyedDecodingContainer { + /// 将 String、数字和 Bool 宽松解码为字符串。 + func decodeSharedLossyString(forKey key: Key) throws -> String { + if let value = try? decodeIfPresent(String.self, forKey: key) { + return 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 "" + } + + /// 将 String、Double 和 Int 宽松解码为 Int。 + func decodeSharedLossyInt(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 + } +} diff --git a/suixinkan/Core/Upload/OSSUploadService.swift b/suixinkan/Core/Upload/OSSUploadService.swift index ea6c562..202d895 100644 --- a/suixinkan/Core/Upload/OSSUploadService.swift +++ b/suixinkan/Core/Upload/OSSUploadService.swift @@ -31,6 +31,9 @@ protocol OSSUploadServing { /// 上传任务附件,并返回可访问的 OSS 文件 URL。 func uploadTaskFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String + /// 上传项目图片,并返回可访问的 OSS 文件 URL。 + func uploadProjectImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String + /// 上传打卡点图片,并返回可访问的 OSS 文件 URL。 func uploadPunchPointImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String @@ -77,6 +80,11 @@ final class OSSUploadService { try await uploadFile(data: data, fileName: fileName, fileType: fileType, scenicId: scenicId, moduleType: "task_upload", onProgress: onProgress) } + /// 上传项目图片,并返回可访问的 OSS 文件 URL。 + func uploadProjectImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + try await uploadFile(data: data, fileName: fileName, fileType: 2, scenicId: scenicId, moduleType: "project", onProgress: onProgress) + } + /// 上传打卡点图片,并返回可访问的 OSS 文件 URL。 func uploadPunchPointImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { try await uploadFile(data: data, fileName: fileName, fileType: 2, scenicId: scenicId, moduleType: "punch_point", onProgress: onProgress) @@ -188,6 +196,8 @@ enum OSSUploadPolicy { return "cloud_driver/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" case "album_upload": return "album/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" + case "project": + return "project/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" case "punch_point": return "punch_point/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" case "scenic_apply": diff --git a/suixinkan/Features/Assets/Models/AssetsModels.swift b/suixinkan/Features/Assets/Models/AssetsModels.swift index 04319bd..61f13f4 100644 --- a/suixinkan/Features/Assets/Models/AssetsModels.swift +++ b/suixinkan/Features/Assets/Models/AssetsModels.swift @@ -721,52 +721,6 @@ struct MediaAlbumAddTagRequest: Encodable, Equatable { let name: String } -/// 摄影项目列表项实体,表示样片上传时可关联的项目。 -struct PhotographerProjectItem: Decodable, Identifiable, Hashable { - let id: Int - let type: Int - let typeName: String - let status: Int - let statusName: String - let name: String - let coverProject: String - let coverVideo: String - let price: String - let otPrice: String - let priceDeposit: String - - /// 字段映射,兼容后端下划线命名。 - enum CodingKeys: String, CodingKey { - case id - case type - case typeName = "type_name" - case status - case statusName = "status_name" - case name - case coverProject = "cover_project" - case coverVideo = "cover_video" - case price - case otPrice = "ot_price" - case priceDeposit = "price_deposit" - } - - /// 宽松解码项目列表项。 - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - id = try container.decodeLossyInt(forKey: .id) ?? 0 - type = try container.decodeLossyInt(forKey: .type) ?? 0 - typeName = try container.decodeLossyString(forKey: .typeName) - status = try container.decodeLossyInt(forKey: .status) ?? 0 - statusName = try container.decodeLossyString(forKey: .statusName) - name = try container.decodeLossyString(forKey: .name) - coverProject = try container.decodeLossyString(forKey: .coverProject) - coverVideo = try container.decodeLossyString(forKey: .coverVideo) - price = try container.decodeLossyString(forKey: .price) - otPrice = try container.decodeLossyString(forKey: .otPrice) - priceDeposit = try container.decodeLossyString(forKey: .priceDeposit) - } -} - /// 素材本地待上传文件实体,表示封面或媒体文件的内存数据。 struct MediaLocalUploadFile: Identifiable, Equatable { let id = UUID() diff --git a/suixinkan/Features/Home/Home.md b/suixinkan/Features/Home/Home.md index c774e99..84fb201 100644 --- a/suixinkan/Features/Home/Home.md +++ b/suixinkan/Features/Home/Home.md @@ -33,6 +33,6 @@ Home 模块负责登录后的首页工作台,包括当前景区展示、工作 ## 后续迁移 -目前 `payment_collection`、`payment_qr`、`payment_code` 已由 `Features/Payment` 的真实收款页面接管,`wallet` 已由 `Features/Wallet` 的真实钱包页面接管,`scenicselection`、`permission_apply`、`permission_apply_status`、`scenicapplication` 已由 `Features/ScenicPermission` 接管,`task_management`、`task_management_editor`、`task_create` 已由 `Features/Tasks` 接管,`cloud_management`、`cloud_storage_transit`、`asset_management`、`material_upload`、`album_list`、`album_trailer`、`sample_management`、`sample_upload` 已由 `Features/Assets` 接管,`checkin_points` 已由 `Features/PunchPoint` 接管,`location_report` 和 `location_report_history` 已由 `Features/LocationReport` 接管。`withdrawal_audit` 仍属于后台性质的提现审核管理模块,`scenic_settlement` 和 `scenic_settlement_review` 仍属于独立结算模块,本轮保留占位,后续应作为独立功能迁移。 +目前 `payment_collection`、`payment_qr`、`payment_code` 已由 `Features/Payment` 的真实收款页面接管,`wallet` 已由 `Features/Wallet` 的真实钱包页面接管,`scenicselection`、`permission_apply`、`permission_apply_status`、`scenicapplication` 已由 `Features/ScenicPermission` 接管,`task_management`、`task_management_editor`、`task_create` 已由 `Features/Tasks` 接管,`cloud_management`、`cloud_storage_transit`、`asset_management`、`material_upload`、`album_list`、`album_trailer`、`sample_management`、`sample_upload` 已由 `Features/Assets` 接管,`checkin_points` 已由 `Features/PunchPoint` 接管,`location_report` 和 `location_report_history` 已由 `Features/LocationReport` 接管,`pm`、`project_edit` 已由 `Features/Projects` 的摄影师项目管理接管,`pm_manager` 已由店铺项目管理接管,`schedule_management` 已由 `Features/Schedule` 接管,`registration_invitation`、`photographer_invite`、`invite_record` 已由 `Features/Invite` 接管。`withdrawal_audit` 仍属于后台性质的提现审核管理模块,`scenic_settlement` 和 `scenic_settlement_review` 仍属于独立结算模块,本轮保留占位,后续应作为独立功能迁移。 后续迁移具体首页子模块时,应先在 `HomeRoute` 增加目标页面,再更新 `HomeMenuRouter.resolve` 对应 URI 的映射,并同步补充单元测试。 diff --git a/suixinkan/Features/Home/Models/HomeMenuItem.swift b/suixinkan/Features/Home/Models/HomeMenuItem.swift index 025365a..6c2cd63 100644 --- a/suixinkan/Features/Home/Models/HomeMenuItem.swift +++ b/suixinkan/Features/Home/Models/HomeMenuItem.swift @@ -32,6 +32,14 @@ enum HomeRoute: Hashable { case taskManagement case taskCreate case taskDetail(id: Int, summary: PhotographerTaskItem?) + case projectManagement + case pmProjectManagement + case projectDetail(id: Int, storeMode: Bool) + case projectEditor(id: Int?, storeMode: Bool) + case scheduleManagement + case scheduleAdd + case photographerInvite + case inviteRecord case cloudStorage case cloudStorageTransit case materialLibrary diff --git a/suixinkan/Features/Home/Routing/HomeMenuRouter.swift b/suixinkan/Features/Home/Routing/HomeMenuRouter.swift index 39e0d27..7b01978 100644 --- a/suixinkan/Features/Home/Routing/HomeMenuRouter.swift +++ b/suixinkan/Features/Home/Routing/HomeMenuRouter.swift @@ -42,8 +42,8 @@ enum HomeMenuRouter { "scenic_settlement": "景区结算", "scenic_settlement_review": "结算审核", "pm": "项目管理", - "pm_manager": "项目管理", - "project_edit": "项目编辑", + "pm_manager": "店铺项目管理", + "project_edit": "项目管理", "location_report": "位置上报", "registration_invitation": "注册邀请", "store": "店铺管理", @@ -95,6 +95,16 @@ enum HomeMenuRouter { return .destination(.taskManagement) case "task_create": return .destination(.taskCreate) + case "schedule_management": + return .destination(.scheduleManagement) + case "pm", "project_edit": + return .destination(.projectManagement) + case "pm_manager": + return .destination(.pmProjectManagement) + case "registration_invitation", "photographer_invite": + return .destination(.photographerInvite) + case "invite_record": + return .destination(.inviteRecord) case "cloud_management": return .destination(.cloudStorage) case "cloud_storage_transit": @@ -132,18 +142,11 @@ enum HomeMenuRouter { "deposit_order", "deposit_order_shooting_info", "withdrawal_audit", - "schedule_management", - "pm", - "pm_manager", - "project_edit", "live_stream_management", "live_album", "scenic_settlement", "scenic_settlement_review", "operating-area", - "registration_invitation", - "photographer_invite", - "invite_record", "pilot_cert": let resolvedTitle = title.isEmpty ? self.title(for: uri) : title return .destination(.modulePlaceholder(uri: uri, title: resolvedTitle)) @@ -237,8 +240,8 @@ enum HomeMenuRouter { case "photographer_invite": return availableURIs.contains("registration_invitation") ? "registration_invitation" : uri case "pm": - return availableURIs.contains("pm_manager") ? "pm_manager" : uri - case "pm_manager": + return availableURIs.contains("project_edit") ? "project_edit" : uri + case "project_edit": return availableURIs.contains("pm") ? "pm" : uri case "payment_code": return availableURIs.contains("payment_qr") ? "payment_qr" : uri @@ -254,7 +257,7 @@ enum HomeMenuRouter { return "task_management" case "registration_invitation", "photographer_invite": return "registration_invitation" - case "pm", "pm_manager", "project_edit": + case "pm", "project_edit": return "pm" case "payment_collection", "payment_qr", "payment_code": return "payment_collection" @@ -276,8 +279,10 @@ enum HomeMenuRouter { return "注册邀请" case "location_report": return "位置上报" - case "pm", "pm_manager": + case "pm", "project_edit": return "项目管理" + case "pm_manager": + return "店铺项目" case "space_settings": return "空间设置" case "task_management", "task_management_editor": diff --git a/suixinkan/Features/Home/Views/HomeSupportViews.swift b/suixinkan/Features/Home/Views/HomeSupportViews.swift index e2b1896..e916576 100644 --- a/suixinkan/Features/Home/Views/HomeSupportViews.swift +++ b/suixinkan/Features/Home/Views/HomeSupportViews.swift @@ -36,6 +36,26 @@ extension HomeRoute { TaskCreateView() case .taskDetail(let id, let summary): TaskDetailView(taskId: id, summary: summary) + case .projectManagement: + ProjectManagementView() + case .pmProjectManagement: + StoreProjectManagementView() + case .projectDetail(let id, let storeMode): + ProjectDetailView(projectId: id, storeMode: storeMode) + case .projectEditor(let id, let storeMode): + if storeMode { + StoreProjectEditorView(projectId: id) + } else { + ProjectEditorView(projectId: id) + } + case .scheduleManagement: + ScheduleManagementView() + case .scheduleAdd: + ScheduleAddView() + case .photographerInvite: + PhotographerInviteView() + case .inviteRecord: + InviteRecordView() case .cloudStorage: CloudStorageView() case .cloudStorageTransit: diff --git a/suixinkan/Features/Invite/API/InviteAPI.swift b/suixinkan/Features/Invite/API/InviteAPI.swift new file mode 100644 index 0000000..fb4f9fa --- /dev/null +++ b/suixinkan/Features/Invite/API/InviteAPI.swift @@ -0,0 +1,50 @@ +// +// InviteAPI.swift +// suixinkan +// +// Created by Codex on 2026/6/24. +// + +import Foundation +import Observation + +/// 邀请服务协议,抽象邀请信息和邀请记录接口。 +@MainActor +protocol InviteServing { + /// 获取邀请二维码、邀请码和规则信息。 + func inviteInfo() async throws -> InviteInfoResponse + + /// 获取邀请用户列表。 + func inviteUserList(page: Int, pageSize: Int) async throws -> [InviteUserItem] +} + +/// 邀请 API,封装摄影师邀请相关接口。 +@MainActor +@Observable +final class InviteAPI: InviteServing { + @ObservationIgnored private let client: APIClient + + /// 初始化邀请 API,并注入共享网络客户端。 + init(client: APIClient) { + self.client = client + } + + /// 获取邀请二维码、邀请码和规则信息。 + func inviteInfo() async throws -> InviteInfoResponse { + try await client.send(APIRequest(method: .get, path: "/api/yf-handset-app/photog/invite-info")) + } + + /// 获取邀请用户列表。 + func inviteUserList(page: Int = 1, pageSize: Int = 20) async throws -> [InviteUserItem] { + try await client.send( + APIRequest( + method: .get, + path: "/api/yf-handset-app/photog/invite/user-list", + queryItems: [ + URLQueryItem(name: "page", value: "\(max(page, 1))"), + URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))") + ] + ) + ) + } +} diff --git a/suixinkan/Features/Invite/Invite.md b/suixinkan/Features/Invite/Invite.md new file mode 100644 index 0000000..f39cba5 --- /dev/null +++ b/suixinkan/Features/Invite/Invite.md @@ -0,0 +1,30 @@ +# Invite 模块业务逻辑 + +## 模块职责 + +Invite 模块负责首页 `registration_invitation`、`photographer_invite` 和 `invite_record` 入口。 + +邀请页展示邀请码、邀请链接、二维码和规则;邀请记录页展示邀请用户和奖励记录。模块不保存业务状态到全局对象。 + +## 邀请页 + +`PhotographerInviteViewModel` 通过 `InviteAPI.inviteInfo()` 获取邀请信息,并使用 CoreImage 在本地生成二维码图片。二维码图片只用于当前页面展示,不落盘。 + +复制邀请链接时只由 View 层写入剪贴板,ViewModel 不关心系统剪贴板状态。 + +## 邀请记录 + +`InviteRecordViewModel` 提供“邀请记录 / 奖励记录”分段: +- 邀请记录来自 `InviteAPI.inviteUserList`。 +- 奖励记录复用 `WalletAPI.walletEarningDetail`。 +- 顶部奖励汇总复用 `WalletAPI.walletSummary(type: 2)`。 + +分页状态、筛选分段和展示行只存在 ViewModel 内存中。 + +## 接口边界 + +`InviteAPI` 只封装邀请信息和邀请用户列表。钱包收益和提现入口继续由 Wallet 模块负责,Invite 模块只调用其公开服务协议,不持有钱包业务状态。 + +## 缓存边界 + +邀请码、邀请记录、奖励记录和二维码都不落盘。后续如果需要分享图片,应在分享流程中临时生成。 diff --git a/suixinkan/Features/Invite/Models/InviteModels.swift b/suixinkan/Features/Invite/Models/InviteModels.swift new file mode 100644 index 0000000..c74d25b --- /dev/null +++ b/suixinkan/Features/Invite/Models/InviteModels.swift @@ -0,0 +1,156 @@ +// +// InviteModels.swift +// suixinkan +// +// Created by Codex on 2026/6/24. +// + +import Foundation + +/// 邀请信息响应实体,表示邀请码、邀请链接和规则文案。 +struct InviteInfoResponse: Decodable, Equatable { + let enableInvite: Bool + let inviteCode: String + let inviteUrl: String + let description: [String] + + /// 字段映射,兼容后端下划线命名。 + enum CodingKeys: String, CodingKey { + case enableInvite = "enable_invite" + case inviteCode = "invite_code" + case inviteUrl = "invite_url" + case description + } + + /// 宽松解码邀请信息。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + enableInvite = try container.decodeInviteLossyBool(forKey: .enableInvite) ?? true + inviteCode = try container.decodeInviteLossyString(forKey: .inviteCode) + inviteUrl = try container.decodeInviteLossyString(forKey: .inviteUrl) + description = (try? container.decodeIfPresent([String].self, forKey: .description)) ?? [] + } +} + +/// 邀请用户实体,表示邀请记录里的摄影师。 +struct InviteUserItem: Decodable, Identifiable, Hashable { + let id: Int + let realName: String + let phone: String + let avatar: String + let createdAt: String + let inviteLevel: Int + + /// 字段映射,兼容后端下划线命名。 + enum CodingKeys: String, CodingKey { + case id + case realName = "real_name" + case phone + case avatar + case createdAt = "created_at" + case inviteLevel = "invite_level" + } + + /// 宽松解码邀请用户字段。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeInviteLossyInt(forKey: .id) ?? 0 + realName = try container.decodeInviteLossyString(forKey: .realName) + phone = try container.decodeInviteLossyString(forKey: .phone) + avatar = try container.decodeInviteLossyString(forKey: .avatar) + createdAt = try container.decodeInviteLossyString(forKey: .createdAt) + inviteLevel = try container.decodeInviteLossyInt(forKey: .inviteLevel) ?? 0 + } +} + +/// 邀请记录页面展示行实体,统一邀请用户和奖励明细的展示字段。 +struct InviteDisplayRow: Identifiable, Equatable { + let id: String + let title: String + let subtitle: String + let amount: String + let time: String + let phone: String + let avatar: String + let inviteLevel: Int + + /// 邀请层级文案。 + var levelText: String { + inviteLevel == 1 ? "一级" : "二级" + } + + /// 头像占位文案。 + var avatarPlaceholder: String { + if let first = title.first { + return String(first) + } + if let first = phone.first { + return String(first) + } + return "?" + } +} + +/// 邀请记录分段类型。 +enum InviteRecordTab: CaseIterable, Equatable { + case invite + case reward + + /// 分段标题。 + var title: String { + switch self { + case .invite: return "邀请记录" + case .reward: return "奖励记录" + } + } +} + +private extension KeyedDecodingContainer { + /// 将 String、数字和 Bool 宽松解码为字符串。 + func decodeInviteLossyString(forKey key: Key) throws -> String { + if let value = try? decodeIfPresent(String.self, forKey: key) { + return 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 "" + } + + /// 将 String、Double 和 Int 宽松解码为 Int。 + func decodeInviteLossyInt(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 + } + + /// 将 String、数字和 Bool 宽松解码为 Bool。 + func decodeInviteLossyBool(forKey key: Key) throws -> Bool? { + if let value = try? decodeIfPresent(Bool.self, forKey: key) { + return value + } + if let value = try? decodeIfPresent(Int.self, forKey: key) { + return value != 0 + } + if let value = try? decodeIfPresent(String.self, forKey: key) { + let text = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if ["1", "true", "yes"].contains(text) { return true } + if ["0", "false", "no"].contains(text) { return false } + } + return nil + } +} diff --git a/suixinkan/Features/Invite/ViewModels/InviteViewModels.swift b/suixinkan/Features/Invite/ViewModels/InviteViewModels.swift new file mode 100644 index 0000000..f7631a4 --- /dev/null +++ b/suixinkan/Features/Invite/ViewModels/InviteViewModels.swift @@ -0,0 +1,204 @@ +// +// InviteViewModels.swift +// suixinkan +// +// Created by Codex on 2026/6/24. +// + +import CoreImage.CIFilterBuiltins +import Foundation +import Observation +import UIKit + +/// 邀请页 ViewModel,负责加载邀请信息、生成二维码和复制内容。 +@MainActor +@Observable +final class PhotographerInviteViewModel { + private(set) var loading = false + private(set) var inviteCode = "" + private(set) var inviteUrl = "" + private(set) var rules: [String] = [] + private(set) var qrImage: UIImage? + var errorMessage: String? + + @ObservationIgnored private let qrContext = CIContext() + @ObservationIgnored private let qrFilter = CIFilter.qrCodeGenerator() + + /// 重新加载邀请信息。 + func reload(api: any InviteServing) async { + loading = true + errorMessage = nil + defer { loading = false } + do { + let response = try await api.inviteInfo() + inviteCode = response.inviteCode + inviteUrl = response.inviteUrl + rules = response.description + qrImage = makeQrImage(from: response.inviteUrl) + } catch { + clear() + errorMessage = error.localizedDescription + } + } + + /// 复制邀请码。 + func copyInviteCode() { + UIPasteboard.general.string = inviteCode + } + + /// 复制邀请链接。 + func copyInviteUrl() { + UIPasteboard.general.string = inviteUrl + } + + /// 根据文本生成二维码图片。 + func makeQrImage(from text: String) -> UIImage? { + guard !text.isEmpty else { return nil } + qrFilter.setValue(Data(text.utf8), forKey: "inputMessage") + qrFilter.setValue("M", forKey: "inputCorrectionLevel") + guard let output = qrFilter.outputImage else { return nil } + let scaled = output.transformed(by: CGAffineTransform(scaleX: 12, y: 12)) + guard let cgImage = qrContext.createCGImage(scaled, from: scaled.extent) else { return nil } + return UIImage(cgImage: cgImage) + } + + private func clear() { + inviteCode = "" + inviteUrl = "" + rules = [] + qrImage = nil + } +} + +/// 邀请记录 ViewModel,负责邀请用户、奖励明细和钱包汇总分页。 +@MainActor +@Observable +final class InviteRecordViewModel { + var tab: InviteRecordTab = .invite + private(set) var totalRewardText = "¥ 0.00" + private(set) var withdrawableText = "¥ 0.00" + private(set) var displayRows: [InviteDisplayRow] = [] + private(set) var loading = false + private(set) var loadingMore = false + private(set) var hasMore = false + var errorMessage: String? + + private var inviteRows: [InviteDisplayRow] = [] + private var rewardRows: [InviteDisplayRow] = [] + private var invitePage = 1 + private var rewardPage = 1 + private let invitePageSize = 20 + private let rewardPageSize = 10 + + /// 重新加载或追加邀请记录数据。 + func reload(inviteAPI: any InviteServing, walletAPI: any WalletServing, refresh: Bool) async { + if refresh { + loading = true + } else { + guard hasMore else { return } + loadingMore = true + } + errorMessage = nil + defer { + loading = false + loadingMore = false + } + + do { + async let summary = walletAPI.walletSummary(type: 2) + if tab == .invite { + try await loadInviteRows(api: inviteAPI, refresh: refresh) + } else { + try await loadRewardRows(api: walletAPI, refresh: refresh) + } + let summaryValue = try await summary + totalRewardText = "¥ \(summaryValue.amountTotal)" + withdrawableText = "¥ \(summaryValue.amountWithdrawable)" + displayRows = tab == .invite ? inviteRows : rewardRows + } catch { + if refresh { + clear() + } + errorMessage = error.localizedDescription + } + } + + /// 切换邀请记录分段并刷新。 + func selectTab(_ newTab: InviteRecordTab, inviteAPI: any InviteServing, walletAPI: any WalletServing) async { + tab = newTab + await reload(inviteAPI: inviteAPI, walletAPI: walletAPI, refresh: true) + } + + private func loadInviteRows(api: any InviteServing, refresh: Bool) async throws { + let users = try await api.inviteUserList(page: refresh ? 1 : invitePage, pageSize: invitePageSize) + let mapped = users.map { + InviteDisplayRow( + id: "invite_\($0.id)", + title: $0.realName.isEmpty ? "**\($0.phone.suffix(4))" : $0.realName, + subtitle: "", + amount: "", + time: $0.createdAt, + phone: $0.phone, + avatar: $0.avatar, + inviteLevel: $0.inviteLevel + ) + } + if refresh { + inviteRows = mapped + invitePage = 2 + } else { + inviteRows.append(contentsOf: mapped) + invitePage += 1 + } + hasMore = users.count >= invitePageSize + } + + private func loadRewardRows(api: any WalletServing, refresh: Bool) async throws { + let response = try await api.walletEarningDetail( + startDate: "2025-01-01", + endDate: Self.dayFormatter.string(from: Date()), + page: refresh ? 1 : rewardPage, + pageSize: rewardPageSize + ) + let mapped = response.list.flatMap { group in + group.items.map { + InviteDisplayRow( + id: "reward_\($0.id)", + title: $0.typeLabel.isEmpty ? "奖励记录" : $0.typeLabel, + subtitle: $0.withdrawLabel ?? "订单尾号:\($0.orderNumberSuffix.isEmpty ? "--" : $0.orderNumberSuffix)", + amount: $0.amount.isEmpty ? "--" : $0.amount, + time: $0.createdAt.isEmpty ? group.date : $0.createdAt, + phone: "", + avatar: "", + inviteLevel: 0 + ) + } + } + if refresh { + rewardRows = mapped + rewardPage = 2 + } else { + rewardRows.append(contentsOf: mapped) + rewardPage += 1 + } + hasMore = rewardRows.count < response.total && !mapped.isEmpty + } + + private func clear() { + totalRewardText = "¥ 0.00" + withdrawableText = "¥ 0.00" + displayRows = [] + inviteRows = [] + rewardRows = [] + invitePage = 1 + rewardPage = 1 + hasMore = false + } + + private static let dayFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "zh_CN") + formatter.dateFormat = "yyyy-MM-dd" + return formatter + }() +} diff --git a/suixinkan/Features/Invite/Views/InviteViews.swift b/suixinkan/Features/Invite/Views/InviteViews.swift new file mode 100644 index 0000000..7e29084 --- /dev/null +++ b/suixinkan/Features/Invite/Views/InviteViews.swift @@ -0,0 +1,284 @@ +// +// InviteViews.swift +// suixinkan +// +// Created by Codex on 2026/6/24. +// + +import SwiftUI + +/// 摄影师邀请页面,展示邀请二维码、邀请码、规则和邀请记录入口。 +struct PhotographerInviteView: View { + @Environment(InviteAPI.self) private var inviteAPI + @Environment(ToastCenter.self) private var toastCenter + @Environment(\.globalLoading) private var globalLoading + @State private var viewModel = PhotographerInviteViewModel() + + var body: some View { + ScrollView { + VStack(spacing: AppMetrics.Spacing.medium) { + inviteCard + ruleCard + benefitCard + NavigationLink(value: HomeRoute.inviteRecord) { + Label("邀请记录", systemImage: "calendar.badge.clock") + .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .frame(height: 48) + .background(AppDesign.primary, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.button)) + } + .buttonStyle(.plain) + } + .padding(AppMetrics.Spacing.medium) + } + .background(Color(hex: 0xF5F7FA)) + .navigationTitle("摄影师邀请") + .navigationBarTitleDisplayMode(.inline) + .task { await reload(showLoading: true) } + .refreshable { await reload(showLoading: false) } + } + + private var inviteCard: some View { + VStack(spacing: AppMetrics.Spacing.medium) { + if let qrImage = viewModel.qrImage { + Image(uiImage: qrImage) + .resizable() + .interpolation(.none) + .scaledToFit() + .frame(width: 240, height: 240) + } else { + Image(systemName: "qrcode") + .font(.system(size: 72)) + .foregroundStyle(AppDesign.textSecondary) + .frame(width: 240, height: 240) + .background(Color(hex: 0xF3F4F6), in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) + } + HStack { + Text("邀请码") + .foregroundStyle(AppDesign.textSecondary) + Text(viewModel.inviteCode.isEmpty ? "--" : viewModel.inviteCode) + .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) + .foregroundStyle(AppDesign.primary) + Spacer() + Button("复制") { + viewModel.copyInviteCode() + toastCenter.show("邀请码已复制") + } + .font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold)) + } + HStack(spacing: AppMetrics.Spacing.small) { + Button { + viewModel.copyInviteUrl() + toastCenter.show("邀请链接已复制") + } label: { + Label("复制链接", systemImage: "link") + .frame(maxWidth: .infinity) + .frame(height: 44) + } + Button { + viewModel.copyInviteUrl() + toastCenter.show("链接已复制,可粘贴分享") + } label: { + Label("分享", systemImage: "square.and.arrow.up") + .frame(maxWidth: .infinity) + .frame(height: 44) + } + } + .font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold)) + .foregroundStyle(.white) + .background(AppDesign.primary, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.button)) + } + .padding(AppMetrics.Spacing.medium) + .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) + } + + private var ruleCard: some View { + VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) { + Text("收益规则") + .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) + let rules = viewModel.rules.isEmpty ? ["直接邀请将获得订单奖励", "间接邀请将获得额外奖励", "具体规则以后端配置为准"] : viewModel.rules + ForEach(Array(rules.enumerated()), id: \.offset) { index, rule in + HStack(alignment: .top, spacing: AppMetrics.Spacing.small) { + Text("\(index + 1)") + .font(.system(size: AppMetrics.FontSize.caption, weight: .bold)) + .foregroundStyle(.white) + .frame(width: 22, height: 22) + .background(AppDesign.primary, in: Circle()) + Text(rule) + .font(.system(size: AppMetrics.FontSize.subheadline)) + .foregroundStyle(AppDesign.textPrimary) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(AppMetrics.Spacing.medium) + .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) + } + + private var benefitCard: some View { + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: AppMetrics.Spacing.small) { + ForEach(["免费剪辑修图", "免费云盘相册", "免费线上流量", "景区运营身份"], id: \.self) { title in + Text(title) + .font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium)) + .foregroundStyle(AppDesign.primary) + .frame(maxWidth: .infinity) + .frame(height: 54) + .background(AppDesign.primarySoft, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input)) + } + } + .padding(AppMetrics.Spacing.medium) + .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) + } + + private func reload(showLoading: Bool) async { + await globalLoading.withOptionalLoading(showLoading) { + await viewModel.reload(api: inviteAPI) + } + } +} + +/// 邀请记录页面,展示邀请用户和奖励记录。 +struct InviteRecordView: View { + @Environment(InviteAPI.self) private var inviteAPI + @Environment(WalletAPI.self) private var walletAPI + @Environment(\.globalLoading) private var globalLoading + @State private var viewModel = InviteRecordViewModel() + + var body: some View { + VStack(spacing: 0) { + header + Picker("记录类型", selection: $viewModel.tab) { + ForEach(InviteRecordTab.allCases, id: \.self) { tab in + Text(tab.title).tag(tab) + } + } + .pickerStyle(.segmented) + .padding(AppMetrics.Spacing.medium) + .onChange(of: viewModel.tab) { _, newValue in + Task { await viewModel.selectTab(newValue, inviteAPI: inviteAPI, walletAPI: walletAPI) } + } + recordList + NavigationLink(value: HomeRoute.wallet) { + Text("去钱包提现") + .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .frame(height: 50) + .background(AppDesign.primary, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.button)) + } + .buttonStyle(.plain) + .padding(AppMetrics.Spacing.medium) + .background(.white) + } + .background(Color(hex: 0xF5F7FA)) + .navigationTitle("邀请记录") + .navigationBarTitleDisplayMode(.inline) + .task { await reload(refresh: true, showLoading: true) } + } + + private var header: some View { + HStack(spacing: 0) { + metric("累计奖励金额", viewModel.totalRewardText) + Rectangle() + .fill(.white.opacity(0.6)) + .frame(width: 1, height: 54) + metric("可提现金额", viewModel.withdrawableText) + } + .padding(.vertical, AppMetrics.Spacing.large) + .background(AppDesign.primary) + } + + private func metric(_ title: String, _ value: String) -> some View { + VStack(spacing: 6) { + Text(title) + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(.white.opacity(0.78)) + Text(value) + .font(.system(size: AppMetrics.FontSize.title2, weight: .semibold)) + .foregroundStyle(.white) + } + .frame(maxWidth: .infinity) + } + + private var recordList: some View { + ScrollView { + LazyVStack(spacing: 0) { + if viewModel.displayRows.isEmpty && !viewModel.loading { + ContentUnavailableView("暂无记录", systemImage: "tray") + .frame(minHeight: 280) + } + ForEach(viewModel.displayRows) { row in + InviteRecordRow(row: row, tab: viewModel.tab) + } + if viewModel.hasMore { + ProgressView() + .padding(AppMetrics.Spacing.medium) + .onAppear { + Task { await reload(refresh: false, showLoading: false) } + } + } + } + .padding(.horizontal, AppMetrics.Spacing.medium) + } + .refreshable { await reload(refresh: true, showLoading: false) } + } + + private func reload(refresh: Bool, showLoading: Bool) async { + await globalLoading.withOptionalLoading(showLoading && refresh) { + await viewModel.reload(inviteAPI: inviteAPI, walletAPI: walletAPI, refresh: refresh) + } + } +} + +/// 邀请记录展示行。 +private struct InviteRecordRow: View { + let row: InviteDisplayRow + let tab: InviteRecordTab + + var body: some View { + HStack(spacing: AppMetrics.Spacing.medium) { + if tab == .invite { + RemoteAvatarImage(urlString: row.avatar, iconSize: 26) + .frame(width: 48, height: 48) + .clipShape(Circle()) + } else { + Image(systemName: "yensign.circle.fill") + .font(.system(size: 34)) + .foregroundStyle(AppDesign.success) + .frame(width: 48, height: 48) + } + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(row.title) + .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) + .foregroundStyle(AppDesign.textPrimary) + if tab == .invite { + Text(row.levelText) + .font(.system(size: AppMetrics.FontSize.caption, weight: .semibold)) + .foregroundStyle(AppDesign.primary) + } + } + Text(tab == .invite ? row.phone : row.subtitle) + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(AppDesign.textSecondary) + Text(row.time) + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(AppDesign.textSecondary) + } + Spacer() + if tab == .reward { + Text(row.amount) + .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) + .foregroundStyle(AppDesign.success) + } + } + .padding(AppMetrics.Spacing.medium) + .background(.white) + .overlay(alignment: .bottom) { + Rectangle() + .fill(Color(hex: 0xE5E7EB)) + .frame(height: 1) + } + } +} diff --git a/suixinkan/Features/Projects/API/ProjectAPI.swift b/suixinkan/Features/Projects/API/ProjectAPI.swift new file mode 100644 index 0000000..04adcfc --- /dev/null +++ b/suixinkan/Features/Projects/API/ProjectAPI.swift @@ -0,0 +1,178 @@ +// +// ProjectAPI.swift +// suixinkan +// +// Created by Codex on 2026/6/24. +// + +import Foundation +import Observation + +/// 项目服务协议,抽象摄影师项目和店铺项目接口以便 ViewModel 测试替换。 +@MainActor +protocol ProjectServing { + /// 获取摄影师项目列表。 + func projectList(scenicId: Int, name: String?, page: Int, pageSize: Int) async throws -> ListPayload + + /// 获取摄影师项目详情。 + func projectDetail(id: Int) async throws -> PhotographerProjectDetailResponse + + /// 创建摄影师项目。 + func createProject(_ request: ProjectCreateRequest) async throws + + /// 编辑摄影师项目。 + func editProject(_ request: ProjectCreateRequest) async throws + + /// 删除摄影师项目。 + func deleteProject(id: Int) async throws + + /// 获取店铺项目可管理景区。 + func storeManagerScenicList(userId: String) async throws -> ListPayload + + /// 获取店铺项目列表。 + func storeManagerProjectList(userId: String?, page: Int, pageSize: Int) async throws -> ListPayload + + /// 获取店铺项目详情。 + func storeManagerProjectDetail(id: Int) async throws -> PhotographerProjectDetailResponse + + /// 创建店铺多点位项目。 + func storeManagerCreate(_ request: StoreManagerCreateRequest) async throws + + /// 创建店铺押金项目。 + func storeManagerOfflineCreate(_ request: StoreManagerOfflineCreateRequest) async throws + + /// 更新店铺多点位项目。 + func storeManagerUpdate(_ request: StoreManagerUpdateRequest) async throws + + /// 更新店铺押金项目。 + func storeManagerOfflineUpdate(_ request: StoreManagerOfflineUpdateRequest) async throws + + /// 删除店铺项目。 + func storeManagerDeleteProject(id: Int) async throws + + /// 获取全部门店,用于店铺押金项目选择门店。 + func storeAll() async throws -> ListPayload +} + +/// 项目 API,封装项目管理、店铺项目管理相关接口。 +@MainActor +@Observable +final class ProjectAPI: ProjectServing { + @ObservationIgnored private let client: APIClient + + /// 初始化项目 API,并注入共享网络客户端。 + init(client: APIClient) { + self.client = client + } + + /// 获取摄影师项目列表。 + func projectList(scenicId: Int, name: String? = nil, page: Int = 1, pageSize: Int = 10) async throws -> ListPayload { + var query = [ + URLQueryItem(name: "scenic_id", value: "\(scenicId)"), + URLQueryItem(name: "page", value: "\(max(page, 1))"), + URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))") + ] + if let name = name?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty { + query.append(URLQueryItem(name: "name", value: name)) + } + return try await client.send( + APIRequest(method: .get, path: "/api/yf-handset-app/photog/project/list", queryItems: query) + ) + } + + /// 获取摄影师项目详情。 + func projectDetail(id: Int) async throws -> PhotographerProjectDetailResponse { + try await client.send( + APIRequest( + method: .get, + path: "/api/yf-handset-app/photog/project/info-view", + queryItems: [URLQueryItem(name: "id", value: "\(id)")] + ) + ) + } + + /// 创建摄影师项目。 + func createProject(_ request: ProjectCreateRequest) async throws { + _ = try await client.send( + APIRequest(method: .post, path: "/api/yf-handset-app/photog/project/create", body: request) + ) as EmptyPayload + } + + /// 编辑摄影师项目。 + func editProject(_ request: ProjectCreateRequest) async throws { + _ = try await client.send( + APIRequest(method: .post, path: "/api/yf-handset-app/photog/project/edit", body: request) + ) as EmptyPayload + } + + /// 删除摄影师项目。 + func deleteProject(id: Int) async throws { + _ = try await client.send( + APIRequest(method: .post, path: "/api/yf-handset-app/photog/project/delete", body: ProjectDeleteRequest(id: id)) + ) as EmptyPayload + } + + /// 获取店铺项目可管理景区。 + func storeManagerScenicList(userId: String) async throws -> ListPayload { + try await client.send( + APIRequest( + method: .get, + path: "/api/app/store-manager/scenic-list", + queryItems: [URLQueryItem(name: "user_id", value: userId)] + ) + ) + } + + /// 获取店铺项目列表。 + func storeManagerProjectList(userId: String?, page: Int = 1, pageSize: Int = 20) async throws -> ListPayload { + var query = [ + URLQueryItem(name: "page", value: "\(max(page, 1))"), + URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))") + ] + if let userId = userId?.trimmingCharacters(in: .whitespacesAndNewlines), !userId.isEmpty { + query.append(URLQueryItem(name: "user_id", value: userId)) + } + return try await client.send( + APIRequest(method: .get, path: "/api/app/store-manager/list", queryItems: query) + ) + } + + /// 获取店铺项目详情。 + func storeManagerProjectDetail(id: Int) async throws -> PhotographerProjectDetailResponse { + try await client.send( + APIRequest(method: .get, path: "/api/app/store-manager/detail", queryItems: [URLQueryItem(name: "id", value: "\(id)")]) + ) + } + + /// 创建店铺多点位项目。 + func storeManagerCreate(_ request: StoreManagerCreateRequest) async throws { + _ = try await client.send(APIRequest(method: .post, path: "/api/app/store-manager/create", body: request)) as EmptyPayload + } + + /// 创建店铺押金项目。 + func storeManagerOfflineCreate(_ request: StoreManagerOfflineCreateRequest) async throws { + _ = try await client.send(APIRequest(method: .post, path: "/api/app/store-manager/offline-create", body: request)) as EmptyPayload + } + + /// 更新店铺多点位项目。 + func storeManagerUpdate(_ request: StoreManagerUpdateRequest) async throws { + _ = try await client.send(APIRequest(method: .post, path: "/api/app/store-manager/update", body: request)) as EmptyPayload + } + + /// 更新店铺押金项目。 + func storeManagerOfflineUpdate(_ request: StoreManagerOfflineUpdateRequest) async throws { + _ = try await client.send(APIRequest(method: .post, path: "/api/app/store-manager/offline-update", body: request)) as EmptyPayload + } + + /// 删除店铺项目。 + func storeManagerDeleteProject(id: Int) async throws { + _ = try await client.send( + APIRequest(method: .post, path: "/api/app/store-manager/delete", body: ProjectDeleteRequest(id: id)) + ) as EmptyPayload + } + + /// 获取全部门店。 + func storeAll() async throws -> ListPayload { + try await client.send(APIRequest(method: .get, path: "/api/app/store/all")) + } +} diff --git a/suixinkan/Features/Projects/Models/ProjectModels.swift b/suixinkan/Features/Projects/Models/ProjectModels.swift new file mode 100644 index 0000000..7e596b7 --- /dev/null +++ b/suixinkan/Features/Projects/Models/ProjectModels.swift @@ -0,0 +1,440 @@ +// +// ProjectModels.swift +// suixinkan +// +// Created by Codex on 2026/6/24. +// + +import Foundation + +/// 项目详情实体,表示摄影师项目或店铺项目详情页需要展示的完整业务字段。 +struct PhotographerProjectDetailResponse: Decodable, Identifiable, Hashable { + let id: Int + let scenicId: Int + let cover: String + let coverProject: String + let coverCarousel: [String] + let name: String + let type: Int + let typeName: String + let status: Int + let statusName: String + let price: String + let otPrice: String + let priceDeposit: String + let description: String + let attrLabel: [String] + let projectContent: String + let sold: Int + let unitName: String + let materialNum: Int + let photoNum: Int + let videoNum: Int + let createdAt: String + let updatedAt: String + let creatorName: String + let creatorPhone: String + let creatorRoleName: String + let auditRemark: String + let photogList: [ProjectPhotographerBrief] + let scenicList: [ProjectScenicSpotBrief] + + /// 字段映射,兼容后端下划线命名。 + enum CodingKeys: String, CodingKey { + case id + case scenicId = "scenic_id" + case cover + case coverProject = "cover_project" + case coverCarousel = "cover_carousel" + case name + case type + case typeName = "type_name" + case status + case statusName = "status_name" + case price + case otPrice = "ot_price" + case priceDeposit = "price_deposit" + case description + case attrLabel = "attr_label" + case projectContent = "project_content" + case sold + case unitName = "unit_name" + case materialNum = "material_num" + case photoNum = "photo_num" + case videoNum = "video_num" + case createdAt = "created_at" + case updatedAt = "updated_at" + case creatorName = "creator_name" + case creatorPhone = "creator_phone" + case creatorRoleName = "creator_role_name" + case auditRemark = "audit_remark" + case photogList = "photog_list" + case scenicList = "scenic_list" + } + + /// 宽松解码项目详情字段。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeProjectLossyInt(forKey: .id) ?? 0 + scenicId = try container.decodeProjectLossyInt(forKey: .scenicId) ?? 0 + cover = try container.decodeProjectLossyString(forKey: .cover) + coverProject = try container.decodeProjectLossyString(forKey: .coverProject) + coverCarousel = try container.decodeProjectLossyStringArray(forKey: .coverCarousel) + name = try container.decodeProjectLossyString(forKey: .name) + type = try container.decodeProjectLossyInt(forKey: .type) ?? 0 + typeName = try container.decodeProjectLossyString(forKey: .typeName) + status = try container.decodeProjectLossyInt(forKey: .status) ?? 0 + statusName = try container.decodeProjectLossyString(forKey: .statusName) + price = try container.decodeProjectLossyString(forKey: .price) + otPrice = try container.decodeProjectLossyString(forKey: .otPrice) + priceDeposit = try container.decodeProjectLossyString(forKey: .priceDeposit) + description = try container.decodeProjectLossyString(forKey: .description) + attrLabel = try container.decodeProjectLossyStringArray(forKey: .attrLabel) + projectContent = try container.decodeProjectLossyString(forKey: .projectContent) + sold = try container.decodeProjectLossyInt(forKey: .sold) ?? 0 + unitName = try container.decodeProjectLossyString(forKey: .unitName) + materialNum = try container.decodeProjectLossyInt(forKey: .materialNum) ?? 0 + photoNum = try container.decodeProjectLossyInt(forKey: .photoNum) ?? 0 + videoNum = try container.decodeProjectLossyInt(forKey: .videoNum) ?? 0 + createdAt = try container.decodeProjectLossyString(forKey: .createdAt) + updatedAt = try container.decodeProjectLossyString(forKey: .updatedAt) + creatorName = try container.decodeProjectLossyString(forKey: .creatorName) + creatorPhone = try container.decodeProjectLossyString(forKey: .creatorPhone) + creatorRoleName = try container.decodeProjectLossyString(forKey: .creatorRoleName) + auditRemark = try container.decodeProjectLossyString(forKey: .auditRemark) + photogList = (try? container.decode([ProjectPhotographerBrief].self, forKey: .photogList)) ?? [] + scenicList = (try? container.decode([ProjectScenicSpotBrief].self, forKey: .scenicList)) ?? [] + } +} + +/// 项目摄影师摘要实体,表示项目详情中的摄影师信息。 +struct ProjectPhotographerBrief: Decodable, Identifiable, Hashable { + let id: Int + let photogUid: Int + let nickname: String + let avatar: String + let orderNum: Int + let completedOrderCount: Int + + /// 字段映射,兼容后端下划线命名。 + enum CodingKeys: String, CodingKey { + case id + case photogUid = "photog_uid" + case nickname + case avatar + case orderNum = "order_num" + case completedOrderCount = "completed_order_count" + } + + /// 宽松解码摄影师摘要。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeProjectLossyInt(forKey: .id) ?? 0 + photogUid = try container.decodeProjectLossyInt(forKey: .photogUid) ?? 0 + nickname = try container.decodeProjectLossyString(forKey: .nickname) + avatar = try container.decodeProjectLossyString(forKey: .avatar) + orderNum = try container.decodeProjectLossyInt(forKey: .orderNum) ?? 0 + completedOrderCount = try container.decodeProjectLossyInt(forKey: .completedOrderCount) ?? 0 + } +} + +/// 项目打卡点摘要实体,表示项目关联的景点或打卡点。 +struct ProjectScenicSpotBrief: Decodable, Identifiable, Hashable { + let id: Int + let name: String + + /// 字段映射。 + enum CodingKeys: String, CodingKey { + case id + case name + } + + /// 宽松解码打卡点摘要。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeProjectLossyInt(forKey: .id) ?? 0 + name = try container.decodeProjectLossyString(forKey: .name) + } +} + +/// 摄影师项目创建或编辑请求实体。 +struct ProjectCreateRequest: Encodable, Equatable { + let id: Int? + let type: Int + let scenicId: Int + let name: String + let price: String + let otPrice: String? + let coverProject: String + let coverCarousel: [String]? + let description: String + let attrLabel: [String]? + let extra: ProjectCreateExtra + let allowNFC: Int = 1 + + /// 请求字段映射,兼容后端下划线命名。 + enum CodingKeys: String, CodingKey { + case id + case type + case scenicId = "scenic_id" + case name + case price + case otPrice = "ot_price" + case coverProject = "cover_project" + case coverCarousel = "cover_carousel" + case description + case attrLabel = "attr_label" + case extra + case allowNFC = "allow_nfc" + } +} + +/// 摄影师项目扩展配置实体,表示交付数量、押金和关联打卡点/摄影师。 +struct ProjectCreateExtra: Encodable, Equatable { + let priceDeposit: String + let materialNum: Int + let photoNum: Int + let videoNum: Int + let scenicSpotId: [Int] + let photogUid: [Int] + + /// 请求字段映射,兼容后端下划线命名。 + enum CodingKeys: String, CodingKey { + case priceDeposit = "price_deposit" + case materialNum = "material_num" + case photoNum = "photo_num" + case videoNum = "video_num" + case scenicSpotId = "scenic_spot_id" + case photogUid = "photog_uid" + } +} + +/// 项目删除请求实体。 +struct ProjectDeleteRequest: Encodable, Equatable { + let id: Int +} + +/// 店铺项目可管理景区实体。 +struct StoreManagerScenicItem: Decodable, Identifiable, Hashable { + let id: Int + let name: String + + /// 字段映射。 + enum CodingKeys: String, CodingKey { + case id + case name + } + + /// 宽松解码店铺项目景区。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeProjectLossyInt(forKey: .id) ?? 0 + name = try container.decodeProjectLossyString(forKey: .name) + } +} + +/// 店铺项目套餐实体,表示多点位项目的套餐价格配置。 +struct StoreManagerPackageItem: Codable, Hashable { + let materialNum: Int + let price: String + + /// 请求字段映射,兼容后端下划线命名。 + enum CodingKeys: String, CodingKey { + case materialNum = "material_num" + case price + } +} + +/// 店铺项目创建请求实体,用于多点位项目。 +struct StoreManagerCreateRequest: Encodable, Equatable { + let name: String + let storeId: Int? + let type: Int + let description: String + let coverProject: String? + let coverCarousel: [String]? + let projectRule: String? + let scenicId: [Int] + let settleSpotNum: Int + let price: Double + let priceMaterial: Double + let pricePhoto: Double + let priceVideo: Double + let priceMaterialAll: Double? + let packageList: [StoreManagerPackageItem]? + let userId: Int + let singleSpotMaterialNum: Int + let singleSpotPhotoNum: Int + let singleSpotVideoNum: Int + + /// 请求字段映射,兼容后端下划线命名。 + enum CodingKeys: String, CodingKey { + case name + case storeId = "store_id" + case type + case description + case coverProject = "cover_project" + case coverCarousel = "cover_carousel" + case projectRule = "project_rule" + case scenicId = "scenic_id" + case settleSpotNum = "settle_spot_num" + case price + case priceMaterial = "price_material" + case pricePhoto = "price_photo" + case priceVideo = "price_video" + case priceMaterialAll = "price_material_all" + case packageList = "package" + case userId = "user_id" + case singleSpotMaterialNum = "single_spot_material_num" + case singleSpotPhotoNum = "single_spot_photo_num" + case singleSpotVideoNum = "single_spot_video_num" + } +} + +/// 店铺项目更新请求实体,用于多点位项目。 +struct StoreManagerUpdateRequest: Encodable, Equatable { + let id: Int + let name: String + let type: Int + let description: String + let coverProject: String? + let coverCarousel: [String]? + let projectRule: String? + let scenicId: [Int] + let settleSpotNum: Int + let price: Double + let priceMaterial: Double + let pricePhoto: Double + let priceVideo: Double + let priceMaterialAll: Double? + let packageList: [StoreManagerPackageItem]? + let userId: Int + let singleSpotMaterialNum: Int + let singleSpotPhotoNum: Int + let singleSpotVideoNum: Int + + /// 请求字段映射,兼容后端下划线命名。 + enum CodingKeys: String, CodingKey { + case id + case name + case type + case description + case coverProject = "cover_project" + case coverCarousel = "cover_carousel" + case projectRule = "project_rule" + case scenicId = "scenic_id" + case settleSpotNum = "settle_spot_num" + case price + case priceMaterial = "price_material" + case pricePhoto = "price_photo" + case priceVideo = "price_video" + case priceMaterialAll = "price_material_all" + case packageList = "package" + case userId = "user_id" + case singleSpotMaterialNum = "single_spot_material_num" + case singleSpotPhotoNum = "single_spot_photo_num" + case singleSpotVideoNum = "single_spot_video_num" + } +} + +/// 店铺押金项目创建请求实体。 +struct StoreManagerOfflineCreateRequest: Encodable, Equatable { + let name: String + let scenicId: Int + let storeId: Int + let description: String + let price: Double + let coverProject: String + let coverCarousel: [String] + + /// 请求字段映射,兼容后端下划线命名。 + enum CodingKeys: String, CodingKey { + case name + case scenicId = "scenic_id" + case storeId = "store_id" + case description + case price + case coverProject = "cover_project" + case coverCarousel = "cover_carousel" + } +} + +/// 店铺押金项目更新请求实体。 +struct StoreManagerOfflineUpdateRequest: Encodable, Equatable { + let id: Int + let name: String + let scenicId: Int + let description: String + let price: Double + let coverProject: String? + let coverCarousel: [String]? + let storeId: Int? + + /// 请求字段映射,兼容后端下划线命名。 + enum CodingKeys: String, CodingKey { + case id + case name + case scenicId = "scenic_id" + case description + case price + case coverProject = "cover_project" + case coverCarousel = "cover_carousel" + case storeId = "store_id" + } +} + +/// 项目本地待上传图片实体,保存 PhotosPicker 读取后的图片数据。 +struct ProjectLocalImage: Identifiable, Equatable { + let id = UUID() + let data: Data + let fileName: String + let previewURL: String? +} + +private extension KeyedDecodingContainer { + /// 将 String、数字和 Bool 宽松解码为字符串。 + func decodeProjectLossyString(forKey key: Key) throws -> String { + if let value = try? decodeIfPresent(String.self, forKey: key) { + return 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 "" + } + + /// 将 String、Double 和 Int 宽松解码为 Int。 + func decodeProjectLossyInt(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 + } + + /// 将数组或逗号分隔字符串宽松解码为字符串数组。 + func decodeProjectLossyStringArray(forKey key: Key) throws -> [String] { + if let values = try? decodeIfPresent([String].self, forKey: key) { + return values + } + if let value = try? decodeIfPresent(String.self, forKey: key) { + return value + .components(separatedBy: CharacterSet(charactersIn: ",,\n")) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + } + return [] + } +} diff --git a/suixinkan/Features/Projects/Projects.md b/suixinkan/Features/Projects/Projects.md new file mode 100644 index 0000000..5914f5a --- /dev/null +++ b/suixinkan/Features/Projects/Projects.md @@ -0,0 +1,31 @@ +# Projects 模块业务逻辑 + +## 模块职责 + +Projects 模块负责首页项目相关入口: +- `pm`、`project_edit`:摄影师项目管理。 +- `pm_manager`:店铺项目管理。 + +模块只保存项目列表、详情、编辑表单和上传中的临时状态,不写入 `AppSession`、`AccountContext`、TabBar 或首页全局状态。 + +## 摄影师项目 + +`ProjectManagementViewModel` 管理摄影师项目列表、搜索、分页、详情入口和删除动作。列表请求依赖当前景区 ID,缺少景区时清空本模块列表并停止请求。 + +`ProjectEditorViewModel` 管理新建和编辑表单。项目封面、轮播图先通过 `OSSUploadService.uploadProjectImage` 上传到 `project/yyyyMMdd/scenicId/...` 路径,再把最终 URL 提交给项目创建或编辑接口。 + +## 店铺项目 + +`StoreProjectManagementViewModel` 管理店铺项目列表和可管理景区。店铺项目按业务账号 ID 查询,缺少用户 ID 时不请求。 + +`StoreProjectEditorViewModel` 支持多点位项目和押金项目两种提交路径。多点位项目提交景区、点位、展示图和项目套餐;押金项目提交门店、押金金额、底片数量等字段。 + +## 接口边界 + +`ProjectAPI` 只封装项目模块需要的接口,包括摄影师项目列表/详情/创建/编辑/删除,以及店铺项目列表/详情/创建/更新/删除和可管理景区列表。 + +样片上传需要的轻量项目选择复用共享 `PhotographerProjectItem`,但不反向依赖 Projects 页面状态。 + +## 缓存边界 + +项目图片临时数据、OSS STS、编辑表单和分页状态都不落盘。网络图片展示继续交给 `RemoteImage` / Kingfisher。 diff --git a/suixinkan/Features/Projects/ViewModels/ProjectViewModels.swift b/suixinkan/Features/Projects/ViewModels/ProjectViewModels.swift new file mode 100644 index 0000000..68ca1d4 --- /dev/null +++ b/suixinkan/Features/Projects/ViewModels/ProjectViewModels.swift @@ -0,0 +1,609 @@ +// +// ProjectViewModels.swift +// suixinkan +// +// Created by Codex on 2026/6/24. +// + +import Foundation +import Observation + +/// 项目表单模式,区分新建和编辑。 +enum ProjectEditorMode: Equatable { + case create + case edit(PhotographerProjectDetailResponse) +} + +/// 店铺项目表单模式,区分新建和编辑。 +enum StoreProjectEditorMode: Equatable { + case create + case edit(PhotographerProjectDetailResponse) +} + +/// 项目表单错误实体,表示校验、上传或提交失败。 +enum ProjectEditorError: LocalizedError, Equatable { + case missingScenic + case missingUser + case missingName + case missingDescription + case missingPrice + case missingSpot + case missingImage + case missingStore + + /// 错误文案。 + var errorDescription: String? { + switch self { + case .missingScenic: return "当前缺少景区信息" + case .missingUser: return "当前缺少用户信息" + case .missingName: return "请输入项目名称" + case .missingDescription: return "请输入项目简介" + case .missingPrice: return "请填写有效价格" + case .missingSpot: return "请至少选择一个打卡点" + case .missingImage: return "请至少上传项目封面" + case .missingStore: return "请选择所属门店" + } + } +} + +/// 摄影师项目管理 ViewModel,负责项目列表、分页、详情和删除。 +@MainActor +@Observable +final class ProjectManagementViewModel { + private(set) var items: [PhotographerProjectItem] = [] + private(set) var loading = false + private(set) var loadingMore = false + private(set) var hasMore = false + private(set) var selectedDetail: PhotographerProjectDetailResponse? + var searchText = "" + var errorMessage: String? + + private var page = 1 + private var total = 0 + private let pageSize = 10 + + /// 重新加载摄影师项目列表。 + func reload(api: any ProjectServing, scenicId: Int?) async { + guard let scenicId else { + resetList() + return + } + loading = true + errorMessage = nil + defer { loading = false } + do { + page = 1 + let response = try await api.projectList( + scenicId: scenicId, + name: normalizedSearch, + page: page, + pageSize: pageSize + ) + total = response.total + items = sorted(response.list) + hasMore = items.count < total + page = 2 + } catch { + resetList() + errorMessage = error.localizedDescription + } + } + + /// 加载下一页摄影师项目。 + func loadMore(api: any ProjectServing, scenicId: Int?) async { + guard let scenicId, hasMore, !loadingMore else { return } + loadingMore = true + defer { loadingMore = false } + do { + let response = try await api.projectList(scenicId: scenicId, name: normalizedSearch, page: page, pageSize: pageSize) + total = response.total + items = sorted(deduplicated(items + response.list)) + hasMore = items.count < total + page += 1 + } catch { + errorMessage = error.localizedDescription + } + } + + /// 加载项目详情。 + func loadDetail(id: Int, api: any ProjectServing) async { + do { + selectedDetail = try await api.projectDetail(id: id) + } catch { + errorMessage = error.localizedDescription + } + } + + /// 删除摄影师项目并从本地列表移除。 + func delete(id: Int, api: any ProjectServing) async { + do { + try await api.deleteProject(id: id) + items.removeAll { $0.id == id } + total = max(total - 1, 0) + hasMore = items.count < total + } catch { + errorMessage = error.localizedDescription + } + } + + private var normalizedSearch: String? { + let value = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } + + private func resetList() { + items = [] + loading = false + loadingMore = false + hasMore = false + page = 1 + total = 0 + } + + private func sorted(_ values: [PhotographerProjectItem]) -> [PhotographerProjectItem] { + values.sorted { lhs, rhs in + if lhs.status != rhs.status { return lhs.status < rhs.status } + return lhs.id > rhs.id + } + } + + private func deduplicated(_ values: [PhotographerProjectItem]) -> [PhotographerProjectItem] { + var result: [Int: PhotographerProjectItem] = [:] + values.forEach { result[$0.id] = $0 } + return Array(result.values) + } +} + +/// 项目编辑 ViewModel,负责摄影师项目表单、图片上传和提交。 +@MainActor +@Observable +final class ProjectEditorViewModel { + var name = "" + var descriptionText = "" + var price = "" + var otPrice = "" + var deposit = "" + var attrLabelText = "" + var materialNum = "1" + var photoNum = "1" + var videoNum = "1" + var selectedSpotIds: Set = [] + var coverImage: ProjectLocalImage? + var carouselImages: [ProjectLocalImage] = [] + var existingCoverURL = "" + var existingCarouselURLs: [String] = [] + private(set) var submitting = false + private(set) var uploadProgress = 0 + var errorMessage: String? + + private let mode: ProjectEditorMode + + /// 初始化摄影师项目表单,并在编辑模式下回填详情。 + init(mode: ProjectEditorMode) { + self.mode = mode + if case let .edit(detail) = mode { + name = detail.name + descriptionText = detail.description + price = detail.price + otPrice = detail.otPrice + deposit = detail.priceDeposit + attrLabelText = detail.attrLabel.joined(separator: ",") + materialNum = "\(max(detail.materialNum, 0))" + photoNum = "\(max(detail.photoNum, 0))" + videoNum = "\(max(detail.videoNum, 0))" + selectedSpotIds = Set(detail.scenicList.map(\.id)) + existingCoverURL = detail.coverProject + existingCarouselURLs = detail.coverCarousel + } + } + + /// 提交摄影师项目,必要时先上传封面和轮播图。 + func submit( + scenicId: Int?, + userId: Int?, + api: any ProjectServing, + uploadService: any OSSUploadServing + ) async -> Bool { + guard !submitting else { return false } + guard let scenicId else { return fail(.missingScenic) } + guard let userId, userId > 0 else { return fail(.missingUser) } + let normalizedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedDescription = descriptionText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedName.isEmpty else { return fail(.missingName) } + guard !normalizedDescription.isEmpty else { return fail(.missingDescription) } + guard Double(price) ?? 0 > 0 else { return fail(.missingPrice) } + guard !selectedSpotIds.isEmpty else { return fail(.missingSpot) } + + submitting = true + errorMessage = nil + uploadProgress = 0 + defer { submitting = false } + + do { + let coverURL = try await resolveCoverURL(scenicId: scenicId, uploadService: uploadService) + guard !coverURL.isEmpty else { throw ProjectEditorError.missingImage } + let carouselURLs = try await resolveCarouselURLs(scenicId: scenicId, uploadService: uploadService) + let request = ProjectCreateRequest( + id: editID, + type: 11, + scenicId: scenicId, + name: normalizedName, + price: normalizedMoney(price) ?? "0.00", + otPrice: normalizedMoney(otPrice), + coverProject: coverURL, + coverCarousel: carouselURLs.isEmpty ? nil : carouselURLs, + description: normalizedDescription, + attrLabel: normalizedLabels, + extra: ProjectCreateExtra( + priceDeposit: normalizedMoney(deposit) ?? "0.00", + materialNum: max(Int(materialNum) ?? 0, 0), + photoNum: max(Int(photoNum) ?? 0, 0), + videoNum: max(Int(videoNum) ?? 0, 0), + scenicSpotId: selectedSpotIds.sorted(), + photogUid: [userId] + ) + ) + if editID == nil { + try await api.createProject(request) + } else { + try await api.editProject(request) + } + uploadProgress = 100 + return true + } catch { + errorMessage = error.localizedDescription + return false + } + } + + private var editID: Int? { + if case let .edit(detail) = mode { + return detail.id + } + return nil + } + + private var normalizedLabels: [String]? { + let labels = attrLabelText + .components(separatedBy: CharacterSet(charactersIn: ",,")) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + return labels.isEmpty ? nil : labels + } + + private func resolveCoverURL(scenicId: Int, uploadService: any OSSUploadServing) async throws -> String { + if let coverImage { + return try await uploadService.uploadProjectImage(data: coverImage.data, fileName: coverImage.fileName, scenicId: scenicId) { [weak self] progress in + self?.uploadProgress = progress + } + } + return existingCoverURL + } + + private func resolveCarouselURLs(scenicId: Int, uploadService: any OSSUploadServing) async throws -> [String] { + var urls = existingCarouselURLs + for image in carouselImages { + let url = try await uploadService.uploadProjectImage(data: image.data, fileName: image.fileName, scenicId: scenicId) { [weak self] progress in + self?.uploadProgress = progress + } + urls.append(url) + } + return urls + } + + private func normalizedMoney(_ value: String) -> String? { + let amount = Double(value.trimmingCharacters(in: .whitespacesAndNewlines)) ?? 0 + return amount > 0 ? String(format: "%.2f", amount) : nil + } + + private func fail(_ error: ProjectEditorError) -> Bool { + errorMessage = error.localizedDescription + return false + } +} + +/// 店铺项目管理 ViewModel,负责店铺项目列表、筛选、详情和删除。 +@MainActor +@Observable +final class StoreProjectManagementViewModel { + private(set) var items: [PhotographerProjectItem] = [] + private(set) var loading = false + private(set) var selectedDetail: PhotographerProjectDetailResponse? + var searchText = "" + var selectedType = 0 + var errorMessage: String? + + /// 根据本地筛选条件返回展示项目。 + var filteredItems: [PhotographerProjectItem] { + let keyword = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + return items.filter { item in + let typeMatched = selectedType == 0 || item.type == selectedType + let keywordMatched = keyword.isEmpty || item.name.localizedCaseInsensitiveContains(keyword) + return typeMatched && keywordMatched + } + } + + /// 重新加载店铺项目列表。 + func reload(api: any ProjectServing, userId: String?) async { + let normalizedUserId = userId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !normalizedUserId.isEmpty else { + items = [] + return + } + loading = true + errorMessage = nil + defer { loading = false } + do { + let response = try await api.storeManagerProjectList(userId: normalizedUserId, page: 1, pageSize: 100) + items = response.list + } catch { + items = [] + errorMessage = error.localizedDescription + } + } + + /// 加载店铺项目详情。 + func loadDetail(id: Int, api: any ProjectServing) async { + do { + selectedDetail = try await api.storeManagerProjectDetail(id: id) + } catch { + errorMessage = error.localizedDescription + } + } + + /// 删除店铺项目。 + func delete(id: Int, api: any ProjectServing) async { + do { + try await api.storeManagerDeleteProject(id: id) + items.removeAll { $0.id == id } + } catch { + errorMessage = error.localizedDescription + } + } +} + +/// 店铺项目编辑 ViewModel,负责多点位和押金项目表单提交。 +@MainActor +@Observable +final class StoreProjectEditorViewModel { + var projectType = 19 + var scenicList: [StoreManagerScenicItem] = [] + var storeList: [StoreItem] = [] + var selectedScenicIds: Set = [] + var selectedStoreId: Int? + var name = "" + var descriptionText = "" + var coverImage: ProjectLocalImage? + var carouselImages: [ProjectLocalImage] = [] + var existingCoverURL = "" + var existingCarouselURLs: [String] = [] + var settleSpotNum = "1" + var projectPrice = "" + var priceMaterial = "" + var pricePhoto = "" + var priceVideo = "" + var priceDeposit = "" + var materialNum = "1" + var photoNum = "1" + var videoNum = "1" + private(set) var submitting = false + var errorMessage: String? + + private let mode: StoreProjectEditorMode + + /// 初始化店铺项目表单,并在编辑模式下回填详情。 + init(mode: StoreProjectEditorMode) { + self.mode = mode + if case let .edit(detail) = mode { + projectType = detail.type == 4 ? 4 : 19 + name = detail.name + descriptionText = detail.description + existingCoverURL = detail.coverProject + existingCarouselURLs = detail.coverCarousel + selectedScenicIds = Set(detail.scenicList.map(\.id)) + projectPrice = detail.price + priceDeposit = detail.priceDeposit + materialNum = "\(max(detail.materialNum, 0))" + photoNum = "\(max(detail.photoNum, 0))" + videoNum = "\(max(detail.videoNum, 0))" + } + } + + /// 加载店铺项目可管理景区。 + func loadScenicList(api: any ProjectServing, userId: Int) async { + guard scenicList.isEmpty, userId > 0 else { return } + do { + scenicList = try await api.storeManagerScenicList(userId: "\(userId)").list + } catch { + errorMessage = error.localizedDescription + } + } + + /// 加载当前景区下门店列表。 + func loadStoreList(api: any ProjectServing) async { + guard projectType == 4, let scenicId = selectedScenicIds.first else { return } + do { + storeList = try await api.storeAll().list.filter { $0.scenicId == scenicId } + if selectedStoreId == nil { + selectedStoreId = storeList.first?.id + } + } catch { + errorMessage = error.localizedDescription + } + } + + /// 切换店铺项目景区选择。 + func toggleScenic(_ id: Int) { + if projectType == 4 { + selectedScenicIds = [id] + } else if selectedScenicIds.contains(id) { + selectedScenicIds.remove(id) + } else { + selectedScenicIds.insert(id) + } + } + + /// 提交店铺项目。 + func submit(userId: Int, api: any ProjectServing, uploadService: any OSSUploadServing) async -> Bool { + guard !submitting else { return false } + guard userId > 0 else { return fail(.missingUser) } + let normalizedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedDescription = descriptionText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedName.isEmpty else { return fail(.missingName) } + guard !normalizedDescription.isEmpty else { return fail(.missingDescription) } + guard !selectedScenicIds.isEmpty else { return fail(.missingScenic) } + guard Double(projectType == 4 ? priceDeposit : projectPrice) ?? 0 > 0 else { return fail(.missingPrice) } + if projectType == 4, selectedStoreId == nil { return fail(.missingStore) } + + submitting = true + errorMessage = nil + defer { submitting = false } + + do { + let scenicIdForUpload = selectedScenicIds.first ?? 0 + let coverURL = try await resolveCoverURL(scenicId: scenicIdForUpload, uploadService: uploadService) + guard !coverURL.isEmpty else { throw ProjectEditorError.missingImage } + let carouselURLs = try await resolveCarouselURLs(scenicId: scenicIdForUpload, uploadService: uploadService) + if projectType == 4 { + try await submitOfflineProject( + api: api, + name: normalizedName, + description: normalizedDescription, + coverURL: coverURL, + carouselURLs: carouselURLs + ) + } else { + try await submitMultiPointProject( + userId: userId, + api: api, + name: normalizedName, + description: normalizedDescription, + coverURL: coverURL, + carouselURLs: carouselURLs + ) + } + return true + } catch { + errorMessage = error.localizedDescription + return false + } + } + + private var editID: Int? { + if case let .edit(detail) = mode { + return detail.id + } + return nil + } + + private func resolveCoverURL(scenicId: Int, uploadService: any OSSUploadServing) async throws -> String { + if let coverImage { + return try await uploadService.uploadProjectImage(data: coverImage.data, fileName: coverImage.fileName, scenicId: scenicId) { _ in } + } + return existingCoverURL + } + + private func resolveCarouselURLs(scenicId: Int, uploadService: any OSSUploadServing) async throws -> [String] { + var urls = existingCarouselURLs + for image in carouselImages { + let url = try await uploadService.uploadProjectImage(data: image.data, fileName: image.fileName, scenicId: scenicId) { _ in } + urls.append(url) + } + return urls + } + + private func submitMultiPointProject( + userId: Int, + api: any ProjectServing, + name: String, + description: String, + coverURL: String, + carouselURLs: [String] + ) async throws { + if let editID { + try await api.storeManagerUpdate( + StoreManagerUpdateRequest( + id: editID, + name: name, + type: 19, + description: description, + coverProject: coverURL, + coverCarousel: carouselURLs, + projectRule: nil, + scenicId: selectedScenicIds.sorted(), + settleSpotNum: max(Int(settleSpotNum) ?? 1, 1), + price: Double(projectPrice) ?? 0, + priceMaterial: Double(priceMaterial) ?? 0, + pricePhoto: Double(pricePhoto) ?? 0, + priceVideo: Double(priceVideo) ?? 0, + priceMaterialAll: nil, + packageList: nil, + userId: userId, + singleSpotMaterialNum: max(Int(materialNum) ?? 0, 0), + singleSpotPhotoNum: max(Int(photoNum) ?? 0, 0), + singleSpotVideoNum: max(Int(videoNum) ?? 0, 0) + ) + ) + } else { + try await api.storeManagerCreate( + StoreManagerCreateRequest( + name: name, + storeId: nil, + type: 19, + description: description, + coverProject: coverURL, + coverCarousel: carouselURLs, + projectRule: nil, + scenicId: selectedScenicIds.sorted(), + settleSpotNum: max(Int(settleSpotNum) ?? 1, 1), + price: Double(projectPrice) ?? 0, + priceMaterial: Double(priceMaterial) ?? 0, + pricePhoto: Double(pricePhoto) ?? 0, + priceVideo: Double(priceVideo) ?? 0, + priceMaterialAll: nil, + packageList: nil, + userId: userId, + singleSpotMaterialNum: max(Int(materialNum) ?? 0, 0), + singleSpotPhotoNum: max(Int(photoNum) ?? 0, 0), + singleSpotVideoNum: max(Int(videoNum) ?? 0, 0) + ) + ) + } + } + + private func submitOfflineProject(api: any ProjectServing, name: String, description: String, coverURL: String, carouselURLs: [String]) async throws { + let scenicId = selectedScenicIds.first ?? 0 + let storeId = selectedStoreId ?? 0 + if let editID { + try await api.storeManagerOfflineUpdate( + StoreManagerOfflineUpdateRequest( + id: editID, + name: name, + scenicId: scenicId, + description: description, + price: Double(priceDeposit) ?? 0, + coverProject: coverURL, + coverCarousel: carouselURLs, + storeId: storeId + ) + ) + } else { + try await api.storeManagerOfflineCreate( + StoreManagerOfflineCreateRequest( + name: name, + scenicId: scenicId, + storeId: storeId, + description: description, + price: Double(priceDeposit) ?? 0, + coverProject: coverURL, + coverCarousel: carouselURLs + ) + ) + } + } + + private func fail(_ error: ProjectEditorError) -> Bool { + errorMessage = error.localizedDescription + return false + } +} diff --git a/suixinkan/Features/Projects/Views/ProjectViews.swift b/suixinkan/Features/Projects/Views/ProjectViews.swift new file mode 100644 index 0000000..6997cf4 --- /dev/null +++ b/suixinkan/Features/Projects/Views/ProjectViews.swift @@ -0,0 +1,776 @@ +// +// ProjectViews.swift +// suixinkan +// +// Created by Codex on 2026/6/24. +// + +import PhotosUI +import SwiftUI +import UniformTypeIdentifiers + +/// 摄影师项目管理页面,展示项目列表并提供新建、详情和删除入口。 +struct ProjectManagementView: View { + @Environment(AccountContext.self) private var accountContext + @Environment(AccountSnapshotStore.self) private var snapshotStore + @Environment(ProjectAPI.self) private var projectAPI + @Environment(\.globalLoading) private var globalLoading + @State private var viewModel = ProjectManagementViewModel() + + var body: some View { + VStack(spacing: 0) { + filterBar + if accountContext.currentScenic?.id == nil { + ContentUnavailableView("缺少景区", systemImage: "map", description: Text("请选择景区后再管理项目。")) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + projectList + } + } + .navigationTitle("项目管理") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + NavigationLink(value: HomeRoute.projectEditor(id: nil, storeMode: false)) { + Image(systemName: "plus") + } + .accessibilityLabel("新建项目") + } + } + .task(id: accountContext.currentScenic?.id) { + await reload(showLoading: true) + } + } + + private var filterBar: some View { + HStack(spacing: AppMetrics.Spacing.small) { + Image(systemName: "magnifyingglass") + .foregroundStyle(AppDesign.textSecondary) + TextField("搜索项目名称", text: $viewModel.searchText) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + .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) + } + .padding(.horizontal, AppMetrics.Spacing.medium) + .frame(height: 52) + .background(.white) + } + + private var projectList: some View { + ScrollView { + LazyVStack(spacing: AppMetrics.Spacing.medium) { + if viewModel.items.isEmpty && !viewModel.loading { + ContentUnavailableView("暂无项目", systemImage: "folder", description: Text("可点击右上角创建项目。")) + .frame(maxWidth: .infinity, minHeight: 260) + } + ForEach(viewModel.items) { item in + NavigationLink(value: HomeRoute.projectDetail(id: item.id, storeMode: false)) { + ProjectCardView(item: item) + } + .buttonStyle(.plain) + .contextMenu { + Button(role: .destructive) { + Task { await viewModel.delete(id: item.id, api: projectAPI) } + } label: { + Label("删除", systemImage: "trash") + } + } + } + if viewModel.hasMore { + ProgressView() + .padding(.vertical, AppMetrics.Spacing.medium) + .onAppear { + Task { await viewModel.loadMore(api: projectAPI, scenicId: accountContext.currentScenic?.id) } + } + } else if !viewModel.items.isEmpty { + Text("没有更多") + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(AppDesign.textSecondary) + .padding(.vertical, AppMetrics.Spacing.medium) + } + } + .padding(AppMetrics.Spacing.medium) + } + .background(Color(hex: 0xF5F7FA)) + .refreshable { await reload(showLoading: false) } + } + + private func reload(showLoading: Bool) async { + await globalLoading.withOptionalLoading(showLoading && accountContext.currentScenic?.id != nil) { + await viewModel.reload(api: projectAPI, scenicId: accountContext.currentScenic?.id) + } + } +} + +/// 店铺项目管理页面,展示店铺项目列表、筛选和新建入口。 +struct StoreProjectManagementView: View { + @Environment(AccountSnapshotStore.self) private var snapshotStore + @Environment(ProjectAPI.self) private var projectAPI + @Environment(\.globalLoading) private var globalLoading + @State private var viewModel = StoreProjectManagementViewModel() + + var body: some View { + ScrollView { + VStack(spacing: AppMetrics.Spacing.medium) { + storeFilterCard + storeSummaryCard + ForEach(viewModel.filteredItems) { item in + NavigationLink(value: HomeRoute.projectDetail(id: item.id, storeMode: true)) { + ProjectCompactCardView(item: item) + } + .buttonStyle(.plain) + } + if viewModel.filteredItems.isEmpty && !viewModel.loading { + ContentUnavailableView("暂无店铺项目", systemImage: "folder", description: Text("可点击右上角新建店铺项目。")) + .frame(minHeight: 260) + } + } + .padding(AppMetrics.Spacing.medium) + } + .background(Color(hex: 0xF5F7FA)) + .navigationTitle("店铺项目管理") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + NavigationLink(value: HomeRoute.projectEditor(id: nil, storeMode: true)) { + Image(systemName: "plus") + } + .accessibilityLabel("新建店铺项目") + } + } + .task { await reload(showLoading: true) } + .refreshable { await reload(showLoading: false) } + } + + private var storeFilterCard: some View { + VStack(spacing: AppMetrics.Spacing.small) { + Picker("项目类型", selection: $viewModel.selectedType) { + Text("全部").tag(0) + Text("多点位").tag(19) + Text("押金").tag(4) + } + .pickerStyle(.segmented) + TextField("搜索项目名称", text: $viewModel.searchText) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + .appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight) + } + .padding(AppMetrics.Spacing.medium) + .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) + } + + private var storeSummaryCard: some View { + HStack(spacing: AppMetrics.Spacing.small) { + stat("总数", "\(viewModel.filteredItems.count)") + stat("启用", "\(viewModel.filteredItems.filter { $0.status == 1 }.count)") + stat("押金", "\(viewModel.filteredItems.filter { $0.type == 4 }.count)") + } + } + + private func stat(_ title: String, _ value: String) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(AppDesign.textSecondary) + Text(value) + .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) + .foregroundStyle(AppDesign.textPrimary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(AppMetrics.Spacing.small) + .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) + } + + private func reload(showLoading: Bool) async { + await globalLoading.withOptionalLoading(showLoading) { + await viewModel.reload(api: projectAPI, userId: snapshotStore.load()?.profile?.userId ?? snapshotStore.load()?.businessUserId.map(String.init)) + } + } +} + +/// 项目详情页面,展示项目基础信息、媒体、打卡点和摄影师摘要。 +struct ProjectDetailView: View { + @Environment(ProjectAPI.self) private var projectAPI + @Environment(\.globalLoading) private var globalLoading + let projectId: Int + let storeMode: Bool + @State private var detail: PhotographerProjectDetailResponse? + @State private var errorMessage: String? + + var body: some View { + ScrollView { + VStack(spacing: AppMetrics.Spacing.medium) { + if let detail { + ProjectDetailContentView(detail: detail) + } else if let errorMessage { + ContentUnavailableView("加载失败", systemImage: "exclamationmark.triangle", description: Text(errorMessage)) + .frame(minHeight: 360) + } else { + ProgressView() + .frame(maxWidth: .infinity, minHeight: 360) + } + } + .padding(AppMetrics.Spacing.medium) + } + .background(Color(hex: 0xF5F7FA)) + .navigationTitle("项目详情") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + if detail != nil { + ToolbarItem(placement: .topBarTrailing) { + NavigationLink(value: HomeRoute.projectEditor(id: projectId, storeMode: storeMode)) { + Text("编辑") + } + } + } + } + .task { await load() } + } + + private func load() async { + await globalLoading.withLoading { + do { + detail = storeMode ? try await projectAPI.storeManagerProjectDetail(id: projectId) : try await projectAPI.projectDetail(id: projectId) + } catch { + errorMessage = error.localizedDescription + } + } + } +} + +/// 摄影师项目编辑页面,支持新建和编辑项目。 +struct ProjectEditorView: View { + @Environment(\.dismiss) private var dismiss + @Environment(AccountContext.self) private var accountContext + @Environment(AccountSnapshotStore.self) private var snapshotStore + @Environment(ScenicSpotContext.self) private var scenicSpotContext + @Environment(ProjectAPI.self) private var projectAPI + @Environment(OSSUploadService.self) private var uploadService + @Environment(\.globalLoading) private var globalLoading + let projectId: Int? + @State private var viewModel = ProjectEditorViewModel(mode: .create) + @State private var coverItem: PhotosPickerItem? + @State private var carouselItems: [PhotosPickerItem] = [] + @State private var loadingDetail = false + + var body: some View { + ScrollView { + VStack(spacing: AppMetrics.Spacing.medium) { + basicForm + projectImageForm + countForm + spotForm + 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: 0xE5484D).opacity(0.1), in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) + } + } + .padding(AppMetrics.Spacing.medium) + } + .background(Color(hex: 0xF5F7FA)) + .navigationTitle(projectId == nil ? "新建项目" : "编辑项目") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button(viewModel.submitting ? "保存中..." : "保存") { + Task { await submit() } + } + .disabled(viewModel.submitting || loadingDetail) + } + } + .task { + await prepare() + } + .onChange(of: coverItem) { _, newValue in + Task { await loadCover(newValue) } + } + .onChange(of: carouselItems) { _, newValue in + Task { await loadCarousel(newValue) } + } + } + + private var basicForm: some View { + ProjectFormCard(title: "基础信息") { + TextField("项目名称", text: $viewModel.name) + .appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight) + TextField("项目简介", text: $viewModel.descriptionText, axis: .vertical) + .lineLimit(3...5) + .appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: 96) + HStack(spacing: AppMetrics.Spacing.small) { + TextField("优惠价", text: $viewModel.price) + .keyboardType(.decimalPad) + .appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight) + TextField("原价", text: $viewModel.otPrice) + .keyboardType(.decimalPad) + .appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight) + } + TextField("押金", text: $viewModel.deposit) + .keyboardType(.decimalPad) + .appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight) + TextField("标签,多个用逗号分隔", text: $viewModel.attrLabelText) + .appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight) + } + } + + private var projectImageForm: some View { + ProjectFormCard(title: "项目图片") { + PhotosPicker(selection: $coverItem, matching: .images) { + ProjectImagePickerLabel(title: "项目封面", url: viewModel.coverImage?.previewURL ?? viewModel.existingCoverURL) + } + PhotosPicker(selection: $carouselItems, maxSelectionCount: 8, matching: .images) { + Label("选择轮播图(\(viewModel.carouselImages.count + viewModel.existingCarouselURLs.count))", systemImage: "photo.stack") + .frame(maxWidth: .infinity) + .frame(height: 44) + .background(AppDesign.primarySoft, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input)) + } + .buttonStyle(.plain) + } + } + + private var countForm: some View { + ProjectFormCard(title: "交付数量") { + HStack(spacing: AppMetrics.Spacing.small) { + TextField("底片", text: $viewModel.materialNum) + TextField("精修", text: $viewModel.photoNum) + TextField("视频", text: $viewModel.videoNum) + } + .keyboardType(.numberPad) + .textFieldStyle(.roundedBorder) + } + } + + private var spotForm: some View { + ProjectFormCard(title: "打卡点") { + if scenicSpotContext.spots.isEmpty { + Text("暂无打卡点数据") + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(AppDesign.textSecondary) + } else { + ForEach(scenicSpotContext.spots) { spot in + Button { + if viewModel.selectedSpotIds.contains(spot.id) { + viewModel.selectedSpotIds.remove(spot.id) + } else { + viewModel.selectedSpotIds.insert(spot.id) + } + } label: { + HStack { + Text(spot.name) + Spacer() + Image(systemName: viewModel.selectedSpotIds.contains(spot.id) ? "checkmark.circle.fill" : "circle") + .foregroundStyle(viewModel.selectedSpotIds.contains(spot.id) ? AppDesign.primary : AppDesign.textSecondary) + } + .font(.system(size: AppMetrics.FontSize.subheadline)) + .foregroundStyle(AppDesign.textPrimary) + .padding(AppMetrics.Spacing.small) + .background(Color(hex: 0xF8FAFC), in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input)) + } + .buttonStyle(.plain) + } + } + } + } + + private func prepare() async { + guard let projectId else { return } + loadingDetail = true + defer { loadingDetail = false } + do { + let detail = try await projectAPI.projectDetail(id: projectId) + viewModel = ProjectEditorViewModel(mode: .edit(detail)) + } catch { + viewModel.errorMessage = error.localizedDescription + } + } + + private func submit() async { + await globalLoading.withLoading { + let success = await viewModel.submit( + scenicId: accountContext.currentScenic?.id, + userId: snapshotStore.load()?.businessUserId ?? Int(snapshotStore.load()?.profile?.userId ?? ""), + api: projectAPI, + uploadService: uploadService + ) + if success { dismiss() } + } + } + + private func loadCover(_ item: PhotosPickerItem?) async { + guard let file = await ProjectPickerLoader.loadImage(from: item) else { return } + viewModel.coverImage = file + } + + private func loadCarousel(_ items: [PhotosPickerItem]) async { + viewModel.carouselImages = await ProjectPickerLoader.loadImages(from: items) + } +} + +/// 店铺项目编辑页面,支持新建和编辑店铺项目。 +struct StoreProjectEditorView: View { + @Environment(\.dismiss) private var dismiss + @Environment(AccountSnapshotStore.self) private var snapshotStore + @Environment(ProjectAPI.self) private var projectAPI + @Environment(OSSUploadService.self) private var uploadService + @Environment(\.globalLoading) private var globalLoading + let projectId: Int? + @State private var viewModel = StoreProjectEditorViewModel(mode: .create) + @State private var coverItem: PhotosPickerItem? + @State private var carouselItems: [PhotosPickerItem] = [] + + var body: some View { + ScrollView { + VStack(spacing: AppMetrics.Spacing.medium) { + storeBasicForm + storeImageForm + storeScenicForm + storePriceForm + if let error = viewModel.errorMessage { + Text(error) + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(Color(hex: 0xE5484D)) + } + } + .padding(AppMetrics.Spacing.medium) + } + .background(Color(hex: 0xF5F7FA)) + .navigationTitle(projectId == nil ? "新建店铺项目" : "编辑店铺项目") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button(viewModel.submitting ? "保存中..." : "保存") { + Task { await submit() } + } + .disabled(viewModel.submitting) + } + } + .task { await prepare() } + .onChange(of: coverItem) { _, newValue in + Task { viewModel.coverImage = await ProjectPickerLoader.loadImage(from: newValue) } + } + .onChange(of: carouselItems) { _, newValue in + Task { viewModel.carouselImages = await ProjectPickerLoader.loadImages(from: newValue) } + } + } + + private var storeBasicForm: some View { + ProjectFormCard(title: "基础信息") { + Picker("类型", selection: $viewModel.projectType) { + Text("多点位").tag(19) + Text("押金").tag(4) + } + .pickerStyle(.segmented) + TextField("项目名称", text: $viewModel.name) + .appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight) + TextField("项目简介", text: $viewModel.descriptionText, axis: .vertical) + .lineLimit(3...5) + .appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: 96) + } + } + + private var storeImageForm: some View { + ProjectFormCard(title: "项目图片") { + PhotosPicker(selection: $coverItem, matching: .images) { + ProjectImagePickerLabel(title: "项目封面", url: viewModel.coverImage?.previewURL ?? viewModel.existingCoverURL) + } + PhotosPicker(selection: $carouselItems, maxSelectionCount: 8, matching: .images) { + Label("选择轮播图(\(viewModel.carouselImages.count + viewModel.existingCarouselURLs.count))", systemImage: "photo.stack") + .frame(maxWidth: .infinity) + .frame(height: 44) + .background(AppDesign.primarySoft, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input)) + } + .buttonStyle(.plain) + } + } + + private var storeScenicForm: some View { + ProjectFormCard(title: viewModel.projectType == 4 ? "景区与门店" : "景区") { + ForEach(viewModel.scenicList) { scenic in + Button { + viewModel.toggleScenic(scenic.id) + Task { await viewModel.loadStoreList(api: projectAPI) } + } label: { + HStack { + Text(scenic.name) + Spacer() + Image(systemName: viewModel.selectedScenicIds.contains(scenic.id) ? "checkmark.circle.fill" : "circle") + } + .foregroundStyle(viewModel.selectedScenicIds.contains(scenic.id) ? AppDesign.primary : AppDesign.textPrimary) + } + .buttonStyle(.plain) + } + if viewModel.projectType == 4 { + Picker("门店", selection: Binding(get: { viewModel.selectedStoreId ?? 0 }, set: { viewModel.selectedStoreId = $0 == 0 ? nil : $0 })) { + Text("请选择").tag(0) + ForEach(viewModel.storeList) { store in + Text(store.name).tag(store.id) + } + } + } + } + } + + private var storePriceForm: some View { + ProjectFormCard(title: "价格与数量") { + if viewModel.projectType == 4 { + TextField("押金金额", text: $viewModel.priceDeposit) + .keyboardType(.decimalPad) + .appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight) + } else { + TextField("项目价格", text: $viewModel.projectPrice) + .keyboardType(.decimalPad) + .appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight) + HStack { + TextField("底片价", text: $viewModel.priceMaterial) + TextField("精修价", text: $viewModel.pricePhoto) + TextField("视频价", text: $viewModel.priceVideo) + } + .keyboardType(.decimalPad) + .textFieldStyle(.roundedBorder) + } + } + } + + private func prepare() async { + let userId = snapshotStore.load()?.businessUserId ?? Int(snapshotStore.load()?.profile?.userId ?? "") ?? 0 + if let projectId { + do { + let detail = try await projectAPI.storeManagerProjectDetail(id: projectId) + viewModel = StoreProjectEditorViewModel(mode: .edit(detail)) + } catch { + viewModel.errorMessage = error.localizedDescription + } + } + await viewModel.loadScenicList(api: projectAPI, userId: userId) + await viewModel.loadStoreList(api: projectAPI) + } + + private func submit() async { + let userId = snapshotStore.load()?.businessUserId ?? Int(snapshotStore.load()?.profile?.userId ?? "") ?? 0 + await globalLoading.withLoading { + let success = await viewModel.submit(userId: userId, api: projectAPI, uploadService: uploadService) + if success { dismiss() } + } + } +} + +/// 项目卡片视图,展示封面、名称、状态和价格。 +private struct ProjectCardView: View { + let item: PhotographerProjectItem + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + RemoteImage(urlString: item.coverProject.isEmpty ? item.coverVideo : item.coverProject, contentMode: .fill) { + Image(systemName: "photo") + .font(.system(size: 30)) + .foregroundStyle(AppDesign.primary) + } + .frame(height: 180) + .clipped() + VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) { + HStack { + Text(item.name.isEmpty ? "未命名项目" : item.name) + .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) + .foregroundStyle(AppDesign.textPrimary) + Spacer() + Text(item.statusName.isEmpty ? "状态\(item.status)" : item.statusName) + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(AppDesign.primary) + } + Text("¥\(item.price.isEmpty ? "0.00" : item.price)") + .font(.system(size: AppMetrics.FontSize.title3, weight: .bold)) + .foregroundStyle(AppDesign.textPrimary) + } + .padding(AppMetrics.Spacing.medium) + } + .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) + .clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) + } +} + +/// 紧凑项目卡片视图,展示店铺项目摘要。 +private struct ProjectCompactCardView: View { + let item: PhotographerProjectItem + + var body: some View { + HStack(spacing: AppMetrics.Spacing.medium) { + RemoteImage(urlString: item.coverProject.isEmpty ? item.coverVideo : item.coverProject, contentMode: .fill) { + Image(systemName: "photo") + .foregroundStyle(AppDesign.primary) + } + .frame(width: 76, height: 76) + .clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input)) + VStack(alignment: .leading, spacing: 6) { + Text(item.name.isEmpty ? "未命名项目" : item.name) + .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) + .foregroundStyle(AppDesign.textPrimary) + Text(item.typeName.isEmpty ? "类型\(item.type)" : item.typeName) + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(AppDesign.textSecondary) + Text("¥\(item.price.isEmpty ? item.priceDeposit : item.price)") + .font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold)) + .foregroundStyle(AppDesign.primary) + } + Spacer() + } + .padding(AppMetrics.Spacing.medium) + .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) + } +} + +/// 项目详情内容视图。 +private struct ProjectDetailContentView: View { + let detail: PhotographerProjectDetailResponse + + var body: some View { + VStack(spacing: AppMetrics.Spacing.medium) { + ProjectCardView(item: detail.asListItem) + ProjectFormCard(title: "服务内容") { + HStack { + service("底片", "\(detail.materialNum)") + service("精修", "\(detail.photoNum)") + service("视频", "\(detail.videoNum)") + } + } + ProjectFormCard(title: "基础信息") { + row("项目类型", detail.typeName) + row("状态", detail.statusName) + row("创建人", detail.creatorName) + row("简介", detail.description) + if !detail.auditRemark.isEmpty { + row("审核备注", detail.auditRemark) + } + } + if !detail.scenicList.isEmpty { + ProjectFormCard(title: "打卡点") { + Text(detail.scenicList.map(\.name).joined(separator: "、")) + .font(.system(size: AppMetrics.FontSize.subheadline)) + .foregroundStyle(AppDesign.textPrimary) + } + } + } + } + + private func service(_ title: String, _ value: String) -> some View { + VStack(spacing: 4) { + Text(value) + .font(.system(size: AppMetrics.FontSize.title3, weight: .bold)) + .foregroundStyle(AppDesign.primary) + Text(title) + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(AppDesign.textSecondary) + } + .frame(maxWidth: .infinity) + } + + private func row(_ title: String, _ value: String) -> some View { + HStack(alignment: .top) { + Text(title) + .foregroundStyle(AppDesign.textSecondary) + Spacer() + Text(value.isEmpty ? "--" : value) + .multilineTextAlignment(.trailing) + .foregroundStyle(AppDesign.textPrimary) + } + .font(.system(size: AppMetrics.FontSize.subheadline)) + } +} + +/// 项目表单通用卡片。 +private struct ProjectFormCard: View { + let title: String + @ViewBuilder let content: () -> Content + + var body: some View { + VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) { + Text(title) + .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) + .foregroundStyle(AppDesign.textPrimary) + content() + } + .padding(AppMetrics.Spacing.medium) + .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) + } +} + +/// 项目图片选择标签。 +private struct ProjectImagePickerLabel: View { + let title: String + let url: String + + var body: some View { + ZStack { + RemoteImage(urlString: url, contentMode: .fill) { + VStack(spacing: 8) { + Image(systemName: "photo.badge.plus") + Text(title) + } + .foregroundStyle(AppDesign.primary) + } + } + .frame(maxWidth: .infinity) + .frame(height: 160) + .background(AppDesign.primarySoft, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input)) + .clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input)) + } +} + +/// PhotosPicker 项目图片加载器。 +private enum ProjectPickerLoader { + /// 从单个 PhotosPickerItem 读取项目图片。 + static func loadImage(from item: PhotosPickerItem?) async -> ProjectLocalImage? { + guard let item, let data = try? await item.loadTransferable(type: Data.self) else { return nil } + let fileName = fileName(for: item) + return ProjectLocalImage(data: data, fileName: fileName, previewURL: dataURL(data: data, fileName: fileName)) + } + + /// 从多个 PhotosPickerItem 读取项目图片。 + static func loadImages(from items: [PhotosPickerItem]) async -> [ProjectLocalImage] { + var result: [ProjectLocalImage] = [] + for item in items { + if let image = await loadImage(from: item) { + result.append(image) + } + } + return result + } + + private static func fileName(for item: PhotosPickerItem) -> String { + let ext = item.supportedContentTypes.first?.preferredFilenameExtension ?? "jpg" + return "project_\(UUID().uuidString).\(ext)" + } + + private static func dataURL(data: Data, fileName: String) -> String? { + let mime = UTType(filenameExtension: URL(fileURLWithPath: fileName).pathExtension)?.preferredMIMEType ?? "image/jpeg" + return "data:\(mime);base64,\(data.base64EncodedString())" + } +} + +private extension PhotographerProjectDetailResponse { + /// 将详情转换为列表卡片展示项。 + var asListItem: PhotographerProjectItem { + PhotographerProjectItem( + id: id, + type: type, + typeName: typeName, + status: status, + statusName: statusName, + name: name, + coverProject: coverProject, + coverVideo: cover, + price: price, + otPrice: otPrice, + priceDeposit: priceDeposit, + attrLabel: attrLabel + ) + } +} diff --git a/suixinkan/Features/Schedule/API/ScheduleAPI.swift b/suixinkan/Features/Schedule/API/ScheduleAPI.swift new file mode 100644 index 0000000..aad6256 --- /dev/null +++ b/suixinkan/Features/Schedule/API/ScheduleAPI.swift @@ -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)")] + ) + ) + } +} diff --git a/suixinkan/Features/Schedule/Models/ScheduleModels.swift b/suixinkan/Features/Schedule/Models/ScheduleModels.swift new file mode 100644 index 0000000..0f14f9a --- /dev/null +++ b/suixinkan/Features/Schedule/Models/ScheduleModels.swift @@ -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 + } +} diff --git a/suixinkan/Features/Schedule/Schedule.md b/suixinkan/Features/Schedule/Schedule.md new file mode 100644 index 0000000..951bf02 --- /dev/null +++ b/suixinkan/Features/Schedule/Schedule.md @@ -0,0 +1,27 @@ +# Schedule 模块业务逻辑 + +## 模块职责 + +Schedule 模块负责首页 `schedule_management` 入口,提供排班日历、某日排班列表、新增排班和删除排班能力。 + +模块状态只保存在 `ScheduleManagementViewModel` 和 `ScheduleAddViewModel` 内,不写入全局登录态、账号上下文或首页状态。 + +## 排班列表 + +`ScheduleManagementViewModel` 按当前月份加载有排班的日期标记,并按选中日期加载日排班列表。切换月份或日期时只刷新当前模块数据。 + +缺少当前景区时,ViewModel 会清空月份标记和日程列表,并展示缺少经营上下文的空状态。 + +## 新增排班 + +`ScheduleAddViewModel` 管理名称、备注、日期、开始/结束时间和关联订单。关联订单可以从接口返回的可选订单中选择,也可以手填订单号兜底。 + +提交前会校验名称、景区、日期和时间顺序;重复提交会被忽略。提交成功后返回排班列表并刷新当前日期。 + +## 接口边界 + +`ScheduleAPI` 封装月排班日期、日排班列表、新增排班、删除排班和可关联订单接口。可关联订单模型放在共享业务模型中,避免 Schedule 直接依赖 Tasks 模块。 + +## 缓存边界 + +排班表单、关联订单、月标记和日程列表都只保存在内存中,不做本地缓存。 diff --git a/suixinkan/Features/Schedule/ViewModels/ScheduleViewModels.swift b/suixinkan/Features/Schedule/ViewModels/ScheduleViewModels.swift new file mode 100644 index 0000000..7ffaed0 --- /dev/null +++ b/suixinkan/Features/Schedule/ViewModels/ScheduleViewModels.swift @@ -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 = [] + 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) + } + } +} diff --git a/suixinkan/Features/Schedule/Views/ScheduleViews.swift b/suixinkan/Features/Schedule/Views/ScheduleViews.swift new file mode 100644 index 0000000..558d7d9 --- /dev/null +++ b/suixinkan/Features/Schedule/Views/ScheduleViews.swift @@ -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(_ 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)) + } +} diff --git a/suixinkan/Features/Tasks/Models/TaskModels.swift b/suixinkan/Features/Tasks/Models/TaskModels.swift index 91760c8..8ed362a 100644 --- a/suixinkan/Features/Tasks/Models/TaskModels.swift +++ b/suixinkan/Features/Tasks/Models/TaskModels.swift @@ -203,38 +203,6 @@ struct TaskDetailOrder: Decodable, Hashable { } } -/// 可关联订单实体,表示发布任务时可选择的订单。 -struct AvailableOrderResponse: Decodable, Identifiable, Hashable { - var id: String { orderNumber } - let projectName: String - let orderNumber: String - let orderStatus: Int - let orderStatusLabel: String - let payTime: String - let userPhone: String - - /// 可关联订单字段映射,兼容后端下划线命名。 - enum CodingKeys: String, CodingKey { - case projectName = "project_name" - case orderNumber = "order_number" - case orderStatus = "order_status" - case orderStatusLabel = "order_status_label" - case payTime = "pay_time" - case userPhone = "user_phone" - } - - /// 宽松解码可关联订单字段。 - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - projectName = try container.decodeLossyString(forKey: .projectName) - orderNumber = try container.decodeLossyString(forKey: .orderNumber) - orderStatus = try container.decodeLossyInt(forKey: .orderStatus) ?? 0 - orderStatusLabel = try container.decodeLossyString(forKey: .orderStatusLabel) - payTime = try container.decodeLossyString(forKey: .payTime) - userPhone = try container.decodeLossyString(forKey: .userPhone) - } -} - /// 发布任务请求实体,描述提交给后端的新任务内容。 struct AddTaskRequest: Encodable, Equatable { let scenicId: String diff --git a/suixinkan/Features/Wallet/Models/WalletModels.swift b/suixinkan/Features/Wallet/Models/WalletModels.swift index d711321..523052d 100644 --- a/suixinkan/Features/Wallet/Models/WalletModels.swift +++ b/suixinkan/Features/Wallet/Models/WalletModels.swift @@ -125,6 +125,29 @@ struct WalletEarningDetailItem: Decodable, Equatable, Identifiable { case source } + /// 创建收益明细实体,主要用于测试替身和本地空状态。 + init( + id: Int64 = 0, + amount: String = "", + points: Int = 0, + type: Int = 0, + typeLabel: String = "", + orderNumberSuffix: String = "", + createdAt: String = "", + withdrawLabel: String? = nil, + source: String? = nil + ) { + self.id = id + self.amount = amount + self.points = points + self.type = type + self.typeLabel = typeLabel + self.orderNumberSuffix = orderNumberSuffix + self.createdAt = createdAt + self.withdrawLabel = withdrawLabel + self.source = source + } + /// 自定义解码,兼容字段类型不稳定。 init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) diff --git a/suixinkanTests/Assets/AssetsViewModelTests.swift b/suixinkanTests/Assets/AssetsViewModelTests.swift index 2bee0f9..2711386 100644 --- a/suixinkanTests/Assets/AssetsViewModelTests.swift +++ b/suixinkanTests/Assets/AssetsViewModelTests.swift @@ -546,6 +546,11 @@ private final class MockAssetsUploader: OSSUploadServing { try await upload(fileName: fileName, fileType: fileType, scenicId: scenicId, onProgress: onProgress) } + /// 上传项目图片。 + func uploadProjectImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + try await upload(fileName: fileName, fileType: 2, scenicId: scenicId, onProgress: onProgress) + } + /// 上传打卡点图片。 func uploadPunchPointImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { try await upload(fileName: fileName, fileType: 2, scenicId: scenicId, onProgress: onProgress) diff --git a/suixinkanTests/HomeCommonMenuStoreTests.swift b/suixinkanTests/HomeCommonMenuStoreTests.swift index d00ee2c..00a0793 100644 --- a/suixinkanTests/HomeCommonMenuStoreTests.swift +++ b/suixinkanTests/HomeCommonMenuStoreTests.swift @@ -34,12 +34,12 @@ final class HomeCommonMenuStoreTests: XCTestCase { let store = HomeCommonMenuStore(defaults: defaults) let menus = [ menu("task_management_editor"), - menu("pm_manager") + menu("project_edit") ] let uris = store.load(menuItems: menus) - XCTAssertEqual(uris, ["task_management_editor", "pm_manager"]) + XCTAssertEqual(uris, ["task_management_editor", "project_edit"]) } /// 测试添加常用应用时会按同义 URI 去重。 @@ -60,9 +60,9 @@ final class HomeCommonMenuStoreTests: XCTestCase { let defaults = makeIsolatedDefaults() let store = HomeCommonMenuStore(defaults: defaults) - let uris = store.remove("pm", current: ["pm_manager", "location_report"]) + let uris = store.remove("pm", current: ["project_edit", "pm_manager", "location_report"]) - XCTAssertEqual(uris, ["location_report"]) + XCTAssertEqual(uris, ["pm_manager", "location_report"]) } /// 创建测试菜单实体。 diff --git a/suixinkanTests/HomeMenuRouterTests.swift b/suixinkanTests/HomeMenuRouterTests.swift index 5e51841..bdc21d3 100644 --- a/suixinkanTests/HomeMenuRouterTests.swift +++ b/suixinkanTests/HomeMenuRouterTests.swift @@ -46,6 +46,13 @@ final class HomeMenuRouterTests: XCTestCase { XCTAssertEqual(HomeMenuRouter.resolve(uri: "checkin_points", title: ""), .destination(.punchPointList)) XCTAssertEqual(HomeMenuRouter.resolve(uri: "location_report", title: ""), .destination(.locationReport)) XCTAssertEqual(HomeMenuRouter.resolve(uri: "location_report_history", title: ""), .destination(.locationReportHistory)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "pm", title: ""), .destination(.projectManagement)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "project_edit", title: ""), .destination(.projectManagement)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "pm_manager", title: ""), .destination(.pmProjectManagement)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "schedule_management", title: ""), .destination(.scheduleManagement)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "registration_invitation", title: ""), .destination(.photographerInvite)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "photographer_invite", title: ""), .destination(.photographerInvite)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "invite_record", title: ""), .destination(.inviteRecord)) } /// 测试已知但未迁移的首页路由会进入安全占位。 @@ -90,10 +97,11 @@ final class HomeMenuRouterTests: XCTestCase { /// 测试 canonicalURI 会使用当前权限可用的同义 URI。 func testCanonicalURIUsesAvailableAlias() { - let available: Set = ["task_management_editor", "photographer_invite", "pm_manager", "payment_qr"] + let available: Set = ["task_management_editor", "photographer_invite", "project_edit", "pm_manager", "payment_qr"] XCTAssertEqual(HomeMenuRouter.canonicalURI(for: "task_management", availableURIs: available), "task_management_editor") XCTAssertEqual(HomeMenuRouter.canonicalURI(for: "registration_invitation", availableURIs: available), "photographer_invite") - XCTAssertEqual(HomeMenuRouter.canonicalURI(for: "pm", availableURIs: available), "pm_manager") + XCTAssertEqual(HomeMenuRouter.canonicalURI(for: "pm", availableURIs: available), "project_edit") + XCTAssertEqual(HomeMenuRouter.canonicalURI(for: "pm_manager", availableURIs: available), "pm_manager") XCTAssertEqual(HomeMenuRouter.canonicalURI(for: "payment_code", availableURIs: available), "payment_qr") } @@ -101,7 +109,8 @@ final class HomeMenuRouterTests: XCTestCase { func testMenuAliasKeyGroupsDuplicateAndroidEntries() { XCTAssertEqual(HomeMenuRouter.menuAliasKey(for: "task_management_editor"), "task_management") XCTAssertEqual(HomeMenuRouter.menuAliasKey(for: "photographer_invite"), "registration_invitation") - XCTAssertEqual(HomeMenuRouter.menuAliasKey(for: "pm_manager"), "pm") + XCTAssertEqual(HomeMenuRouter.menuAliasKey(for: "project_edit"), "pm") + XCTAssertEqual(HomeMenuRouter.menuAliasKey(for: "pm_manager"), "pm_manager") XCTAssertEqual(HomeMenuRouter.menuAliasKey(for: "payment_code"), "payment_collection") XCTAssertEqual(HomeMenuRouter.menuAliasKey(for: "deposit_order_shooting_info"), "deposit_order") XCTAssertEqual(HomeMenuRouter.menuAliasKey(for: "/scenic-order-manage"), "photographer_orders") @@ -173,11 +182,21 @@ final class HomeMenuRouterTests: XCTestCase { XCTAssertTrue(uris.contains("sample_management")) XCTAssertTrue(uris.contains("checkin_points")) XCTAssertTrue(uris.contains("location_report")) + XCTAssertTrue(uris.contains("pm")) + XCTAssertTrue(uris.contains("pm_manager")) + XCTAssertTrue(uris.contains("schedule_management")) + XCTAssertTrue(uris.contains("registration_invitation")) + XCTAssertTrue(uris.contains("invite_record")) XCTAssertEqual(HomeMenuRouter.resolve(uri: "album_list", title: ""), .destination(.albumList)) XCTAssertEqual(HomeMenuRouter.resolve(uri: "sample_management", title: ""), .destination(.sampleLibrary)) XCTAssertEqual(HomeMenuRouter.resolve(uri: "sample_upload", title: ""), .destination(.sampleUpload)) XCTAssertEqual(HomeMenuRouter.resolve(uri: "checkin_points", title: ""), .destination(.punchPointList)) XCTAssertEqual(HomeMenuRouter.resolve(uri: "location_report", title: ""), .destination(.locationReport)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "pm", title: ""), .destination(.projectManagement)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "pm_manager", title: ""), .destination(.pmProjectManagement)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "schedule_management", title: ""), .destination(.scheduleManagement)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "registration_invitation", title: ""), .destination(.photographerInvite)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "invite_record", title: ""), .destination(.inviteRecord)) } #endif diff --git a/suixinkanTests/HomeViewModelTests.swift b/suixinkanTests/HomeViewModelTests.swift index 206718f..ffe8053 100644 --- a/suixinkanTests/HomeViewModelTests.swift +++ b/suixinkanTests/HomeViewModelTests.swift @@ -77,7 +77,7 @@ final class HomeViewModelTests: XCTestCase { viewModel.buildMenus(from: permissions, currentRoleId: 1) - XCTAssertEqual(viewModel.menuItems.map(\.uri), ["deposit_order_detail", "pm_manager", "payment_code"]) + XCTAssertEqual(viewModel.menuItems.map(\.uri), ["pm", "deposit_order_detail", "pm_manager", "payment_code"]) } /// 测试菜单按旧工程 preferred order 排序。 diff --git a/suixinkanTests/Invite/InviteTests.swift b/suixinkanTests/Invite/InviteTests.swift new file mode 100644 index 0000000..d61cbdc --- /dev/null +++ b/suixinkanTests/Invite/InviteTests.swift @@ -0,0 +1,154 @@ +// +// InviteTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/24. +// + +import XCTest +@testable import suixinkan + +@MainActor +/// 邀请 API 和 ViewModel 测试。 +final class InviteTests: XCTestCase { + /// 测试邀请 API path 和分页参数。 + func testInviteAPIUsesExpectedRequests() async throws { + let session = InviteRecordingURLSession(responses: [Self.inviteInfoResponse, Self.inviteUsersResponse]) + let api = InviteAPI(client: APIClient(session: session)) + + let info = try await api.inviteInfo() + let users = try await api.inviteUserList(page: 0, pageSize: 0) + + XCTAssertEqual(info.inviteCode, "ABC123") + XCTAssertEqual(users.first?.id, 1) + XCTAssertEqual(session.requests.map { $0.url?.path }, [ + "/api/yf-handset-app/photog/invite-info", + "/api/yf-handset-app/photog/invite/user-list" + ]) + XCTAssertEqual(inviteQueryItems(from: session.requests[1])["page"], "1") + XCTAssertEqual(inviteQueryItems(from: session.requests[1])["page_size"], "1") + } + + /// 测试邀请页加载信息并生成二维码。 + func testInviteViewModelLoadsInfoAndGeneratesQRCode() async { + let api = FakeInviteService() + let viewModel = PhotographerInviteViewModel() + + await viewModel.reload(api: api) + + XCTAssertEqual(viewModel.inviteCode, "ABC123") + XCTAssertEqual(viewModel.rules, ["规则1"]) + XCTAssertNotNil(viewModel.qrImage) + } + + /// 测试邀请记录加载邀请用户和钱包汇总。 + func testInviteRecordLoadsInviteRowsAndSummary() async { + let inviteAPI = FakeInviteService() + let walletAPI = FakeWalletService() + let viewModel = InviteRecordViewModel() + + await viewModel.reload(inviteAPI: inviteAPI, walletAPI: walletAPI, refresh: true) + + XCTAssertEqual(viewModel.displayRows.first?.title, "摄影师") + XCTAssertEqual(viewModel.totalRewardText, "¥ 100.00") + XCTAssertEqual(walletAPI.summaryTypes, [2]) + } + + /// 测试奖励分段调用钱包收益明细。 + func testInviteRecordRewardTabLoadsWalletDetails() async { + let inviteAPI = FakeInviteService() + let walletAPI = FakeWalletService() + let viewModel = InviteRecordViewModel() + + await viewModel.selectTab(.reward, inviteAPI: inviteAPI, walletAPI: walletAPI) + + XCTAssertEqual(walletAPI.earningPages, [1]) + XCTAssertNil(viewModel.errorMessage, viewModel.errorMessage ?? "") + XCTAssertEqual(viewModel.displayRows.first?.amount, "8.00") + } + + private static let inviteInfoResponse = Data(#"{"code":100000,"message":"ok","data":{"enable_invite":"1","invite_code":"ABC123","invite_url":"https://invite.example.com","description":["规则1"]}}"#.utf8) + private static let inviteUsersResponse = Data(#"{"code":100000,"message":"ok","data":[{"id":"1","real_name":"摄影师","phone":"13800000000","avatar":"","created_at":"2026-06-24","invite_level":"1"}]}"#.utf8) +} + +/// 邀请 API 测试 URLSession。 +private final class InviteRecordingURLSession: URLSessionProtocol { + private var responses: [Data] + private(set) var requests: [URLRequest] = [] + + /// 初始化测试 Session。 + init(responses: [Data]) { + self.responses = responses + } + + /// 记录请求并返回响应。 + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + requests.append(request) + let data = responses.removeFirst() + return (data, HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!) + } +} + +/// 邀请服务测试替身。 +@MainActor +private final class FakeInviteService: InviteServing { + func inviteInfo() async throws -> InviteInfoResponse { + try JSONDecoder().decode(InviteInfoResponse.self, from: Data(#"{"enable_invite":"1","invite_code":"ABC123","invite_url":"https://invite.example.com","description":["规则1"]}"#.utf8)) + } + + func inviteUserList(page: Int, pageSize: Int) async throws -> [InviteUserItem] { + [try JSONDecoder().decode(InviteUserItem.self, from: Data(#"{"id":"1","real_name":"摄影师","phone":"13800000000","avatar":"","created_at":"2026-06-24","invite_level":"1"}"#.utf8))] + } +} + +/// 钱包服务测试替身。 +@MainActor +private final class FakeWalletService: WalletServing { + var summaryTypes: [Int] = [] + var earningPages: [Int] = [] + + func walletSummary(type: Int) async throws -> WalletSummaryResponse { + summaryTypes.append(type) + return WalletSummaryResponse(amountTotal: "100.00", amountCurrentBalance: "20.00", amountWithdrawable: "50.00") + } + + func walletEarningDetail(startDate: String, endDate: String, page: Int, pageSize: Int) async throws -> WalletEarningDetailResponse { + earningPages.append(page) + let item = WalletEarningDetailItem( + id: 1, + amount: "8.00", + type: 2, + typeLabel: "邀请奖励", + orderNumberSuffix: "0001", + createdAt: "2026-06-24" + ) + return WalletEarningDetailResponse( + totalAmount: "8.00", + total: 1, + list: [WalletEarningDetailGroup(date: "2026-06-24", dayAmount: "8.00", items: [item])] + ) + } + + func walletWithdrawList(page: Int, pageSize: Int) async throws -> WalletWithdrawListResponse { WalletWithdrawListResponse() } + func withdrawInfo() async throws -> WithdrawInfoResponse { try JSONDecoder().decode(WithdrawInfoResponse.self, from: Data("{}".utf8)) } + func withdrawSendSms() async throws {} + func withdrawApply(amount: String, smsCode: String) async throws {} + func bankCardInfo() async throws -> BankCardInfoResponse { try JSONDecoder().decode(BankCardInfoResponse.self, from: Data("{}".utf8)) } + func bankList() async throws -> BankListResponse { BankListResponse(banks: []) } + func areas() async throws -> [AreaNode] { [] } + func bankCardVerifyCode() async throws {} + func updateBankInfo(_ request: UpdateBankInfoRequest) async throws {} + func pointOverview(staffId: Int) async throws -> PointOverviewResponse { try JSONDecoder().decode(PointOverviewResponse.self, from: Data("{}".utf8)) } + func pointWithdrawApply(points: Int, remark: String) async throws {} + func pointWithdrawList(status: Int?, page: Int, pageSize: Int) async throws -> PointWithdrawListResponse { PointWithdrawListResponse(total: 0, list: []) } +} + +/// 从请求中提取 query 字典。 +private func inviteQueryItems(from request: URLRequest) -> [String: String] { + guard let url = request.url, let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return [:] + } + return Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).compactMap { item in + item.value.map { (item.name, $0) } + }) +} diff --git a/suixinkanTests/ProfileSecondaryPagesTests.swift b/suixinkanTests/ProfileSecondaryPagesTests.swift index b53f1f2..82b5f8f 100644 --- a/suixinkanTests/ProfileSecondaryPagesTests.swift +++ b/suixinkanTests/ProfileSecondaryPagesTests.swift @@ -361,6 +361,11 @@ private final class MockOSSUploader: OSSUploadServing { avatarURL } + /// 模拟上传项目图片。 + func uploadProjectImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + avatarURL + } + /// 模拟上传打卡点图片。 func uploadPunchPointImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { avatarURL diff --git a/suixinkanTests/Projects/ProjectAPITests.swift b/suixinkanTests/Projects/ProjectAPITests.swift new file mode 100644 index 0000000..58b54b8 --- /dev/null +++ b/suixinkanTests/Projects/ProjectAPITests.swift @@ -0,0 +1,150 @@ +// +// ProjectAPITests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/24. +// + +import XCTest +@testable import suixinkan + +@MainActor +/// 项目 API 测试,覆盖摄影师项目和店铺项目请求。 +final class ProjectAPITests: XCTestCase { + /// 测试摄影师项目列表接口 path 和 query。 + func testProjectListUsesExpectedPathAndQuery() async throws { + let session = ProjectRecordingURLSession(data: Self.projectListResponse) + let api = ProjectAPI(client: APIClient(session: session)) + + let payload = try await api.projectList(scenicId: 9, name: " 亲子 ", page: 0, pageSize: 0) + + let request = try XCTUnwrap(session.requests.first) + XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/project/list") + let query = projectQueryItems(from: request) + XCTAssertEqual(query["scenic_id"], "9") + XCTAssertEqual(query["name"], "亲子") + XCTAssertEqual(query["page"], "1") + XCTAssertEqual(query["page_size"], "1") + XCTAssertEqual(payload.list.first?.id, 88) + } + + /// 测试项目详情和删除接口路径正确。 + func testProjectDetailAndDeleteUseExpectedPath() async throws { + let session = ProjectRecordingURLSession(responses: [Self.projectDetailResponse, Self.emptyResponse]) + let api = ProjectAPI(client: APIClient(session: session)) + + let detail = try await api.projectDetail(id: 88) + try await api.deleteProject(id: 88) + + XCTAssertEqual(session.requests[0].url?.path, "/api/yf-handset-app/photog/project/info-view") + XCTAssertEqual(projectQueryItems(from: session.requests[0])["id"], "88") + XCTAssertEqual(detail.materialNum, 12) + XCTAssertEqual(session.requests[1].url?.path, "/api/yf-handset-app/photog/project/delete") + XCTAssertEqual(try bodyObject(from: session.requests[1])["id"] as? Int, 88) + } + + /// 测试创建和编辑摄影师项目请求体。 + func testCreateAndEditProjectUseExpectedBody() async throws { + let session = ProjectRecordingURLSession(data: Self.emptyResponse) + let api = ProjectAPI(client: APIClient(session: session)) + let request = ProjectCreateRequest( + id: 7, + type: 11, + scenicId: 9, + name: "项目", + price: "99.00", + otPrice: "199.00", + coverProject: "https://cdn/p.jpg", + coverCarousel: ["https://cdn/a.jpg"], + description: "描述", + attrLabel: ["亲子"], + extra: ProjectCreateExtra(priceDeposit: "10.00", materialNum: 1, photoNum: 2, videoNum: 3, scenicSpotId: [6], photogUid: [100]) + ) + + try await api.createProject(request) + try await api.editProject(request) + + XCTAssertEqual(session.requests.map { $0.url?.path }, [ + "/api/yf-handset-app/photog/project/create", + "/api/yf-handset-app/photog/project/edit" + ]) + let body = try bodyObject(from: session.requests[0]) + XCTAssertEqual(body["scenic_id"] as? Int, 9) + XCTAssertEqual(body["cover_project"] as? String, "https://cdn/p.jpg") + XCTAssertEqual((body["extra"] as? [String: Any])?["photo_num"] as? Int, 2) + } + + /// 测试店铺项目接口 path、query 和 body。 + func testStoreManagerRequestsUseExpectedPathAndBody() async throws { + let session = ProjectRecordingURLSession(responses: [ + Self.scenicListResponse, + Self.projectListResponse, + Self.projectDetailResponse, + Self.emptyResponse, + Self.emptyResponse + ]) + let api = ProjectAPI(client: APIClient(session: session)) + + _ = try await api.storeManagerScenicList(userId: "100") + _ = try await api.storeManagerProjectList(userId: "100", page: 0, pageSize: 0) + _ = try await api.storeManagerProjectDetail(id: 88) + try await api.storeManagerCreate(StoreManagerCreateRequest(name: "店铺项目", storeId: nil, type: 19, description: "描述", coverProject: "u", coverCarousel: [], projectRule: nil, scenicId: [9], settleSpotNum: 1, price: 100, priceMaterial: 1, pricePhoto: 2, priceVideo: 3, priceMaterialAll: nil, packageList: nil, userId: 100, singleSpotMaterialNum: 1, singleSpotPhotoNum: 2, singleSpotVideoNum: 3)) + try await api.storeManagerOfflineCreate(StoreManagerOfflineCreateRequest(name: "押金", scenicId: 9, storeId: 3, description: "描述", price: 20, coverProject: "u", coverCarousel: [])) + + XCTAssertEqual(session.requests.map { $0.url?.path }, [ + "/api/app/store-manager/scenic-list", + "/api/app/store-manager/list", + "/api/app/store-manager/detail", + "/api/app/store-manager/create", + "/api/app/store-manager/offline-create" + ]) + XCTAssertEqual(projectQueryItems(from: session.requests[0])["user_id"], "100") + XCTAssertEqual(projectQueryItems(from: session.requests[1])["page"], "1") + XCTAssertEqual((try bodyObject(from: session.requests[3]))["settle_spot_num"] as? Int, 1) + XCTAssertEqual((try bodyObject(from: session.requests[4]))["store_id"] as? Int, 3) + } + + private static let emptyResponse = Data(#"{"code":100000,"message":"ok","data":{}}"#.utf8) + private static let projectListResponse = Data(#"{"code":100000,"message":"ok","data":{"total":"2","list":[{"id":"88","type":"11","type_name":"旅拍","status":"1","status_name":"运营中","name":"亲子旅拍","cover_project":"https://cdn/p.jpg","price":"99.00","ot_price":199,"price_deposit":"10","attr_label":"亲子,航拍"}]}}"#.utf8) + private static let projectDetailResponse = Data(#"{"code":100000,"message":"ok","data":{"id":"88","scenic_id":"9","cover_project":"https://cdn/p.jpg","cover_carousel":["https://cdn/a.jpg"],"name":"亲子旅拍","type":"11","type_name":"旅拍","status":"1","status_name":"运营中","price":"99.00","ot_price":"199.00","price_deposit":"10.00","description":"描述","attr_label":["亲子"],"sold":"3","material_num":"12","photo_num":"6","video_num":"1","created_at":"2026-06-24","creator_name":"摄影师","photog_list":[{"id":"1","photog_uid":"100","nickname":"小李","avatar":"","completed_order_count":"8"}],"scenic_list":[{"id":"6","name":"大门"}]}}"#.utf8) + private static let scenicListResponse = Data(#"{"code":100000,"message":"ok","data":{"total":1,"list":[{"id":"9","name":"测试景区"}]}}"#.utf8) +} + +/// 项目 API 测试 URLSession,记录请求并返回固定数据。 +private final class ProjectRecordingURLSession: URLSessionProtocol { + private var responses: [Data] + private(set) var requests: [URLRequest] = [] + + /// 初始化单响应测试 Session。 + init(data: Data) { + self.responses = [data] + } + + /// 初始化多响应测试 Session。 + init(responses: [Data]) { + self.responses = responses + } + + /// 记录请求并返回响应。 + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + requests.append(request) + let data = responses.count > 1 ? responses.removeFirst() : responses[0] + return (data, HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!) + } +} + +/// 从请求中提取 query 字典。 +private func projectQueryItems(from request: URLRequest) -> [String: String] { + guard let url = request.url, let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return [:] + } + return Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).compactMap { item in + item.value.map { (item.name, $0) } + }) +} + +/// 将请求体解析为字典。 +private func bodyObject(from request: URLRequest) throws -> [String: Any] { + let body = try XCTUnwrap(request.httpBody) + return try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any]) +} diff --git a/suixinkanTests/Projects/ProjectViewModelTests.swift b/suixinkanTests/Projects/ProjectViewModelTests.swift new file mode 100644 index 0000000..4340cb3 --- /dev/null +++ b/suixinkanTests/Projects/ProjectViewModelTests.swift @@ -0,0 +1,195 @@ +// +// ProjectViewModelTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/24. +// + +import XCTest +@testable import suixinkan + +@MainActor +/// 项目 ViewModel 测试,覆盖分页、校验和上传提交顺序。 +final class ProjectViewModelTests: XCTestCase { + /// 测试无景区时清空列表且不请求接口。 + func testProjectReloadWithoutScenicClearsListAndSkipsRequest() async { + let api = FakeProjectService() + let viewModel = ProjectManagementViewModel() + + await viewModel.reload(api: api, scenicId: nil) + + XCTAssertTrue(viewModel.items.isEmpty) + XCTAssertEqual(api.projectListCalls.count, 0) + } + + /// 测试项目列表首屏和加载更多分页。 + func testProjectListReloadAndLoadMore() async { + let api = FakeProjectService() + api.projectPages = [ + ListPayload(total: 2, list: [.fixture(id: 1, name: "A")]), + ListPayload(total: 2, list: [.fixture(id: 2, name: "B")]) + ] + let viewModel = ProjectManagementViewModel() + + await viewModel.reload(api: api, scenicId: 9) + await viewModel.loadMore(api: api, scenicId: 9) + + XCTAssertEqual(api.projectListCalls.map(\.page), [1, 2]) + XCTAssertEqual(viewModel.items.map(\.id).sorted(), [1, 2]) + XCTAssertFalse(viewModel.hasMore) + } + + /// 测试摄影师项目提交会先上传图片再调用创建接口。 + func testProjectEditorUploadsImagesBeforeCreate() async { + let api = FakeProjectService() + let uploader = FakeProjectUploader() + let viewModel = ProjectEditorViewModel(mode: .create) + viewModel.name = "新项目" + viewModel.descriptionText = "描述" + viewModel.price = "99" + viewModel.otPrice = "199" + viewModel.deposit = "10" + viewModel.selectedSpotIds = [6] + viewModel.coverImage = ProjectLocalImage(data: Data([1]), fileName: "cover.jpg", previewURL: nil) + viewModel.carouselImages = [ProjectLocalImage(data: Data([2]), fileName: "banner.jpg", previewURL: nil)] + + let success = await viewModel.submit(scenicId: 9, userId: 100, api: api, uploadService: uploader) + + XCTAssertTrue(success) + XCTAssertEqual(uploader.uploadedFileNames, ["cover.jpg", "banner.jpg"]) + XCTAssertEqual(api.createdProjects.first?.coverProject, "https://cdn/cover.jpg") + XCTAssertEqual(api.createdProjects.first?.coverCarousel, ["https://cdn/banner.jpg"]) + } + + /// 测试上传失败时不提交业务接口。 + func testProjectEditorUploadFailureDoesNotCreateProject() async { + let api = FakeProjectService() + let uploader = FakeProjectUploader() + uploader.shouldFail = true + let viewModel = ProjectEditorViewModel(mode: .create) + viewModel.name = "新项目" + viewModel.descriptionText = "描述" + viewModel.price = "99" + viewModel.selectedSpotIds = [6] + viewModel.coverImage = ProjectLocalImage(data: Data([1]), fileName: "cover.jpg", previewURL: nil) + + let success = await viewModel.submit(scenicId: 9, userId: 100, api: api, uploadService: uploader) + + XCTAssertFalse(success) + XCTAssertTrue(api.createdProjects.isEmpty) + } + + /// 测试店铺项目缺用户 ID 时不提交。 + func testStoreProjectEditorRequiresUserId() async { + let viewModel = StoreProjectEditorViewModel(mode: .create) + let success = await viewModel.submit(userId: 0, api: FakeProjectService(), uploadService: FakeProjectUploader()) + + XCTAssertFalse(success) + XCTAssertEqual(viewModel.errorMessage, ProjectEditorError.missingUser.localizedDescription) + } +} + +private extension PhotographerProjectItem { + /// 创建测试项目列表项。 + static func fixture(id: Int, name: String) -> PhotographerProjectItem { + PhotographerProjectItem(id: id, name: name, price: "99.00") + } +} + +/// 项目测试服务替身。 +@MainActor +private final class FakeProjectService: ProjectServing { + var projectPages: [ListPayload] = [] + var projectListCalls: [(scenicId: Int, name: String?, page: Int, pageSize: Int)] = [] + var createdProjects: [ProjectCreateRequest] = [] + + /// 获取摄影师项目列表。 + func projectList(scenicId: Int, name: String?, page: Int, pageSize: Int) async throws -> ListPayload { + projectListCalls.append((scenicId, name, page, pageSize)) + return projectPages.isEmpty ? ListPayload(total: 0, list: []) : projectPages.removeFirst() + } + + /// 获取摄影师项目详情。 + func projectDetail(id: Int) async throws -> PhotographerProjectDetailResponse { + try Self.detail() + } + + /// 创建摄影师项目。 + func createProject(_ request: ProjectCreateRequest) async throws { + createdProjects.append(request) + } + + /// 编辑摄影师项目。 + func editProject(_ request: ProjectCreateRequest) async throws {} + + /// 删除摄影师项目。 + func deleteProject(id: Int) async throws {} + + /// 获取店铺项目可管理景区。 + func storeManagerScenicList(userId: String) async throws -> ListPayload { + ListPayload(total: 0, list: []) + } + + /// 获取店铺项目列表。 + func storeManagerProjectList(userId: String?, page: Int, pageSize: Int) async throws -> ListPayload { + ListPayload(total: 0, list: []) + } + + /// 获取店铺项目详情。 + func storeManagerProjectDetail(id: Int) async throws -> PhotographerProjectDetailResponse { + try Self.detail() + } + + /// 创建店铺多点位项目。 + func storeManagerCreate(_ request: StoreManagerCreateRequest) async throws {} + + /// 创建店铺押金项目。 + func storeManagerOfflineCreate(_ request: StoreManagerOfflineCreateRequest) async throws {} + + /// 更新店铺多点位项目。 + func storeManagerUpdate(_ request: StoreManagerUpdateRequest) async throws {} + + /// 更新店铺押金项目。 + func storeManagerOfflineUpdate(_ request: StoreManagerOfflineUpdateRequest) async throws {} + + /// 删除店铺项目。 + func storeManagerDeleteProject(id: Int) async throws {} + + /// 获取全部门店。 + func storeAll() async throws -> ListPayload { + ListPayload(total: 0, list: []) + } + + private static func detail() throws -> PhotographerProjectDetailResponse { + let data = Data(#"{"id":1,"name":"项目","type":11,"status":1,"price":"99","description":"描述","material_num":1,"photo_num":1,"video_num":1}"#.utf8) + return try JSONDecoder().decode(PhotographerProjectDetailResponse.self, from: data) + } +} + +/// 项目上传测试替身。 +@MainActor +private final class FakeProjectUploader: OSSUploadServing { + var uploadedFileNames: [String] = [] + var shouldFail = false + + /// 上传项目图片。 + func uploadProjectImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + if shouldFail { throw TestUploadError.failed } + uploadedFileNames.append(fileName) + return "https://cdn/\(fileName)" + } + + func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadCloudFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadTaskFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadPunchPointImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadScenicApplicationImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } +} + +/// 测试上传错误。 +private enum TestUploadError: Error { + case failed +} diff --git a/suixinkanTests/PunchPoint/PunchPointAPITests.swift b/suixinkanTests/PunchPoint/PunchPointAPITests.swift index b7368e6..f495926 100644 --- a/suixinkanTests/PunchPoint/PunchPointAPITests.swift +++ b/suixinkanTests/PunchPoint/PunchPointAPITests.swift @@ -211,6 +211,7 @@ private final class MockPunchPointUploader: OSSUploadServing { func uploadCloudFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } func uploadAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } func uploadTaskFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadProjectImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } func uploadScenicApplicationImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } } diff --git a/suixinkanTests/ScenicPermissionViewModelTests.swift b/suixinkanTests/ScenicPermissionViewModelTests.swift index 6c460d3..bcbff0b 100644 --- a/suixinkanTests/ScenicPermissionViewModelTests.swift +++ b/suixinkanTests/ScenicPermissionViewModelTests.swift @@ -291,6 +291,11 @@ private final class OSSUploadServingFake: OSSUploadServing { try await upload(data: data, fileName: fileName, scenicId: scenicId, onProgress: onProgress) } + /// 上传项目图片测试替身。 + func uploadProjectImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + try await upload(data: data, fileName: fileName, scenicId: scenicId, onProgress: onProgress) + } + /// 上传打卡点图片测试替身。 func uploadPunchPointImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { try await upload(data: data, fileName: fileName, scenicId: scenicId, onProgress: onProgress) diff --git a/suixinkanTests/Schedule/ScheduleTests.swift b/suixinkanTests/Schedule/ScheduleTests.swift new file mode 100644 index 0000000..50b4821 --- /dev/null +++ b/suixinkanTests/Schedule/ScheduleTests.swift @@ -0,0 +1,184 @@ +// +// ScheduleTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/24. +// + +import XCTest +@testable import suixinkan + +@MainActor +/// 排班 API 和 ViewModel 测试。 +final class ScheduleTests: XCTestCase { + /// 测试排班 API path 和 query/body。 + func testScheduleAPIUsesExpectedRequests() async throws { + let session = ScheduleRecordingURLSession(responses: [ + Data(#"{"code":100000,"message":"ok","data":["2026-06-24"]}"#.utf8), + Data(#"{"code":100000,"message":"ok","data":[{"id":"1","name":"拍摄","start_time":"09:00","end_time":"10:00","schedule_date":"2026-06-24"}]}"#.utf8), + Data(#"{"code":100000,"message":"ok","data":{}}"#.utf8), + Data(#"{"code":100000,"message":"ok","data":{}}"#.utf8) + ]) + let api = ScheduleAPI(client: APIClient(session: session)) + + let days = try await api.monthScheduleDays(scenicId: 9, yearMonth: "2026-06") + let list = try await api.dayScheduleList(scenicId: 9, date: "2026-06-24") + try await api.addSchedule(AddScheduleRequest(scenicId: "9", name: "拍摄", remark: "备注", startTime: "09:00", endTime: "10:00", scheduleDate: "2026-06-24", orderNumber: "ORD001")) + try await api.deleteSchedule(id: 1) + + XCTAssertEqual(days, ["2026-06-24"]) + XCTAssertEqual(list.first?.id, 1) + XCTAssertEqual(session.requests.map { $0.url?.path }, [ + "/api/yf-handset-app/photog/schedule/list-date", + "/api/yf-handset-app/photog/schedule/list", + "/api/yf-handset-app/photog/schedule/add", + "/api/yf-handset-app/photog/schedule/delete" + ]) + XCTAssertEqual(scheduleQueryItems(from: session.requests[0])["date"], "2026-06") + XCTAssertEqual(scheduleQueryItems(from: session.requests[1])["date"], "2026-06-24") + XCTAssertEqual((try scheduleBodyObject(from: session.requests[2]))["order_number"] as? String, "ORD001") + XCTAssertEqual((try scheduleBodyObject(from: session.requests[3]))["id"] as? Int, 1) + } + + /// 测试无景区时排班管理清空状态且不请求接口。 + func testScheduleManagementWithoutScenicClearsState() async { + let api = FakeScheduleService() + let viewModel = ScheduleManagementViewModel() + + await viewModel.reload(api: api, scenicId: nil) + + XCTAssertTrue(viewModel.items.isEmpty) + XCTAssertTrue(viewModel.markedDays.isEmpty) + XCTAssertEqual(api.monthCalls.count, 0) + } + + /// 测试排班管理加载月份和当天列表。 + func testScheduleManagementReloadLoadsMonthAndDay() async { + let api = FakeScheduleService() + api.monthDays = ["2026-06-24"] + api.dayItems = [.fixture(id: 1)] + let viewModel = ScheduleManagementViewModel() + + await viewModel.reload(api: api, scenicId: 9) + + XCTAssertEqual(api.monthCalls.first?.scenicId, 9) + XCTAssertEqual(api.dayCalls.first?.scenicId, 9) + XCTAssertEqual(viewModel.markedDays, ["2026-06-24"]) + XCTAssertEqual(viewModel.items.first?.id, 1) + } + + /// 测试新增排班优先使用选择的订单号。 + func testScheduleAddUsesSelectedOrderBeforeManualOrder() async { + let api = FakeScheduleService() + let viewModel = ScheduleAddViewModel() + viewModel.draft.name = "拍摄" + viewModel.draft.remark = "" + viewModel.draft.manualOrderNumber = "MANUAL" + viewModel.draft.selectedOrder = AvailableOrderResponse.fixture(orderNumber: "PICKED") + let start = fixedDate(hour: 9) + let end = fixedDate(hour: 10) + + let success = await viewModel.submit(api: api, scenicId: 9, startDate: start, endDate: end) + + XCTAssertTrue(success) + XCTAssertEqual(api.addedRequests.first?.orderNumber, "PICKED") + XCTAssertEqual(api.addedRequests.first?.remark, "拍摄") + } + + /// 测试结束时间早于开始时间时禁止提交。 + func testScheduleAddRejectsInvalidTime() async { + let api = FakeScheduleService() + let viewModel = ScheduleAddViewModel() + viewModel.draft.name = "拍摄" + + let success = await viewModel.submit(api: api, scenicId: 9, startDate: fixedDate(hour: 10), endDate: fixedDate(hour: 9)) + + XCTAssertFalse(success) + XCTAssertTrue(api.addedRequests.isEmpty) + XCTAssertEqual(viewModel.errorMessage, ScheduleValidationError.invalidTime.localizedDescription) + } + + private func fixedDate(hour: Int) -> Date { + Calendar.current.date(from: DateComponents(year: 2026, month: 6, day: 24, hour: hour, minute: 0)) ?? Date() + } +} + +private extension ScheduleItem { + /// 创建排班测试项。 + static func fixture(id: Int) -> ScheduleItem { + let data = Data(#"{"id":\#(id),"name":"拍摄","start_time":"09:00","end_time":"10:00","schedule_date":"2026-06-24"}"#.utf8) + return try! JSONDecoder().decode(ScheduleItem.self, from: data) + } +} + +private extension AvailableOrderResponse { + /// 创建可关联订单测试项。 + static func fixture(orderNumber: String) -> AvailableOrderResponse { + let data = Data(#"{"project_name":"项目","order_number":"\#(orderNumber)","order_status":"1","order_status_label":"已支付","pay_time":"2026-06-24","user_phone":"13800000000"}"#.utf8) + return try! JSONDecoder().decode(AvailableOrderResponse.self, from: data) + } +} + +/// 排班 API 测试 URLSession。 +private final class ScheduleRecordingURLSession: URLSessionProtocol { + private var responses: [Data] + private(set) var requests: [URLRequest] = [] + + /// 初始化测试 Session。 + init(responses: [Data]) { + self.responses = responses + } + + /// 记录请求并返回响应。 + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + requests.append(request) + let data = responses.removeFirst() + return (data, HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!) + } +} + +/// 排班服务测试替身。 +@MainActor +private final class FakeScheduleService: ScheduleServing { + var monthDays: [String] = [] + var dayItems: [ScheduleItem] = [] + var monthCalls: [(scenicId: Int, yearMonth: String)] = [] + var dayCalls: [(scenicId: Int, date: String)] = [] + var addedRequests: [AddScheduleRequest] = [] + + func monthScheduleDays(scenicId: Int, yearMonth: String) async throws -> [String] { + monthCalls.append((scenicId, yearMonth)) + return monthDays + } + + func dayScheduleList(scenicId: Int, date: String) async throws -> [ScheduleItem] { + dayCalls.append((scenicId, date)) + return dayItems + } + + func addSchedule(_ request: AddScheduleRequest) async throws { + addedRequests.append(request) + } + + func deleteSchedule(id: Int) async throws {} + + func availableOrderList(scenicId: Int) async throws -> [AvailableOrderResponse] { + [] + } +} + +/// 从请求中提取 query 字典。 +private func scheduleQueryItems(from request: URLRequest) -> [String: String] { + guard let url = request.url, let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return [:] + } + return Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).compactMap { item in + item.value.map { (item.name, $0) } + }) +} + +/// 将请求体解析为字典。 +private func scheduleBodyObject(from request: URLRequest) throws -> [String: Any] { + let body = try XCTUnwrap(request.httpBody) + return try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any]) +} diff --git a/suixinkanTests/TaskViewModelTests.swift b/suixinkanTests/TaskViewModelTests.swift index 361eede..37cc48f 100644 --- a/suixinkanTests/TaskViewModelTests.swift +++ b/suixinkanTests/TaskViewModelTests.swift @@ -300,6 +300,9 @@ private final class TaskMockUploadService: OSSUploadServing { return taskUploadURLs.isEmpty ? "https://cdn.example.com/task/default.jpg" : taskUploadURLs.removeFirst() } + /// 模拟上传项目图片。 + func uploadProjectImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + /// 模拟上传头像。 func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } diff --git a/suixinkanTests/UploadTests.swift b/suixinkanTests/UploadTests.swift index c01b15f..2350077 100644 --- a/suixinkanTests/UploadTests.swift +++ b/suixinkanTests/UploadTests.swift @@ -47,6 +47,10 @@ final class UploadTests: XCTestCase { OSSUploadPolicy.objectKey(fileName: "bank.jpg", scenicId: 9, moduleType: "bank_card", date: date, uuid: uuid, timeZone: timeZone), "bank_card/20260101/9/123456781234123412341234567890AB_bank.jpg" ) + XCTAssertEqual( + OSSUploadPolicy.objectKey(fileName: "cover.jpg", scenicId: 9, moduleType: "project", date: date, uuid: uuid, timeZone: timeZone), + "project/20260101/9/123456781234123412341234567890AB_cover.jpg" + ) } /// 测试 MIME 类型推断和 URL 拼接规则。 diff --git a/suixinkanTests/WalletViewModelTests.swift b/suixinkanTests/WalletViewModelTests.swift index 7854aad..05b6bba 100644 --- a/suixinkanTests/WalletViewModelTests.swift +++ b/suixinkanTests/WalletViewModelTests.swift @@ -217,6 +217,7 @@ private final class WalletUploadMock: OSSUploadServing { func uploadCloudFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } func uploadAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } func uploadTaskFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadProjectImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } func uploadPunchPointImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } func uploadScenicApplicationImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } } diff --git a/功能同步Checklist.md b/功能同步Checklist.md index 32ebc7d..32dafb2 100644 --- a/功能同步Checklist.md +++ b/功能同步Checklist.md @@ -18,7 +18,7 @@ | 完成 | 全局状态拆分 | `AppSession`、`AccountContext`、`PermissionContext`、`ScenicSpotContext`、`AppRouter`、`ToastCenter` 拆分。 | 继续避免新增大而全全局状态。 | | 完成 | 登录态缓存 | token 使用 Keychain;账号快照、上次手机号、偏好使用 UserDefaults;冷启动恢复登录态。 | 后续业务缓存按账号和景区/门店作用域隔离。 | | 完成 | 网络框架 | `APIClient`、`APIRequest`、`APIEnvelope`、统一错误处理和 token 注入。 | 业务 API 继续按模块拆分。 | -| 完成 | OSS 上传 | `UploadAPI`、`OSSUploadService`、上传策略、头像、实名图片、任务附件、云盘文件、相册文件、素材/样片文件上传处理。 | 后续新上传场景继续复用。 | +| 完成 | OSS 上传 | `UploadAPI`、`OSSUploadService`、上传策略、头像、实名图片、任务附件、云盘文件、相册文件、素材/样片文件、项目图片上传处理。 | 后续新上传场景继续复用。 | | 完成 | 网络图片 | 新增 `RemoteImage` / `RemoteAvatarImage`,当前网络图片展示均使用 Kingfisher。 | 后续新页面禁止直接使用 `AsyncImage` 加载网络图片。 | | 完成 | 设计常量 | `AppDesign`、`AppMetrics` 统一颜色、字号、间距等通用尺寸。 | 继续替换新页面中的零散常量。 | | 完成 | 文档规范 | 已有 `AGENTS.md` 约束;现有模块均有模块说明文档。 | 后续每个新功能模块同步新增模块 md。 | @@ -90,9 +90,10 @@ | 部分完成 | 押金订单 | 入口已识别,进入占位页。 | 迁移押金订单详情和拍摄信息。 | | 完成 | 任务管理 | 任务列表、筛选、分页、任务详情、发布任务入口。 | 旧工程里订单/核销订单拼成待办任务的逻辑本轮未迁移,订单仍归订单 Tab 管理。 | | 完成 | 发布任务 | 发布表单、选择订单、选择云盘文件、本地图片/视频 OSS 上传、提交任务。 | 完整云盘资产管理已由相册云盘模块接管,任务页只保留发布所需选择能力。 | -| 部分完成 | 日程管理 | 入口已识别,进入占位页。 | 迁移日程列表、日历和详情。 | +| 完成 | 日程管理 | 月份切换、日期标记、日程列表、下拉刷新、删除日程、新增排班、关联订单或手填订单号兜底。 | 后续如旧工程有排班详情页或高级重复规则再继续迁移。 | | 完成 | 打卡点管理 | 列表、筛选、分页、详情、二维码、新建、编辑、删除、前台定位选点和图片 OSS 上传已接入。 | 后续可把高德地图完整选点 UI 替换进编辑页。 | -| 部分完成 | 项目管理 | `pm` / `pm_manager` / `project_edit` 已识别,进入占位页。 | 迁移项目列表、详情、编辑、新建。 | +| 完成 | 摄影师项目管理 | `pm` / `project_edit` 已接真实页面;支持列表、搜索、分页、详情、新建、编辑、删除和项目图片 OSS 上传。 | 后续可继续补项目数据分析、项目订单深层流程。 | +| 完成 | 店铺项目管理 | `pm_manager` 已接真实页面;支持店铺项目列表、详情、新建、编辑、删除、多点位项目和押金项目。 | 后续如有店铺后台审核扩展再单独迁移。 | | 完成 | 相册云盘 | 云盘文件/文件夹浏览、搜索筛选、排序、分页、上传、预览、下载、重命名、移动、删除、传输记录。 | 云盘下载只保存到 App 沙盒 `Documents/CloudDownloads`,不写入系统相册。 | | 完成 | 相册管理 | 相册列表、搜索筛选、日期筛选、创建相册、相册详情、图片/视频列表、预览、设置封面、删除文件、编辑名称/备注。 | 后续如旧工程补充更多相册运营动作再继续迁移。 | | 完成 | 相册预览上传 | `album_trailer` 入口已接真实页面;支持选择相册、本地图片/视频 OSS 上传、上传后入库到相册。 | 不恢复旧工程手填 URL 主流程。 | @@ -104,7 +105,7 @@ | 部分完成 | 景区结算 | 入口已识别,进入占位页。 | 迁移结算列表、审核、详情。 | | 部分完成 | 运营区域 | 入口已识别,进入占位页。 | 迁移区域列表、地图/范围配置。 | | 完成 | 位置上报 | 当前位置、标记点、在线状态、立即上报、提醒设置、历史记录、筛选和分页已接入。 | 本轮不做后台定位、离线队列或推送提醒。 | -| 部分完成 | 注册邀请 | 入口已识别,进入占位页。 | 迁移邀请摄影师、邀请记录。 | +| 完成 | 注册邀请 | 邀请码、邀请链接、二维码、复制链接、规则展示、邀请记录和奖励记录已接入。 | 邀请二维码不落盘,后续如需分享图片再临时生成。 | | 部分完成 | 飞手认证 | 入口已识别,进入占位页。 | 迁移飞手认证页面。 | | 不迁移 | 飞控/DJI | `fly` / `pilot_controller` 已明确为 iOS 不支持。 | 不再迁移。 | @@ -130,7 +131,7 @@ xcodebuild build -workspace suixinkan.xcworkspace -scheme suixinkan -destination ## 下一批建议迁移顺序 -1. 项目管理/排班/邀请:依赖当前景区和角色权限,能继续按首页入口逐个迁移。 -2. 押金订单/退款/历史拍摄:订单主链路已完成,适合继续补订单长尾。 -3. 提现审核/景区结算:涉及审核和资金流,建议单独一轮迁移并补足单元测试。 -4. 项目管理:样片上传已具备轻量项目选择,后续可迁移完整项目列表、详情和编辑。 +1. 押金订单/退款/历史拍摄:订单主链路已完成,适合继续补订单长尾。 +2. 提现审核/景区结算:涉及审核和资金流,建议单独一轮迁移并补足单元测试。 +3. 排队管理/消息中心:属于常用运营辅助入口,可按列表、详情、操作闭环逐步迁移。 +4. 直播管理/直播相册:入口已识别但业务独立,建议单独拆模块迁移。