Initial commit: suixinkan_ios UIKit rewrite project.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
86
suixinkan_ios/Features/PunchPoint/API/PunchPointAPI.swift
Normal file
86
suixinkan_ios/Features/PunchPoint/API/PunchPointAPI.swift
Normal file
@ -0,0 +1,86 @@
|
||||
//
|
||||
// PunchPointAPI.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 打卡点服务协议,抽象列表、详情和增删改接口以便单元测试替换。
|
||||
@MainActor
|
||||
protocol PunchPointServing {
|
||||
/// 获取指定景区的打卡点列表。
|
||||
func punchPointList(scenicId: Int, status: Int, page: Int, pageSize: Int) async throws -> ListPayload<PunchPointItem>
|
||||
|
||||
/// 获取指定打卡点详情。
|
||||
func punchPointInfo(id: Int) async throws -> PunchPointItem
|
||||
|
||||
/// 新增打卡点。
|
||||
func addPunchPoint(_ request: AddPunchPointRequest) async throws
|
||||
|
||||
/// 编辑打卡点。
|
||||
func editPunchPoint(_ request: EditPunchPointRequest) async throws
|
||||
|
||||
/// 删除打卡点。
|
||||
func deletePunchPoint(id: Int) async throws
|
||||
}
|
||||
|
||||
/// 打卡点 API,负责封装旧工程打卡点管理相关接口。
|
||||
@MainActor
|
||||
final class PunchPointAPI: PunchPointServing {
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化打卡点 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
self.client = client
|
||||
}
|
||||
|
||||
/// 获取指定景区的打卡点列表。
|
||||
func punchPointList(scenicId: Int, status: Int = 0, page: Int = 1, pageSize: Int = 20) async throws -> ListPayload<PunchPointItem> {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/yf-handset-app/photog/scenic-spot/list",
|
||||
queryItems: [
|
||||
URLQueryItem(name: "scenic_area_id", value: "\(scenicId)"),
|
||||
URLQueryItem(name: "status", value: "\(max(status, 0))"),
|
||||
URLQueryItem(name: "page", value: "\(max(page, 1))"),
|
||||
URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))")
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取指定打卡点详情。
|
||||
func punchPointInfo(id: Int) async throws -> PunchPointItem {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/yf-handset-app/photog/scenic-spot/info",
|
||||
queryItems: [URLQueryItem(name: "id", value: "\(id)")]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 新增打卡点。
|
||||
func addPunchPoint(_ request: AddPunchPointRequest) async throws {
|
||||
_ = try await client.send(
|
||||
APIRequest(method: .post, path: "/api/yf-handset-app/photog/scenic-spot/add", body: request)
|
||||
) as EmptyPayload
|
||||
}
|
||||
|
||||
/// 编辑打卡点。
|
||||
func editPunchPoint(_ request: EditPunchPointRequest) async throws {
|
||||
_ = try await client.send(
|
||||
APIRequest(method: .post, path: "/api/yf-handset-app/photog/scenic-spot/edit", body: request)
|
||||
) as EmptyPayload
|
||||
}
|
||||
|
||||
/// 删除打卡点。
|
||||
func deletePunchPoint(id: Int) async throws {
|
||||
_ = try await client.send(
|
||||
APIRequest(method: .post, path: "/api/yf-handset-app/photog/scenic-spot/delete", body: PunchPointDeleteRequest(id: id))
|
||||
) as EmptyPayload
|
||||
}
|
||||
}
|
||||
252
suixinkan_ios/Features/PunchPoint/Models/PunchPointModels.swift
Normal file
252
suixinkan_ios/Features/PunchPoint/Models/PunchPointModels.swift
Normal file
@ -0,0 +1,252 @@
|
||||
//
|
||||
// PunchPointModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 打卡点筛选实体,表示列表页可选择的审核或运营状态。
|
||||
enum PunchPointFilter: Int, CaseIterable, Identifiable {
|
||||
case all = 0
|
||||
case operating = 1
|
||||
case paused = 2
|
||||
case pendingReview = 3
|
||||
case rejected = 4
|
||||
|
||||
var id: Int { rawValue }
|
||||
|
||||
/// 筛选项展示标题。
|
||||
var title: String {
|
||||
switch self {
|
||||
case .all: "全部"
|
||||
case .operating: "运营中"
|
||||
case .paused: "已暂停"
|
||||
case .pendingReview: "待审核"
|
||||
case .rejected: "已驳回"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 打卡点区域实体,表示点位经纬度和地址。
|
||||
struct PunchPointRegion: Codable, Hashable {
|
||||
var lat: Double
|
||||
var lot: Double
|
||||
var address: String
|
||||
var scenicSpotStr: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case lat
|
||||
case lot
|
||||
case address
|
||||
case scenicSpotStr = "scenic_spot_str"
|
||||
}
|
||||
|
||||
/// 创建打卡点区域实体。
|
||||
init(lat: Double, lot: Double, address: String, scenicSpotStr: String? = nil) {
|
||||
self.lat = lat
|
||||
self.lot = lot
|
||||
self.address = address
|
||||
self.scenicSpotStr = scenicSpotStr
|
||||
}
|
||||
|
||||
/// 宽松解码区域字段,兼容经纬度数字和字符串混合返回。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
lat = try container.decodeLossyDouble(forKey: .lat) ?? 0
|
||||
lot = try container.decodeLossyDouble(forKey: .lot) ?? 0
|
||||
address = try container.decodeLossyString(forKey: .address)
|
||||
scenicSpotStr = try container.decodeIfPresent(String.self, forKey: .scenicSpotStr)
|
||||
}
|
||||
}
|
||||
|
||||
/// 打卡点列表项实体,表示一个景区下可管理的打卡点。
|
||||
struct PunchPointItem: Decodable, Hashable, Identifiable {
|
||||
let id: Int
|
||||
let scenicAreaId: Int
|
||||
let name: String
|
||||
let status: Int
|
||||
let statusLabel: String
|
||||
let description: String
|
||||
let region: PunchPointRegion?
|
||||
let scenicSpotStr: String
|
||||
let guideImages: [String]
|
||||
let mpQrcode: String
|
||||
let createdAt: String
|
||||
let creator: String
|
||||
let creatorPhone: String
|
||||
let auditor: String
|
||||
let auditTime: String
|
||||
let auditRemark: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case scenicAreaId = "scenic_area_id"
|
||||
case name
|
||||
case status
|
||||
case statusLabel = "status_label"
|
||||
case description
|
||||
case region
|
||||
case scenicSpotStr = "scenic_spot_str"
|
||||
case guideImages = "guide_imgs"
|
||||
case mpQrcode = "mp_qrcode"
|
||||
case createdAt = "created_at"
|
||||
case creator
|
||||
case creatorPhone = "creator_phone"
|
||||
case auditor
|
||||
case auditTime = "audit_time"
|
||||
case auditRemark = "audit_remark"
|
||||
}
|
||||
|
||||
/// 创建打卡点列表项,主要用于测试和表单兜底展示。
|
||||
init(
|
||||
id: Int,
|
||||
scenicAreaId: Int = 0,
|
||||
name: String,
|
||||
status: Int = 0,
|
||||
statusLabel: String = "",
|
||||
description: String = "",
|
||||
region: PunchPointRegion? = nil,
|
||||
scenicSpotStr: String = "",
|
||||
guideImages: [String] = [],
|
||||
mpQrcode: String = "",
|
||||
createdAt: String = "",
|
||||
creator: String = "",
|
||||
creatorPhone: String = "",
|
||||
auditor: String = "",
|
||||
auditTime: String = "",
|
||||
auditRemark: String = ""
|
||||
) {
|
||||
self.id = id
|
||||
self.scenicAreaId = scenicAreaId
|
||||
self.name = name
|
||||
self.status = status
|
||||
self.statusLabel = statusLabel
|
||||
self.description = description
|
||||
self.region = region
|
||||
self.scenicSpotStr = scenicSpotStr
|
||||
self.guideImages = guideImages
|
||||
self.mpQrcode = mpQrcode
|
||||
self.createdAt = createdAt
|
||||
self.creator = creator
|
||||
self.creatorPhone = creatorPhone
|
||||
self.auditor = auditor
|
||||
self.auditTime = auditTime
|
||||
self.auditRemark = auditRemark
|
||||
}
|
||||
|
||||
/// 宽松解码打卡点字段,兼容后端字符串和数字混合返回。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decodeLossyInt(forKey: .id) ?? 0
|
||||
scenicAreaId = try container.decodeLossyInt(forKey: .scenicAreaId) ?? 0
|
||||
name = try container.decodeLossyString(forKey: .name)
|
||||
status = try container.decodeLossyInt(forKey: .status) ?? 0
|
||||
statusLabel = try container.decodeLossyString(forKey: .statusLabel)
|
||||
description = try container.decodeLossyString(forKey: .description)
|
||||
region = try container.decodeIfPresent(PunchPointRegion.self, forKey: .region)
|
||||
scenicSpotStr = try container.decodeLossyString(forKey: .scenicSpotStr)
|
||||
guideImages = (try? container.decodeIfPresent([String].self, forKey: .guideImages)) ?? []
|
||||
mpQrcode = try container.decodeLossyString(forKey: .mpQrcode)
|
||||
createdAt = try container.decodeLossyString(forKey: .createdAt)
|
||||
creator = try container.decodeLossyString(forKey: .creator)
|
||||
creatorPhone = try container.decodeLossyString(forKey: .creatorPhone)
|
||||
auditor = try container.decodeLossyString(forKey: .auditor)
|
||||
auditTime = try container.decodeLossyString(forKey: .auditTime)
|
||||
auditRemark = try container.decodeLossyString(forKey: .auditRemark)
|
||||
}
|
||||
}
|
||||
|
||||
/// 新增打卡点请求实体,表示提交给后端的点位表单。
|
||||
struct AddPunchPointRequest: Encodable, Equatable {
|
||||
let scenicAreaId: String
|
||||
let name: String
|
||||
let description: String
|
||||
let region: PunchPointRegion
|
||||
let scenicSpotStr: String
|
||||
let guideImages: [String]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case scenicAreaId = "scenic_area_id"
|
||||
case name
|
||||
case description
|
||||
case region
|
||||
case scenicSpotStr = "scenic_spot_str"
|
||||
case guideImages = "guide_imgs"
|
||||
}
|
||||
}
|
||||
|
||||
/// 编辑打卡点请求实体,表示已有点位的完整修改内容。
|
||||
struct EditPunchPointRequest: Encodable, Equatable {
|
||||
let id: Int
|
||||
let scenicAreaId: String
|
||||
let name: String
|
||||
let description: String
|
||||
let region: PunchPointRegion
|
||||
let scenicSpotStr: String
|
||||
let guideImages: [String]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case scenicAreaId = "scenic_area_id"
|
||||
case name
|
||||
case description
|
||||
case region
|
||||
case scenicSpotStr = "scenic_spot_str"
|
||||
case guideImages = "guide_imgs"
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除打卡点请求实体,表示要删除的点位 ID。
|
||||
struct PunchPointDeleteRequest: Encodable, Equatable {
|
||||
let id: Int
|
||||
}
|
||||
|
||||
/// 打卡点本地待上传图片实体,保存 PhotosPicker 读取后的内存图片。
|
||||
struct PunchPointLocalImage: Identifiable, Equatable {
|
||||
let id = UUID()
|
||||
let data: Data
|
||||
let fileName: String
|
||||
var remoteURL: String?
|
||||
var progress: Int
|
||||
|
||||
/// 创建待上传打卡点图片。
|
||||
init(data: Data, fileName: String, remoteURL: String? = nil, progress: Int = 0) {
|
||||
self.data = data
|
||||
self.fileName = fileName
|
||||
self.remoteURL = remoteURL
|
||||
self.progress = progress
|
||||
}
|
||||
}
|
||||
|
||||
private extension KeyedDecodingContainer {
|
||||
/// 将 String、数字和 Bool 宽松解码为字符串。
|
||||
func decodeLossyString(forKey key: Key) throws -> String {
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) { return value }
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) { return String(value) }
|
||||
if let value = try? decodeIfPresent(Double.self, forKey: key) { return String(value) }
|
||||
if let value = try? decodeIfPresent(Bool.self, forKey: key) { return value ? "1" : "0" }
|
||||
return ""
|
||||
}
|
||||
|
||||
/// 将 String、Double 和 Int 宽松解码为 Int。
|
||||
func decodeLossyInt(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) {
|
||||
return Int(value.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 将 String、Double 和 Int 宽松解码为 Double。
|
||||
func decodeLossyDouble(forKey key: Key) throws -> Double? {
|
||||
if let value = try? decodeIfPresent(Double.self, forKey: key) { return value }
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) { return Double(value) }
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
return Double(value.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
37
suixinkan_ios/Features/PunchPoint/PunchPoint.md
Normal file
37
suixinkan_ios/Features/PunchPoint/PunchPoint.md
Normal file
@ -0,0 +1,37 @@
|
||||
# PunchPoint 模块业务逻辑
|
||||
|
||||
## 模块职责
|
||||
|
||||
PunchPoint 模块负责首页 `checkin_points` 打卡点管理入口。
|
||||
|
||||
本模块包含打卡点列表、状态筛选、分页、详情、二维码、新建、编辑、删除、前台定位选点和图片 OSS 上传。打卡点列表和表单状态只保存在模块 ViewModel 内,不进入 `AppSession`、`AccountContext`、TabBar 或首页状态。
|
||||
|
||||
## 数据来源
|
||||
|
||||
- 当前景区 ID 从 `AccountContext.currentScenic` 读取。
|
||||
- 列表接口使用 `/api/yf-handset-app/photog/scenic-spot/list`。
|
||||
- 详情接口使用 `/api/yf-handset-app/photog/scenic-spot/info`。
|
||||
- 新建、编辑、删除分别使用 `/add`、`/edit`、`/delete`。
|
||||
- 图片上传统一使用 `OSSUploadService.uploadPunchPointImage`。
|
||||
|
||||
## 页面流程
|
||||
|
||||
`PunchPointListViewModel` 管理状态筛选、分页、详情兜底和删除刷新。缺少当前景区时清空列表并停止请求。
|
||||
|
||||
`PunchPointEditorViewModel` 管理新增和编辑表单。提交前校验名称、坐标、地址和图片;本地图片会先上传 OSS,全部拿到最终 URL 后再提交打卡点接口。上传失败时不提交业务接口。
|
||||
|
||||
编辑或删除成功后,页面会触发 `ScenicSpotContext.reload`,保证素材、样片等依赖打卡点选择器的页面能看到最新数据。
|
||||
|
||||
## 定位边界
|
||||
|
||||
当前实现使用 `ForegroundLocationProvider` 做前台即时定位和地址反解析,并允许手动填写经纬度。本轮不做后台定位、不缓存定位结果,也不把定位权限状态落盘。
|
||||
|
||||
后续如果接入完整高德地图选点 UI,只需要替换编辑页的选点组件,继续把经纬度、地址回填到 `PunchPointEditorViewModel`。
|
||||
|
||||
## 缓存边界
|
||||
|
||||
打卡点列表、详情、表单、本地图片、上传进度和 OSS STS 都不落盘。远程图片缓存继续交给 Kingfisher 的 `RemoteImage`。
|
||||
|
||||
## 测试要求
|
||||
|
||||
新增打卡点逻辑时,需要同步补充 API、ViewModel 和路由测试。测试失败时先修复问题,再继续迁移后续功能。
|
||||
@ -0,0 +1,217 @@
|
||||
//
|
||||
// PunchPointViewControllers.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/26.
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
private extension PunchPointItem {
|
||||
var displayAddress: String {
|
||||
region?.address ?? scenicSpotStr
|
||||
}
|
||||
}
|
||||
|
||||
/// 打卡点列表页。
|
||||
final class PunchPointListViewController: ModuleTableViewController {
|
||||
private let viewModel = PunchPointListViewModel()
|
||||
|
||||
override func viewDidLoad() {
|
||||
title = "打卡点"
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
title: "新建",
|
||||
style: .plain,
|
||||
target: self,
|
||||
action: #selector(createPunchPoint)
|
||||
)
|
||||
super.viewDidLoad()
|
||||
wireViewModel(viewModel) { }
|
||||
}
|
||||
|
||||
override func tableRowCount() -> Int { viewModel.items.count }
|
||||
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let item = viewModel.items[indexPath.row]
|
||||
cell.configure(title: item.name, subtitle: item.displayAddress, detail: item.statusLabel)
|
||||
}
|
||||
|
||||
override func didSelectTableRow(at indexPath: IndexPath) {
|
||||
let item = viewModel.items[indexPath.row]
|
||||
navigationController?.pushViewController(
|
||||
PunchPointDetailViewController(punchPointId: item.id, summary: item),
|
||||
animated: true
|
||||
)
|
||||
}
|
||||
|
||||
override func reloadContent() async {
|
||||
await viewModel.reload(scenicId: services.currentScenicId, api: services.punchPointAPI)
|
||||
}
|
||||
|
||||
@objc private func createPunchPoint() {
|
||||
navigationController?.pushViewController(PunchPointEditorViewController(punchPointId: nil), animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
extension PunchPointListViewModel: ViewModelBindable {}
|
||||
|
||||
/// 打卡点详情页。
|
||||
final class PunchPointDetailViewController: ModuleTableViewController {
|
||||
private let punchPointId: Int
|
||||
private let summary: PunchPointItem?
|
||||
private let viewModel = PunchPointListViewModel()
|
||||
|
||||
init(punchPointId: Int, summary: PunchPointItem?) {
|
||||
self.punchPointId = punchPointId
|
||||
self.summary = summary
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
title = summary?.name ?? "打卡点详情"
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
title: "二维码",
|
||||
style: .plain,
|
||||
target: self,
|
||||
action: #selector(showQR)
|
||||
)
|
||||
super.viewDidLoad()
|
||||
viewModel.onChange = { [weak self] in
|
||||
self?.title = self?.viewModel.selectedDetail?.name ?? self?.summary?.name
|
||||
self?.reloadTable()
|
||||
}
|
||||
}
|
||||
|
||||
override func tableRowCount() -> Int {
|
||||
guard viewModel.selectedDetail != nil || summary != nil else { return 0 }
|
||||
return 4
|
||||
}
|
||||
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let detail = viewModel.selectedDetail ?? summary
|
||||
guard let detail else { return }
|
||||
switch indexPath.row {
|
||||
case 0: cell.configure(title: "名称", subtitle: detail.name)
|
||||
case 1: cell.configure(title: "地址", subtitle: detail.displayAddress)
|
||||
case 2: cell.configure(title: "状态", subtitle: detail.statusLabel)
|
||||
default: cell.configure(title: "创建时间", subtitle: detail.createdAt)
|
||||
}
|
||||
}
|
||||
|
||||
override func reloadContent() async {
|
||||
await viewModel.loadDetail(id: punchPointId, api: services.punchPointAPI)
|
||||
}
|
||||
|
||||
@objc private func showQR() {
|
||||
guard let detail = viewModel.selectedDetail ?? summary else { return }
|
||||
navigationController?.pushViewController(
|
||||
PunchPointQRViewController(title: detail.name, qrURL: detail.mpQrcode),
|
||||
animated: true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 打卡点编辑页。
|
||||
final class PunchPointEditorViewController: ModuleTableViewController {
|
||||
private let viewModel = PunchPointEditorViewModel()
|
||||
private let nameField = UITextField()
|
||||
private let punchPointId: Int?
|
||||
|
||||
init(punchPointId: Int?) {
|
||||
self.punchPointId = punchPointId
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
title = punchPointId == nil ? "新建打卡点" : "编辑打卡点"
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
title: "保存",
|
||||
style: .done,
|
||||
target: self,
|
||||
action: #selector(save)
|
||||
)
|
||||
super.viewDidLoad()
|
||||
nameField.placeholder = "打卡点名称"
|
||||
nameField.borderStyle = .roundedRect
|
||||
nameField.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 52)
|
||||
tableView.tableHeaderView = nameField
|
||||
wireViewModel(viewModel) { [weak self] in
|
||||
if self?.nameField.text?.isEmpty != false {
|
||||
self?.nameField.text = self?.viewModel.name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func tableRowCount() -> Int { 1 }
|
||||
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
cell.configure(title: "地址", subtitle: viewModel.address)
|
||||
}
|
||||
|
||||
override func reloadContent() async {
|
||||
guard let punchPointId else { return }
|
||||
if let detail = try? await services.punchPointAPI.punchPointInfo(id: punchPointId) {
|
||||
viewModel.apply(detail)
|
||||
nameField.text = viewModel.name
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func save() {
|
||||
viewModel.name = nameField.text ?? ""
|
||||
Task {
|
||||
let success = await viewModel.submit(
|
||||
scenicId: services.currentScenicId,
|
||||
api: services.punchPointAPI,
|
||||
uploadService: services.ossUploadService
|
||||
)
|
||||
if success { navigationController?.popViewController(animated: true) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension PunchPointEditorViewModel: ViewModelBindable {}
|
||||
|
||||
/// 打卡点二维码页。
|
||||
final class PunchPointQRViewController: UIViewController {
|
||||
private let pageTitle: String
|
||||
private let qrURL: String
|
||||
|
||||
init(title: String, qrURL: String) {
|
||||
pageTitle = title
|
||||
self.qrURL = qrURL
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = pageTitle
|
||||
view.backgroundColor = UIColor(hex: 0xF5F7FA)
|
||||
|
||||
let urlLabel = UILabel()
|
||||
urlLabel.text = qrURL
|
||||
urlLabel.numberOfLines = 0
|
||||
urlLabel.textAlignment = .center
|
||||
urlLabel.textColor = AppDesign.textSecondary
|
||||
view.addSubview(urlLabel)
|
||||
urlLabel.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
make.leading.trailing.equalToSuperview().inset(24)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,286 @@
|
||||
//
|
||||
// PunchPointViewModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 打卡点列表 ViewModel,负责筛选、分页、详情加载和删除刷新。
|
||||
@MainActor
|
||||
final class PunchPointListViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
var selectedFilter: PunchPointFilter = .all { didSet { onChange?() } }
|
||||
var items: [PunchPointItem] = [] { didSet { onChange?() } }
|
||||
var selectedDetail: PunchPointItem? { didSet { onChange?() } }
|
||||
var errorMessage: String? { didSet { onChange?() } }
|
||||
var isLoading = false { didSet { onChange?() } }
|
||||
var isLoadingMore = false { didSet { onChange?() } }
|
||||
var total = 0 { didSet { onChange?() } }
|
||||
|
||||
private var page = 1
|
||||
private let pageSize = 20
|
||||
|
||||
/// 是否还存在下一页数据。
|
||||
var hasMore: Bool {
|
||||
items.count < total
|
||||
}
|
||||
|
||||
/// 重新加载当前筛选下的打卡点列表。
|
||||
func reload(scenicId: Int?, api: any PunchPointServing) async {
|
||||
guard let scenicId else {
|
||||
reset()
|
||||
return
|
||||
}
|
||||
page = 1
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
defer { isLoading = false }
|
||||
|
||||
do {
|
||||
let payload = try await api.punchPointList(
|
||||
scenicId: scenicId,
|
||||
status: selectedFilter.rawValue,
|
||||
page: page,
|
||||
pageSize: pageSize
|
||||
)
|
||||
items = payload.list
|
||||
total = payload.total
|
||||
} catch {
|
||||
items = []
|
||||
total = 0
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载下一页打卡点列表。
|
||||
func loadMore(scenicId: Int?, api: any PunchPointServing) async {
|
||||
guard let scenicId, hasMore, !isLoadingMore, !isLoading else { return }
|
||||
isLoadingMore = true
|
||||
let nextPage = page + 1
|
||||
defer { isLoadingMore = false }
|
||||
|
||||
do {
|
||||
let payload = try await api.punchPointList(
|
||||
scenicId: scenicId,
|
||||
status: selectedFilter.rawValue,
|
||||
page: nextPage,
|
||||
pageSize: pageSize
|
||||
)
|
||||
page = nextPage
|
||||
items.append(contentsOf: payload.list)
|
||||
total = payload.total
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 切换筛选并重载列表。
|
||||
func selectFilter(_ filter: PunchPointFilter, scenicId: Int?, api: any PunchPointServing) async {
|
||||
guard selectedFilter != filter else { return }
|
||||
selectedFilter = filter
|
||||
await reload(scenicId: scenicId, api: api)
|
||||
}
|
||||
|
||||
/// 加载单个打卡点详情。
|
||||
func loadDetail(id: Int, api: any PunchPointServing) async {
|
||||
errorMessage = nil
|
||||
do {
|
||||
selectedDetail = try await api.punchPointInfo(id: id)
|
||||
} catch {
|
||||
selectedDetail = items.first { $0.id == id }
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除打卡点并刷新当前列表。
|
||||
func delete(_ item: PunchPointItem, scenicId: Int?, api: any PunchPointServing) async -> Bool {
|
||||
do {
|
||||
try await api.deletePunchPoint(id: item.id)
|
||||
await reload(scenicId: scenicId, api: api)
|
||||
return true
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// 清空列表状态,通常用于缺少当前景区时。
|
||||
func reset() {
|
||||
items = []
|
||||
selectedDetail = nil
|
||||
errorMessage = nil
|
||||
total = 0
|
||||
page = 1
|
||||
isLoading = false
|
||||
isLoadingMore = false
|
||||
}
|
||||
}
|
||||
|
||||
/// 打卡点编辑 ViewModel,负责新增、编辑、表单校验和 OSS 图片上传。
|
||||
@MainActor
|
||||
final class PunchPointEditorViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
var name = "" { didSet { onChange?() } }
|
||||
var description = "" { didSet { onChange?() } }
|
||||
var address = "" { didSet { onChange?() } }
|
||||
var latitudeText = "" { didSet { onChange?() } }
|
||||
var longitudeText = "" { didSet { onChange?() } }
|
||||
var scenicSpotText = "" { didSet { onChange?() } }
|
||||
var remoteImages: [String] = [] { didSet { onChange?() } }
|
||||
var localImages: [PunchPointLocalImage] = [] { didSet { onChange?() } }
|
||||
var errorMessage: String? { didSet { onChange?() } }
|
||||
var isSubmitting = false { didSet { onChange?() } }
|
||||
|
||||
private(set) var editingItem: PunchPointItem?
|
||||
|
||||
/// 初始化编辑 ViewModel,可传入已有打卡点作为编辑表单初值。
|
||||
init(item: PunchPointItem? = nil) {
|
||||
editingItem = item
|
||||
if let item {
|
||||
apply(item)
|
||||
}
|
||||
}
|
||||
|
||||
/// 回填编辑详情,保留同一个 ObservableObject 实例。
|
||||
func apply(_ item: PunchPointItem) {
|
||||
editingItem = item
|
||||
name = item.name
|
||||
description = item.description
|
||||
address = item.region?.address ?? ""
|
||||
latitudeText = item.region.map { String($0.lat) } ?? ""
|
||||
longitudeText = item.region.map { String($0.lot) } ?? ""
|
||||
scenicSpotText = item.scenicSpotStr
|
||||
remoteImages = item.guideImages
|
||||
localImages = []
|
||||
}
|
||||
|
||||
/// 设置经纬度和地址,通常由定位或地图选点回填。
|
||||
func applyLocation(latitude: Double, longitude: Double, address: String) {
|
||||
latitudeText = String(format: "%.6f", latitude)
|
||||
longitudeText = String(format: "%.6f", longitude)
|
||||
self.address = address
|
||||
if scenicSpotText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
scenicSpotText = address
|
||||
}
|
||||
}
|
||||
|
||||
/// 添加本地待上传图片。
|
||||
func addLocalImages(_ images: [PunchPointLocalImage]) {
|
||||
localImages.append(contentsOf: images)
|
||||
}
|
||||
|
||||
/// 删除远程图片。
|
||||
func removeRemoteImage(_ url: String) {
|
||||
remoteImages.removeAll { $0 == url }
|
||||
}
|
||||
|
||||
/// 删除本地待上传图片。
|
||||
func removeLocalImage(id: UUID) {
|
||||
localImages.removeAll { $0.id == id }
|
||||
}
|
||||
|
||||
/// 提交新增或编辑表单,图片会先上传 OSS,再提交业务接口。
|
||||
func submit(
|
||||
scenicId: Int?,
|
||||
api: any PunchPointServing,
|
||||
uploadService: any OSSUploadServing
|
||||
) async -> Bool {
|
||||
guard !isSubmitting else { return false }
|
||||
guard let scenicId else {
|
||||
errorMessage = "缺少当前景区"
|
||||
return false
|
||||
}
|
||||
guard let region = makeRegion() else { return false }
|
||||
|
||||
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedName.isEmpty else {
|
||||
errorMessage = "请输入打卡点名称"
|
||||
return false
|
||||
}
|
||||
guard !region.address.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
errorMessage = "请选择或填写打卡点地址"
|
||||
return false
|
||||
}
|
||||
guard !remoteImages.isEmpty || !localImages.isEmpty else {
|
||||
errorMessage = "请至少上传一张打卡点图片"
|
||||
return false
|
||||
}
|
||||
|
||||
isSubmitting = true
|
||||
errorMessage = nil
|
||||
defer { isSubmitting = false }
|
||||
|
||||
do {
|
||||
let uploadedImages = try await uploadImages(scenicId: scenicId, uploadService: uploadService)
|
||||
let allImages = remoteImages + uploadedImages
|
||||
if let editingItem {
|
||||
try await api.editPunchPoint(
|
||||
EditPunchPointRequest(
|
||||
id: editingItem.id,
|
||||
scenicAreaId: "\(scenicId)",
|
||||
name: trimmedName,
|
||||
description: description.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
region: region,
|
||||
scenicSpotStr: scenicSpotText.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
guideImages: allImages
|
||||
)
|
||||
)
|
||||
} else {
|
||||
try await api.addPunchPoint(
|
||||
AddPunchPointRequest(
|
||||
scenicAreaId: "\(scenicId)",
|
||||
name: trimmedName,
|
||||
description: description.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
region: region,
|
||||
scenicSpotStr: scenicSpotText.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
guideImages: allImages
|
||||
)
|
||||
)
|
||||
}
|
||||
remoteImages = allImages
|
||||
localImages = []
|
||||
return true
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// 构建提交区域,校验经纬度是否可用。
|
||||
private func makeRegion() -> PunchPointRegion? {
|
||||
let trimmedAddress = address.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let lat = Double(latitudeText.trimmingCharacters(in: .whitespacesAndNewlines)),
|
||||
let lot = Double(longitudeText.trimmingCharacters(in: .whitespacesAndNewlines)) else {
|
||||
errorMessage = "请选择打卡点坐标"
|
||||
return nil
|
||||
}
|
||||
return PunchPointRegion(
|
||||
lat: lat,
|
||||
lot: lot,
|
||||
address: trimmedAddress,
|
||||
scenicSpotStr: scenicSpotText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
)
|
||||
}
|
||||
|
||||
/// 上传本地图片,并返回 OSS URL 列表。
|
||||
private func uploadImages(scenicId: Int, uploadService: any OSSUploadServing) async throws -> [String] {
|
||||
var uploaded: [String] = []
|
||||
for index in localImages.indices {
|
||||
let local = localImages[index]
|
||||
let url = try await uploadService.uploadPunchPointImage(
|
||||
data: local.data,
|
||||
fileName: local.fileName,
|
||||
scenicId: scenicId
|
||||
) { [weak self] progress in
|
||||
Task { @MainActor in
|
||||
self?.localImages[index].progress = progress
|
||||
}
|
||||
}
|
||||
localImages[index].remoteURL = url
|
||||
uploaded.append(url)
|
||||
}
|
||||
return uploaded
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user