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:
2026-06-24 15:57:15 +08:00
parent 0d4b63d273
commit fb8430889b
40 changed files with 4496 additions and 108 deletions

View File

@ -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)

View 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 ""
}
/// StringDouble 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
}
}

View File

@ -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":

View File

@ -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()

View File

@ -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 的映射,并同步补充单元测试。

View File

@ -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

View File

@ -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":

View File

@ -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:

View 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))")
]
)
)
}
}

View 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 模块只调用其公开服务协议,不持有钱包业务状态。
## 缓存边界
邀请码、邀请记录、奖励记录和二维码都不落盘。后续如果需要分享图片,应在分享流程中临时生成。

View 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 ""
}
/// StringDouble 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
}
}

View 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
}()
}

View 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)
}
}
}

View 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"))
}
}

View 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 ""
}
/// StringDouble 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 []
}
}

View 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。

View 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
}
}

View 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
)
}
}

View 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)")]
)
)
}
}

View 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
}
/// StringDouble 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
}
}

View File

@ -0,0 +1,27 @@
# Schedule 模块业务逻辑
## 模块职责
Schedule 模块负责首页 `schedule_management` 入口,提供排班日历、某日排班列表、新增排班和删除排班能力。
模块状态只保存在 `ScheduleManagementViewModel``ScheduleAddViewModel` 内,不写入全局登录态、账号上下文或首页状态。
## 排班列表
`ScheduleManagementViewModel` 按当前月份加载有排班的日期标记,并按选中日期加载日排班列表。切换月份或日期时只刷新当前模块数据。
缺少当前景区时ViewModel 会清空月份标记和日程列表,并展示缺少经营上下文的空状态。
## 新增排班
`ScheduleAddViewModel` 管理名称、备注、日期、开始/结束时间和关联订单。关联订单可以从接口返回的可选订单中选择,也可以手填订单号兜底。
提交前会校验名称、景区、日期和时间顺序;重复提交会被忽略。提交成功后返回排班列表并刷新当前日期。
## 接口边界
`ScheduleAPI` 封装月排班日期、日排班列表、新增排班、删除排班和可关联订单接口。可关联订单模型放在共享业务模型中,避免 Schedule 直接依赖 Tasks 模块。
## 缓存边界
排班表单、关联订单、月标记和日程列表都只保存在内存中,不做本地缓存。

View 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)
}
}
}

View 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))
}
}

View File

@ -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

View File

@ -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)