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