Initial commit: suixinkan_ios UIKit rewrite project.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
176
suixinkan_ios/Features/Projects/API/ProjectAPI.swift
Normal file
176
suixinkan_ios/Features/Projects/API/ProjectAPI.swift
Normal file
@ -0,0 +1,176 @@
|
||||
//
|
||||
// ProjectAPI.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 项目服务协议,抽象摄影师项目和店铺项目接口以便 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
|
||||
final class ProjectAPI: ProjectServing {
|
||||
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_ios/Features/Projects/Models/ProjectModels.swift
Normal file
440
suixinkan_ios/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_ios/Features/Projects/Projects.md
Normal file
31
suixinkan_ios/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。
|
||||
@ -0,0 +1,285 @@
|
||||
//
|
||||
// ProjectViewControllers.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/26.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
/// 摄影师项目管理页。
|
||||
final class ProjectManagementViewController: ModuleTableViewController {
|
||||
private let viewModel = ProjectManagementViewModel()
|
||||
|
||||
override func viewDidLoad() {
|
||||
title = "项目管理"
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
title: "新建",
|
||||
style: .plain,
|
||||
target: self,
|
||||
action: #selector(createProject)
|
||||
)
|
||||
super.viewDidLoad()
|
||||
wireViewModel(viewModel) { }
|
||||
}
|
||||
|
||||
override func tableRowCount() -> Int { viewModel.items.count }
|
||||
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let item = viewModel.items[indexPath.row]
|
||||
cell.configure(title: item.name, subtitle: item.statusName, detail: "¥\(item.price)")
|
||||
}
|
||||
|
||||
override func didSelectTableRow(at indexPath: IndexPath) {
|
||||
let item = viewModel.items[indexPath.row]
|
||||
navigationController?.pushViewController(
|
||||
ProjectDetailViewController(projectId: item.id, storeMode: false),
|
||||
animated: true
|
||||
)
|
||||
}
|
||||
|
||||
override func reloadContent() async {
|
||||
await viewModel.reload(api: services.projectAPI, scenicId: services.currentScenicId)
|
||||
}
|
||||
|
||||
@objc private func createProject() {
|
||||
navigationController?.pushViewController(
|
||||
ProjectEditorViewController(projectId: nil),
|
||||
animated: true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension ProjectManagementViewModel: ViewModelBindable {}
|
||||
|
||||
/// 店铺项目管理页。
|
||||
final class StoreProjectManagementViewController: ModuleTableViewController {
|
||||
private let viewModel = StoreProjectManagementViewModel()
|
||||
|
||||
override func viewDidLoad() {
|
||||
title = "店铺项目"
|
||||
super.viewDidLoad()
|
||||
wireViewModel(viewModel) { }
|
||||
}
|
||||
|
||||
override func tableRowCount() -> Int { viewModel.filteredItems.count }
|
||||
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let item = viewModel.filteredItems[indexPath.row]
|
||||
cell.configure(title: item.name, subtitle: item.typeName, detail: "¥\(item.price)")
|
||||
}
|
||||
|
||||
override func didSelectTableRow(at indexPath: IndexPath) {
|
||||
let item = viewModel.filteredItems[indexPath.row]
|
||||
navigationController?.pushViewController(
|
||||
ProjectDetailViewController(projectId: item.id, storeMode: true),
|
||||
animated: true
|
||||
)
|
||||
}
|
||||
|
||||
override func reloadContent() async {
|
||||
await viewModel.reload(api: services.projectAPI, userId: services.userId)
|
||||
}
|
||||
}
|
||||
|
||||
extension StoreProjectManagementViewModel: ViewModelBindable {}
|
||||
|
||||
/// 项目详情页。
|
||||
final class ProjectDetailViewController: ModuleTableViewController {
|
||||
private let projectId: Int
|
||||
private let storeMode: Bool
|
||||
private let photographerViewModel = ProjectManagementViewModel()
|
||||
private let storeViewModel = StoreProjectManagementViewModel()
|
||||
|
||||
init(projectId: Int, storeMode: Bool) {
|
||||
self.projectId = projectId
|
||||
self.storeMode = storeMode
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
title = "项目详情"
|
||||
super.viewDidLoad()
|
||||
if storeMode {
|
||||
storeViewModel.onChange = { [weak self] in self?.reloadTable() }
|
||||
} else {
|
||||
photographerViewModel.onChange = { [weak self] in self?.reloadTable() }
|
||||
}
|
||||
}
|
||||
|
||||
private var detail: PhotographerProjectDetailResponse? {
|
||||
storeMode ? storeViewModel.selectedDetail : photographerViewModel.selectedDetail
|
||||
}
|
||||
|
||||
override func tableRowCount() -> Int {
|
||||
detail == nil ? 0 : 4
|
||||
}
|
||||
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
guard let detail else { return }
|
||||
switch indexPath.row {
|
||||
case 0: cell.configure(title: "项目名称", subtitle: detail.name)
|
||||
case 1: cell.configure(title: "简介", subtitle: detail.description)
|
||||
case 2: cell.configure(title: "价格", subtitle: detail.price)
|
||||
default: cell.configure(title: "打卡点", subtitle: "\(detail.scenicList.count) 个")
|
||||
}
|
||||
}
|
||||
|
||||
override func reloadContent() async {
|
||||
if storeMode {
|
||||
await storeViewModel.loadDetail(id: projectId, api: services.projectAPI)
|
||||
} else {
|
||||
await photographerViewModel.loadDetail(id: projectId, api: services.projectAPI)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 摄影师项目编辑页。
|
||||
final class ProjectEditorViewController: ModuleTableViewController {
|
||||
private let viewModel = ProjectEditorViewModel(mode: .create)
|
||||
private let nameField = UITextField()
|
||||
private let descriptionField = UITextField()
|
||||
private let priceField = UITextField()
|
||||
private let projectId: Int?
|
||||
|
||||
init(projectId: Int?) {
|
||||
self.projectId = projectId
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
title = projectId == nil ? "新建项目" : "编辑项目"
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
title: "保存",
|
||||
style: .done,
|
||||
target: self,
|
||||
action: #selector(save)
|
||||
)
|
||||
super.viewDidLoad()
|
||||
setupHeader()
|
||||
wireViewModel(viewModel) { [weak self] in self?.fillFormIfNeeded() }
|
||||
}
|
||||
|
||||
private func setupHeader() {
|
||||
nameField.placeholder = "项目名称"
|
||||
nameField.borderStyle = .roundedRect
|
||||
descriptionField.placeholder = "项目简介"
|
||||
descriptionField.borderStyle = .roundedRect
|
||||
priceField.placeholder = "价格"
|
||||
priceField.borderStyle = .roundedRect
|
||||
priceField.keyboardType = .decimalPad
|
||||
let stack = UIStackView(arrangedSubviews: [nameField, descriptionField, priceField])
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 8
|
||||
stack.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 132)
|
||||
stack.layoutMargins = UIEdgeInsets(top: 12, left: 16, bottom: 12, right: 16)
|
||||
stack.isLayoutMarginsRelativeArrangement = true
|
||||
tableView.tableHeaderView = stack
|
||||
}
|
||||
|
||||
override func tableRowCount() -> Int { 0 }
|
||||
|
||||
override func reloadContent() async {
|
||||
guard let projectId else { return }
|
||||
if let detail = try? await services.projectAPI.projectDetail(id: projectId) {
|
||||
viewModel.apply(detail)
|
||||
fillFormIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
private func fillFormIfNeeded() {
|
||||
if nameField.text?.isEmpty != false { nameField.text = viewModel.name }
|
||||
if descriptionField.text?.isEmpty != false { descriptionField.text = viewModel.descriptionText }
|
||||
if priceField.text?.isEmpty != false { priceField.text = viewModel.price }
|
||||
}
|
||||
|
||||
@objc private func save() {
|
||||
viewModel.name = nameField.text ?? ""
|
||||
viewModel.descriptionText = descriptionField.text ?? ""
|
||||
viewModel.price = priceField.text ?? ""
|
||||
Task {
|
||||
let userId = Int(services.userId ?? "")
|
||||
let success = await viewModel.submit(
|
||||
scenicId: services.currentScenicId,
|
||||
userId: userId,
|
||||
api: services.projectAPI,
|
||||
uploadService: services.ossUploadService
|
||||
)
|
||||
if success {
|
||||
navigationController?.popViewController(animated: true)
|
||||
} else if let message = viewModel.errorMessage {
|
||||
services.toastCenter.show(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ProjectEditorViewModel: ViewModelBindable {}
|
||||
|
||||
/// 店铺项目编辑页。
|
||||
final class StoreProjectEditorViewController: ModuleTableViewController {
|
||||
private let viewModel = StoreProjectEditorViewModel(mode: .create)
|
||||
private let nameField = UITextField()
|
||||
|
||||
init(projectId: Int?) {
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
title = "店铺项目"
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
title: "保存",
|
||||
style: .done,
|
||||
target: self,
|
||||
action: #selector(save)
|
||||
)
|
||||
super.viewDidLoad()
|
||||
nameField.placeholder = "项目名称"
|
||||
nameField.borderStyle = .roundedRect
|
||||
nameField.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 52)
|
||||
tableView.tableHeaderView = nameField
|
||||
wireViewModel(viewModel) { }
|
||||
}
|
||||
|
||||
override func tableRowCount() -> Int { viewModel.scenicList.count }
|
||||
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let scenic = viewModel.scenicList[indexPath.row]
|
||||
cell.configure(title: scenic.name, subtitle: viewModel.selectedScenicIds.contains(scenic.id) ? "已选择" : nil)
|
||||
}
|
||||
|
||||
override func reloadContent() async {
|
||||
guard let userId = Int(services.userId ?? "") else { return }
|
||||
await viewModel.loadScenicList(api: services.projectAPI, userId: userId)
|
||||
}
|
||||
|
||||
@objc private func save() {
|
||||
viewModel.name = nameField.text ?? ""
|
||||
Task {
|
||||
guard let userId = Int(services.userId ?? "") else { return }
|
||||
let success = await viewModel.submit(
|
||||
userId: userId,
|
||||
api: services.projectAPI,
|
||||
uploadService: services.ossUploadService
|
||||
)
|
||||
if success { navigationController?.popViewController(animated: true) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension StoreProjectEditorViewModel: ViewModelBindable {}
|
||||
@ -0,0 +1,624 @@
|
||||
//
|
||||
// ProjectViewModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 项目表单模式,区分新建和编辑。
|
||||
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
|
||||
final class ProjectManagementViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
private(set) var items: [PhotographerProjectItem] = [] { didSet { onChange?() } }
|
||||
private(set) var loading = false { didSet { onChange?() } }
|
||||
private(set) var loadingMore = false { didSet { onChange?() } }
|
||||
private(set) var hasMore = false { didSet { onChange?() } }
|
||||
private(set) var selectedDetail: PhotographerProjectDetailResponse? { didSet { onChange?() } }
|
||||
var searchText = "" { didSet { onChange?() } }
|
||||
var errorMessage: String? { didSet { onChange?() } }
|
||||
|
||||
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
|
||||
final class ProjectEditorViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
var name = "" { didSet { onChange?() } }
|
||||
var descriptionText = "" { didSet { onChange?() } }
|
||||
var price = "" { didSet { onChange?() } }
|
||||
var otPrice = "" { didSet { onChange?() } }
|
||||
var deposit = "" { didSet { onChange?() } }
|
||||
var attrLabelText = "" { didSet { onChange?() } }
|
||||
var materialNum = "1" { didSet { onChange?() } }
|
||||
var photoNum = "1" { didSet { onChange?() } }
|
||||
var videoNum = "1" { didSet { onChange?() } }
|
||||
var selectedSpotIds: Set<Int> = [] { didSet { onChange?() } }
|
||||
var coverImage: ProjectLocalImage? { didSet { onChange?() } }
|
||||
var carouselImages: [ProjectLocalImage] = [] { didSet { onChange?() } }
|
||||
var existingCoverURL = "" { didSet { onChange?() } }
|
||||
var existingCarouselURLs: [String] = [] { didSet { onChange?() } }
|
||||
private(set) var submitting = false { didSet { onChange?() } }
|
||||
private(set) var uploadProgress = 0 { didSet { onChange?() } }
|
||||
var errorMessage: String? { didSet { onChange?() } }
|
||||
|
||||
private var mode: ProjectEditorMode
|
||||
|
||||
/// 初始化摄影师项目表单,并在编辑模式下回填详情。
|
||||
init(mode: ProjectEditorMode) {
|
||||
self.mode = mode
|
||||
if case let .edit(detail) = mode {
|
||||
apply(detail)
|
||||
}
|
||||
}
|
||||
|
||||
/// 回填编辑详情,保留同一个 ObservableObject 实例。
|
||||
func apply(_ detail: PhotographerProjectDetailResponse) {
|
||||
mode = .edit(detail)
|
||||
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
|
||||
coverImage = nil
|
||||
carouselImages = []
|
||||
}
|
||||
|
||||
/// 提交摄影师项目,必要时先上传封面和轮播图。
|
||||
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
|
||||
final class StoreProjectManagementViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
private(set) var items: [PhotographerProjectItem] = [] { didSet { onChange?() } }
|
||||
private(set) var loading = false { didSet { onChange?() } }
|
||||
private(set) var selectedDetail: PhotographerProjectDetailResponse? { didSet { onChange?() } }
|
||||
var searchText = "" { didSet { onChange?() } }
|
||||
var selectedType = 0 { didSet { onChange?() } }
|
||||
var errorMessage: String? { didSet { onChange?() } }
|
||||
|
||||
/// 根据本地筛选条件返回展示项目。
|
||||
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
|
||||
final class StoreProjectEditorViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
var projectType = 19 { didSet { onChange?() } }
|
||||
var scenicList: [StoreManagerScenicItem] = [] { didSet { onChange?() } }
|
||||
var storeList: [StoreItem] = [] { didSet { onChange?() } }
|
||||
var selectedScenicIds: Set<Int> = [] { didSet { onChange?() } }
|
||||
var selectedStoreId: Int? { didSet { onChange?() } }
|
||||
var name = "" { didSet { onChange?() } }
|
||||
var descriptionText = "" { didSet { onChange?() } }
|
||||
var coverImage: ProjectLocalImage? { didSet { onChange?() } }
|
||||
var carouselImages: [ProjectLocalImage] = [] { didSet { onChange?() } }
|
||||
var existingCoverURL = "" { didSet { onChange?() } }
|
||||
var existingCarouselURLs: [String] = [] { didSet { onChange?() } }
|
||||
var settleSpotNum = "1" { didSet { onChange?() } }
|
||||
var projectPrice = "" { didSet { onChange?() } }
|
||||
var priceMaterial = "" { didSet { onChange?() } }
|
||||
var pricePhoto = "" { didSet { onChange?() } }
|
||||
var priceVideo = "" { didSet { onChange?() } }
|
||||
var priceDeposit = "" { didSet { onChange?() } }
|
||||
var materialNum = "1" { didSet { onChange?() } }
|
||||
var photoNum = "1" { didSet { onChange?() } }
|
||||
var videoNum = "1" { didSet { onChange?() } }
|
||||
private(set) var submitting = false { didSet { onChange?() } }
|
||||
var errorMessage: String? { didSet { onChange?() } }
|
||||
|
||||
private var mode: StoreProjectEditorMode
|
||||
|
||||
/// 初始化店铺项目表单,并在编辑模式下回填详情。
|
||||
init(mode: StoreProjectEditorMode) {
|
||||
self.mode = mode
|
||||
if case let .edit(detail) = mode {
|
||||
apply(detail)
|
||||
}
|
||||
}
|
||||
|
||||
/// 回填编辑详情,保留同一个 ObservableObject 实例。
|
||||
func apply(_ detail: PhotographerProjectDetailResponse) {
|
||||
mode = .edit(detail)
|
||||
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))"
|
||||
coverImage = nil
|
||||
carouselImages = []
|
||||
}
|
||||
|
||||
/// 加载店铺项目可管理景区。
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user