Initial commit: suixinkan_ios UIKit rewrite project.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
76
suixinkan_ios/Features/Account/API/AccountContextAPI.swift
Normal file
76
suixinkan_ios/Features/Account/API/AccountContextAPI.swift
Normal file
@ -0,0 +1,76 @@
|
||||
//
|
||||
// AccountContextAPI.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
/// 账号上下文服务协议,抽象权限、景区、门店和景点读取能力以便测试替换。
|
||||
protocol AccountContextServing {
|
||||
/// 获取当前账号的角色权限列表。
|
||||
func rolePermissions() async throws -> [RolePermissionResponse]
|
||||
|
||||
/// 获取当前账号可访问的景区列表。
|
||||
func scenicListAll() async throws -> ScenicListAllResponse
|
||||
|
||||
/// 获取当前账号可访问的门店列表。
|
||||
func storeAll() async throws -> ListPayload<StoreItem>
|
||||
|
||||
/// 获取指定景区下的景点或打卡点列表。
|
||||
func scenicSpotListAll(scenicId: Int) async throws -> ListPayload<ScenicSpotItem>
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 账号上下文 API,封装角色权限、景区、门店和景点读取接口。
|
||||
final class AccountContextAPI: AccountContextServing {
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化账号上下文 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
self.client = client
|
||||
}
|
||||
|
||||
/// 获取当前账号的角色权限列表。
|
||||
func rolePermissions() async throws -> [RolePermissionResponse] {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/yf-handset-app/role-permission"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取当前账号可访问的景区列表。
|
||||
func scenicListAll() async throws -> ScenicListAllResponse {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/yf-handset-app/photog/scenic/list-all"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取当前账号可访问的门店列表。
|
||||
func storeAll() async throws -> ListPayload<StoreItem> {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/app/store/all"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取指定景区下的景点或打卡点列表。
|
||||
func scenicSpotListAll(scenicId: Int) async throws -> ListPayload<ScenicSpotItem> {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/yf-handset-app/photog/scenic-spot/list-all",
|
||||
queryItems: [URLQueryItem(name: "scenic_id", value: "\(scenicId)")]
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
36
suixinkan_ios/Features/Account/Account.md
Normal file
36
suixinkan_ios/Features/Account/Account.md
Normal file
@ -0,0 +1,36 @@
|
||||
# Account 模块业务逻辑
|
||||
|
||||
## 模块职责
|
||||
|
||||
Account 模块负责同步账号进入业务页前必须具备的上下文数据,包括角色权限、景区、门店和当前景区下的景点/打卡点。
|
||||
|
||||
该模块只做读取和上下文装配,不负责登录 token 存储,也不负责页面导航。
|
||||
|
||||
## 数据边界
|
||||
|
||||
- 角色权限:由 `/api/yf-handset-app/role-permission` 返回,写入 `PermissionContext`。
|
||||
- 景区列表:由 `/api/yf-handset-app/photog/scenic/list-all` 返回,用于账号业务作用域。
|
||||
- 门店列表:由 `/api/app/store/all` 返回,并通过 `scenic_id` 关联景区。
|
||||
- 景点/打卡点:由 `/api/yf-handset-app/photog/scenic-spot/list-all` 按当前景区懒加载。
|
||||
|
||||
## 加载流程
|
||||
|
||||
登录成功或冷启动恢复时,`AccountContextLoader` 会并行请求用户资料和角色权限。权限成功后继续读取景区和门店:
|
||||
|
||||
1. 用户资料刷新 `AccountContext.profile`。
|
||||
2. 角色权限刷新 `PermissionContext`,并计算当前角色的权限 URI 集合。
|
||||
3. 景区列表失败时,从角色权限里的景区数据去重兜底。
|
||||
4. 门店列表失败时不阻断登录,只写入空门店列表。
|
||||
5. 当前景区确定后,`ScenicSpotContext` 再按景区 ID 拉取景点/打卡点。
|
||||
|
||||
## 切换规则
|
||||
|
||||
- 切换角色时,当前景区优先保留在新角色可访问景区中,否则选择新角色第一个景区。
|
||||
- 切换景区时,当前门店优先保留同景区门店,否则选择该景区第一个门店。
|
||||
- 切换景区后,景点/打卡点列表会重新加载。
|
||||
|
||||
## 缓存规则
|
||||
|
||||
`AccountSnapshotStore` 只保存非敏感上下文快照,包括当前角色 ID、当前景区 ID、当前门店 ID 和基础账号展示信息。
|
||||
|
||||
景点/打卡点列表只保存在内存中,不长期落盘。后续业务需要离线缓存时,需要按账号类型、业务账号 ID 和景区 ID 做隔离。
|
||||
355
suixinkan_ios/Features/Account/Models/AccountContextModels.swift
Normal file
355
suixinkan_ios/Features/Account/Models/AccountContextModels.swift
Normal file
@ -0,0 +1,355 @@
|
||||
//
|
||||
// AccountContextModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 角色权限响应实体,表示一个角色、该角色权限树以及可访问景区。
|
||||
struct RolePermissionResponse: Decodable, Equatable {
|
||||
let role: RoleInfo
|
||||
let scenic: [ScenicInfo]
|
||||
}
|
||||
|
||||
/// 角色实体,表示用户可切换的业务角色及其权限树。
|
||||
struct RoleInfo: Decodable, Equatable, Identifiable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let notes: String?
|
||||
let permission: [PermissionItem]
|
||||
|
||||
/// 角色响应的 JSON 字段映射。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case name
|
||||
case notes
|
||||
case permission
|
||||
}
|
||||
|
||||
/// 创建角色实体,主要用于测试和本地状态恢复。
|
||||
init(id: Int, name: String, notes: String? = nil, permission: [PermissionItem] = []) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.notes = notes
|
||||
self.permission = permission
|
||||
}
|
||||
|
||||
/// 自定义解码,兼容 ID 类型不稳定和权限树缺失。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decodeLossyInt(forKey: .id) ?? 0
|
||||
name = try container.decodeLossyString(forKey: .name)
|
||||
notes = try container.decodeIfPresent(String.self, forKey: .notes)
|
||||
permission = try container.decodeIfPresent([PermissionItem].self, forKey: .permission) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
/// 权限节点实体,表示一个菜单或功能权限,并可递归包含子权限。
|
||||
struct PermissionItem: Decodable, Equatable, Identifiable {
|
||||
let id: Int
|
||||
let pid: Int
|
||||
let name: String
|
||||
let uri: String
|
||||
let iconSrc: String?
|
||||
let children: [PermissionItem]
|
||||
|
||||
/// 权限节点响应的 JSON 字段映射。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case pid
|
||||
case name
|
||||
case uri
|
||||
case iconSrc = "icon_src"
|
||||
case children
|
||||
}
|
||||
|
||||
/// 创建权限节点,主要用于测试和本地构造权限树。
|
||||
init(id: Int, pid: Int = 0, name: String, uri: String = "", iconSrc: String? = nil, children: [PermissionItem] = []) {
|
||||
self.id = id
|
||||
self.pid = pid
|
||||
self.name = name
|
||||
self.uri = uri
|
||||
self.iconSrc = iconSrc
|
||||
self.children = children
|
||||
}
|
||||
|
||||
/// 自定义解码,兼容 ID 类型不稳定和 children 缺失。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decodeLossyInt(forKey: .id) ?? 0
|
||||
pid = try container.decodeLossyInt(forKey: .pid) ?? 0
|
||||
name = try container.decodeLossyString(forKey: .name)
|
||||
uri = try container.decodeLossyString(forKey: .uri)
|
||||
iconSrc = try container.decodeIfPresent(String.self, forKey: .iconSrc)
|
||||
children = try container.decodeIfPresent([PermissionItem].self, forKey: .children) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
/// 景区权限实体,表示某个角色可访问的景区。
|
||||
struct ScenicInfo: Decodable, Equatable, Identifiable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let status: Int?
|
||||
let location: ScenicLocationInfo?
|
||||
let coverImg: String?
|
||||
|
||||
/// 景区权限响应的 JSON 字段映射。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case name
|
||||
case status
|
||||
case location
|
||||
case coverImg = "cover_img"
|
||||
}
|
||||
|
||||
/// 创建景区权限实体,主要用于测试和兜底列表生成。
|
||||
init(id: Int, name: String, status: Int? = nil, location: ScenicLocationInfo? = nil, coverImg: String? = nil) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.status = status
|
||||
self.location = location
|
||||
self.coverImg = coverImg
|
||||
}
|
||||
|
||||
/// 自定义解码,兼容 location 是对象或 JSON 字符串。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decodeLossyInt(forKey: .id) ?? 0
|
||||
name = try container.decodeLossyString(forKey: .name)
|
||||
status = try container.decodeLossyInt(forKey: .status)
|
||||
coverImg = try container.decodeIfPresent(String.self, forKey: .coverImg)
|
||||
if let value = try? container.decodeIfPresent(ScenicLocationInfo.self, forKey: .location) {
|
||||
location = value
|
||||
} else if let rawValue = try? container.decodeIfPresent(String.self, forKey: .location),
|
||||
let data = rawValue.data(using: .utf8) {
|
||||
location = try? JSONDecoder().decode(ScenicLocationInfo.self, from: data)
|
||||
} else {
|
||||
location = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 景区位置实体,表示景区经纬度和地址。
|
||||
struct ScenicLocationInfo: Decodable, Equatable {
|
||||
let lng: Double
|
||||
let lat: Double
|
||||
let address: String
|
||||
|
||||
/// 景区位置响应的 JSON 字段映射。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case lng
|
||||
case lat
|
||||
case address
|
||||
}
|
||||
|
||||
/// 创建景区位置实体,主要用于测试和本地构造。
|
||||
init(lng: Double, lat: Double, address: String) {
|
||||
self.lng = lng
|
||||
self.lat = lat
|
||||
self.address = address
|
||||
}
|
||||
|
||||
/// 自定义解码,兼容经纬度是字符串或数字。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
lng = try container.decodeLossyDouble(forKey: .lng) ?? 0
|
||||
lat = try container.decodeLossyDouble(forKey: .lat) ?? 0
|
||||
address = try container.decodeLossyString(forKey: .address)
|
||||
}
|
||||
}
|
||||
|
||||
/// 景区列表响应实体,表示用户可选择的景区列表。
|
||||
struct ScenicListAllResponse: Decodable, Equatable {
|
||||
let total: Int
|
||||
let list: [ScenicListItem]
|
||||
|
||||
/// 景区列表响应的 JSON 字段映射。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case total
|
||||
case list
|
||||
}
|
||||
|
||||
/// 创建景区列表响应,主要用于测试和兜底数据。
|
||||
init(total: Int, list: [ScenicListItem]) {
|
||||
self.total = total
|
||||
self.list = list
|
||||
}
|
||||
|
||||
/// 自定义解码,兼容 total 类型不稳定。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
total = try container.decodeLossyInt(forKey: .total) ?? 0
|
||||
list = try container.decodeIfPresent([ScenicListItem].self, forKey: .list) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
/// 景区列表项实体,表示一个可进入的景区。
|
||||
struct ScenicListItem: Decodable, Equatable, Identifiable {
|
||||
let id: Int
|
||||
let name: String
|
||||
|
||||
/// 景区列表项响应的 JSON 字段映射。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case name
|
||||
}
|
||||
|
||||
/// 创建景区列表项,主要用于测试和本地构造。
|
||||
init(id: Int, name: String) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
}
|
||||
|
||||
/// 自定义解码,兼容 ID 类型不稳定。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decodeLossyInt(forKey: .id) ?? 0
|
||||
name = try container.decodeLossyString(forKey: .name)
|
||||
}
|
||||
}
|
||||
|
||||
/// 门店实体,表示一个景区下可进入的门店。
|
||||
struct StoreItem: Decodable, Equatable, Identifiable {
|
||||
let id: Int
|
||||
let scenicId: Int
|
||||
let name: String
|
||||
let address: String
|
||||
let status: Int
|
||||
let statusText: String
|
||||
let tel: String?
|
||||
let logo: String?
|
||||
|
||||
/// 门店响应的 JSON 字段映射。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case scenicId = "scenic_id"
|
||||
case name
|
||||
case address
|
||||
case status
|
||||
case statusText = "status_text"
|
||||
case tel
|
||||
case logo
|
||||
}
|
||||
|
||||
/// 创建门店实体,主要用于测试和本地构造。
|
||||
init(
|
||||
id: Int,
|
||||
scenicId: Int,
|
||||
name: String,
|
||||
address: String = "",
|
||||
status: Int = 0,
|
||||
statusText: String = "",
|
||||
tel: String? = nil,
|
||||
logo: String? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.scenicId = scenicId
|
||||
self.name = name
|
||||
self.address = address
|
||||
self.status = status
|
||||
self.statusText = statusText
|
||||
self.tel = tel
|
||||
self.logo = logo
|
||||
}
|
||||
|
||||
/// 自定义解码,兼容字段类型不稳定和可选字段缺失。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decodeLossyInt(forKey: .id) ?? 0
|
||||
scenicId = try container.decodeLossyInt(forKey: .scenicId) ?? 0
|
||||
name = try container.decodeLossyString(forKey: .name)
|
||||
address = try container.decodeLossyString(forKey: .address)
|
||||
status = try container.decodeLossyInt(forKey: .status) ?? 0
|
||||
statusText = try container.decodeLossyString(forKey: .statusText)
|
||||
tel = try container.decodeIfPresent(String.self, forKey: .tel)
|
||||
logo = try container.decodeIfPresent(String.self, forKey: .logo)
|
||||
}
|
||||
}
|
||||
|
||||
/// 景点实体,表示某个景区下的景点或打卡点。
|
||||
struct ScenicSpotItem: Decodable, Equatable, Identifiable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let status: Int
|
||||
let statusLabel: String
|
||||
|
||||
/// 景点响应的 JSON 字段映射。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case name
|
||||
case status
|
||||
case statusLabel = "status_label"
|
||||
}
|
||||
|
||||
/// 创建景点实体,主要用于测试和本地构造。
|
||||
init(id: Int, name: String, status: Int = 0, statusLabel: String = "") {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.status = status
|
||||
self.statusLabel = statusLabel
|
||||
}
|
||||
|
||||
/// 自定义解码,兼容字段类型不稳定。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decodeLossyInt(forKey: .id) ?? 0
|
||||
name = try container.decodeLossyString(forKey: .name)
|
||||
status = try container.decodeLossyInt(forKey: .status) ?? 0
|
||||
statusLabel = try container.decodeLossyString(forKey: .statusLabel)
|
||||
}
|
||||
}
|
||||
|
||||
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 ? "true" : "false"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
/// 将 Int、Double 或数字字符串宽松解码为整数。
|
||||
func decodeLossyInt(forKey key: Key) throws -> Int? {
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let intValue = Int(text) {
|
||||
return intValue
|
||||
}
|
||||
if let doubleValue = Double(text) {
|
||||
return Int(doubleValue)
|
||||
}
|
||||
}
|
||||
if let value = try? decodeIfPresent(Double.self, forKey: key) {
|
||||
return Int(value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 将 Double、Int 或数字字符串宽松解码为浮点数。
|
||||
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
|
||||
}
|
||||
}
|
||||
373
suixinkan_ios/Features/Assets/API/AssetsAPI.swift
Normal file
373
suixinkan_ios/Features/Assets/API/AssetsAPI.swift
Normal file
@ -0,0 +1,373 @@
|
||||
//
|
||||
// AssetsAPI.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 资产服务协议,抽象云盘、媒体库和相册接口以便 ViewModel 测试替换。
|
||||
@MainActor
|
||||
protocol AssetsServing {
|
||||
/// 获取云盘文件列表。
|
||||
func cloudFileList(parentFolderId: Int, name: String, type: Int, orderBy: Int, page: Int, pageSize: Int) async throws -> ListPayload<CloudDriveFile>
|
||||
|
||||
/// 创建云盘文件夹。
|
||||
func cloudFolderCreate(_ request: CloudFolderCreateRequest) async throws
|
||||
|
||||
/// 登记已上传到 OSS 的云盘文件。
|
||||
func cloudFileUpload(_ request: CloudFileUploadRequest) async throws
|
||||
|
||||
/// 删除云盘文件或文件夹。
|
||||
func cloudFileDelete(_ request: CloudFileDeleteRequest) async throws
|
||||
|
||||
/// 移动云盘文件或文件夹。
|
||||
func cloudFileMove(_ request: CloudFileMoveRequest) async throws
|
||||
|
||||
/// 重命名云盘文件夹。
|
||||
func cloudFolderEdit(_ request: CloudFolderModifyRequest) async throws
|
||||
|
||||
/// 重命名云盘文件。
|
||||
func cloudFileEdit(_ request: CloudFileModifyRequest) async throws
|
||||
|
||||
/// 检查当前账号是否允许上传云盘文件。
|
||||
func checkCloudUploadPermission() async throws -> CheckCloudUploadResponse
|
||||
|
||||
/// 获取素材或样片列表。
|
||||
func mediaAlbumList(kind: MediaLibraryKind, keyword: String, status: Int?, page: Int, pageSize: Int) async throws -> MediaLibraryListResponse
|
||||
|
||||
/// 获取素材或样片详情。
|
||||
func mediaAlbumDetail(id: Int, kind: MediaLibraryKind) async throws -> MediaLibraryDetail
|
||||
|
||||
/// 更新素材或样片上下架状态。
|
||||
func mediaAlbumOperation(_ request: MediaAlbumOperationRequest) async throws
|
||||
|
||||
/// 删除素材或样片。
|
||||
func mediaAlbumDelete(_ request: MediaAlbumDeleteRequest) async throws
|
||||
|
||||
/// 新增素材标签。
|
||||
func mediaAlbumAddTag(_ request: MediaAlbumAddTagRequest) async throws
|
||||
|
||||
/// 上传素材或样片。
|
||||
func mediaAlbumUpload(_ request: MediaAlbumUploadRequest) async throws
|
||||
|
||||
/// 编辑素材。
|
||||
func mediaAlbumEdit(_ request: MediaAlbumEditRequest) async throws
|
||||
|
||||
/// 获取样片上传可关联的项目列表。
|
||||
func projectList(scenicId: Int, name: String?, page: Int, pageSize: Int) async throws -> ListPayload<PhotographerProjectItem>
|
||||
|
||||
/// 获取相册文件夹列表。
|
||||
func albumFolderList(scenicId: Int, page: Int, pageSize: Int, name: String, startTime: String?, endTime: String?) async throws -> ListPayload<AlbumFolderItem>
|
||||
|
||||
/// 获取相册文件夹详情。
|
||||
func albumFolderInfo(id: Int) async throws -> AlbumFolderItem
|
||||
|
||||
/// 获取相册内文件列表。
|
||||
func albumFileList(scenicId: Int, folderId: Int, fileType: Int, page: Int, pageSize: Int) async throws -> ListPayload<AlbumFileItem>
|
||||
|
||||
/// 新建相册文件夹。
|
||||
func addAlbumFolder(scenicId: Int, name: String, remark: String) async throws
|
||||
|
||||
/// 编辑相册文件夹名称、备注或封面。
|
||||
func editAlbumFolder(folderId: Int, coverFileId: Int?, name: String?, remark: String?) async throws
|
||||
|
||||
/// 删除相册内文件。
|
||||
func deleteAlbumFiles(folderId: Int, idList: [Int]) async throws
|
||||
|
||||
/// 登记已上传到 OSS 的相册文件。
|
||||
func albumFileUploadURL(scenicId: Int, fileURL: String, folderId: Int) async throws
|
||||
}
|
||||
|
||||
/// 资产 API,负责封装相册云盘和素材管理相关接口。
|
||||
@MainActor
|
||||
final class AssetsAPI: AssetsServing {
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化资产 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
self.client = client
|
||||
}
|
||||
|
||||
/// 获取云盘文件列表。
|
||||
func cloudFileList(
|
||||
parentFolderId: Int,
|
||||
name: String = "",
|
||||
type: Int = 0,
|
||||
orderBy: Int = 2,
|
||||
page: Int = 1,
|
||||
pageSize: Int = 20
|
||||
) async throws -> ListPayload<CloudDriveFile> {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/yf-handset-app/photog/cloud-driver/list",
|
||||
queryItems: [
|
||||
URLQueryItem(name: "parent_folder_id", value: "\(parentFolderId)"),
|
||||
URLQueryItem(name: "name", value: name),
|
||||
URLQueryItem(name: "type", value: "\(type)"),
|
||||
URLQueryItem(name: "order_by", value: "\(orderBy)"),
|
||||
URLQueryItem(name: "page", value: "\(max(page, 1))"),
|
||||
URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))")
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 创建云盘文件夹。
|
||||
func cloudFolderCreate(_ request: CloudFolderCreateRequest) async throws {
|
||||
_ = try await client.send(
|
||||
APIRequest(method: .post, path: "/api/yf-handset-app/photog/cloud-driver/folder-create", body: request)
|
||||
) as EmptyPayload
|
||||
}
|
||||
|
||||
/// 登记已上传到 OSS 的云盘文件。
|
||||
func cloudFileUpload(_ request: CloudFileUploadRequest) async throws {
|
||||
_ = try await client.send(
|
||||
APIRequest(method: .post, path: "/api/yf-handset-app/photog/cloud-driver/file-upload", body: request)
|
||||
) as EmptyPayload
|
||||
}
|
||||
|
||||
/// 删除云盘文件或文件夹。
|
||||
func cloudFileDelete(_ request: CloudFileDeleteRequest) async throws {
|
||||
_ = try await client.send(
|
||||
APIRequest(method: .post, path: "/api/yf-handset-app/photog/cloud-driver/delete", body: request)
|
||||
) as EmptyPayload
|
||||
}
|
||||
|
||||
/// 移动云盘文件或文件夹。
|
||||
func cloudFileMove(_ request: CloudFileMoveRequest) async throws {
|
||||
_ = try await client.send(
|
||||
APIRequest(method: .post, path: "/api/yf-handset-app/photog/cloud-driver/move", body: request)
|
||||
) as EmptyPayload
|
||||
}
|
||||
|
||||
/// 重命名云盘文件夹。
|
||||
func cloudFolderEdit(_ request: CloudFolderModifyRequest) async throws {
|
||||
_ = try await client.send(
|
||||
APIRequest(method: .post, path: "/api/yf-handset-app/photog/cloud-driver/folder-edit", body: request)
|
||||
) as EmptyPayload
|
||||
}
|
||||
|
||||
/// 重命名云盘文件。
|
||||
func cloudFileEdit(_ request: CloudFileModifyRequest) async throws {
|
||||
_ = try await client.send(
|
||||
APIRequest(method: .post, path: "/api/yf-handset-app/photog/cloud-driver/file-edit", body: request)
|
||||
) as EmptyPayload
|
||||
}
|
||||
|
||||
/// 检查当前账号是否允许上传云盘文件。
|
||||
func checkCloudUploadPermission() async throws -> CheckCloudUploadResponse {
|
||||
try await client.send(
|
||||
APIRequest(method: .get, path: "/api/yf-handset-app/photog/cloud-driver/check-upload-permission")
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取素材或样片列表。
|
||||
func mediaAlbumList(
|
||||
kind: MediaLibraryKind = .material,
|
||||
keyword: String = "",
|
||||
status: Int? = nil,
|
||||
page: Int = 1,
|
||||
pageSize: Int = 10
|
||||
) async throws -> MediaLibraryListResponse {
|
||||
var query = [
|
||||
URLQueryItem(name: "type", value: "\(kind.rawValue)"),
|
||||
URLQueryItem(name: "keyword", value: keyword),
|
||||
URLQueryItem(name: "page", value: "\(max(page, 1))"),
|
||||
URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))")
|
||||
]
|
||||
if let status, status >= 0 {
|
||||
query.append(URLQueryItem(name: "status", value: "\(status)"))
|
||||
}
|
||||
return try await client.send(
|
||||
APIRequest(method: .get, path: "/api/app/media-album/list", queryItems: query)
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取素材或样片详情。
|
||||
func mediaAlbumDetail(id: Int, kind: MediaLibraryKind = .material) async throws -> MediaLibraryDetail {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/app/media-album/detail",
|
||||
queryItems: [
|
||||
URLQueryItem(name: "id", value: "\(id)"),
|
||||
URLQueryItem(name: "type", value: "\(kind.rawValue)")
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 更新素材或样片上下架状态。
|
||||
func mediaAlbumOperation(_ request: MediaAlbumOperationRequest) async throws {
|
||||
_ = try await client.send(
|
||||
APIRequest(method: .post, path: "/api/app/media-album/operation", body: request)
|
||||
) as EmptyPayload
|
||||
}
|
||||
|
||||
/// 删除素材或样片。
|
||||
func mediaAlbumDelete(_ request: MediaAlbumDeleteRequest) async throws {
|
||||
_ = try await client.send(
|
||||
APIRequest(method: .post, path: "/api/app/media-album/delete", body: request)
|
||||
) as EmptyPayload
|
||||
}
|
||||
|
||||
/// 新增素材标签。
|
||||
func mediaAlbumAddTag(_ request: MediaAlbumAddTagRequest) async throws {
|
||||
_ = try await client.send(
|
||||
APIRequest(method: .post, path: "/api/app/media-album/add-tag", body: request)
|
||||
) as EmptyPayload
|
||||
}
|
||||
|
||||
/// 上传素材或样片。
|
||||
func mediaAlbumUpload(_ request: MediaAlbumUploadRequest) async throws {
|
||||
_ = try await client.send(
|
||||
APIRequest(method: .post, path: "/api/app/media-album/upload", body: request)
|
||||
) as EmptyPayload
|
||||
}
|
||||
|
||||
/// 编辑素材。
|
||||
func mediaAlbumEdit(_ request: MediaAlbumEditRequest) async throws {
|
||||
_ = try await client.send(
|
||||
APIRequest(method: .post, path: "/api/app/media-album/edit", body: request)
|
||||
) as EmptyPayload
|
||||
}
|
||||
|
||||
/// 获取样片上传可关联的项目列表。
|
||||
func projectList(
|
||||
scenicId: Int,
|
||||
name: String? = nil,
|
||||
page: Int = 1,
|
||||
pageSize: Int = 50
|
||||
) 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.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 albumFolderList(
|
||||
scenicId: Int,
|
||||
page: Int = 1,
|
||||
pageSize: Int = 10,
|
||||
name: String = "",
|
||||
startTime: String? = nil,
|
||||
endTime: String? = nil
|
||||
) async throws -> ListPayload<AlbumFolderItem> {
|
||||
var query = [
|
||||
URLQueryItem(name: "scenic_id", value: "\(scenicId)"),
|
||||
URLQueryItem(name: "page", value: "\(max(page, 1))"),
|
||||
URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))"),
|
||||
URLQueryItem(name: "cloud_folder_type", value: "1")
|
||||
]
|
||||
if !name.isEmpty {
|
||||
query.append(URLQueryItem(name: "name", value: name))
|
||||
}
|
||||
if let startTime, !startTime.isEmpty {
|
||||
query.append(URLQueryItem(name: "start_time", value: startTime))
|
||||
}
|
||||
if let endTime, !endTime.isEmpty {
|
||||
query.append(URLQueryItem(name: "end_time", value: endTime))
|
||||
}
|
||||
return try await client.send(
|
||||
APIRequest(method: .get, path: "/api/yf-handset-app/photog/album/folder-list", queryItems: query)
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取相册文件夹详情。
|
||||
func albumFolderInfo(id: Int) async throws -> AlbumFolderItem {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/yf-handset-app/photog/album/folder-info",
|
||||
queryItems: [URLQueryItem(name: "id", value: "\(id)")]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取相册内文件列表。
|
||||
func albumFileList(
|
||||
scenicId: Int,
|
||||
folderId: Int,
|
||||
fileType: Int,
|
||||
page: Int = 1,
|
||||
pageSize: Int = 20
|
||||
) async throws -> ListPayload<AlbumFileItem> {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/yf-handset-app/photog/album/file-list",
|
||||
queryItems: [
|
||||
URLQueryItem(name: "scenic_id", value: "\(scenicId)"),
|
||||
URLQueryItem(name: "folder_id", value: "\(folderId)"),
|
||||
URLQueryItem(name: "file_type", value: "\(fileType)"),
|
||||
URLQueryItem(name: "page", value: "\(max(page, 1))"),
|
||||
URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))")
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 新建相册文件夹。
|
||||
func addAlbumFolder(scenicId: Int, name: String, remark: String) async throws {
|
||||
_ = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/yf-handset-app/photog/album/folder-add",
|
||||
body: AlbumFolderAddRequest(
|
||||
scenicId: "\(scenicId)",
|
||||
name: name,
|
||||
remark: remark,
|
||||
cloudFolderType: 1
|
||||
)
|
||||
)
|
||||
) as EmptyPayload
|
||||
}
|
||||
|
||||
/// 编辑相册文件夹名称、备注或封面。
|
||||
func editAlbumFolder(folderId: Int, coverFileId: Int? = nil, name: String? = nil, remark: String? = nil) async throws {
|
||||
_ = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/yf-handset-app/photog/album/folder-edit",
|
||||
body: AlbumFolderEditRequest(folderId: folderId, coverFileId: coverFileId, name: name, remark: remark)
|
||||
)
|
||||
) as EmptyPayload
|
||||
}
|
||||
|
||||
/// 删除相册内文件。
|
||||
func deleteAlbumFiles(folderId: Int, idList: [Int]) async throws {
|
||||
_ = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/yf-handset-app/photog/album/file-delete",
|
||||
body: AlbumFileDeleteRequest(idList: idList, folderId: folderId)
|
||||
)
|
||||
) as EmptyPayload
|
||||
}
|
||||
|
||||
/// 登记已上传到 OSS 的相册文件。
|
||||
func albumFileUploadURL(scenicId: Int, fileURL: String, folderId: Int) async throws {
|
||||
_ = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/yf-handset-app/photog/album/file-upload-url",
|
||||
queryItems: [
|
||||
URLQueryItem(name: "scenic_id", value: "\(scenicId)"),
|
||||
URLQueryItem(name: "file_url", value: fileURL),
|
||||
URLQueryItem(name: "folder_id", value: "\(folderId)")
|
||||
]
|
||||
)
|
||||
) as EmptyPayload
|
||||
}
|
||||
}
|
||||
90
suixinkan_ios/Features/Assets/Assets.md
Normal file
90
suixinkan_ios/Features/Assets/Assets.md
Normal file
@ -0,0 +1,90 @@
|
||||
# Assets 模块业务逻辑
|
||||
|
||||
## 模块职责
|
||||
|
||||
Assets 模块负责首页中的相册、相册云盘、素材管理和样片管理入口。
|
||||
|
||||
- `album_list` 进入 `AlbumListView`。
|
||||
- `album_trailer` 进入 `AlbumTrailerEntryView`。
|
||||
- `cloud_management` 进入 `CloudStorageView`。
|
||||
- `cloud_storage_transit` 进入 `CloudStorageTransitView`。
|
||||
- `asset_management` 进入 `MediaLibraryView(kind: .material)`。
|
||||
- `material_upload` 进入 `MediaLibraryUploadView`。
|
||||
- `sample_management` 进入 `MediaLibraryView(kind: .sample)`。
|
||||
- `sample_upload` 进入 `MediaLibraryUploadView(kind: .sample)`。
|
||||
|
||||
样片复用媒体库列表、详情、上下架、删除和 OSS 上传链路;上传样片额外要求选择关联项目。
|
||||
|
||||
## 相册管理逻辑
|
||||
|
||||
`AlbumListViewModel` 只保存相册列表、搜索条件、日期筛选、分页和创建状态。缺少当前景区时清空列表并停止请求接口。
|
||||
|
||||
相册列表流程:
|
||||
|
||||
1. 页面从 `AccountContext.currentScenic` 读取当前景区 ID。
|
||||
2. 调用 `albumFolderList` 获取 `cloud_folder_type = 1` 的相册文件夹。
|
||||
3. 支持按相册名称、开始时间、结束时间筛选。
|
||||
4. 新建相册调用 `addAlbumFolder`,成功后刷新第一页。
|
||||
|
||||
`AlbumDetailViewModel` 管理单个相册的信息和文件列表。图片和视频通过 `AlbumFileTab` 区分,图片使用 `file_type = 2`,视频使用 `file_type = 1`。详情页支持编辑相册名称、编辑备注、设置封面和删除相册文件,操作成功后刷新详情。
|
||||
|
||||
相册文件预览使用 `RemoteImage` 展示图片,视频使用系统 `VideoPlayer` 播放。
|
||||
|
||||
## 相册预览上传逻辑
|
||||
|
||||
`AlbumTrailerViewModel` 管理相册选择、本地图片/视频选择、上传进度和提交状态。
|
||||
|
||||
上传流程:
|
||||
|
||||
1. 页面加载当前景区下的相册列表。
|
||||
2. 用户通过 `PhotosPicker` 选择图片或视频。
|
||||
3. 提交时先调用 `OSSUploadService.uploadAlbumFile` 上传到 OSS。
|
||||
4. 上传成功后调用 `albumFileUploadURL` 把文件 URL 写入相册。
|
||||
5. 任一文件上传失败时停止后续入库,并保留错误提示。
|
||||
|
||||
本地文件数据、OSS STS 和上传进度只保存在当前上传流程内,不落盘。
|
||||
|
||||
## 云盘逻辑
|
||||
|
||||
`CloudStorageViewModel` 只保存云盘模块内状态,包括目录路径、文件列表、筛选、排序、分页和当前操作状态。
|
||||
|
||||
云盘上传流程:
|
||||
|
||||
1. 页面通过 `PhotosPicker` 选择图片或视频。
|
||||
2. `CloudStorageViewModel` 调用 `checkCloudUploadPermission`。
|
||||
3. 权限通过后调用 `OSSUploadService.uploadCloudFile` 上传到 OSS。
|
||||
4. 上传成功后调用 `cloudFileUpload` 把文件 URL 写入云盘。
|
||||
5. 刷新当前目录列表。
|
||||
|
||||
下载文件只保存到 App 沙盒 `Documents/CloudDownloads`,不自动写入系统相册。上传/下载记录由 `CloudTransferStore` 保存,生命周期仅限本次 App 会话,不落盘。
|
||||
|
||||
## 素材/样片管理逻辑
|
||||
|
||||
`MediaLibraryViewModel` 按 `MediaLibraryKind` 管理素材或样片列表、关键词、审核状态、分页、详情、删除和上下架。素材接口使用 `type = 1`,样片接口使用 `type = 2`。
|
||||
|
||||
媒体库上下架规则:
|
||||
|
||||
- 审核通过的素材或样片可以上下架。
|
||||
- 审核未通过或待审核的素材或样片禁止上下架。
|
||||
- 操作成功后刷新当前媒体库列表。
|
||||
|
||||
`MediaLibraryEditorViewModel` 管理上传和编辑表单。提交前会校验名称、当前景区、打卡点、封面、媒体文件和标签长度。样片上传还会加载当前景区下的项目列表,并强制选择关联项目。
|
||||
|
||||
素材/样片上传流程:
|
||||
|
||||
1. 页面通过 `PhotosPicker` 选择封面和媒体文件。
|
||||
2. 打卡点来源于 `ScenicSpotContext.spots`。
|
||||
3. 样片上传从 `projectList` 获取可关联项目,提交时携带 `project_id`。
|
||||
4. 提交时先把封面和媒体文件上传 OSS。
|
||||
5. 再把最终 OSS URL、文件尺寸、文件大小、打卡点 ID、标签和项目 ID 提交给媒体库接口。
|
||||
6. 上传失败时不提交媒体库接口。
|
||||
|
||||
## 缓存边界
|
||||
|
||||
相册列表、相册文件列表、云盘文件列表、素材列表、样片列表、项目选择、上传进度、下载记录、OSS STS、本地文件数据和媒体库表单都不进入 `AppSession`、`AccountContext` 或 TabBar 状态。
|
||||
|
||||
图片缓存继续交给 Kingfisher 的 `RemoteImage`。业务模块不自行缓存远程图片文件或 `Data`。
|
||||
|
||||
## 测试要求
|
||||
|
||||
新增相册、云盘、素材或样片逻辑时,需要同步补充 API、ViewModel 和路由测试。测试不通过时先修复问题,再继续迁移后续功能。
|
||||
1021
suixinkan_ios/Features/Assets/Models/AssetsModels.swift
Normal file
1021
suixinkan_ios/Features/Assets/Models/AssetsModels.swift
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,231 @@
|
||||
//
|
||||
// AssetsViewControllers.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/26.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
/// 云盘列表页。
|
||||
final class CloudStorageViewController: ModuleTableViewController {
|
||||
private let viewModel = CloudStorageViewModel()
|
||||
|
||||
override func viewDidLoad() {
|
||||
title = "云盘"
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
title: "传输",
|
||||
style: .plain,
|
||||
target: self,
|
||||
action: #selector(openTransit)
|
||||
)
|
||||
super.viewDidLoad()
|
||||
wireViewModel(viewModel) { }
|
||||
}
|
||||
|
||||
override func tableRowCount() -> Int { viewModel.files.count }
|
||||
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let file = viewModel.files[indexPath.row]
|
||||
cell.configure(title: file.name, subtitle: file.updatedAt, detail: "\(file.fileSize)")
|
||||
}
|
||||
|
||||
override func reloadContent() async {
|
||||
await viewModel.reload(api: services.assetsAPI)
|
||||
}
|
||||
|
||||
override func willDisplayTableRow(at indexPath: IndexPath) {
|
||||
guard indexPath.row >= viewModel.files.count - 2 else { return }
|
||||
Task { await viewModel.loadMore(api: services.assetsAPI) }
|
||||
}
|
||||
|
||||
@objc private func openTransit() {
|
||||
navigationController?.pushViewController(CloudStorageTransitViewController(), animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
extension CloudStorageViewModel: ViewModelBindable {}
|
||||
|
||||
/// 云盘传输记录页。
|
||||
final class CloudStorageTransitViewController: ModuleTableViewController {
|
||||
private let store = CloudTransferStore.shared
|
||||
|
||||
override func viewDidLoad() {
|
||||
title = "传输记录"
|
||||
super.viewDidLoad()
|
||||
store.onChange = { [weak self] in self?.reloadTable() }
|
||||
}
|
||||
|
||||
override func tableRowCount() -> Int { store.records.count }
|
||||
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let record = store.records[indexPath.row]
|
||||
cell.configure(title: record.fileName, subtitle: record.message, detail: "\(record.progress)%")
|
||||
}
|
||||
|
||||
override func reloadContent() async {}
|
||||
}
|
||||
|
||||
enum MediaLibraryKindRoute {
|
||||
case material
|
||||
case sample
|
||||
}
|
||||
|
||||
/// 素材库 / 样片库列表页。
|
||||
final class MediaLibraryViewController: ModuleTableViewController {
|
||||
private let kind: MediaLibraryKindRoute
|
||||
private let viewModel: MediaLibraryViewModel
|
||||
|
||||
init(kind: MediaLibraryKindRoute = .material) {
|
||||
self.kind = kind
|
||||
switch kind {
|
||||
case .material:
|
||||
viewModel = MediaLibraryViewModel(kind: .material)
|
||||
case .sample:
|
||||
viewModel = MediaLibraryViewModel(kind: .sample)
|
||||
}
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
title = kind == .sample ? "样片库" : "素材库"
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
title: "上传",
|
||||
style: .plain,
|
||||
target: self,
|
||||
action: #selector(openUpload)
|
||||
)
|
||||
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.projectName, detail: item.createdAt)
|
||||
}
|
||||
|
||||
override func reloadContent() async {
|
||||
await viewModel.reload(api: services.assetsAPI)
|
||||
}
|
||||
|
||||
@objc private func openUpload() {
|
||||
navigationController?.pushViewController(MediaLibraryUploadViewController(kind: kind), animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
extension MediaLibraryViewModel: ViewModelBindable {}
|
||||
|
||||
/// 素材 / 样片上传页。
|
||||
final class MediaLibraryUploadViewController: ModuleTableViewController {
|
||||
private let kind: MediaLibraryKindRoute
|
||||
private let viewModel = MediaLibraryEditorViewModel()
|
||||
private let nameField = UITextField()
|
||||
|
||||
init(kind: MediaLibraryKindRoute) {
|
||||
self.kind = kind
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
title = kind == .sample ? "上传样片" : "上传素材"
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
title: "提交",
|
||||
style: .done,
|
||||
target: self,
|
||||
action: #selector(submit)
|
||||
)
|
||||
super.viewDidLoad()
|
||||
nameField.placeholder = "素材名称"
|
||||
nameField.borderStyle = .roundedRect
|
||||
nameField.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 52)
|
||||
tableView.tableHeaderView = nameField
|
||||
wireViewModel(viewModel) { }
|
||||
}
|
||||
|
||||
override func tableRowCount() -> Int { viewModel.projects.count }
|
||||
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let project = viewModel.projects[indexPath.row]
|
||||
cell.configure(title: project.name, subtitle: project.statusName)
|
||||
}
|
||||
|
||||
override func reloadContent() async {
|
||||
await viewModel.loadProjects(scenicId: services.currentScenicId, api: services.assetsAPI)
|
||||
}
|
||||
|
||||
@objc private func submit() {
|
||||
viewModel.name = nameField.text ?? ""
|
||||
Task {
|
||||
let mediaKind: MediaLibraryKind = kind == .sample ? .sample : .material
|
||||
let success = await viewModel.submit(
|
||||
kind: mediaKind,
|
||||
scenicId: services.currentScenicId,
|
||||
api: services.assetsAPI,
|
||||
uploadService: services.ossUploadService
|
||||
)
|
||||
if success { navigationController?.popViewController(animated: true) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension MediaLibraryEditorViewModel: ViewModelBindable {}
|
||||
|
||||
/// 相册列表页。
|
||||
final class AlbumListViewController: ModuleTableViewController {
|
||||
private let viewModel = AlbumListViewModel()
|
||||
|
||||
override func viewDidLoad() {
|
||||
title = "相册"
|
||||
super.viewDidLoad()
|
||||
wireViewModel(viewModel) { }
|
||||
}
|
||||
|
||||
override func tableRowCount() -> Int { viewModel.folders.count }
|
||||
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let folder = viewModel.folders[indexPath.row]
|
||||
cell.configure(title: folder.name, subtitle: folder.createTime, detail: "\(folder.totalCount) 张")
|
||||
}
|
||||
|
||||
override func reloadContent() async {
|
||||
await viewModel.reload(api: services.assetsAPI, scenicId: services.currentScenicId)
|
||||
}
|
||||
}
|
||||
|
||||
extension AlbumListViewModel: ViewModelBindable {}
|
||||
|
||||
/// 相册预告页。
|
||||
final class AlbumTrailerViewController: ModuleTableViewController {
|
||||
private let viewModel = AlbumTrailerViewModel()
|
||||
|
||||
override func viewDidLoad() {
|
||||
title = "相册预告"
|
||||
super.viewDidLoad()
|
||||
wireViewModel(viewModel) { }
|
||||
}
|
||||
|
||||
override func tableRowCount() -> Int { viewModel.folders.count }
|
||||
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let folder = viewModel.folders[indexPath.row]
|
||||
cell.configure(title: folder.name, subtitle: folder.createTime, detail: "\(folder.totalCount) 张")
|
||||
}
|
||||
|
||||
override func reloadContent() async {
|
||||
await viewModel.loadFolders(api: services.assetsAPI, scenicId: services.currentScenicId)
|
||||
}
|
||||
}
|
||||
|
||||
extension AlbumTrailerViewModel: ViewModelBindable {}
|
||||
974
suixinkan_ios/Features/Assets/ViewModels/AssetsViewModels.swift
Normal file
974
suixinkan_ios/Features/Assets/ViewModels/AssetsViewModels.swift
Normal file
@ -0,0 +1,974 @@
|
||||
//
|
||||
// AssetsViewModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 云盘传输记录仓库,保存本次 App 会话内的上传和下载进度。
|
||||
@MainActor
|
||||
final class CloudTransferStore {
|
||||
|
||||
var onChange: (() -> Void)?
|
||||
static let shared = CloudTransferStore()
|
||||
|
||||
private(set) var records: [CloudTransferItem] = [] { didSet { onChange?() } }
|
||||
|
||||
/// 新增一条传输记录并返回记录 ID。
|
||||
@discardableResult
|
||||
func start(fileName: String, direction: CloudTransferDirection) -> UUID {
|
||||
let id = UUID()
|
||||
records.insert(CloudTransferItem(id: id, fileName: fileName, direction: direction), at: 0)
|
||||
return id
|
||||
}
|
||||
|
||||
/// 更新指定传输记录的进度。
|
||||
func update(id: UUID, progress: Int, message: String = "") {
|
||||
guard let index = records.firstIndex(where: { $0.id == id }) else { return }
|
||||
records[index].progress = max(0, min(100, progress))
|
||||
if !message.isEmpty {
|
||||
records[index].message = message
|
||||
}
|
||||
}
|
||||
|
||||
/// 标记指定传输记录成功。
|
||||
func succeed(id: UUID, message: String = "已完成") {
|
||||
guard let index = records.firstIndex(where: { $0.id == id }) else { return }
|
||||
records[index].progress = 100
|
||||
records[index].status = .success
|
||||
records[index].message = message
|
||||
}
|
||||
|
||||
/// 标记指定传输记录失败。
|
||||
func fail(id: UUID, message: String) {
|
||||
guard let index = records.firstIndex(where: { $0.id == id }) else { return }
|
||||
records[index].status = .failed
|
||||
records[index].message = message
|
||||
}
|
||||
|
||||
/// 清空当前会话传输记录。
|
||||
func clear() {
|
||||
records = []
|
||||
}
|
||||
}
|
||||
|
||||
/// 云盘 ViewModel,负责文件列表、筛选、分页、文件操作和上传闭环。
|
||||
@MainActor
|
||||
final class CloudStorageViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
var path: [CloudDriveFile] = [CloudDriveFile(id: 0, name: "云盘")] { didSet { onChange?() } }
|
||||
var files: [CloudDriveFile] = [] { didSet { onChange?() } }
|
||||
var total = 0 { didSet { onChange?() } }
|
||||
var page = 1 { didSet { onChange?() } }
|
||||
var searchText = "" { didSet { onChange?() } }
|
||||
var selectedFilter: CloudDriveFilter = .all { didSet { onChange?() } }
|
||||
var selectedSort: CloudDriveSort = .updatedDesc { didSet { onChange?() } }
|
||||
var isLoading = false { didSet { onChange?() } }
|
||||
var isLoadingMore = false { didSet { onChange?() } }
|
||||
var isMutating = false { didSet { onChange?() } }
|
||||
var errorMessage: String? { didSet { onChange?() } }
|
||||
|
||||
private let pageSize = 20
|
||||
|
||||
/// 当前目录 ID。
|
||||
var currentFolderId: Int {
|
||||
path.last?.id ?? 0
|
||||
}
|
||||
|
||||
/// 判断当前目录是否还有下一页。
|
||||
var hasMore: Bool {
|
||||
files.count < total
|
||||
}
|
||||
|
||||
/// 重新加载当前目录第一页。
|
||||
func reload(api: any AssetsServing) async {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
defer { isLoading = false }
|
||||
|
||||
do {
|
||||
let payload = try await requestFiles(api: api, page: 1)
|
||||
page = 1
|
||||
files = payload.list
|
||||
total = payload.total
|
||||
} catch {
|
||||
files = []
|
||||
total = 0
|
||||
page = 1
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载当前目录下一页。
|
||||
func loadMore(api: any AssetsServing) async {
|
||||
guard hasMore, !isLoadingMore else { return }
|
||||
isLoadingMore = true
|
||||
defer { isLoadingMore = false }
|
||||
|
||||
do {
|
||||
let nextPage = page + 1
|
||||
let payload = try await requestFiles(api: api, page: nextPage)
|
||||
page = nextPage
|
||||
total = payload.total
|
||||
files.append(contentsOf: payload.list)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 进入指定文件夹并刷新列表。
|
||||
func enterFolder(_ folder: CloudDriveFile, api: any AssetsServing) async {
|
||||
guard folder.isFolder else { return }
|
||||
path.append(folder)
|
||||
await reload(api: api)
|
||||
}
|
||||
|
||||
/// 回到指定面包屑目录并刷新列表。
|
||||
func popToFolder(at index: Int, api: any AssetsServing) async {
|
||||
guard path.indices.contains(index) else { return }
|
||||
path = Array(path.prefix(index + 1))
|
||||
await reload(api: api)
|
||||
}
|
||||
|
||||
/// 创建文件夹并刷新当前目录。
|
||||
func createFolder(name: String, api: any AssetsServing) async -> Bool {
|
||||
let folderName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !folderName.isEmpty else {
|
||||
errorMessage = "请输入文件夹名称"
|
||||
return false
|
||||
}
|
||||
return await mutate(api: api) {
|
||||
try await api.cloudFolderCreate(CloudFolderCreateRequest(parentFolderId: currentFolderId, name: folderName))
|
||||
}
|
||||
}
|
||||
|
||||
/// 重命名文件或文件夹并刷新当前目录。
|
||||
func rename(_ item: CloudDriveFile, newName: String, api: any AssetsServing) async -> Bool {
|
||||
let fileName = newName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !fileName.isEmpty else {
|
||||
errorMessage = "请输入名称"
|
||||
return false
|
||||
}
|
||||
return await mutate(api: api) {
|
||||
if item.isFolder {
|
||||
try await api.cloudFolderEdit(CloudFolderModifyRequest(id: item.id, name: fileName))
|
||||
} else {
|
||||
try await api.cloudFileEdit(CloudFileModifyRequest(id: item.id, fileName: fileName))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除文件或文件夹并刷新当前目录。
|
||||
func delete(_ item: CloudDriveFile, api: any AssetsServing) async -> Bool {
|
||||
await mutate(api: api) {
|
||||
let action = CloudFileActionItem(id: item.id, type: item.type)
|
||||
try await api.cloudFileDelete(CloudFileDeleteRequest(list: [action]))
|
||||
}
|
||||
}
|
||||
|
||||
/// 移动文件或文件夹并刷新当前目录。
|
||||
func move(_ item: CloudDriveFile, targetFolderId: Int, api: any AssetsServing) async -> Bool {
|
||||
await mutate(api: api) {
|
||||
let action = CloudFileActionItem(id: item.id, type: item.type)
|
||||
try await api.cloudFileMove(CloudFileMoveRequest(targetFolderId: targetFolderId, list: [action]))
|
||||
}
|
||||
}
|
||||
|
||||
/// 上传本地文件到 OSS 后写入云盘。
|
||||
func upload(
|
||||
localFiles: [CloudLocalUploadFile],
|
||||
scenicId: Int?,
|
||||
api: any AssetsServing,
|
||||
uploadService: any OSSUploadServing,
|
||||
transferStore: CloudTransferStore
|
||||
) async -> Bool {
|
||||
guard let scenicId else {
|
||||
errorMessage = "缺少当前景区,无法上传"
|
||||
return false
|
||||
}
|
||||
guard !localFiles.isEmpty else { return true }
|
||||
|
||||
isMutating = true
|
||||
defer { isMutating = false }
|
||||
|
||||
do {
|
||||
let permission = try await api.checkCloudUploadPermission()
|
||||
guard permission.canUpload else {
|
||||
let reason = permission.reason.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
errorMessage = reason.isEmpty ? "当前账号暂无上传权限" : reason
|
||||
return false
|
||||
}
|
||||
|
||||
for file in localFiles {
|
||||
let transferId = transferStore.start(fileName: file.fileName, direction: .upload)
|
||||
do {
|
||||
let url = try await uploadService.uploadCloudFile(
|
||||
data: file.data,
|
||||
fileName: file.fileName,
|
||||
fileType: file.fileType,
|
||||
scenicId: scenicId
|
||||
) { progress in
|
||||
Task { @MainActor in
|
||||
transferStore.update(id: transferId, progress: progress)
|
||||
}
|
||||
}
|
||||
try await api.cloudFileUpload(
|
||||
CloudFileUploadRequest(parentFolderId: currentFolderId, fileUrl: url, fileName: file.fileName)
|
||||
)
|
||||
transferStore.succeed(id: transferId)
|
||||
} catch {
|
||||
transferStore.fail(id: transferId, message: error.localizedDescription)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
await reload(api: api)
|
||||
return true
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// 执行一次云盘变更操作并刷新当前目录。
|
||||
private func mutate(api: any AssetsServing, operation: () async throws -> Void) async -> Bool {
|
||||
isMutating = true
|
||||
defer { isMutating = false }
|
||||
do {
|
||||
try await operation()
|
||||
await reload(api: api)
|
||||
return true
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// 组装并发送云盘文件列表请求。
|
||||
private func requestFiles(api: any AssetsServing, page: Int) async throws -> ListPayload<CloudDriveFile> {
|
||||
try await api.cloudFileList(
|
||||
parentFolderId: currentFolderId,
|
||||
name: searchText.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
type: selectedFilter.rawValue,
|
||||
orderBy: selectedSort.rawValue,
|
||||
page: page,
|
||||
pageSize: pageSize
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 媒体库 ViewModel,负责素材或样片列表、筛选、分页、详情和上下架。
|
||||
@MainActor
|
||||
final class MediaLibraryViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
let kind: MediaLibraryKind
|
||||
var items: [MediaLibraryItem] = [] { didSet { onChange?() } }
|
||||
var selectedDetail: MediaLibraryDetail? { didSet { onChange?() } }
|
||||
var orderInfo: MediaLibraryOrderInfo? { didSet { onChange?() } }
|
||||
var total = 0 { didSet { onChange?() } }
|
||||
var page = 1 { didSet { onChange?() } }
|
||||
var keyword = "" { didSet { onChange?() } }
|
||||
var selectedAuditFilter: MediaLibraryAuditFilter = .all { didSet { onChange?() } }
|
||||
var isLoading = false { didSet { onChange?() } }
|
||||
var isLoadingMore = false { didSet { onChange?() } }
|
||||
var isLoadingDetail = false { didSet { onChange?() } }
|
||||
var errorMessage: String? { didSet { onChange?() } }
|
||||
|
||||
private let pageSize = 10
|
||||
|
||||
/// 初始化媒体库 ViewModel。
|
||||
init(kind: MediaLibraryKind = .material) {
|
||||
self.kind = kind
|
||||
}
|
||||
|
||||
/// 判断当前列表是否还有下一页。
|
||||
var hasMore: Bool {
|
||||
items.count < total
|
||||
}
|
||||
|
||||
/// 重新加载媒体库列表第一页。
|
||||
func reload(api: any AssetsServing) async {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
defer { isLoading = false }
|
||||
|
||||
do {
|
||||
let response = try await requestList(api: api, page: 1)
|
||||
page = 1
|
||||
items = response.list
|
||||
total = response.total
|
||||
orderInfo = response.order
|
||||
} catch {
|
||||
items = []
|
||||
total = 0
|
||||
orderInfo = nil
|
||||
page = 1
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载媒体库列表下一页。
|
||||
func loadMore(api: any AssetsServing) async {
|
||||
guard hasMore, !isLoadingMore else { return }
|
||||
isLoadingMore = true
|
||||
defer { isLoadingMore = false }
|
||||
|
||||
do {
|
||||
let nextPage = page + 1
|
||||
let response = try await requestList(api: api, page: nextPage)
|
||||
page = nextPage
|
||||
total = response.total
|
||||
orderInfo = response.order ?? orderInfo
|
||||
items.append(contentsOf: response.list)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载媒体库详情,失败时保留已有详情或摘要。
|
||||
func loadDetail(id: Int, api: any AssetsServing) async {
|
||||
isLoadingDetail = true
|
||||
errorMessage = nil
|
||||
defer { isLoadingDetail = false }
|
||||
|
||||
do {
|
||||
selectedDetail = try await api.mediaAlbumDetail(id: id, kind: kind)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新素材或样片上下架状态,审核未通过时禁止操作。
|
||||
func toggleListing(_ item: MediaLibraryItem, api: any AssetsServing) async -> Bool {
|
||||
guard item.isApproved else {
|
||||
errorMessage = "审核通过后才能上下架"
|
||||
return false
|
||||
}
|
||||
return await operate(api: api) {
|
||||
let target = item.listing.toggled
|
||||
try await api.mediaAlbumOperation(MediaAlbumOperationRequest(id: item.id, type: kind.rawValue, listingStatus: target.rawValue))
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新素材或样片详情页上下架状态,审核未通过时禁止操作。
|
||||
func toggleListing(detail: MediaLibraryDetail, api: any AssetsServing) async -> Bool {
|
||||
guard detail.isApproved else {
|
||||
errorMessage = "审核通过后才能上下架"
|
||||
return false
|
||||
}
|
||||
return await operate(api: api) {
|
||||
let target = detail.listing.toggled
|
||||
try await api.mediaAlbumOperation(MediaAlbumOperationRequest(id: detail.id, type: kind.rawValue, listingStatus: target.rawValue))
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除素材或样片并刷新列表。
|
||||
func delete(id: Int, api: any AssetsServing) async -> Bool {
|
||||
await operate(api: api) {
|
||||
try await api.mediaAlbumDelete(MediaAlbumDeleteRequest(id: id, type: kind.rawValue))
|
||||
}
|
||||
}
|
||||
|
||||
/// 执行媒体库变更操作并刷新列表。
|
||||
private func operate(api: any AssetsServing, operation: () async throws -> Void) async -> Bool {
|
||||
do {
|
||||
try await operation()
|
||||
await reload(api: api)
|
||||
return true
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// 组装并发送媒体库列表请求。
|
||||
private func requestList(api: any AssetsServing, page: Int) async throws -> MediaLibraryListResponse {
|
||||
try await api.mediaAlbumList(
|
||||
kind: kind,
|
||||
keyword: keyword.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
status: selectedAuditFilter.rawValue >= 0 ? selectedAuditFilter.rawValue : nil,
|
||||
page: page,
|
||||
pageSize: pageSize
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 媒体库上传编辑 ViewModel,负责表单校验、OSS 上传和提交素材或样片。
|
||||
@MainActor
|
||||
final class MediaLibraryEditorViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
var name = "" { didSet { onChange?() } }
|
||||
var description = "" { didSet { onChange?() } }
|
||||
var selectedSpotId: Int? { didSet { onChange?() } }
|
||||
var selectedProjectId: Int? { didSet { onChange?() } }
|
||||
var projects: [PhotographerProjectItem] = [] { didSet { onChange?() } }
|
||||
var tagsText = "" { didSet { onChange?() } }
|
||||
var coverFile: MediaLocalUploadFile? { didSet { onChange?() } }
|
||||
var mediaFiles: [MediaLocalUploadFile] = [] { didSet { onChange?() } }
|
||||
var isSubmitting = false { didSet { onChange?() } }
|
||||
var isLoadingProjects = false { didSet { onChange?() } }
|
||||
var errorMessage: String? { didSet { onChange?() } }
|
||||
var didSubmitSuccessfully = false { didSet { onChange?() } }
|
||||
|
||||
private let editingId: Int?
|
||||
|
||||
/// 初始化上传或编辑素材表单。
|
||||
init(detail: MediaLibraryDetail? = nil) {
|
||||
editingId = detail?.id
|
||||
name = detail?.name ?? ""
|
||||
description = detail?.description ?? ""
|
||||
}
|
||||
|
||||
/// 判断当前是否处于编辑模式。
|
||||
var isEditing: Bool {
|
||||
editingId != nil
|
||||
}
|
||||
|
||||
/// 加载样片上传可关联的项目列表。
|
||||
func loadProjects(scenicId: Int?, api: any AssetsServing) async {
|
||||
guard let scenicId else {
|
||||
projects = []
|
||||
errorMessage = "缺少当前景区,无法加载项目"
|
||||
return
|
||||
}
|
||||
isLoadingProjects = true
|
||||
errorMessage = nil
|
||||
defer { isLoadingProjects = false }
|
||||
|
||||
do {
|
||||
let payload = try await api.projectList(scenicId: scenicId, name: nil, page: 1, pageSize: 50)
|
||||
projects = payload.list
|
||||
if let selectedProjectId, !projects.contains(where: { $0.id == selectedProjectId }) {
|
||||
self.selectedProjectId = nil
|
||||
}
|
||||
} catch {
|
||||
projects = []
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 添加封面本地文件。
|
||||
func setCover(_ file: MediaLocalUploadFile) {
|
||||
coverFile = file
|
||||
}
|
||||
|
||||
/// 添加素材媒体文件。
|
||||
func addMediaFiles(_ files: [MediaLocalUploadFile]) {
|
||||
mediaFiles.append(contentsOf: files)
|
||||
}
|
||||
|
||||
/// 移除指定媒体文件。
|
||||
func removeMediaFile(id: UUID) {
|
||||
mediaFiles.removeAll { $0.id == id }
|
||||
}
|
||||
|
||||
/// 提交素材上传或编辑。
|
||||
func submit(
|
||||
kind: MediaLibraryKind = .material,
|
||||
scenicId: Int?,
|
||||
api: any AssetsServing,
|
||||
uploadService: any OSSUploadServing
|
||||
) async -> Bool {
|
||||
guard validate(kind: kind, scenicId: scenicId) else { return false }
|
||||
guard !isSubmitting else { return false }
|
||||
isSubmitting = true
|
||||
defer { isSubmitting = false }
|
||||
|
||||
do {
|
||||
let actualScenicId = scenicId ?? 0
|
||||
let coverUpload = try await uploadCover(scenicId: actualScenicId, uploadService: uploadService)
|
||||
let uploads = try await uploadMediaFiles(scenicId: actualScenicId, uploadService: uploadService)
|
||||
if let editingId {
|
||||
let request = MediaAlbumEditRequest(
|
||||
id: editingId,
|
||||
name: name.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
type: kind.rawValue,
|
||||
mediaType: resolvedMediaType(),
|
||||
mediaList: uploads,
|
||||
coverUrl: coverUpload.url,
|
||||
coverSize: coverUpload.size,
|
||||
scenicSpotId: selectedSpotId ?? 0,
|
||||
description: description,
|
||||
materialTag: normalizedTags()
|
||||
)
|
||||
try await api.mediaAlbumEdit(request)
|
||||
} else {
|
||||
let request = MediaAlbumUploadRequest(
|
||||
name: name.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
type: kind.rawValue,
|
||||
mediaType: resolvedMediaType(),
|
||||
mediaList: uploads,
|
||||
coverUrl: coverUpload.url,
|
||||
coverSize: coverUpload.size,
|
||||
scenicSpotId: selectedSpotId ?? 0,
|
||||
description: description,
|
||||
materialTag: normalizedTags(),
|
||||
projectId: kind == .sample ? (selectedProjectId ?? 0) : 0
|
||||
)
|
||||
try await api.mediaAlbumUpload(request)
|
||||
}
|
||||
didSubmitSuccessfully = true
|
||||
return true
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// 校验素材或样片表单。
|
||||
private func validate(kind: MediaLibraryKind, scenicId: Int?) -> Bool {
|
||||
let title = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard scenicId != nil else {
|
||||
errorMessage = "缺少当前景区,无法提交\(kind == .sample ? "样片" : "素材")"
|
||||
return false
|
||||
}
|
||||
guard !title.isEmpty else {
|
||||
errorMessage = "请输入\(kind == .sample ? "样片" : "素材")名称"
|
||||
return false
|
||||
}
|
||||
guard selectedSpotId != nil else {
|
||||
errorMessage = "请选择打卡点"
|
||||
return false
|
||||
}
|
||||
guard kind != .sample || selectedProjectId != nil else {
|
||||
errorMessage = "请选择关联项目"
|
||||
return false
|
||||
}
|
||||
guard coverFile != nil else {
|
||||
errorMessage = "请选择封面"
|
||||
return false
|
||||
}
|
||||
guard !mediaFiles.isEmpty else {
|
||||
errorMessage = "请选择\(kind == .sample ? "样片" : "素材")文件"
|
||||
return false
|
||||
}
|
||||
guard normalizedTags().count <= 120 else {
|
||||
errorMessage = "标签内容过长"
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/// 上传封面并返回 URL 和尺寸。
|
||||
private func uploadCover(
|
||||
scenicId: Int,
|
||||
uploadService: any OSSUploadServing
|
||||
) async throws -> (url: String, size: MediaAlbumFileSize) {
|
||||
guard let coverFile else {
|
||||
throw APIError.emptyData
|
||||
}
|
||||
let url = try await uploadService.uploadCloudFile(
|
||||
data: coverFile.data,
|
||||
fileName: coverFile.fileName,
|
||||
fileType: coverFile.fileType,
|
||||
scenicId: scenicId,
|
||||
onProgress: { _ in }
|
||||
)
|
||||
return (url, MediaAlbumFileSize(width: coverFile.width, height: coverFile.height))
|
||||
}
|
||||
|
||||
/// 上传素材媒体文件并组装服务端请求项。
|
||||
private func uploadMediaFiles(
|
||||
scenicId: Int,
|
||||
uploadService: any OSSUploadServing
|
||||
) async throws -> [MediaAlbumUploadItem] {
|
||||
var result: [MediaAlbumUploadItem] = []
|
||||
for file in mediaFiles {
|
||||
let url = try await uploadService.uploadCloudFile(
|
||||
data: file.data,
|
||||
fileName: file.fileName,
|
||||
fileType: file.fileType,
|
||||
scenicId: scenicId,
|
||||
onProgress: { _ in }
|
||||
)
|
||||
result.append(
|
||||
MediaAlbumUploadItem(
|
||||
originalName: file.fileName,
|
||||
ossUrl: url,
|
||||
size: Int64(file.data.count),
|
||||
fileWidthSize: MediaAlbumFileSize(width: file.width, height: file.height)
|
||||
)
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// 根据已选文件推断素材媒体类型。
|
||||
private func resolvedMediaType() -> Int {
|
||||
mediaFiles.contains { $0.fileType == 1 } ? 1 : 2
|
||||
}
|
||||
|
||||
/// 规范化用户输入的标签文本。
|
||||
private func normalizedTags() -> String {
|
||||
tagsText
|
||||
.split { $0 == "," || $0 == "," || $0 == " " || $0 == "\n" }
|
||||
.map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
.joined(separator: ",")
|
||||
}
|
||||
}
|
||||
|
||||
/// 相册列表 ViewModel,负责相册文件夹搜索、日期筛选、分页和新建相册。
|
||||
@MainActor
|
||||
final class AlbumListViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
var folders: [AlbumFolderItem] = [] { didSet { onChange?() } }
|
||||
var total = 0 { didSet { onChange?() } }
|
||||
var page = 1 { didSet { onChange?() } }
|
||||
var searchText = "" { didSet { onChange?() } }
|
||||
var startTime = "" { didSet { onChange?() } }
|
||||
var endTime = "" { didSet { onChange?() } }
|
||||
var isLoading = false { didSet { onChange?() } }
|
||||
var isLoadingMore = false { didSet { onChange?() } }
|
||||
var isMutating = false { didSet { onChange?() } }
|
||||
var errorMessage: String? { didSet { onChange?() } }
|
||||
|
||||
private let pageSize = 10
|
||||
|
||||
/// 判断当前相册列表是否还有下一页。
|
||||
var hasMore: Bool {
|
||||
folders.count < total
|
||||
}
|
||||
|
||||
/// 重新加载相册第一页。
|
||||
func reload(api: any AssetsServing, scenicId: Int?) async {
|
||||
guard let scenicId else {
|
||||
folders = []
|
||||
total = 0
|
||||
page = 1
|
||||
errorMessage = "缺少当前景区,无法加载相册"
|
||||
return
|
||||
}
|
||||
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
defer { isLoading = false }
|
||||
|
||||
do {
|
||||
let payload = try await requestFolders(api: api, scenicId: scenicId, page: 1)
|
||||
self.page = 1
|
||||
folders = payload.list
|
||||
total = payload.total
|
||||
} catch {
|
||||
folders = []
|
||||
total = 0
|
||||
page = 1
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载相册下一页。
|
||||
func loadMore(api: any AssetsServing, scenicId: Int?) async {
|
||||
guard let scenicId, hasMore, !isLoadingMore else { return }
|
||||
isLoadingMore = true
|
||||
defer { isLoadingMore = false }
|
||||
|
||||
do {
|
||||
let nextPage = page + 1
|
||||
let payload = try await requestFolders(api: api, scenicId: scenicId, page: nextPage)
|
||||
page = nextPage
|
||||
total = payload.total
|
||||
folders.append(contentsOf: payload.list)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 新建相册并刷新列表。
|
||||
func createFolder(name: String, remark: String, scenicId: Int?, api: any AssetsServing) async -> Bool {
|
||||
let folderName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let scenicId else {
|
||||
errorMessage = "缺少当前景区,无法创建相册"
|
||||
return false
|
||||
}
|
||||
guard !folderName.isEmpty else {
|
||||
errorMessage = "请输入相册名称"
|
||||
return false
|
||||
}
|
||||
guard !isMutating else { return false }
|
||||
|
||||
isMutating = true
|
||||
defer { isMutating = false }
|
||||
|
||||
do {
|
||||
try await api.addAlbumFolder(scenicId: scenicId, name: folderName, remark: remark.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
await reload(api: api, scenicId: scenicId)
|
||||
return true
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// 组装相册列表请求参数。
|
||||
private func requestFolders(api: any AssetsServing, scenicId: Int, page: Int) async throws -> ListPayload<AlbumFolderItem> {
|
||||
try await api.albumFolderList(
|
||||
scenicId: scenicId,
|
||||
page: page,
|
||||
pageSize: pageSize,
|
||||
name: searchText.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
startTime: startTime.trimmingCharacters(in: .whitespacesAndNewlines).assetsNilIfEmpty,
|
||||
endTime: endTime.trimmingCharacters(in: .whitespacesAndNewlines).assetsNilIfEmpty
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 相册详情 ViewModel,负责相册信息、图片/视频列表和文件操作。
|
||||
@MainActor
|
||||
final class AlbumDetailViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
let folderId: Int
|
||||
var folder: AlbumFolderItem? { didSet { onChange?() } }
|
||||
var files: [AlbumFileItem] = [] { didSet { onChange?() } }
|
||||
var selectedTab: AlbumFileTab = .image { didSet { onChange?() } }
|
||||
var total = 0 { didSet { onChange?() } }
|
||||
var page = 1 { didSet { onChange?() } }
|
||||
var isLoading = false { didSet { onChange?() } }
|
||||
var isLoadingMore = false { didSet { onChange?() } }
|
||||
var isMutating = false { didSet { onChange?() } }
|
||||
var errorMessage: String? { didSet { onChange?() } }
|
||||
|
||||
private let pageSize = 20
|
||||
|
||||
/// 初始化相册详情 ViewModel。
|
||||
init(folderId: Int, summary: AlbumFolderItem? = nil) {
|
||||
self.folderId = folderId
|
||||
folder = summary
|
||||
}
|
||||
|
||||
/// 判断当前文件列表是否还有下一页。
|
||||
var hasMore: Bool {
|
||||
files.count < total
|
||||
}
|
||||
|
||||
/// 重新加载相册信息和当前 Tab 文件列表。
|
||||
func reload(api: any AssetsServing, scenicId: Int?) async {
|
||||
guard let scenicId else {
|
||||
files = []
|
||||
total = 0
|
||||
page = 1
|
||||
errorMessage = "缺少当前景区,无法加载相册内容"
|
||||
return
|
||||
}
|
||||
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
defer { isLoading = false }
|
||||
|
||||
do {
|
||||
folder = try await api.albumFolderInfo(id: folderId)
|
||||
let listPayload = try await api.albumFileList(
|
||||
scenicId: scenicId,
|
||||
folderId: folderId,
|
||||
fileType: selectedTab.rawValue,
|
||||
page: 1,
|
||||
pageSize: pageSize
|
||||
)
|
||||
files = listPayload.list
|
||||
total = listPayload.total
|
||||
page = 1
|
||||
} catch {
|
||||
page = 1
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 切换图片或视频列表并重新加载第一页。
|
||||
func selectTab(_ tab: AlbumFileTab, api: any AssetsServing, scenicId: Int?) async {
|
||||
guard selectedTab != tab else { return }
|
||||
selectedTab = tab
|
||||
files = []
|
||||
total = 0
|
||||
page = 1
|
||||
await reload(api: api, scenicId: scenicId)
|
||||
}
|
||||
|
||||
/// 加载当前 Tab 下一页文件。
|
||||
func loadMore(api: any AssetsServing, scenicId: Int?) async {
|
||||
guard let scenicId, hasMore, !isLoadingMore else { return }
|
||||
isLoadingMore = true
|
||||
defer { isLoadingMore = false }
|
||||
|
||||
do {
|
||||
let nextPage = page + 1
|
||||
let payload = try await api.albumFileList(
|
||||
scenicId: scenicId,
|
||||
folderId: folderId,
|
||||
fileType: selectedTab.rawValue,
|
||||
page: nextPage,
|
||||
pageSize: pageSize
|
||||
)
|
||||
page = nextPage
|
||||
total = payload.total
|
||||
files.append(contentsOf: payload.list)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除相册文件并刷新当前列表。
|
||||
func delete(file: AlbumFileItem, scenicId: Int?, api: any AssetsServing) async -> Bool {
|
||||
await mutate(api: api, scenicId: scenicId) {
|
||||
try await api.deleteAlbumFiles(folderId: folderId, idList: [file.id])
|
||||
}
|
||||
}
|
||||
|
||||
/// 将指定相册文件设为封面。
|
||||
func setCover(file: AlbumFileItem, scenicId: Int?, api: any AssetsServing) async -> Bool {
|
||||
await mutate(api: api, scenicId: scenicId) {
|
||||
try await api.editAlbumFolder(folderId: folderId, coverFileId: file.id, name: nil, remark: nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新相册名称。
|
||||
func rename(name: String, scenicId: Int?, api: any AssetsServing) async -> Bool {
|
||||
let folderName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !folderName.isEmpty else {
|
||||
errorMessage = "请输入相册名称"
|
||||
return false
|
||||
}
|
||||
return await mutate(api: api, scenicId: scenicId) {
|
||||
try await api.editAlbumFolder(folderId: folderId, coverFileId: nil, name: folderName, remark: nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新相册备注。
|
||||
func updateRemark(_ remark: String, scenicId: Int?, api: any AssetsServing) async -> Bool {
|
||||
await mutate(api: api, scenicId: scenicId) {
|
||||
try await api.editAlbumFolder(folderId: folderId, coverFileId: nil, name: nil, remark: remark.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
}
|
||||
}
|
||||
|
||||
/// 执行相册变更操作并刷新详情。
|
||||
private func mutate(api: any AssetsServing, scenicId: Int?, operation: () async throws -> Void) async -> Bool {
|
||||
guard !isMutating else { return false }
|
||||
guard scenicId != nil else {
|
||||
errorMessage = "缺少当前景区,无法操作相册"
|
||||
return false
|
||||
}
|
||||
isMutating = true
|
||||
defer { isMutating = false }
|
||||
|
||||
do {
|
||||
try await operation()
|
||||
await reload(api: api, scenicId: scenicId)
|
||||
return true
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 相册预览上传 ViewModel,负责选择相册、本地文件上传和相册入库。
|
||||
@MainActor
|
||||
final class AlbumTrailerViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
var folders: [AlbumFolderItem] = [] { didSet { onChange?() } }
|
||||
var selectedFolderId: Int? { didSet { onChange?() } }
|
||||
var localFiles: [AlbumLocalUploadFile] = [] { didSet { onChange?() } }
|
||||
var uploadProgress = 0 { didSet { onChange?() } }
|
||||
var isLoadingFolders = false { didSet { onChange?() } }
|
||||
var isSubmitting = false { didSet { onChange?() } }
|
||||
var errorMessage: String? { didSet { onChange?() } }
|
||||
var didUploadSuccessfully = false { didSet { onChange?() } }
|
||||
|
||||
/// 加载可上传的相册列表。
|
||||
func loadFolders(api: any AssetsServing, scenicId: Int?) async {
|
||||
guard let scenicId else {
|
||||
folders = []
|
||||
selectedFolderId = nil
|
||||
errorMessage = "缺少当前景区,无法加载相册"
|
||||
return
|
||||
}
|
||||
|
||||
isLoadingFolders = true
|
||||
errorMessage = nil
|
||||
defer { isLoadingFolders = false }
|
||||
|
||||
do {
|
||||
let payload = try await api.albumFolderList(
|
||||
scenicId: scenicId,
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
name: "",
|
||||
startTime: nil,
|
||||
endTime: nil
|
||||
)
|
||||
folders = payload.list
|
||||
if selectedFolderId == nil {
|
||||
selectedFolderId = folders.first?.id
|
||||
}
|
||||
} catch {
|
||||
folders = []
|
||||
selectedFolderId = nil
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 添加本地待上传文件。
|
||||
func addLocalFiles(_ files: [AlbumLocalUploadFile]) {
|
||||
localFiles.append(contentsOf: files)
|
||||
}
|
||||
|
||||
/// 移除一个本地待上传文件。
|
||||
func removeLocalFile(id: UUID) {
|
||||
localFiles.removeAll { $0.id == id }
|
||||
}
|
||||
|
||||
/// 上传本地文件到 OSS,并登记到指定相册。
|
||||
func submit(scenicId: Int?, api: any AssetsServing, uploadService: any OSSUploadServing) async -> Bool {
|
||||
guard let scenicId else {
|
||||
errorMessage = "缺少当前景区,无法上传"
|
||||
return false
|
||||
}
|
||||
guard let folderId = selectedFolderId else {
|
||||
errorMessage = "请选择相册"
|
||||
return false
|
||||
}
|
||||
guard !localFiles.isEmpty else {
|
||||
errorMessage = "请选择要上传的图片或视频"
|
||||
return false
|
||||
}
|
||||
guard !isSubmitting else { return false }
|
||||
|
||||
isSubmitting = true
|
||||
didUploadSuccessfully = false
|
||||
uploadProgress = 0
|
||||
defer { isSubmitting = false }
|
||||
|
||||
do {
|
||||
let fileCount = max(localFiles.count, 1)
|
||||
for (index, file) in localFiles.enumerated() {
|
||||
let url = try await uploadService.uploadAlbumFile(
|
||||
data: file.data,
|
||||
fileName: file.fileName,
|
||||
fileType: file.fileType,
|
||||
scenicId: scenicId
|
||||
) { progress in
|
||||
Task { @MainActor in
|
||||
let base = Double(index) / Double(fileCount)
|
||||
let step = Double(progress) / Double(fileCount)
|
||||
self.uploadProgress = min(99, Int((base + step / 100) * 100))
|
||||
}
|
||||
}
|
||||
try await api.albumFileUploadURL(scenicId: scenicId, fileURL: url, folderId: folderId)
|
||||
}
|
||||
uploadProgress = 100
|
||||
didUploadSuccessfully = true
|
||||
localFiles = []
|
||||
return true
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
/// 空字符串转 nil,方便可选筛选参数传递。
|
||||
var assetsNilIfEmpty: String? {
|
||||
isEmpty ? nil : self
|
||||
}
|
||||
}
|
||||
42
suixinkan_ios/Features/Auth/API/AuthAPI.swift
Normal file
42
suixinkan_ios/Features/Auth/API/AuthAPI.swift
Normal file
@ -0,0 +1,42 @@
|
||||
//
|
||||
// AuthAPI.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/18.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
/// 登录认证 API,封装 v9 登录和账号选择相关接口。
|
||||
final class AuthAPI {
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化登录 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
self.client = client
|
||||
}
|
||||
|
||||
/// 使用手机号和密码发起 v9 登录,返回临时 token 及可选账号列表。
|
||||
func login(username: String, password: String) async throws -> V9AuthResponse {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/app/v9/login",
|
||||
body: LoginRequest(username: username, password: password)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 选择具体景区或门店账号,换取正式登录 token。
|
||||
func setUser(_ request: SetUserRequest, tokenOverride: String? = nil) async throws -> V9AuthResponse {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/app/v9/set-user",
|
||||
body: request
|
||||
),
|
||||
tokenOverride: tokenOverride
|
||||
)
|
||||
}
|
||||
}
|
||||
63
suixinkan_ios/Features/Auth/Auth.md
Normal file
63
suixinkan_ios/Features/Auth/Auth.md
Normal file
@ -0,0 +1,63 @@
|
||||
# Auth 模块业务逻辑
|
||||
|
||||
## 模块职责
|
||||
|
||||
Auth 模块负责登录页、手机号密码登录、多账号选择和账号选择后的正式登录确认。
|
||||
|
||||
该模块只处理登录流程本身:
|
||||
- 表单输入、校验和协议确认。
|
||||
- 调用 v9 登录接口获取临时 token 和账号列表。
|
||||
- 单账号时自动调用 set-user。
|
||||
- 多账号时展示账号选择页。
|
||||
- 选择账号后调用 set-user 换取正式 token。
|
||||
|
||||
正式 token 存储、账号快照和全局登录态写入由 App 模块的 `AuthSessionCoordinator` 统一处理。
|
||||
|
||||
## 核心对象
|
||||
|
||||
- `LoginView`:登录页 UI,展示手机号、密码、协议确认和登录按钮。
|
||||
- `AccountSelectionView`:多账号选择弹窗,展示景区账号和门店账号。
|
||||
- `LoginViewModel`:维护表单状态、校验状态、登录请求状态和多账号选择状态。
|
||||
- `AuthAPI`:封装 `/api/app/v9/login` 和 `/api/app/v9/set-user`。
|
||||
- `LoginRequest`:登录请求体。
|
||||
- `SetUserRequest`:账号选择请求体。
|
||||
- `V9AuthResponse`:v9 登录和 set-user 响应。
|
||||
- `V9ScenicUser` / `V9StoreUser`:后端返回的景区账号和门店账号。
|
||||
- `AccountSwitchAccount`:统一后的可选择账号模型。
|
||||
|
||||
## 登录流程
|
||||
|
||||
1. 用户输入手机号和密码。
|
||||
2. `LoginViewModel.validateForLogin` 校验手机号、密码和协议勾选状态。
|
||||
3. 未勾选协议时,`LoginView` 弹出协议确认 Sheet。
|
||||
4. 校验通过后,`LoginViewModel.login` 调用 `AuthAPI.login`。
|
||||
5. 登录接口返回临时 token 和可用账号列表。
|
||||
6. `LoginViewModel` 过滤掉业务账号 ID 无效的账号。
|
||||
7. 没有可用账号时抛出 `LoginFlowError.noAvailableAccount`。
|
||||
8. 只有一个账号时,自动调用 `AuthAPI.setUser`,并返回 `.completed`。
|
||||
9. 多个账号时,保存 `AccountSelectionPayload`,并返回 `.needsAccountSelection`。
|
||||
10. `LoginView` 展示 `AccountSelectionView`。
|
||||
11. 用户确认账号后,`LoginViewModel.selectAccount` 使用临时 token 调用 set-user。
|
||||
12. set-user 返回正式 token 后,`LoginView` 调用 `AuthSessionCoordinator.completeLogin` 写入 token,并同步用户资料、角色权限、景区和门店。
|
||||
|
||||
## 账号模型转换
|
||||
|
||||
后端景区账号和门店账号字段不完全一致,统一转换为 `AccountSwitchAccount`:
|
||||
- 景区账号通过 `ssUserId`、`scenicUserId`、`userId`、`id` 兜底生成业务账号 ID。
|
||||
- 门店账号通过 `storeUserId`、`userId`、`id` 兜底生成业务账号 ID。
|
||||
- `AccountSwitchAccount.toSetUserRequest` 根据账号类型生成 `store_user_id` 或 `ss_user_id`。
|
||||
|
||||
`V9AuthResponse` 还会派生:
|
||||
- `accounts`:合并后的账号选择列表。
|
||||
- `primaryProfile`:登录后用于全局展示的账号资料。
|
||||
- `scenicScopes`:当前账号可用景区作用域。
|
||||
- `storeScopes`:当前账号可用门店作用域。
|
||||
|
||||
## 缓存规则
|
||||
|
||||
Auth 模块不直接写缓存。登录成功后的缓存由 `AuthSessionCoordinator` 负责:
|
||||
- 正式 token 写 Keychain。
|
||||
- 上次手机号和协议状态写 UserDefaults。
|
||||
- 账号资料、当前角色和业务作用域写入账号快照。
|
||||
|
||||
临时 token 只存在于 `AccountSelectionPayload`,不落盘。
|
||||
409
suixinkan_ios/Features/Auth/Models/AuthModels.swift
Normal file
409
suixinkan_ios/Features/Auth/Models/AuthModels.swift
Normal file
@ -0,0 +1,409 @@
|
||||
//
|
||||
// AuthModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/18.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 登录请求实体,表示手机号密码登录接口需要的参数。
|
||||
struct LoginRequest: Encodable {
|
||||
let username: String
|
||||
let type: Int
|
||||
let password: String
|
||||
let mobile: String
|
||||
let code: String
|
||||
|
||||
/// 创建手机号密码登录请求,并填充后端要求的默认字段。
|
||||
init(username: String, password: String) {
|
||||
self.username = username
|
||||
self.type = 1
|
||||
self.password = password
|
||||
self.mobile = ""
|
||||
self.code = ""
|
||||
}
|
||||
}
|
||||
|
||||
/// 账号选择请求实体,表示 set-user 接口需要的景区账号或门店账号 ID。
|
||||
struct SetUserRequest: Encodable, Equatable {
|
||||
let storeUserId: Int?
|
||||
let ssUserId: Int?
|
||||
|
||||
/// set-user 请求的 JSON 字段映射。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case storeUserId = "store_user_id"
|
||||
case ssUserId = "ss_user_id"
|
||||
}
|
||||
|
||||
/// 创建账号选择请求,门店账号传 storeUserId,景区账号传 ssUserId。
|
||||
init(storeUserId: Int? = nil, ssUserId: Int? = nil) {
|
||||
self.storeUserId = storeUserId
|
||||
self.ssUserId = ssUserId
|
||||
}
|
||||
}
|
||||
|
||||
/// 可切换账号实体,统一表示登录返回的景区账号和门店账号。
|
||||
struct AccountSwitchAccount: Identifiable, Hashable {
|
||||
let accountType: String
|
||||
let businessUserId: Int
|
||||
let title: String
|
||||
let subtitle: String
|
||||
let phone: String
|
||||
let avatar: String
|
||||
let scenicName: String
|
||||
let storeId: Int?
|
||||
let storeName: String
|
||||
let scenicId: Int?
|
||||
let isCurrent: Bool
|
||||
|
||||
var id: String {
|
||||
"\(accountType)_\(businessUserId)"
|
||||
}
|
||||
|
||||
var isStoreUser: Bool {
|
||||
accountType == V9StoreUser.accountTypeValue
|
||||
}
|
||||
|
||||
var accountTypeLabel: String {
|
||||
isStoreUser ? "门店账号" : "景区账号"
|
||||
}
|
||||
|
||||
/// 转换为 set-user 接口请求参数。
|
||||
func toSetUserRequest() -> SetUserRequest {
|
||||
if isStoreUser {
|
||||
return SetUserRequest(storeUserId: businessUserId)
|
||||
}
|
||||
return SetUserRequest(ssUserId: businessUserId)
|
||||
}
|
||||
}
|
||||
|
||||
/// 登录账号选择载荷,保存临时 token 和待用户选择的账号列表。
|
||||
struct AccountSelectionPayload: Equatable, Identifiable {
|
||||
let id = UUID()
|
||||
let tempToken: String
|
||||
let accounts: [AccountSwitchAccount]
|
||||
|
||||
var hasTempToken: Bool {
|
||||
!tempToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
/// v9 登录响应实体,包含 token、景区账号列表和门店账号列表。
|
||||
struct V9AuthResponse: Decodable, Equatable {
|
||||
let token: String
|
||||
let scenicUsers: [V9ScenicUser]
|
||||
let storeUsers: [V9StoreUser]
|
||||
|
||||
/// 合并景区账号和门店账号,供账号选择页展示。
|
||||
var accounts: [AccountSwitchAccount] {
|
||||
scenicUsers.map { $0.toAccountSwitchAccount() } + storeUsers.map { $0.toAccountSwitchAccount() }
|
||||
}
|
||||
|
||||
/// 提取当前或首个账号作为全局账号资料。
|
||||
var primaryProfile: AccountProfile? {
|
||||
if let storeUser = storeUsers.first(where: \.isCurrent) ?? storeUsers.first {
|
||||
return AccountProfile(
|
||||
userId: String(storeUser.userId),
|
||||
displayName: storeUser.displayName,
|
||||
phone: storeUser.phone.nonEmpty,
|
||||
avatarURL: storeUser.avatar.nonEmpty
|
||||
)
|
||||
}
|
||||
|
||||
if let scenicUser = scenicUsers.first(where: \.isCurrent) ?? scenicUsers.first {
|
||||
return AccountProfile(
|
||||
userId: String(scenicUser.userId),
|
||||
displayName: scenicUser.displayName,
|
||||
phone: scenicUser.phone.nonEmpty,
|
||||
avatarURL: nil
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 提取登录响应中的景区作用域,并按当前账号优先排序。
|
||||
var scenicScopes: [BusinessScope] {
|
||||
var seen = Set<Int>()
|
||||
let scenicFromScenicUsers = scenicUsers
|
||||
.sorted { $0.isCurrent && !$1.isCurrent }
|
||||
.compactMap { user -> BusinessScope? in
|
||||
guard user.scenicId > 0, seen.insert(user.scenicId).inserted else { return nil }
|
||||
return BusinessScope(id: user.scenicId, name: user.scenicName, kind: .scenic)
|
||||
}
|
||||
let scenicFromStoreUsers = storeUsers
|
||||
.sorted { $0.isCurrent && !$1.isCurrent }
|
||||
.compactMap { user -> BusinessScope? in
|
||||
guard user.scenicId > 0, seen.insert(user.scenicId).inserted else { return nil }
|
||||
return BusinessScope(id: user.scenicId, name: user.scenicName, kind: .scenic)
|
||||
}
|
||||
return scenicFromScenicUsers + scenicFromStoreUsers
|
||||
}
|
||||
|
||||
/// 提取登录响应中的门店作用域,并按当前账号优先排序。
|
||||
var storeScopes: [BusinessScope] {
|
||||
var seen = Set<Int>()
|
||||
return storeUsers
|
||||
.sorted { $0.isCurrent && !$1.isCurrent }
|
||||
.compactMap { user -> BusinessScope? in
|
||||
guard user.storeId > 0, seen.insert(user.storeId).inserted else { return nil }
|
||||
return BusinessScope(
|
||||
id: user.storeId,
|
||||
name: user.storeName,
|
||||
kind: .store,
|
||||
parentScenicId: user.scenicId > 0 ? user.scenicId : nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// v9 登录响应的 JSON 字段映射。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case token
|
||||
case scenicUsers = "scenic_users"
|
||||
case storeUsers = "store_users"
|
||||
}
|
||||
|
||||
/// 创建空响应或测试响应。
|
||||
init(token: String = "", scenicUsers: [V9ScenicUser] = [], storeUsers: [V9StoreUser] = []) {
|
||||
self.token = token
|
||||
self.scenicUsers = scenicUsers
|
||||
self.storeUsers = storeUsers
|
||||
}
|
||||
|
||||
/// 自定义解码,兼容后端账号数组缺失的情况。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
token = try container.decodeLossyString(forKey: .token)
|
||||
scenicUsers = (try? container.decodeIfPresent([V9ScenicUser].self, forKey: .scenicUsers)) ?? []
|
||||
storeUsers = (try? container.decodeIfPresent([V9StoreUser].self, forKey: .storeUsers)) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
/// v9 景区账号实体,表示用户可进入的某个景区身份。
|
||||
struct V9ScenicUser: Decodable, Equatable {
|
||||
static let accountTypeValue = "scenic_user"
|
||||
|
||||
let accountType: String
|
||||
let id: Int
|
||||
let userId: Int
|
||||
let scenicUserId: Int
|
||||
let ssUserId: Int
|
||||
let username: String
|
||||
let realName: String
|
||||
let nickname: String
|
||||
let phone: String
|
||||
let scenicId: Int
|
||||
let scenicName: String
|
||||
let isCurrent: Bool
|
||||
|
||||
/// set-user 使用的业务账号 ID,按后端可用字段优先级兜底。
|
||||
var businessUserId: Int {
|
||||
[ssUserId, scenicUserId, userId, id].first { $0 > 0 } ?? 0
|
||||
}
|
||||
|
||||
/// 用于界面展示的景区账号名称。
|
||||
var displayName: String {
|
||||
scenicName.nonEmpty ?? nickname.nonEmpty ?? realName.nonEmpty ?? username
|
||||
}
|
||||
|
||||
/// 转换为账号选择页使用的统一账号实体。
|
||||
func toAccountSwitchAccount() -> AccountSwitchAccount {
|
||||
AccountSwitchAccount(
|
||||
accountType: accountType.nonEmpty ?? Self.accountTypeValue,
|
||||
businessUserId: businessUserId,
|
||||
title: scenicName.nonEmpty ?? nickname.nonEmpty ?? realName.nonEmpty ?? "景区账号",
|
||||
subtitle: [realName.nonEmpty, nickname.nonEmpty]
|
||||
.compactMap(\.self)
|
||||
.joined(separator: " · "),
|
||||
phone: phone,
|
||||
avatar: "",
|
||||
scenicName: scenicName,
|
||||
storeId: nil,
|
||||
storeName: "",
|
||||
scenicId: scenicId > 0 ? scenicId : nil,
|
||||
isCurrent: isCurrent
|
||||
)
|
||||
}
|
||||
|
||||
/// 景区账号响应的 JSON 字段映射。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case accountType = "account_type"
|
||||
case id
|
||||
case userId = "user_id"
|
||||
case scenicUserId = "scenic_user_id"
|
||||
case ssUserId = "ss_user_id"
|
||||
case username
|
||||
case realName = "real_name"
|
||||
case nickname
|
||||
case phone
|
||||
case scenicId = "scenic_id"
|
||||
case scenicName = "scenic_name"
|
||||
case isCurrent = "is_current"
|
||||
}
|
||||
|
||||
/// 自定义解码,兼容 ID 字段类型不稳定和字段缺失。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
accountType = try container.decodeLossyString(forKey: .accountType).nonEmpty ?? Self.accountTypeValue
|
||||
id = try container.decodeLossyInt(forKey: .id) ?? 0
|
||||
userId = try container.decodeLossyInt(forKey: .userId) ?? 0
|
||||
scenicUserId = try container.decodeLossyInt(forKey: .scenicUserId) ?? 0
|
||||
ssUserId = try container.decodeLossyInt(forKey: .ssUserId) ?? 0
|
||||
username = try container.decodeLossyString(forKey: .username)
|
||||
realName = try container.decodeLossyString(forKey: .realName)
|
||||
nickname = try container.decodeLossyString(forKey: .nickname)
|
||||
phone = try container.decodeLossyString(forKey: .phone)
|
||||
scenicId = try container.decodeLossyInt(forKey: .scenicId) ?? 0
|
||||
scenicName = try container.decodeLossyString(forKey: .scenicName)
|
||||
isCurrent = try container.decodeLossyBool(forKey: .isCurrent) ?? false
|
||||
}
|
||||
}
|
||||
|
||||
/// v9 门店账号实体,表示用户可进入的某个门店身份。
|
||||
struct V9StoreUser: Decodable, Equatable {
|
||||
static let accountTypeValue = "store_user"
|
||||
|
||||
let accountType: String
|
||||
let id: Int
|
||||
let userId: Int
|
||||
let storeUserId: Int
|
||||
let username: String
|
||||
let userName: String
|
||||
let realName: String
|
||||
let phone: String
|
||||
let avatar: String
|
||||
let scenicId: Int
|
||||
let scenicName: String
|
||||
let storeId: Int
|
||||
let storeName: String
|
||||
let isCurrent: Bool
|
||||
|
||||
/// set-user 使用的业务账号 ID,按后端可用字段优先级兜底。
|
||||
var businessUserId: Int {
|
||||
[storeUserId, userId, id].first { $0 > 0 } ?? 0
|
||||
}
|
||||
|
||||
/// 用于界面展示的门店账号名称。
|
||||
var displayName: String {
|
||||
storeName.nonEmpty ?? scenicName.nonEmpty ?? realName.nonEmpty ?? userName.nonEmpty ?? username
|
||||
}
|
||||
|
||||
/// 转换为账号选择页使用的统一账号实体。
|
||||
func toAccountSwitchAccount() -> AccountSwitchAccount {
|
||||
AccountSwitchAccount(
|
||||
accountType: accountType.nonEmpty ?? Self.accountTypeValue,
|
||||
businessUserId: businessUserId,
|
||||
title: storeName.nonEmpty ?? scenicName.nonEmpty ?? realName.nonEmpty ?? userName.nonEmpty ?? "门店账号",
|
||||
subtitle: [scenicName.nonEmpty, realName.nonEmpty ?? userName.nonEmpty]
|
||||
.compactMap(\.self)
|
||||
.joined(separator: " · "),
|
||||
phone: phone,
|
||||
avatar: avatar,
|
||||
scenicName: scenicName,
|
||||
storeId: storeId > 0 ? storeId : nil,
|
||||
storeName: storeName,
|
||||
scenicId: scenicId > 0 ? scenicId : nil,
|
||||
isCurrent: isCurrent
|
||||
)
|
||||
}
|
||||
|
||||
/// 门店账号响应的 JSON 字段映射。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case accountType = "account_type"
|
||||
case id
|
||||
case userId = "user_id"
|
||||
case storeUserId = "store_user_id"
|
||||
case username
|
||||
case userName = "user_name"
|
||||
case realName = "real_name"
|
||||
case phone
|
||||
case avatar
|
||||
case scenicId = "scenic_id"
|
||||
case scenicName = "scenic_name"
|
||||
case storeId = "store_id"
|
||||
case storeName = "store_name"
|
||||
case isCurrent = "is_current"
|
||||
}
|
||||
|
||||
/// 自定义解码,兼容 ID 字段类型不稳定和字段缺失。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
accountType = try container.decodeLossyString(forKey: .accountType).nonEmpty ?? Self.accountTypeValue
|
||||
id = try container.decodeLossyInt(forKey: .id) ?? 0
|
||||
userId = try container.decodeLossyInt(forKey: .userId) ?? 0
|
||||
storeUserId = try container.decodeLossyInt(forKey: .storeUserId) ?? 0
|
||||
username = try container.decodeLossyString(forKey: .username)
|
||||
userName = try container.decodeLossyString(forKey: .userName)
|
||||
realName = try container.decodeLossyString(forKey: .realName)
|
||||
phone = try container.decodeLossyString(forKey: .phone)
|
||||
avatar = try container.decodeLossyString(forKey: .avatar)
|
||||
scenicId = try container.decodeLossyInt(forKey: .scenicId) ?? 0
|
||||
scenicName = try container.decodeLossyString(forKey: .scenicName)
|
||||
storeId = try container.decodeLossyInt(forKey: .storeId) ?? 0
|
||||
storeName = try container.decodeLossyString(forKey: .storeName)
|
||||
isCurrent = try container.decodeLossyBool(forKey: .isCurrent) ?? false
|
||||
}
|
||||
}
|
||||
|
||||
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 ? "true" : "false"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
/// 将 Int、Double 或数字字符串宽松解码为整数。
|
||||
func decodeLossyInt(forKey key: Key) throws -> Int? {
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let intValue = Int(text) {
|
||||
return intValue
|
||||
}
|
||||
if let doubleValue = Double(text) {
|
||||
return Int(doubleValue)
|
||||
}
|
||||
}
|
||||
if let value = try? decodeIfPresent(Double.self, forKey: key) {
|
||||
return Int(value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 将 Bool、数字或布尔字符串宽松解码为布尔值。
|
||||
func decodeLossyBool(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 normalized = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
if ["1", "true", "yes"].contains(normalized) { return true }
|
||||
if ["0", "false", "no"].contains(normalized) { return false }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
var nonEmpty: String? {
|
||||
let text = trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return text.isEmpty ? nil : text
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,291 @@
|
||||
//
|
||||
// AccountSelectionViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
/// 多账号登录时的账号选择页,展示景区/门店账号列表并提交选择。
|
||||
final class AccountSelectionViewController: UIViewController {
|
||||
|
||||
private let payload: AccountSelectionPayload
|
||||
private var isLoading: Bool
|
||||
private let onCancel: () -> Void
|
||||
private let onConfirm: (AccountSwitchAccount) -> Void
|
||||
|
||||
private var selectedAccountId: String?
|
||||
private let tableView = UITableView(frame: .zero, style: .plain)
|
||||
private let confirmButton = UIButton(type: .system)
|
||||
private let bottomBar = UIView()
|
||||
|
||||
init(
|
||||
payload: AccountSelectionPayload,
|
||||
isLoading: Bool,
|
||||
onCancel: @escaping () -> Void,
|
||||
onConfirm: @escaping (AccountSwitchAccount) -> Void
|
||||
) {
|
||||
self.payload = payload
|
||||
self.isLoading = isLoading
|
||||
self.onCancel = onCancel
|
||||
self.onConfirm = onConfirm
|
||||
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 = "选择账号"
|
||||
view.backgroundColor = UIColor(hex: 0xF5F7FB)
|
||||
navigationItem.leftBarButtonItem = UIBarButtonItem(title: "取消", style: .plain, target: self, action: #selector(cancelTapped))
|
||||
isModalInPresentation = isLoading
|
||||
|
||||
selectedAccountId = payload.accounts.first?.id
|
||||
configureTableView()
|
||||
configureBottomBar()
|
||||
}
|
||||
|
||||
func updateLoading(_ loading: Bool) {
|
||||
isLoading = loading
|
||||
isModalInPresentation = loading
|
||||
confirmButton.isEnabled = canConfirm
|
||||
confirmButton.backgroundColor = canConfirm ? AppDesign.primary : UIColor(hex: 0xC9CED6)
|
||||
}
|
||||
|
||||
private var selectedAccount: AccountSwitchAccount? {
|
||||
payload.accounts.first { $0.id == selectedAccountId }
|
||||
}
|
||||
|
||||
private var canConfirm: Bool {
|
||||
selectedAccount != nil && !isLoading
|
||||
}
|
||||
|
||||
private func configureTableView() {
|
||||
tableView.backgroundColor = .clear
|
||||
tableView.separatorStyle = .none
|
||||
tableView.dataSource = self
|
||||
tableView.delegate = self
|
||||
tableView.register(AccountSelectionCell.self, forCellReuseIdentifier: AccountSelectionCell.reuseIdentifier)
|
||||
view.addSubview(tableView)
|
||||
tableView.snp.makeConstraints { make in
|
||||
make.top.leading.trailing.equalToSuperview()
|
||||
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(90)
|
||||
}
|
||||
}
|
||||
|
||||
private func configureBottomBar() {
|
||||
bottomBar.backgroundColor = .white
|
||||
|
||||
let divider = UIView()
|
||||
divider.backgroundColor = UIColor.separator
|
||||
|
||||
confirmButton.setTitle("进入系统", for: .normal)
|
||||
confirmButton.titleLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.body, weight: .semibold)
|
||||
confirmButton.setTitleColor(.white, for: .normal)
|
||||
confirmButton.layer.cornerRadius = AppMetrics.CornerRadius.button
|
||||
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
|
||||
updateLoading(isLoading)
|
||||
|
||||
view.addSubview(bottomBar)
|
||||
bottomBar.addSubview(divider)
|
||||
bottomBar.addSubview(confirmButton)
|
||||
|
||||
bottomBar.snp.makeConstraints { make in
|
||||
make.leading.trailing.bottom.equalToSuperview()
|
||||
}
|
||||
|
||||
divider.snp.makeConstraints { make in
|
||||
make.top.leading.trailing.equalToSuperview()
|
||||
make.height.equalTo(0.5)
|
||||
}
|
||||
|
||||
confirmButton.snp.makeConstraints { make in
|
||||
make.top.equalTo(divider.snp.bottom).offset(AppMetrics.Spacing.medium)
|
||||
make.leading.trailing.equalToSuperview().inset(AppMetrics.Spacing.medium)
|
||||
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(AppMetrics.Spacing.medium)
|
||||
make.height.equalTo(AppMetrics.ControlSize.primaryButtonHeight)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func cancelTapped() {
|
||||
onCancel()
|
||||
dismiss(animated: true)
|
||||
}
|
||||
|
||||
@objc private func confirmTapped() {
|
||||
guard let selectedAccount else { return }
|
||||
onConfirm(selectedAccount)
|
||||
}
|
||||
}
|
||||
|
||||
extension AccountSelectionViewController: UITableViewDataSource, UITableViewDelegate {
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
payload.accounts.count
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
guard let cell = tableView.dequeueReusableCell(
|
||||
withIdentifier: AccountSelectionCell.reuseIdentifier,
|
||||
for: indexPath
|
||||
) as? AccountSelectionCell else {
|
||||
return UITableViewCell()
|
||||
}
|
||||
let account = payload.accounts[indexPath.row]
|
||||
cell.configure(account: account, selected: account.id == selectedAccountId)
|
||||
return cell
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
selectedAccountId = payload.accounts[indexPath.row].id
|
||||
tableView.reloadData()
|
||||
confirmButton.isEnabled = canConfirm
|
||||
confirmButton.backgroundColor = canConfirm ? AppDesign.primary : UIColor(hex: 0xC9CED6)
|
||||
}
|
||||
}
|
||||
|
||||
/// 账号选择列表 Cell。
|
||||
private final class AccountSelectionCell: UITableViewCell {
|
||||
static let reuseIdentifier = "AccountSelectionCell"
|
||||
|
||||
private let avatarView = UIView()
|
||||
private let avatarLabel = UILabel()
|
||||
private let titleLabel = UILabel()
|
||||
private let subtitleLabel = UILabel()
|
||||
private let phoneLabel = UILabel()
|
||||
private let typeTag = UILabel()
|
||||
private let currentTag = UILabel()
|
||||
private let checkmark = UIImageView()
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
selectionStyle = .none
|
||||
backgroundColor = .clear
|
||||
contentView.backgroundColor = .white
|
||||
contentView.layer.cornerRadius = AppMetrics.CornerRadius.button
|
||||
contentView.layer.masksToBounds = true
|
||||
|
||||
avatarView.layer.cornerRadius = AppMetrics.ControlSize.primaryButtonHeight / 2
|
||||
avatarLabel.font = .systemFont(ofSize: AppMetrics.FontSize.callout, weight: .semibold)
|
||||
|
||||
titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.body, weight: .semibold)
|
||||
titleLabel.textColor = AppDesign.textPrimary
|
||||
|
||||
subtitleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.footnote)
|
||||
subtitleLabel.textColor = AppDesign.textSecondary
|
||||
|
||||
phoneLabel.font = .systemFont(ofSize: AppMetrics.FontSize.caption)
|
||||
phoneLabel.textColor = UIColor(hex: 0x9AA1AA)
|
||||
|
||||
typeTag.font = .systemFont(ofSize: 11, weight: .semibold)
|
||||
typeTag.textAlignment = .center
|
||||
typeTag.layer.cornerRadius = AppMetrics.Spacing.xxSmall
|
||||
typeTag.clipsToBounds = true
|
||||
|
||||
currentTag.font = .systemFont(ofSize: 11, weight: .semibold)
|
||||
currentTag.textAlignment = .center
|
||||
currentTag.text = "当前"
|
||||
currentTag.textColor = AppDesign.primary
|
||||
currentTag.backgroundColor = AppDesign.primarySoft
|
||||
currentTag.layer.cornerRadius = AppMetrics.Spacing.xxSmall
|
||||
currentTag.clipsToBounds = true
|
||||
currentTag.isHidden = true
|
||||
|
||||
checkmark.contentMode = .scaleAspectFit
|
||||
|
||||
contentView.addSubview(avatarView)
|
||||
avatarView.addSubview(avatarLabel)
|
||||
contentView.addSubview(titleLabel)
|
||||
contentView.addSubview(subtitleLabel)
|
||||
contentView.addSubview(phoneLabel)
|
||||
contentView.addSubview(typeTag)
|
||||
contentView.addSubview(currentTag)
|
||||
contentView.addSubview(checkmark)
|
||||
|
||||
avatarView.snp.makeConstraints { make in
|
||||
make.leading.equalToSuperview().inset(AppMetrics.FontSize.subheadline)
|
||||
make.centerY.equalToSuperview()
|
||||
make.width.height.equalTo(AppMetrics.ControlSize.primaryButtonHeight)
|
||||
}
|
||||
|
||||
avatarLabel.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
}
|
||||
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.top.equalToSuperview().inset(AppMetrics.FontSize.subheadline)
|
||||
make.leading.equalTo(avatarView.snp.trailing).offset(AppMetrics.FontSize.footnote)
|
||||
make.trailing.lessThanOrEqualTo(checkmark.snp.leading).offset(-8)
|
||||
}
|
||||
|
||||
currentTag.snp.makeConstraints { make in
|
||||
make.leading.equalTo(titleLabel.snp.trailing).offset(AppMetrics.Spacing.xSmall)
|
||||
make.centerY.equalTo(titleLabel)
|
||||
make.height.equalTo(AppMetrics.Spacing.sheet)
|
||||
}
|
||||
|
||||
subtitleLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(titleLabel.snp.bottom).offset(AppMetrics.Spacing.xxSmall)
|
||||
make.leading.equalTo(titleLabel)
|
||||
make.trailing.equalTo(checkmark.snp.leading).offset(-8)
|
||||
}
|
||||
|
||||
phoneLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(subtitleLabel.snp.bottom).offset(AppMetrics.Spacing.xxSmall)
|
||||
make.leading.equalTo(titleLabel)
|
||||
make.bottom.equalToSuperview().inset(AppMetrics.FontSize.subheadline)
|
||||
}
|
||||
|
||||
typeTag.snp.makeConstraints { make in
|
||||
make.trailing.equalTo(checkmark.snp.leading).offset(-AppMetrics.Spacing.xSmall)
|
||||
make.top.equalTo(titleLabel)
|
||||
make.height.equalTo(AppMetrics.Spacing.sheet)
|
||||
}
|
||||
|
||||
checkmark.snp.makeConstraints { make in
|
||||
make.trailing.equalToSuperview().inset(AppMetrics.FontSize.subheadline)
|
||||
make.centerY.equalToSuperview()
|
||||
make.width.height.equalTo(AppMetrics.ControlSize.passwordIcon)
|
||||
}
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
nil
|
||||
}
|
||||
|
||||
func configure(account: AccountSwitchAccount, selected: Bool) {
|
||||
titleLabel.text = account.title.isEmpty ? account.accountTypeLabel : account.title
|
||||
subtitleLabel.text = account.subtitle
|
||||
subtitleLabel.isHidden = account.subtitle.isEmpty
|
||||
phoneLabel.text = account.phone
|
||||
phoneLabel.isHidden = account.phone.isEmpty
|
||||
currentTag.isHidden = !account.isCurrent
|
||||
|
||||
if account.isStoreUser {
|
||||
avatarView.backgroundColor = UIColor(hex: 0xE8F8F1)
|
||||
avatarLabel.text = "店"
|
||||
avatarLabel.textColor = UIColor(hex: 0x0F9F6E)
|
||||
typeTag.text = " 门店 "
|
||||
typeTag.textColor = UIColor(hex: 0x0F9F6E)
|
||||
typeTag.backgroundColor = UIColor(hex: 0xE8F8F1)
|
||||
} else {
|
||||
avatarView.backgroundColor = UIColor(hex: 0xF3ECFF)
|
||||
avatarLabel.text = "景"
|
||||
avatarLabel.textColor = UIColor(hex: 0x7C3AED)
|
||||
typeTag.text = " 景区 "
|
||||
typeTag.textColor = UIColor(hex: 0x7C3AED)
|
||||
typeTag.backgroundColor = UIColor(hex: 0xF3ECFF)
|
||||
}
|
||||
|
||||
let symbolName = selected ? "checkmark.circle.fill" : "circle"
|
||||
checkmark.image = UIImage(systemName: symbolName)
|
||||
checkmark.tintColor = selected ? AppDesign.primary : UIColor(hex: 0xB6BECA)
|
||||
|
||||
contentView.layer.borderWidth = selected ? 1.4 : 1
|
||||
contentView.layer.borderColor = (selected ? AppDesign.primary : UIColor(hex: 0xE6ECF4)).cgColor
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,532 @@
|
||||
//
|
||||
// LoginViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
/// 登录页,展示手机号/密码表单、协议勾选和多账号选择入口。
|
||||
final class LoginViewController: UIViewController {
|
||||
|
||||
private let services: AppServices
|
||||
private let viewModel = LoginViewModel()
|
||||
|
||||
private let scrollView = UIScrollView()
|
||||
private let contentView = UIView()
|
||||
private let backgroundImageView = UIImageView(image: UIImage(named: "LoginBackground"))
|
||||
private let titleLabel = UILabel()
|
||||
private let cardView = UIView()
|
||||
private let usernameField = LoginInputField(iconName: "person", placeholder: "请输入手机号", isSecure: false)
|
||||
private let passwordField = LoginInputField(iconName: "lock", placeholder: "请输入密码", isSecure: true)
|
||||
private let privacyButton = UIButton(type: .custom)
|
||||
private let privacyLabel = UILabel()
|
||||
private let userAgreementButton = UIButton(type: .system)
|
||||
private let privacyPolicyButton = UIButton(type: .system)
|
||||
private let loginButton = UIButton(type: .system)
|
||||
|
||||
init(services: AppServices) {
|
||||
self.services = services
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = UIColor(hex: 0x0B1220)
|
||||
configureViews()
|
||||
bindViewModel()
|
||||
viewModel.applyPreferences(services.authSessionCoordinator.loginPreferences())
|
||||
}
|
||||
|
||||
private func configureViews() {
|
||||
backgroundImageView.contentMode = .scaleAspectFill
|
||||
backgroundImageView.clipsToBounds = true
|
||||
|
||||
titleLabel.text = "欢迎使用\n随心瞰商家版"
|
||||
titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.largeTitle, weight: .semibold)
|
||||
titleLabel.textColor = .white
|
||||
titleLabel.numberOfLines = 0
|
||||
|
||||
cardView.backgroundColor = .white
|
||||
cardView.layer.cornerRadius = AppMetrics.CornerRadius.card
|
||||
cardView.layer.shadowColor = UIColor.black.cgColor
|
||||
cardView.layer.shadowOpacity = 0.12
|
||||
cardView.layer.shadowRadius = 18
|
||||
cardView.layer.shadowOffset = CGSize(width: 0, height: 8)
|
||||
|
||||
usernameField.textField.keyboardType = .phonePad
|
||||
usernameField.textField.textContentType = .telephoneNumber
|
||||
usernameField.textField.autocorrectionType = .no
|
||||
usernameField.textField.autocapitalizationType = .none
|
||||
usernameField.textField.returnKeyType = .next
|
||||
usernameField.textField.accessibilityIdentifier = "login.username"
|
||||
usernameField.textField.delegate = self
|
||||
usernameField.textField.addTarget(self, action: #selector(usernameChanged), for: .editingChanged)
|
||||
|
||||
passwordField.textField.autocorrectionType = .no
|
||||
passwordField.textField.autocapitalizationType = .none
|
||||
passwordField.textField.returnKeyType = .go
|
||||
passwordField.textField.accessibilityIdentifier = "login.password"
|
||||
passwordField.textField.delegate = self
|
||||
passwordField.textField.addTarget(self, action: #selector(passwordChanged), for: .editingChanged)
|
||||
passwordField.onToggleVisibility = { [weak self] in
|
||||
self?.viewModel.showsPassword.toggle()
|
||||
}
|
||||
|
||||
privacyButton.accessibilityIdentifier = "login.privacy"
|
||||
privacyButton.addTarget(self, action: #selector(togglePrivacy), for: .touchUpInside)
|
||||
|
||||
configureAgreementText()
|
||||
|
||||
loginButton.setTitle("登录", for: .normal)
|
||||
loginButton.titleLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.body, weight: .medium)
|
||||
loginButton.layer.cornerRadius = AppMetrics.CornerRadius.button
|
||||
loginButton.accessibilityIdentifier = "login.submit"
|
||||
loginButton.addTarget(self, action: #selector(loginTapped), for: .touchUpInside)
|
||||
|
||||
view.addSubview(backgroundImageView)
|
||||
view.addSubview(scrollView)
|
||||
scrollView.addSubview(contentView)
|
||||
contentView.addSubview(titleLabel)
|
||||
contentView.addSubview(cardView)
|
||||
|
||||
cardView.addSubview(usernameField)
|
||||
cardView.addSubview(passwordField)
|
||||
cardView.addSubview(privacyButton)
|
||||
cardView.addSubview(privacyLabel)
|
||||
cardView.addSubview(loginButton)
|
||||
|
||||
backgroundImageView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
|
||||
scrollView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
|
||||
contentView.snp.makeConstraints { make in
|
||||
make.edges.equalTo(scrollView.contentLayoutGuide)
|
||||
make.width.equalTo(scrollView.frameLayoutGuide)
|
||||
}
|
||||
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(contentView.safeAreaLayoutGuide).offset(72)
|
||||
make.leading.trailing.equalToSuperview().inset(24)
|
||||
}
|
||||
titleLabel.accessibilityIdentifier = "login.title"
|
||||
|
||||
cardView.snp.makeConstraints { make in
|
||||
make.top.equalTo(titleLabel.snp.bottom).offset(AppMetrics.Spacing.xxLarge)
|
||||
make.leading.trailing.equalToSuperview().inset(20)
|
||||
make.bottom.equalToSuperview().inset(AppMetrics.Spacing.xxLarge)
|
||||
}
|
||||
|
||||
usernameField.snp.makeConstraints { make in
|
||||
make.top.leading.trailing.equalToSuperview().inset(AppMetrics.Spacing.medium)
|
||||
make.height.equalTo(AppMetrics.ControlSize.inputHeight)
|
||||
}
|
||||
|
||||
passwordField.snp.makeConstraints { make in
|
||||
make.top.equalTo(usernameField.snp.bottom).offset(AppMetrics.Spacing.xSmall)
|
||||
make.leading.trailing.equalTo(usernameField)
|
||||
make.height.equalTo(AppMetrics.ControlSize.inputHeight)
|
||||
}
|
||||
|
||||
privacyButton.snp.makeConstraints { make in
|
||||
make.top.equalTo(passwordField.snp.bottom).offset(AppMetrics.ControlSize.checkboxTapArea)
|
||||
make.leading.equalToSuperview().inset(AppMetrics.Spacing.medium)
|
||||
make.width.height.equalTo(AppMetrics.ControlSize.checkboxTapArea)
|
||||
}
|
||||
|
||||
privacyLabel.snp.makeConstraints { make in
|
||||
make.leading.equalTo(privacyButton.snp.trailing)
|
||||
make.trailing.equalToSuperview().inset(AppMetrics.Spacing.medium)
|
||||
make.top.equalTo(privacyButton).offset(AppMetrics.Spacing.xxSmall)
|
||||
}
|
||||
|
||||
loginButton.snp.makeConstraints { make in
|
||||
make.top.equalTo(privacyLabel.snp.bottom).offset(AppMetrics.Spacing.mediumLarge)
|
||||
make.leading.trailing.bottom.equalToSuperview().inset(AppMetrics.Spacing.medium)
|
||||
make.height.equalTo(AppMetrics.ControlSize.primaryButtonHeight)
|
||||
}
|
||||
|
||||
let tap = UITapGestureRecognizer(target: self, action: #selector(dismissKeyboard))
|
||||
tap.cancelsTouchesInView = false
|
||||
view.addGestureRecognizer(tap)
|
||||
}
|
||||
|
||||
private func configureAgreementText() {
|
||||
privacyLabel.numberOfLines = 0
|
||||
privacyLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline)
|
||||
|
||||
userAgreementButton.setTitle("《用户协议》", for: .normal)
|
||||
userAgreementButton.titleLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline)
|
||||
userAgreementButton.setTitleColor(UIColor(hex: 0x208BFF), for: .normal)
|
||||
userAgreementButton.accessibilityIdentifier = "login.userAgreement"
|
||||
userAgreementButton.addTarget(self, action: #selector(openUserAgreement), for: .touchUpInside)
|
||||
|
||||
privacyPolicyButton.setTitle("《隐私政策》", for: .normal)
|
||||
privacyPolicyButton.titleLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline)
|
||||
privacyPolicyButton.setTitleColor(UIColor(hex: 0x208BFF), for: .normal)
|
||||
privacyPolicyButton.accessibilityIdentifier = "login.privacyPolicy"
|
||||
privacyPolicyButton.addTarget(self, action: #selector(openPrivacyPolicy), for: .touchUpInside)
|
||||
|
||||
let prefix = UILabel()
|
||||
prefix.text = "已阅读并同意"
|
||||
prefix.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline)
|
||||
prefix.textColor = UIColor(hex: 0x666666)
|
||||
|
||||
let separator = UILabel()
|
||||
separator.text = "与"
|
||||
separator.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline)
|
||||
separator.textColor = UIColor(hex: 0x666666)
|
||||
|
||||
let row = UIStackView(arrangedSubviews: [prefix, userAgreementButton, separator, privacyPolicyButton])
|
||||
row.axis = .horizontal
|
||||
row.spacing = 0
|
||||
row.alignment = .center
|
||||
privacyLabel.addSubview(row)
|
||||
row.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
private func bindViewModel() {
|
||||
viewModel.onChange = { [weak self] in
|
||||
self?.renderViewModel()
|
||||
}
|
||||
renderViewModel()
|
||||
}
|
||||
|
||||
private func renderViewModel() {
|
||||
if usernameField.textField.text != viewModel.username {
|
||||
usernameField.textField.text = viewModel.username
|
||||
}
|
||||
if passwordField.textField.text != viewModel.password {
|
||||
passwordField.textField.text = viewModel.password
|
||||
}
|
||||
|
||||
passwordField.setSecureEntry(!viewModel.showsPassword)
|
||||
|
||||
let checkboxImage = viewModel.privacyChecked ? "LoginCheckboxChecked" : "LoginCheckboxUnchecked"
|
||||
privacyButton.setImage(UIImage(named: checkboxImage), for: .normal)
|
||||
|
||||
loginButton.isEnabled = viewModel.canSubmit && !viewModel.isLoading
|
||||
loginButton.backgroundColor = viewModel.canSubmit ? AppDesign.primary : .gray
|
||||
loginButton.setTitleColor(.white, for: .normal)
|
||||
loginButton.alpha = viewModel.isLoading ? 0.7 : 1
|
||||
|
||||
if viewModel.showsAgreementSheet {
|
||||
viewModel.showsAgreementSheet = false
|
||||
presentAgreementSheet()
|
||||
}
|
||||
|
||||
if let payload = viewModel.pendingAccountSelection {
|
||||
if let controller = accountSelectionController {
|
||||
controller.updateLoading(viewModel.isSelectingAccount)
|
||||
} else if presentedViewController == nil {
|
||||
presentAccountSelection(payload)
|
||||
}
|
||||
} else if accountSelectionController != nil {
|
||||
dismiss(animated: true)
|
||||
accountSelectionController = nil
|
||||
}
|
||||
}
|
||||
|
||||
private weak var accountSelectionController: AccountSelectionViewController?
|
||||
|
||||
@objc private func usernameChanged() {
|
||||
viewModel.username = usernameField.textField.text ?? ""
|
||||
viewModel.normalizeUsernameCountryCodeIfNeeded()
|
||||
services.toastCenter.dismiss()
|
||||
}
|
||||
|
||||
@objc private func passwordChanged() {
|
||||
viewModel.password = passwordField.textField.text ?? ""
|
||||
services.toastCenter.dismiss()
|
||||
}
|
||||
|
||||
@objc private func togglePrivacy() {
|
||||
viewModel.privacyChecked.toggle()
|
||||
}
|
||||
|
||||
@objc private func openUserAgreement() {
|
||||
showToast("用户协议页面待接入")
|
||||
}
|
||||
|
||||
@objc private func openPrivacyPolicy() {
|
||||
showToast("隐私政策页面待接入")
|
||||
}
|
||||
|
||||
@objc private func loginTapped() {
|
||||
view.endEditing(true)
|
||||
performLogin()
|
||||
}
|
||||
|
||||
@objc private func dismissKeyboard() {
|
||||
view.endEditing(true)
|
||||
}
|
||||
|
||||
private func performLogin() {
|
||||
if let validationError = viewModel.validateForLogin() {
|
||||
if validationError == .privacyUnchecked {
|
||||
presentAgreementSheet()
|
||||
} else {
|
||||
showToast(validationError.message)
|
||||
if validationError.focusField == .username {
|
||||
usernameField.textField.becomeFirstResponder()
|
||||
} else if validationError.focusField == .password {
|
||||
passwordField.textField.becomeFirstResponder()
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
Task {
|
||||
do {
|
||||
try await services.globalLoading.withLoading(message: "登录中...") {
|
||||
let resolution = try await viewModel.login(authAPI: services.authAPI)
|
||||
switch resolution {
|
||||
case let .completed(response):
|
||||
await completeLogin(with: response)
|
||||
case .needsAccountSelection:
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
showToast(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func presentAgreementSheet() {
|
||||
let controller = LoginAgreementConsentViewController(
|
||||
onOpenAgreement: { [weak self] title in
|
||||
self?.showToast("\(title)页面待接入")
|
||||
},
|
||||
onAgreeAndContinue: { [weak self] in
|
||||
self?.viewModel.acceptAgreement()
|
||||
self?.performLogin()
|
||||
}
|
||||
)
|
||||
if let sheet = controller.sheetPresentationController {
|
||||
sheet.detents = [.custom(resolver: { _ in 240 })]
|
||||
sheet.prefersGrabberVisible = true
|
||||
}
|
||||
present(controller, animated: true)
|
||||
}
|
||||
|
||||
private func presentAccountSelection(_ payload: AccountSelectionPayload) {
|
||||
let controller = AccountSelectionViewController(
|
||||
payload: payload,
|
||||
isLoading: viewModel.isSelectingAccount,
|
||||
onCancel: { [weak self] in
|
||||
self?.viewModel.clearPendingAccountSelection()
|
||||
self?.accountSelectionController = nil
|
||||
},
|
||||
onConfirm: { [weak self] account in
|
||||
self?.selectAccount(account)
|
||||
}
|
||||
)
|
||||
accountSelectionController = controller
|
||||
let navigation = UINavigationController(rootViewController: controller)
|
||||
navigation.modalPresentationStyle = .formSheet
|
||||
present(navigation, animated: true)
|
||||
}
|
||||
|
||||
private func selectAccount(_ account: AccountSwitchAccount) {
|
||||
Task {
|
||||
do {
|
||||
try await services.globalLoading.withLoading(message: "账号切换中...") {
|
||||
let response = try await viewModel.selectAccount(account, authAPI: services.authAPI)
|
||||
await completeLogin(with: response)
|
||||
accountSelectionController = nil
|
||||
dismiss(animated: true)
|
||||
}
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
showToast(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func completeLogin(with response: V9AuthResponse) async {
|
||||
do {
|
||||
try await services.authSessionCoordinator.completeLogin(
|
||||
with: response,
|
||||
username: viewModel.normalizedUsername,
|
||||
privacyAgreementAccepted: viewModel.privacyChecked,
|
||||
appSession: services.appSession,
|
||||
accountContext: services.accountContext,
|
||||
permissionContext: services.permissionContext,
|
||||
profileAPI: services.profileAPI,
|
||||
accountContextAPI: services.accountContextAPI
|
||||
)
|
||||
} catch {
|
||||
showToast(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension LoginViewController: UITextFieldDelegate {
|
||||
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
|
||||
if textField === usernameField.textField {
|
||||
passwordField.textField.becomeFirstResponder()
|
||||
} else if viewModel.canSubmit, !viewModel.isLoading {
|
||||
performLogin()
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/// 登录输入框,包含图标、文本框和密码可见切换。
|
||||
private final class LoginInputField: UIView {
|
||||
let textField = UITextField()
|
||||
var onToggleVisibility: (() -> Void)?
|
||||
|
||||
private let toggleButton = UIButton(type: .custom)
|
||||
private var isSecure = false
|
||||
|
||||
init(iconName: String, placeholder: String, isSecure: Bool) {
|
||||
self.isSecure = isSecure
|
||||
super.init(frame: .zero)
|
||||
backgroundColor = UIColor(hex: 0xF1F5F9)
|
||||
layer.cornerRadius = AppMetrics.CornerRadius.input
|
||||
layer.borderWidth = 1
|
||||
layer.borderColor = UIColor(hex: 0xDDE3EA).cgColor
|
||||
|
||||
let icon = UIImageView(image: UIImage(systemName: iconName))
|
||||
icon.tintColor = AppDesign.textSecondary
|
||||
icon.contentMode = .scaleAspectFit
|
||||
|
||||
textField.placeholder = placeholder
|
||||
textField.font = .systemFont(ofSize: AppMetrics.FontSize.body)
|
||||
textField.textColor = AppDesign.textPrimary
|
||||
textField.tintColor = AppDesign.primary
|
||||
textField.borderStyle = .none
|
||||
textField.isSecureTextEntry = isSecure
|
||||
|
||||
addSubview(icon)
|
||||
addSubview(textField)
|
||||
|
||||
icon.snp.makeConstraints { make in
|
||||
make.leading.equalToSuperview().inset(AppMetrics.Spacing.medium)
|
||||
make.centerY.equalToSuperview()
|
||||
make.width.height.equalTo(AppMetrics.ControlSize.smallIcon)
|
||||
}
|
||||
|
||||
if isSecure {
|
||||
toggleButton.addTarget(self, action: #selector(toggleVisibility), for: .touchUpInside)
|
||||
addSubview(toggleButton)
|
||||
toggleButton.snp.makeConstraints { make in
|
||||
make.trailing.equalToSuperview().inset(AppMetrics.Spacing.small)
|
||||
make.centerY.equalToSuperview()
|
||||
make.width.height.equalTo(AppMetrics.ControlSize.iconTapArea)
|
||||
}
|
||||
textField.snp.makeConstraints { make in
|
||||
make.leading.equalTo(icon.snp.trailing).offset(AppMetrics.Spacing.xSmall)
|
||||
make.trailing.equalTo(toggleButton.snp.leading)
|
||||
make.centerY.equalToSuperview()
|
||||
}
|
||||
} else {
|
||||
textField.snp.makeConstraints { make in
|
||||
make.leading.equalTo(icon.snp.trailing).offset(AppMetrics.Spacing.xSmall)
|
||||
make.trailing.equalToSuperview().inset(AppMetrics.Spacing.medium)
|
||||
make.centerY.equalToSuperview()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
nil
|
||||
}
|
||||
|
||||
func setSecureEntry(_ secure: Bool) {
|
||||
textField.isSecureTextEntry = secure
|
||||
let imageName = secure ? "LoginPwdInvisible" : "LoginPwdVisible"
|
||||
toggleButton.setImage(UIImage(named: imageName), for: .normal)
|
||||
}
|
||||
|
||||
@objc private func toggleVisibility() {
|
||||
onToggleVisibility?()
|
||||
}
|
||||
}
|
||||
|
||||
/// 协议确认弹窗,引导用户勾选协议后继续登录。
|
||||
private final class LoginAgreementConsentViewController: UIViewController {
|
||||
|
||||
private let onOpenAgreement: (String) -> Void
|
||||
private let onAgreeAndContinue: () -> Void
|
||||
|
||||
init(onOpenAgreement: @escaping (String) -> Void, onAgreeAndContinue: @escaping () -> Void) {
|
||||
self.onOpenAgreement = onOpenAgreement
|
||||
self.onAgreeAndContinue = onAgreeAndContinue
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
nil
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .systemBackground
|
||||
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.text = "请先阅读并同意相关协议"
|
||||
titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.title3, weight: .semibold)
|
||||
titleLabel.textColor = AppDesign.textPrimary
|
||||
titleLabel.textAlignment = .center
|
||||
|
||||
let descriptionLabel = UILabel()
|
||||
descriptionLabel.text = "为了保障你的账号安全和服务体验,请阅读并同意用户协议和隐私政策。"
|
||||
descriptionLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline)
|
||||
descriptionLabel.textColor = AppDesign.textSecondary
|
||||
descriptionLabel.numberOfLines = 0
|
||||
descriptionLabel.textAlignment = .center
|
||||
|
||||
let continueButton = UIButton(type: .system)
|
||||
continueButton.setTitle("同意并继续", for: .normal)
|
||||
continueButton.titleLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.body, weight: .medium)
|
||||
continueButton.backgroundColor = AppDesign.primary
|
||||
continueButton.setTitleColor(.white, for: .normal)
|
||||
continueButton.layer.cornerRadius = AppMetrics.CornerRadius.button
|
||||
continueButton.accessibilityIdentifier = "login.agreement.continue"
|
||||
continueButton.addTarget(self, action: #selector(continueTapped), for: .touchUpInside)
|
||||
|
||||
view.addSubview(titleLabel)
|
||||
view.addSubview(descriptionLabel)
|
||||
view.addSubview(continueButton)
|
||||
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(view.safeAreaLayoutGuide).offset(AppMetrics.Spacing.sheet)
|
||||
make.leading.trailing.equalToSuperview().inset(AppMetrics.Spacing.large)
|
||||
}
|
||||
|
||||
descriptionLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(titleLabel.snp.bottom).offset(AppMetrics.Spacing.small)
|
||||
make.leading.trailing.equalTo(titleLabel)
|
||||
}
|
||||
|
||||
continueButton.snp.makeConstraints { make in
|
||||
make.leading.trailing.equalTo(titleLabel)
|
||||
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(AppMetrics.Spacing.mediumLarge)
|
||||
make.height.equalTo(AppMetrics.ControlSize.sheetButtonHeight)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func continueTapped() {
|
||||
dismiss(animated: true) { [onAgreeAndContinue] in
|
||||
onAgreeAndContinue()
|
||||
}
|
||||
}
|
||||
}
|
||||
212
suixinkan_ios/Features/Auth/ViewModels/LoginViewModel.swift
Normal file
212
suixinkan_ios/Features/Auth/ViewModels/LoginViewModel.swift
Normal file
@ -0,0 +1,212 @@
|
||||
//
|
||||
// LoginViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/18.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 登录输入焦点实体,标识当前需要聚焦的输入框。
|
||||
enum LoginField: Hashable {
|
||||
case username
|
||||
case password
|
||||
}
|
||||
|
||||
/// 登录表单校验错误实体,负责提供用户提示和焦点位置。
|
||||
enum LoginValidationError: Equatable {
|
||||
case invalidPhone
|
||||
case emptyPassword
|
||||
case privacyUnchecked
|
||||
|
||||
var message: String {
|
||||
switch self {
|
||||
case .invalidPhone:
|
||||
"请输入有效的手机号码"
|
||||
case .emptyPassword:
|
||||
"请输入密码"
|
||||
case .privacyUnchecked:
|
||||
"请先阅读并同意相关协议"
|
||||
}
|
||||
}
|
||||
|
||||
var focusField: LoginField? {
|
||||
switch self {
|
||||
case .invalidPhone:
|
||||
.username
|
||||
case .emptyPassword:
|
||||
.password
|
||||
case .privacyUnchecked:
|
||||
nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 登录结果实体,区分已完成登录和需要用户选择账号两种情况。
|
||||
enum LoginResolution {
|
||||
case completed(V9AuthResponse)
|
||||
case needsAccountSelection(AccountSelectionPayload)
|
||||
}
|
||||
|
||||
/// 登录流程错误实体,表示 token、账号列表或账号 ID 异常。
|
||||
enum LoginFlowError: LocalizedError {
|
||||
case missingToken
|
||||
case noAvailableAccount
|
||||
case invalidAccount
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .missingToken:
|
||||
"登录响应缺少 token"
|
||||
case .noAvailableAccount:
|
||||
"当前账号没有可用的景区账号或门店账号,请联系管理员"
|
||||
case .invalidAccount:
|
||||
"账号信息异常,请重新选择账号"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 登录页 ViewModel,负责表单状态、登录请求和多账号选择流程。
|
||||
final class LoginViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
var username = "" { didSet { onChange?() } }
|
||||
var password = "" { didSet { onChange?() } }
|
||||
var privacyChecked = false { didSet { onChange?() } }
|
||||
var isLoading = false { didSet { onChange?() } }
|
||||
var isSelectingAccount = false { didSet { onChange?() } }
|
||||
var showsPassword = false { didSet { onChange?() } }
|
||||
var showsAgreementSheet = false { didSet { onChange?() } }
|
||||
var pendingAccountSelection: AccountSelectionPayload? { didSet { onChange?() } }
|
||||
|
||||
var canSubmit: Bool {
|
||||
isValidPhone && !trimmedPassword.isEmpty
|
||||
}
|
||||
|
||||
var trimmedPassword: String {
|
||||
password.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
var normalizedUsername: String {
|
||||
normalizedPhoneNumber(username)
|
||||
}
|
||||
|
||||
var isValidPhone: Bool {
|
||||
normalizedUsername.count == 11 && normalizedUsername.first == "1"
|
||||
}
|
||||
|
||||
/// 应用本地登录偏好,只恢复手机号和协议状态,不恢复密码。
|
||||
func applyPreferences(_ preferences: LoginPreferences) {
|
||||
if username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
||||
let lastUsername = preferences.lastUsername {
|
||||
username = lastUsername
|
||||
}
|
||||
privacyChecked = preferences.privacyAgreementAccepted
|
||||
}
|
||||
|
||||
/// 校验登录表单,并返回第一个需要处理的错误。
|
||||
func validateForLogin() -> LoginValidationError? {
|
||||
guard isValidPhone else {
|
||||
return .invalidPhone
|
||||
}
|
||||
guard !trimmedPassword.isEmpty else {
|
||||
return .emptyPassword
|
||||
}
|
||||
guard privacyChecked else {
|
||||
return .privacyUnchecked
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 用户同意协议后关闭协议确认弹窗。
|
||||
func acceptAgreement() {
|
||||
privacyChecked = true
|
||||
showsAgreementSheet = false
|
||||
}
|
||||
|
||||
/// 当输入包含 +86 前缀时,把手机号规范化为 11 位国内手机号。
|
||||
func normalizeUsernameCountryCodeIfNeeded() {
|
||||
let digits = username.filter(\.isNumber)
|
||||
let normalizedPhone = normalizedPhoneNumber(username)
|
||||
guard normalizedPhone != digits, normalizedPhone.count == 11 else { return }
|
||||
username = normalizedPhone
|
||||
}
|
||||
|
||||
/// 发起登录,并根据账号数量决定直接完成或进入账号选择。
|
||||
func login(authAPI: AuthAPI) async throws -> LoginResolution {
|
||||
guard !isLoading else {
|
||||
throw CancellationError()
|
||||
}
|
||||
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
|
||||
let response = try await authAPI.login(
|
||||
username: normalizedUsername,
|
||||
password: trimmedPassword
|
||||
)
|
||||
return try await resolveLoginResponse(response, authAPI: authAPI)
|
||||
}
|
||||
|
||||
/// 选择一个账号并调用 set-user 换取正式 token。
|
||||
func selectAccount(_ account: AccountSwitchAccount, authAPI: AuthAPI) async throws -> V9AuthResponse {
|
||||
guard let payload = pendingAccountSelection, payload.hasTempToken else {
|
||||
throw LoginFlowError.missingToken
|
||||
}
|
||||
guard account.businessUserId > 0 else {
|
||||
throw LoginFlowError.invalidAccount
|
||||
}
|
||||
guard !isSelectingAccount else {
|
||||
throw CancellationError()
|
||||
}
|
||||
|
||||
isSelectingAccount = true
|
||||
defer { isSelectingAccount = false }
|
||||
|
||||
let response = try await authAPI.setUser(account.toSetUserRequest(), tokenOverride: payload.tempToken)
|
||||
guard !response.token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
throw LoginFlowError.missingToken
|
||||
}
|
||||
pendingAccountSelection = nil
|
||||
return response
|
||||
}
|
||||
|
||||
/// 清空待选账号信息,通常在用户取消账号选择时调用。
|
||||
func clearPendingAccountSelection() {
|
||||
pendingAccountSelection = nil
|
||||
}
|
||||
|
||||
/// 解析登录响应,单账号自动 set-user,多账号保留选择载荷。
|
||||
private func resolveLoginResponse(_ response: V9AuthResponse, authAPI: AuthAPI) async throws -> LoginResolution {
|
||||
let token = response.token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !token.isEmpty else {
|
||||
throw LoginFlowError.missingToken
|
||||
}
|
||||
|
||||
let accounts = response.accounts.filter { $0.businessUserId > 0 }
|
||||
guard !accounts.isEmpty else {
|
||||
throw LoginFlowError.noAvailableAccount
|
||||
}
|
||||
|
||||
if accounts.count == 1, let account = accounts.first {
|
||||
let finalResponse = try await authAPI.setUser(account.toSetUserRequest(), tokenOverride: token)
|
||||
guard !finalResponse.token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
throw LoginFlowError.missingToken
|
||||
}
|
||||
return .completed(finalResponse)
|
||||
}
|
||||
|
||||
let payload = AccountSelectionPayload(tempToken: token, accounts: accounts)
|
||||
pendingAccountSelection = payload
|
||||
return .needsAccountSelection(payload)
|
||||
}
|
||||
|
||||
/// 过滤手机号中的非数字字符,并去掉 86 国家码前缀。
|
||||
private func normalizedPhoneNumber(_ value: String) -> String {
|
||||
var digits = value.filter(\.isNumber)
|
||||
if digits.hasPrefix("86"), digits.count == 13 {
|
||||
digits.removeFirst(2)
|
||||
}
|
||||
return digits
|
||||
}
|
||||
}
|
||||
38
suixinkan_ios/Features/Home/Home.md
Normal file
38
suixinkan_ios/Features/Home/Home.md
Normal file
@ -0,0 +1,38 @@
|
||||
# Home 模块业务逻辑
|
||||
|
||||
## 模块职责
|
||||
|
||||
Home 模块负责登录后的首页工作台,包括当前景区展示、工作状态、位置上报入口、快捷操作、常用应用和全部功能入口。
|
||||
|
||||
首页菜单来自 `PermissionContext` 中的角色权限。当前模块只同步首页壳和入口路由,入口背后的业务子模块后续逐个迁移。
|
||||
|
||||
## 菜单生成
|
||||
|
||||
`HomeViewModel` 从当前角色的权限树递归提取菜单:
|
||||
- 当前角色 ID 存在但找不到时清空菜单。
|
||||
- 当前角色 ID 为空时使用第一个角色。
|
||||
- URI 按旧工程顺序排序。
|
||||
- 同义 URI 按 `HomeMenuRouter.menuAliasKey` 去重。
|
||||
|
||||
## 常用应用
|
||||
|
||||
`HomeCommonMenuStore` 使用 UserDefaults 保存常用应用 URI:
|
||||
- 首次无配置时写入默认常用应用。
|
||||
- 读取时按当前角色权限过滤不可用 URI。
|
||||
- 添加和移除时按同义 URI 去重。
|
||||
|
||||
## 路由规则
|
||||
|
||||
`HomeMenuRouter` 将权限 URI 分为四类:
|
||||
- `tab`:切换到订单或数据 Tab。
|
||||
- `destination`:进入已接入的本地页面,如个人信息、景区选择、权限申请、全部功能。
|
||||
- `unsupported`:已知 iOS 不支持的入口,进入占位说明。
|
||||
- `placeholder`:未知或未迁移入口,记录诊断并进入占位说明。
|
||||
|
||||
`HomeView` 不创建自己的 `NavigationStack`,而是使用 Main Tab 注入的 `RouterPath` 进行页面跳转。
|
||||
|
||||
## 后续迁移
|
||||
|
||||
目前 `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` 接管,`deposit_order_detail`、`deposit_order`、`deposit_order_shooting_info` 已由 `Features/Orders` 的押金订单页接管,`withdrawal_audit` 已由 `Features/WithdrawalAudit` 接管,`scenic_settlement` 和 `scenic_settlement_review` 已由 `Features/ScenicSettlement` 接管,`message_center` 已由 `Features/MessageCenter` 接管,`/scenic-queue` 和 `queue_management` 已由 `Features/QueueManagement` 接管,`live_stream_management` 和 `live_album` 已由 `Features/Live` 接管,`operating-area` 已由 `Features/OperatingArea` 接管,`pilot_cert` 已由 `Features/PilotCertification` 接管。
|
||||
|
||||
后续迁移具体首页子模块时,应先在 `HomeRoute` 增加目标页面,再更新 `HomeMenuRouter.resolve` 对应 URI 的映射,并同步补充单元测试。
|
||||
85
suixinkan_ios/Features/Home/HomeIconCatalog.swift
Normal file
85
suixinkan_ios/Features/Home/HomeIconCatalog.swift
Normal file
@ -0,0 +1,85 @@
|
||||
//
|
||||
// HomeIconCatalog.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 首页图标目录,负责为权限 URI 提供本地图标兜底。
|
||||
enum HomeIconCatalog {
|
||||
/// 返回指定 URI 对应的 SF Symbols 名称。
|
||||
static func iconName(for uri: String) -> String {
|
||||
switch uri {
|
||||
case "space_settings":
|
||||
"person.crop.square.fill"
|
||||
case "album_list":
|
||||
"photo.stack"
|
||||
case "album_trailer":
|
||||
"square.stack.3d.up.fill"
|
||||
case "wallet":
|
||||
"creditcard.fill"
|
||||
case "cloud_management":
|
||||
"icloud.fill"
|
||||
case "cloud_storage_transit":
|
||||
"arrow.left.arrow.right.circle.fill"
|
||||
case "asset_management", "/scenic-order-manage":
|
||||
"photo.on.rectangle.angled"
|
||||
case "material_upload":
|
||||
"square.and.arrow.up"
|
||||
case "task_management", "task_management_editor":
|
||||
"checklist"
|
||||
case "schedule_management":
|
||||
"calendar"
|
||||
case "system_settings":
|
||||
"gearshape.fill"
|
||||
case "message_center":
|
||||
"bell.fill"
|
||||
case "checkin_points":
|
||||
"mappin.and.ellipse"
|
||||
case "sample_management":
|
||||
"point.3.connected.trianglepath.dotted"
|
||||
case "sample_upload":
|
||||
"square.and.arrow.up.on.square"
|
||||
case "live_stream_management":
|
||||
"dot.radiowaves.left.and.right"
|
||||
case "verification_order":
|
||||
"checkmark.seal.fill"
|
||||
case "live_album":
|
||||
"play.rectangle.on.rectangle.fill"
|
||||
case "pm", "pm_manager", "project_edit":
|
||||
"circle.grid.2x2.fill"
|
||||
case "location_report", "location_report_history":
|
||||
"mappin.circle.fill"
|
||||
case "registration_invitation", "photographer_invite":
|
||||
"envelope.open.fill"
|
||||
case "store":
|
||||
"storefront.fill"
|
||||
case "fly", "pilot_cert", "pilot_controller":
|
||||
"paperplane.fill"
|
||||
case "/scenic-queue", "queue_management":
|
||||
"person.3.fill"
|
||||
case "operating-area":
|
||||
"map.fill"
|
||||
case "scenicselection":
|
||||
"location.magnifyingglass"
|
||||
case "scenicapplication":
|
||||
"doc.badge.plus"
|
||||
case "permission_apply", "permission_apply_status":
|
||||
"person.badge.key"
|
||||
case "scenic_settlement", "scenic_settlement_review":
|
||||
"checklist"
|
||||
case "payment_collection", "payment_qr", "payment_code":
|
||||
"qrcode"
|
||||
case "deposit_order_detail", "deposit_order", "deposit_order_shooting_info":
|
||||
"doc.text.magnifyingglass"
|
||||
case "withdrawal_audit":
|
||||
"banknote.fill"
|
||||
case "invite_record":
|
||||
"list.bullet.rectangle.fill"
|
||||
case "more_functions":
|
||||
"ellipsis"
|
||||
default:
|
||||
"square.grid.2x2.fill"
|
||||
}
|
||||
}
|
||||
}
|
||||
104
suixinkan_ios/Features/Home/Models/HomeMenuItem.swift
Normal file
104
suixinkan_ios/Features/Home/Models/HomeMenuItem.swift
Normal file
@ -0,0 +1,104 @@
|
||||
//
|
||||
// HomeMenuItem.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 首页菜单实体,表示一个由权限 URI 派生出来的可点击功能入口。
|
||||
struct HomeMenuItem: Equatable, Identifiable {
|
||||
let title: String
|
||||
let uri: String
|
||||
let iconSrc: String?
|
||||
|
||||
var id: String {
|
||||
uri
|
||||
}
|
||||
}
|
||||
|
||||
/// 首页可导航目标实体,表示首页菜单能进入的本地页面或占位页面。
|
||||
enum HomeRoute: Hashable {
|
||||
case profileSpace
|
||||
case scenicSelection
|
||||
case permissionApply
|
||||
case permissionApplyStatus
|
||||
case scenicApplication
|
||||
case moreFunctions
|
||||
case settings
|
||||
case paymentCollection
|
||||
case wallet
|
||||
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
|
||||
case materialUpload
|
||||
case sampleLibrary
|
||||
case sampleUpload
|
||||
case albumList
|
||||
case albumTrailer
|
||||
case punchPointList
|
||||
case punchPointDetail(id: Int, summary: PunchPointItem?)
|
||||
case punchPointEditor(id: Int?)
|
||||
case punchPointQR(id: Int, title: String, qrURL: String)
|
||||
case locationReport
|
||||
case locationReportHistory
|
||||
case depositOrders
|
||||
case withdrawalAudit
|
||||
case scenicSettlement
|
||||
case scenicSettlementReview
|
||||
case messageCenter
|
||||
case queueManagement
|
||||
case liveManagement
|
||||
case liveAlbum
|
||||
case operatingArea
|
||||
case pilotCertification
|
||||
case modulePlaceholder(uri: String, title: String)
|
||||
}
|
||||
|
||||
/// 首页权限 URI 解析结果,区分 Tab 跳转、本地页面、已知不支持和未知占位。
|
||||
enum HomeMenuResolvedRoute: Equatable {
|
||||
case tab(AppTab)
|
||||
case orders(OrdersEntry)
|
||||
case destination(HomeRoute)
|
||||
case unsupported(uri: String, title: String, reason: String)
|
||||
case placeholder(uri: String, title: String)
|
||||
}
|
||||
|
||||
/// 未知首页路由记录实体,用于诊断后端新增但 iOS 尚未映射的 URI。
|
||||
struct UnknownHomeRouteRecord: Codable, Equatable {
|
||||
let uri: String
|
||||
let title: String
|
||||
let firstSeenAt: Date
|
||||
let lastSeenAt: Date
|
||||
let count: Int
|
||||
}
|
||||
|
||||
/// 首页权限路由审计项,表示一个权限菜单和它的解析结果。
|
||||
struct HomePermissionRouteAuditEntry: Equatable {
|
||||
let uri: String
|
||||
let title: String
|
||||
let route: HomeMenuResolvedRoute
|
||||
}
|
||||
|
||||
/// 首页权限路由审计结果,按可路由、已知不支持、未知三类聚合。
|
||||
struct HomePermissionRouteAudit: Equatable {
|
||||
let routable: [HomePermissionRouteAuditEntry]
|
||||
let unsupported: [HomePermissionRouteAuditEntry]
|
||||
let unknown: [HomePermissionRouteAuditEntry]
|
||||
|
||||
var hasUnknownRoutes: Bool {
|
||||
!unknown.isEmpty
|
||||
}
|
||||
}
|
||||
503
suixinkan_ios/Features/Home/Routing/HomeMenuRouter.swift
Normal file
503
suixinkan_ios/Features/Home/Routing/HomeMenuRouter.swift
Normal file
@ -0,0 +1,503 @@
|
||||
//
|
||||
// HomeMenuRouter.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// 首页菜单路由器,将后端权限 URI 映射为 iOS 内部路由。
|
||||
enum HomeMenuRouter {
|
||||
private static let titleMap: [String: String] = [
|
||||
"basic_info": "基本信息",
|
||||
"space_settings": "空间设置",
|
||||
"album_list": "相册管理",
|
||||
"album_trailer": "相册预览上传",
|
||||
"photographer_stats": "数据统计",
|
||||
"photographer_orders": "订单管理",
|
||||
"/scenic-order-manage": "景区订单",
|
||||
"verification_order": "核销订单",
|
||||
"wallet": "我的钱包",
|
||||
"cloud_management": "相册云盘",
|
||||
"cloud_storage_transit": "传输管理",
|
||||
"asset_management": "素材管理",
|
||||
"material_upload": "上传素材",
|
||||
"task_management": "任务管理",
|
||||
"task_management_editor": "任务管理",
|
||||
"task_create": "发布任务",
|
||||
"schedule_management": "日程管理",
|
||||
"system_settings": "设置中心",
|
||||
"message_center": "消息中心",
|
||||
"checkin_points": "打卡点管理",
|
||||
"sample_management": "样片管理",
|
||||
"sample_upload": "上传样片",
|
||||
"live_stream_management": "直播管理",
|
||||
"live_album": "直播相册",
|
||||
"scenicselection": "景区选择",
|
||||
"scenicapplication": "景区申请",
|
||||
"permission_apply": "权限申请",
|
||||
"permission_apply_status": "权限申请状态",
|
||||
"scenic_settlement": "景区结算",
|
||||
"scenic_settlement_review": "结算审核",
|
||||
"pm": "项目管理",
|
||||
"pm_manager": "店铺项目管理",
|
||||
"project_edit": "项目管理",
|
||||
"location_report": "位置上报",
|
||||
"registration_invitation": "注册邀请",
|
||||
"store": "店铺管理",
|
||||
"fly": "飞行管理",
|
||||
"pilot_cert": "飞手认证",
|
||||
"pilot_controller": "飞控",
|
||||
"/scenic-queue": "排队管理",
|
||||
"queue_management": "排队管理",
|
||||
"operating-area": "运营区域",
|
||||
"payment_collection": "立即收款",
|
||||
"payment_qr": "收款码",
|
||||
"payment_code": "收款码",
|
||||
"deposit_order_detail": "押金订单详情",
|
||||
"deposit_order": "押金订单详情",
|
||||
"deposit_order_shooting_info": "押金拍摄信息",
|
||||
"withdrawal_audit": "提现审核",
|
||||
"location_report_history": "定位上报历史",
|
||||
"photographer_invite": "邀请摄影师",
|
||||
"invite_record": "邀请记录",
|
||||
"more_functions": "更多功能"
|
||||
]
|
||||
|
||||
/// 根据权限 URI 解析跳转目标。
|
||||
static func resolve(uri: String, title: String) -> HomeMenuResolvedRoute {
|
||||
switch uri {
|
||||
case "photographer_orders", "/scenic-order-manage":
|
||||
return .orders(.storeOrders)
|
||||
case "verification_order":
|
||||
return .orders(.verificationOrders)
|
||||
case "photographer_stats":
|
||||
return .tab(.statistics)
|
||||
case "space_settings", "basic_info":
|
||||
return .destination(.profileSpace)
|
||||
case "system_settings":
|
||||
return .destination(.settings)
|
||||
case "scenicselection":
|
||||
return .destination(.scenicSelection)
|
||||
case "permission_apply":
|
||||
return .destination(.permissionApply)
|
||||
case "permission_apply_status":
|
||||
return .destination(.permissionApplyStatus)
|
||||
case "scenicapplication":
|
||||
return .destination(.scenicApplication)
|
||||
case "wallet":
|
||||
return .destination(.wallet)
|
||||
case "payment_collection", "payment_qr", "payment_code":
|
||||
return .destination(.paymentCollection)
|
||||
case "task_management", "task_management_editor":
|
||||
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":
|
||||
return .destination(.cloudStorageTransit)
|
||||
case "asset_management":
|
||||
return .destination(.materialLibrary)
|
||||
case "material_upload":
|
||||
return .destination(.materialUpload)
|
||||
case "sample_management":
|
||||
return .destination(.sampleLibrary)
|
||||
case "sample_upload":
|
||||
return .destination(.sampleUpload)
|
||||
case "album_list":
|
||||
return .destination(.albumList)
|
||||
case "album_trailer":
|
||||
return .destination(.albumTrailer)
|
||||
case "checkin_points":
|
||||
return .destination(.punchPointList)
|
||||
case "location_report":
|
||||
return .destination(.locationReport)
|
||||
case "location_report_history":
|
||||
return .destination(.locationReportHistory)
|
||||
case "deposit_order_detail", "deposit_order", "deposit_order_shooting_info":
|
||||
return .destination(.depositOrders)
|
||||
case "withdrawal_audit":
|
||||
return .destination(.withdrawalAudit)
|
||||
case "scenic_settlement":
|
||||
return .destination(.scenicSettlement)
|
||||
case "scenic_settlement_review":
|
||||
return .destination(.scenicSettlementReview)
|
||||
case "message_center":
|
||||
return .destination(.messageCenter)
|
||||
case "/scenic-queue", "queue_management":
|
||||
return .destination(.queueManagement)
|
||||
case "live_stream_management":
|
||||
return .destination(.liveManagement)
|
||||
case "live_album":
|
||||
return .destination(.liveAlbum)
|
||||
case "operating-area":
|
||||
return .destination(.operatingArea)
|
||||
case "pilot_cert":
|
||||
return .destination(.pilotCertification)
|
||||
case "store", "more_functions":
|
||||
return .destination(.moreFunctions)
|
||||
case "fly", "pilot_controller":
|
||||
return .unsupported(
|
||||
uri: uri,
|
||||
title: title.isEmpty ? self.title(for: uri) : title,
|
||||
reason: "DJI/飞控模块已明确不纳入 iOS 迁移和后续开发范围。"
|
||||
)
|
||||
default:
|
||||
return .placeholder(uri: uri, title: title.isEmpty ? self.title(for: uri) : title)
|
||||
}
|
||||
}
|
||||
|
||||
/// 返回指定 URI 的默认标题。
|
||||
static func title(for uri: String) -> String {
|
||||
titleMap[uri] ?? uri
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
/// 返回调试页使用的全部已知首页菜单,不读取权限,便于预览迁移页面。
|
||||
static func debugAllMenuItems() -> [HomeMenuItem] {
|
||||
let preferredOrder = [
|
||||
"space_settings",
|
||||
"basic_info",
|
||||
"album_list",
|
||||
"album_trailer",
|
||||
"wallet",
|
||||
"payment_collection",
|
||||
"payment_qr",
|
||||
"payment_code",
|
||||
"cloud_management",
|
||||
"cloud_storage_transit",
|
||||
"asset_management",
|
||||
"material_upload",
|
||||
"task_management",
|
||||
"task_management_editor",
|
||||
"task_create",
|
||||
"schedule_management",
|
||||
"system_settings",
|
||||
"message_center",
|
||||
"checkin_points",
|
||||
"sample_management",
|
||||
"sample_upload",
|
||||
"live_stream_management",
|
||||
"live_album",
|
||||
"scenicselection",
|
||||
"scenicapplication",
|
||||
"permission_apply",
|
||||
"permission_apply_status",
|
||||
"scenic_settlement",
|
||||
"scenic_settlement_review",
|
||||
"verification_order",
|
||||
"photographer_orders",
|
||||
"/scenic-order-manage",
|
||||
"photographer_stats",
|
||||
"pm",
|
||||
"pm_manager",
|
||||
"project_edit",
|
||||
"location_report",
|
||||
"registration_invitation",
|
||||
"photographer_invite",
|
||||
"invite_record",
|
||||
"store",
|
||||
"fly",
|
||||
"pilot_cert",
|
||||
"pilot_controller",
|
||||
"/scenic-queue",
|
||||
"queue_management",
|
||||
"operating-area",
|
||||
"deposit_order_detail",
|
||||
"deposit_order",
|
||||
"deposit_order_shooting_info",
|
||||
"withdrawal_audit",
|
||||
"location_report_history",
|
||||
"more_functions"
|
||||
]
|
||||
let orderedURIs = preferredOrder + titleMap.keys.sorted().filter { !preferredOrder.contains($0) }
|
||||
return orderedURIs.map { uri in
|
||||
HomeMenuItem(title: title(for: uri), uri: uri, iconSrc: nil)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
/// 在可用 URI 集合中选取规范形式。
|
||||
static func canonicalURI(for uri: String, availableURIs: Set<String>) -> String {
|
||||
if availableURIs.contains(uri) {
|
||||
return uri
|
||||
}
|
||||
switch uri {
|
||||
case "task_management":
|
||||
return availableURIs.contains("task_management_editor") ? "task_management_editor" : uri
|
||||
case "task_management_editor":
|
||||
return availableURIs.contains("task_management") ? "task_management" : uri
|
||||
case "registration_invitation":
|
||||
return availableURIs.contains("photographer_invite") ? "photographer_invite" : uri
|
||||
case "photographer_invite":
|
||||
return availableURIs.contains("registration_invitation") ? "registration_invitation" : uri
|
||||
case "pm":
|
||||
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
|
||||
default:
|
||||
return uri
|
||||
}
|
||||
}
|
||||
|
||||
/// 返回同义 URI 的去重键。
|
||||
static func menuAliasKey(for uri: String) -> String {
|
||||
switch uri {
|
||||
case "task_management", "task_management_editor":
|
||||
return "task_management"
|
||||
case "registration_invitation", "photographer_invite":
|
||||
return "registration_invitation"
|
||||
case "pm", "project_edit":
|
||||
return "pm"
|
||||
case "payment_collection", "payment_qr", "payment_code":
|
||||
return "payment_collection"
|
||||
case "deposit_order_detail", "deposit_order", "deposit_order_shooting_info":
|
||||
return "deposit_order"
|
||||
case "photographer_orders", "/scenic-order-manage":
|
||||
return "photographer_orders"
|
||||
case "/scenic-queue", "queue_management":
|
||||
return "queue_management"
|
||||
default:
|
||||
return uri
|
||||
}
|
||||
}
|
||||
|
||||
/// 返回首页展示标题,部分同义入口统一文案。
|
||||
static func displayTitle(for uri: String, fallback: String) -> String {
|
||||
switch uri {
|
||||
case "registration_invitation", "photographer_invite":
|
||||
return "注册邀请"
|
||||
case "location_report":
|
||||
return "位置上报"
|
||||
case "pm", "project_edit":
|
||||
return "项目管理"
|
||||
case "pm_manager":
|
||||
return "店铺项目"
|
||||
case "space_settings":
|
||||
return "空间设置"
|
||||
case "task_management", "task_management_editor":
|
||||
return "任务管理"
|
||||
default:
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 首页权限路由审计器,用于识别权限里哪些 URI 尚未映射。
|
||||
enum HomePermissionRouteAuditor {
|
||||
/// 扫描当前角色权限并分类统计可路由、已知不支持和未知 URI。
|
||||
static func audit(permissions: [RolePermissionResponse], currentRoleId: Int?) -> HomePermissionRouteAudit {
|
||||
let source: RolePermissionResponse?
|
||||
if let currentRoleId {
|
||||
source = permissions.first { $0.role.id == currentRoleId }
|
||||
} else {
|
||||
source = permissions.first
|
||||
}
|
||||
|
||||
let entries = uniquePermissionItems(from: source?.role.permission ?? []).map { item in
|
||||
let title = item.name.isEmpty ? HomeMenuRouter.title(for: item.uri) : item.name
|
||||
return HomePermissionRouteAuditEntry(
|
||||
uri: item.uri,
|
||||
title: title,
|
||||
route: HomeMenuRouter.resolve(uri: item.uri, title: title)
|
||||
)
|
||||
}
|
||||
|
||||
return HomePermissionRouteAudit(
|
||||
routable: entries.filter { entry in
|
||||
switch entry.route {
|
||||
case .tab, .orders, .destination:
|
||||
return true
|
||||
case .unsupported, .placeholder:
|
||||
return false
|
||||
}
|
||||
},
|
||||
unsupported: entries.filter { entry in
|
||||
if case .unsupported = entry.route { return true }
|
||||
return false
|
||||
},
|
||||
unknown: entries.filter { entry in
|
||||
if case .placeholder = entry.route { return true }
|
||||
return false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/// 生成 Markdown 审计报告。
|
||||
static func markdownReport(audit: HomePermissionRouteAudit, generatedAt: Date = Date()) -> String {
|
||||
var lines = [
|
||||
"# Home Permission Route Audit",
|
||||
"",
|
||||
"Generated: \(reportDateFormatter.string(from: generatedAt))",
|
||||
"",
|
||||
"Routable: \(audit.routable.count)",
|
||||
"Unsupported: \(audit.unsupported.count)",
|
||||
"Unknown: \(audit.unknown.count)",
|
||||
""
|
||||
]
|
||||
|
||||
if audit.unknown.isEmpty {
|
||||
lines.append("No unknown permission routes.")
|
||||
} else {
|
||||
lines.append("## Unknown Routes")
|
||||
lines.append("")
|
||||
lines.append("| URI | Title |")
|
||||
lines.append("| --- | --- |")
|
||||
lines.append(contentsOf: audit.unknown.map { "| \(escape($0.uri)) | \(escape($0.title)) |" })
|
||||
lines.append("")
|
||||
lines.append("Action: map these URIs in `HomeMenuRouter`, confirm unsupported scope, or remove them before release.")
|
||||
}
|
||||
|
||||
if !audit.unsupported.isEmpty {
|
||||
lines.append("")
|
||||
lines.append("## Known Unsupported Routes")
|
||||
lines.append("")
|
||||
lines.append("| URI | Title | Reason |")
|
||||
lines.append("| --- | --- | --- |")
|
||||
lines.append(contentsOf: audit.unsupported.map { entry in
|
||||
let reason: String
|
||||
if case let .unsupported(_, _, value) = entry.route {
|
||||
reason = value
|
||||
} else {
|
||||
reason = ""
|
||||
}
|
||||
return "| \(escape(entry.uri)) | \(escape(entry.title)) | \(escape(reason)) |"
|
||||
})
|
||||
}
|
||||
|
||||
return lines.joined(separator: "\n")
|
||||
}
|
||||
|
||||
private static var reportDateFormatter: ISO8601DateFormatter {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
return formatter
|
||||
}
|
||||
|
||||
/// 从权限树提取非空 URI,并按同义 URI 去重。
|
||||
private static func uniquePermissionItems(from items: [PermissionItem]) -> [PermissionItem] {
|
||||
var seen = Set<String>()
|
||||
return flatten(items).filter { item in
|
||||
guard !item.uri.isEmpty else { return false }
|
||||
return seen.insert(HomeMenuRouter.menuAliasKey(for: item.uri)).inserted
|
||||
}
|
||||
}
|
||||
|
||||
/// 递归展开权限树。
|
||||
private static func flatten(_ items: [PermissionItem]) -> [PermissionItem] {
|
||||
items.flatMap { item in
|
||||
[item] + flatten(item.children)
|
||||
}
|
||||
}
|
||||
|
||||
/// 转义 Markdown 表格中的特殊字符。
|
||||
private static func escape(_ value: String) -> String {
|
||||
value
|
||||
.replacingOccurrences(of: "\\", with: "\\\\")
|
||||
.replacingOccurrences(of: "|", with: "\\|")
|
||||
.replacingOccurrences(of: "\n", with: " ")
|
||||
}
|
||||
}
|
||||
|
||||
/// 首页未知路由诊断工具,记录运行时无法识别的权限 URI。
|
||||
enum HomeRouteDiagnostics {
|
||||
private static let logger = Logger(subsystem: "com.yuanzhixiang.suixinkan", category: "HomeRouting")
|
||||
private static let defaultsKey = "home.routing.unknown.records"
|
||||
private static let dateFormatter: ISO8601DateFormatter = {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
return formatter
|
||||
}()
|
||||
|
||||
/// 记录一个未知首页 URI。
|
||||
static func recordUnknown(uri: String, title: String) {
|
||||
logger.warning("Unknown home menu uri: \(uri, privacy: .public), title: \(title, privacy: .public)")
|
||||
var records = unknownRoutes()
|
||||
let now = Date()
|
||||
if let index = records.firstIndex(where: { $0.uri == uri }) {
|
||||
let current = records[index]
|
||||
records[index] = UnknownHomeRouteRecord(
|
||||
uri: current.uri,
|
||||
title: title.isEmpty ? current.title : title,
|
||||
firstSeenAt: current.firstSeenAt,
|
||||
lastSeenAt: now,
|
||||
count: current.count + 1
|
||||
)
|
||||
} else {
|
||||
records.append(UnknownHomeRouteRecord(uri: uri, title: title, firstSeenAt: now, lastSeenAt: now, count: 1))
|
||||
}
|
||||
save(records)
|
||||
}
|
||||
|
||||
/// 读取所有未知首页路由记录。
|
||||
static func unknownRoutes() -> [UnknownHomeRouteRecord] {
|
||||
guard let data = UserDefaults.standard.data(forKey: defaultsKey),
|
||||
let records = try? JSONDecoder().decode([UnknownHomeRouteRecord].self, from: data)
|
||||
else { return [] }
|
||||
return records
|
||||
}
|
||||
|
||||
/// 生成未知首页路由 Markdown 报告。
|
||||
static func unknownRouteReport(generatedAt: Date = Date()) -> String {
|
||||
let records = unknownRoutes().sorted { lhs, rhs in
|
||||
if lhs.count != rhs.count {
|
||||
return lhs.count > rhs.count
|
||||
}
|
||||
return lhs.lastSeenAt > rhs.lastSeenAt
|
||||
}
|
||||
var lines = [
|
||||
"# Home Route Diagnostics",
|
||||
"",
|
||||
"Generated: \(dateFormatter.string(from: generatedAt))",
|
||||
""
|
||||
]
|
||||
|
||||
guard !records.isEmpty else {
|
||||
lines.append("No unknown home routes recorded.")
|
||||
return lines.joined(separator: "\n")
|
||||
}
|
||||
|
||||
lines.append("| URI | Title | Count | First Seen | Last Seen |")
|
||||
lines.append("| --- | --- | ---: | --- | --- |")
|
||||
lines.append(contentsOf: records.map { record in
|
||||
"| \(escape(record.uri)) | \(escape(record.title)) | \(record.count) | \(dateFormatter.string(from: record.firstSeenAt)) | \(dateFormatter.string(from: record.lastSeenAt)) |"
|
||||
})
|
||||
lines.append("")
|
||||
lines.append("Action: each URI above must be mapped in `HomeMenuRouter`, confirmed as unsupported scope, or explicitly accepted before release.")
|
||||
return lines.joined(separator: "\n")
|
||||
}
|
||||
|
||||
/// 清空未知首页路由记录。
|
||||
static func resetUnknownRoutes() {
|
||||
UserDefaults.standard.removeObject(forKey: defaultsKey)
|
||||
}
|
||||
|
||||
/// 保存未知首页路由记录。
|
||||
private static func save(_ records: [UnknownHomeRouteRecord]) {
|
||||
guard let data = try? JSONEncoder().encode(records) else { return }
|
||||
UserDefaults.standard.set(data, forKey: defaultsKey)
|
||||
}
|
||||
|
||||
/// 转义 Markdown 表格中的特殊字符。
|
||||
private static func escape(_ value: String) -> String {
|
||||
value
|
||||
.replacingOccurrences(of: "\\", with: "\\\\")
|
||||
.replacingOccurrences(of: "|", with: "\\|")
|
||||
.replacingOccurrences(of: "\n", with: " ")
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,91 @@
|
||||
//
|
||||
// HomeCommonMenuStore.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 首页常用应用存储服务,负责按当前权限过滤和持久化常用 URI。
|
||||
struct HomeCommonMenuStore {
|
||||
static let defaultStorageKey = "home.common.menu.uris"
|
||||
static let defaultBaselineKey = "home.common.menu.android.baseline.v2"
|
||||
|
||||
private let defaults: UserDefaults
|
||||
private let storageKey: String
|
||||
private let baselineKey: String
|
||||
|
||||
/// 初始化常用应用存储服务,并允许测试注入独立 UserDefaults。
|
||||
init(
|
||||
defaults: UserDefaults = .standard,
|
||||
storageKey: String = Self.defaultStorageKey,
|
||||
baselineKey: String = Self.defaultBaselineKey
|
||||
) {
|
||||
self.defaults = defaults
|
||||
self.storageKey = storageKey
|
||||
self.baselineKey = baselineKey
|
||||
}
|
||||
|
||||
/// 读取常用 URI,首次使用时写入默认值,并始终按当前权限过滤。
|
||||
func load(menuItems: [HomeMenuItem]) -> [String] {
|
||||
let availableURIs = Set(menuItems.map(\.uri))
|
||||
if !defaults.bool(forKey: baselineKey) {
|
||||
let defaults = defaultCommonURIs(availableURIs: availableURIs)
|
||||
save(defaults)
|
||||
self.defaults.set(true, forKey: baselineKey)
|
||||
return defaults
|
||||
}
|
||||
|
||||
let saved = defaults.stringArray(forKey: storageKey) ?? []
|
||||
let filtered = unique(saved.compactMap { uri -> String? in
|
||||
let resolved = HomeMenuRouter.canonicalURI(for: uri, availableURIs: availableURIs)
|
||||
return availableURIs.contains(resolved) ? resolved : nil
|
||||
})
|
||||
save(filtered)
|
||||
return filtered
|
||||
}
|
||||
|
||||
/// 将指定 URI 加入常用应用,按别名避免重复添加。
|
||||
func add(_ uri: String, current: [String], menuItems: [HomeMenuItem]) -> [String] {
|
||||
let availableURIs = Set(menuItems.map(\.uri))
|
||||
let resolved = HomeMenuRouter.canonicalURI(for: uri, availableURIs: availableURIs)
|
||||
guard availableURIs.contains(resolved) else { return current }
|
||||
guard !current.contains(where: { HomeMenuRouter.menuAliasKey(for: $0) == HomeMenuRouter.menuAliasKey(for: resolved) }) else {
|
||||
return current
|
||||
}
|
||||
let next = current + [resolved]
|
||||
save(next)
|
||||
return next
|
||||
}
|
||||
|
||||
/// 从常用应用中移除指定 URI,同义 URI 会一起移除。
|
||||
func remove(_ uri: String, current: [String]) -> [String] {
|
||||
let aliasKey = HomeMenuRouter.menuAliasKey(for: uri)
|
||||
let next = current.filter { HomeMenuRouter.menuAliasKey(for: $0) != aliasKey }
|
||||
save(next)
|
||||
return next
|
||||
}
|
||||
|
||||
/// 保存常用 URI。
|
||||
func save(_ uris: [String]) {
|
||||
defaults.set(unique(uris), forKey: storageKey)
|
||||
}
|
||||
|
||||
/// 生成默认常用应用 URI。
|
||||
private func defaultCommonURIs(availableURIs: Set<String>) -> [String] {
|
||||
let preferred = ["registration_invitation", "location_report", "pm_manager", "pm"]
|
||||
return unique(preferred.compactMap { uri in
|
||||
let resolved = HomeMenuRouter.canonicalURI(for: uri, availableURIs: availableURIs)
|
||||
return availableURIs.contains(resolved) ? resolved : nil
|
||||
})
|
||||
}
|
||||
|
||||
/// 按 URI 别名去重,保留首次出现的 URI。
|
||||
private func unique(_ uris: [String]) -> [String] {
|
||||
var seen = Set<String>()
|
||||
return uris.filter { uri in
|
||||
seen.insert(HomeMenuRouter.menuAliasKey(for: uri)).inserted
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,59 @@
|
||||
//
|
||||
// HomeMenuRouting.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
/// 首页菜单 UIKit 路由,将权限 URI 解析结果映射为 Tab 切换或 Navigation push。
|
||||
enum HomeMenuRouting {
|
||||
|
||||
/// 打开首页菜单项对应的目标。
|
||||
static func openMenu(_ item: HomeMenuItem, from viewController: UIViewController) {
|
||||
openRoute(HomeMenuRouter.resolve(uri: item.uri, title: item.title), from: viewController)
|
||||
}
|
||||
|
||||
/// 打开首页 URI 解析结果。
|
||||
static func openRoute(_ route: HomeMenuResolvedRoute, from viewController: UIViewController) {
|
||||
let services = AppServices.shared
|
||||
switch route {
|
||||
case .tab(let tab):
|
||||
services.appRouter.select(tab)
|
||||
case .orders(let entry):
|
||||
services.appRouter.selectOrders(entry: entry)
|
||||
case .destination(let homeRoute):
|
||||
push(homeRoute, from: viewController)
|
||||
case .unsupported(let uri, let title, _):
|
||||
pushPlaceholder(title: title, uri: uri, from: viewController)
|
||||
case .placeholder(let uri, let title):
|
||||
HomeRouteDiagnostics.recordUnknown(uri: uri, title: title)
|
||||
pushPlaceholder(title: title, uri: uri, from: viewController)
|
||||
}
|
||||
}
|
||||
|
||||
/// Push 首页二级路由页面。
|
||||
static func push(_ route: HomeRoute, from viewController: UIViewController) {
|
||||
let target = AppRouteViewControllerFactory.makeViewController(for: route, services: AppServices.shared)
|
||||
viewController.navigationController?.pushViewController(target, animated: true)
|
||||
}
|
||||
|
||||
/// Push 订单模块路由页面。
|
||||
static func pushOrders(_ route: OrdersRoute, from viewController: UIViewController) {
|
||||
let target = AppRouteViewControllerFactory.makeViewController(for: .orders(route), services: AppServices.shared)
|
||||
viewController.navigationController?.pushViewController(target, animated: true)
|
||||
}
|
||||
|
||||
/// Push 个人中心二级路由页面。
|
||||
static func pushProfile(_ route: ProfileRoute, from viewController: UIViewController) {
|
||||
let target = AppRouteViewControllerFactory.makeViewController(for: .profile(route), services: AppServices.shared)
|
||||
viewController.navigationController?.pushViewController(target, animated: true)
|
||||
}
|
||||
|
||||
private static func pushPlaceholder(title: String, uri: String, from viewController: UIViewController) {
|
||||
viewController.navigationController?.pushViewController(
|
||||
FeaturePlaceholderViewController(title: title, uri: uri),
|
||||
animated: true
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,260 @@
|
||||
//
|
||||
// HomeMoreFunctionsViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 首页全部功能页,支持常用应用增删和更多功能网格展示。
|
||||
final class HomeMoreFunctionsViewController: UIViewController {
|
||||
|
||||
private let viewModel = HomeViewModel()
|
||||
private let commonMenuStore = HomeCommonMenuStore()
|
||||
private var commonURIs: [String] = []
|
||||
|
||||
private lazy var tableView: UITableView = {
|
||||
let table = UITableView(frame: .zero, style: .grouped)
|
||||
table.backgroundColor = AppDesignUIKit.pageBackground
|
||||
table.separatorStyle = .none
|
||||
table.dataSource = self
|
||||
table.delegate = self
|
||||
table.register(HomeMoreMenuGridCell.self, forCellReuseIdentifier: HomeMoreMenuGridCell.reuseID)
|
||||
return table
|
||||
}()
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = "全部功能"
|
||||
view.backgroundColor = AppDesignUIKit.pageBackground
|
||||
navigationItem.largeTitleDisplayMode = .never
|
||||
|
||||
view.addSubview(tableView)
|
||||
tableView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
|
||||
viewModel.onChange = { [weak self] in
|
||||
self?.tableView.reloadData()
|
||||
}
|
||||
rebuildMenus()
|
||||
|
||||
appServices.permissionContext.onChange = { [weak self] in
|
||||
self?.rebuildMenus()
|
||||
}
|
||||
}
|
||||
|
||||
private func rebuildMenus() {
|
||||
let services = appServices
|
||||
viewModel.buildMenus(
|
||||
from: services.permissionContext.rolePermissions,
|
||||
currentRoleId: services.permissionContext.currentRole?.id
|
||||
)
|
||||
commonURIs = commonMenuStore.load(menuItems: viewModel.menuItems)
|
||||
tableView.reloadData()
|
||||
}
|
||||
|
||||
private var commonItems: [HomeMenuItem] {
|
||||
commonURIs.compactMap { menuItem(for: $0) }
|
||||
}
|
||||
|
||||
private var moreItems: [HomeMenuItem] {
|
||||
viewModel.menuItems.filter { !isCommonURI($0.uri) }
|
||||
}
|
||||
|
||||
private func menuItem(for uri: String) -> HomeMenuItem? {
|
||||
let availableURIs = Set(viewModel.menuItems.map(\.uri))
|
||||
let resolvedUri = HomeMenuRouter.canonicalURI(for: uri, availableURIs: availableURIs)
|
||||
guard let existing = viewModel.menuItems.first(where: { $0.uri == resolvedUri }) else { return nil }
|
||||
return HomeMenuItem(
|
||||
title: HomeMenuRouter.displayTitle(for: resolvedUri, fallback: existing.title),
|
||||
uri: resolvedUri,
|
||||
iconSrc: existing.iconSrc
|
||||
)
|
||||
}
|
||||
|
||||
private func isCommonURI(_ uri: String) -> Bool {
|
||||
let aliasKey = HomeMenuRouter.menuAliasKey(for: uri)
|
||||
return commonURIs.contains { HomeMenuRouter.menuAliasKey(for: $0) == aliasKey }
|
||||
}
|
||||
|
||||
private func toggleCommon(_ item: HomeMenuItem, isCommon: Bool) {
|
||||
if isCommon {
|
||||
commonURIs = commonMenuStore.remove(item.uri, current: commonURIs)
|
||||
} else {
|
||||
commonURIs = commonMenuStore.add(item.uri, current: commonURIs, menuItems: viewModel.menuItems)
|
||||
}
|
||||
tableView.reloadData()
|
||||
}
|
||||
|
||||
private func openMenu(_ item: HomeMenuItem) {
|
||||
let route = HomeMenuRouter.resolve(uri: item.uri, title: item.title)
|
||||
if case .destination(let homeRoute) = route, homeRoute == .moreFunctions { return }
|
||||
HomeMenuRouting.openRoute(route, from: self)
|
||||
}
|
||||
}
|
||||
|
||||
extension HomeMoreFunctionsViewController: UITableViewDataSource, UITableViewDelegate {
|
||||
func numberOfSections(in tableView: UITableView) -> Int { 2 }
|
||||
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 1 }
|
||||
|
||||
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||
section == 0 ? "常用应用" : "更多功能"
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: HomeMoreMenuGridCell.reuseID, for: indexPath) as! HomeMoreMenuGridCell
|
||||
let isCommon = indexPath.section == 0
|
||||
let items = isCommon ? commonItems : moreItems
|
||||
cell.configure(items: items, isCommon: isCommon) { [weak self] item in
|
||||
self?.openMenu(item)
|
||||
} onToggle: { [weak self] item in
|
||||
self?.toggleCommon(item, isCommon: isCommon)
|
||||
}
|
||||
return cell
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||
let count = indexPath.section == 0 ? commonItems.count : moreItems.count
|
||||
let rows = max(1, Int(ceil(Double(count) / 3.0)))
|
||||
return CGFloat(rows) * 124 + 8
|
||||
}
|
||||
}
|
||||
|
||||
private final class HomeMoreMenuGridCell: UITableViewCell {
|
||||
static let reuseID = "HomeMoreMenuGridCell"
|
||||
|
||||
private var items: [HomeMenuItem] = []
|
||||
private var isCommon = false
|
||||
private var onSelect: ((HomeMenuItem) -> Void)?
|
||||
private var onToggle: ((HomeMenuItem) -> Void)?
|
||||
|
||||
private lazy var collectionView: UICollectionView = {
|
||||
let layout = UICollectionViewFlowLayout()
|
||||
layout.minimumInteritemSpacing = 14
|
||||
layout.minimumLineSpacing = AppMetrics.Spacing.mediumLarge
|
||||
let view = UICollectionView(frame: .zero, collectionViewLayout: layout)
|
||||
view.backgroundColor = .clear
|
||||
view.isScrollEnabled = false
|
||||
view.dataSource = self
|
||||
view.delegate = self
|
||||
view.register(HomeMoreMenuItemCell.self, forCellWithReuseIdentifier: HomeMoreMenuItemCell.reuseID)
|
||||
return view
|
||||
}()
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
backgroundColor = .clear
|
||||
selectionStyle = .none
|
||||
contentView.addSubview(collectionView)
|
||||
collectionView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(AppMetrics.Spacing.mediumLarge)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func configure(
|
||||
items: [HomeMenuItem],
|
||||
isCommon: Bool,
|
||||
onSelect: @escaping (HomeMenuItem) -> Void,
|
||||
onToggle: @escaping (HomeMenuItem) -> Void
|
||||
) {
|
||||
self.items = items
|
||||
self.isCommon = isCommon
|
||||
self.onSelect = onSelect
|
||||
self.onToggle = onToggle
|
||||
collectionView.reloadData()
|
||||
}
|
||||
}
|
||||
|
||||
extension HomeMoreMenuGridCell: UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
|
||||
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
|
||||
items.count
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
|
||||
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: HomeMoreMenuItemCell.reuseID, for: indexPath) as! HomeMoreMenuItemCell
|
||||
let item = items[indexPath.item]
|
||||
cell.configure(item: item, isCommon: isCommon) { [weak self] in
|
||||
self?.onToggle?(item)
|
||||
}
|
||||
return cell
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||
onSelect?(items[indexPath.item])
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
|
||||
let width = (collectionView.bounds.width - 28) / 3
|
||||
return CGSize(width: width, height: 112)
|
||||
}
|
||||
}
|
||||
|
||||
private final class HomeMoreMenuItemCell: UICollectionViewCell {
|
||||
static let reuseID = "HomeMoreMenuItemCell"
|
||||
|
||||
private let iconView = UIImageView()
|
||||
private let titleLabel = UILabel()
|
||||
private let toggleButton = UIButton(type: .system)
|
||||
private var onToggle: (() -> Void)?
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
contentView.backgroundColor = .white
|
||||
contentView.layer.cornerRadius = 8
|
||||
|
||||
titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.callout)
|
||||
titleLabel.textColor = UIColor(hex: 0x252525)
|
||||
titleLabel.textAlignment = .center
|
||||
titleLabel.numberOfLines = 1
|
||||
titleLabel.adjustsFontSizeToFitWidth = true
|
||||
|
||||
toggleButton.addTarget(self, action: #selector(toggleTapped), for: .touchUpInside)
|
||||
contentView.addSubview(toggleButton)
|
||||
|
||||
let stack = UIStackView(arrangedSubviews: [iconView, titleLabel])
|
||||
stack.axis = .vertical
|
||||
stack.spacing = AppMetrics.Spacing.mediumLarge + 1
|
||||
stack.alignment = .center
|
||||
contentView.addSubview(stack)
|
||||
|
||||
stack.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
make.leading.trailing.equalToSuperview().inset(4)
|
||||
}
|
||||
iconView.snp.makeConstraints { make in
|
||||
make.width.height.equalTo(34)
|
||||
}
|
||||
toggleButton.snp.makeConstraints { make in
|
||||
make.top.trailing.equalToSuperview().inset(2)
|
||||
make.width.height.equalTo(28)
|
||||
}
|
||||
iconView.contentMode = .scaleAspectFit
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func configure(item: HomeMenuItem, isCommon: Bool, onToggle: @escaping () -> Void) {
|
||||
self.onToggle = onToggle
|
||||
titleLabel.text = item.title
|
||||
let symbol = UIImage(systemName: HomeIconCatalog.iconName(for: item.uri))
|
||||
iconView.loadRemoteImage(urlString: item.iconSrc, contentMode: .scaleAspectFit, placeholder: symbol)
|
||||
iconView.tintColor = AppDesign.primary
|
||||
let iconName = isCommon ? "minus.circle.fill" : "plus.circle.fill"
|
||||
toggleButton.setImage(UIImage(systemName: iconName), for: .normal)
|
||||
toggleButton.tintColor = isCommon ? UIColor(hex: 0xFF1111) : AppDesignUIKit.primary
|
||||
}
|
||||
|
||||
@objc private func toggleTapped() {
|
||||
onToggle?()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,63 @@
|
||||
//
|
||||
// HomePlaceholderViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/26.
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 首页迁移占位页,用于尚未完成 UIKit 迁移的入口。
|
||||
final class HomePlaceholderViewController: UIViewController {
|
||||
private let pageTitle: String
|
||||
private let uri: String?
|
||||
|
||||
init(title: String, uri: String? = nil) {
|
||||
pageTitle = title
|
||||
self.uri = uri
|
||||
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 iconView = UIImageView(image: UIImage(systemName: "square.grid.2x2"))
|
||||
iconView.tintColor = AppDesign.primary
|
||||
iconView.contentMode = .scaleAspectFit
|
||||
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.text = pageTitle
|
||||
titleLabel.font = .systemFont(ofSize: 20, weight: .semibold)
|
||||
titleLabel.textColor = AppDesign.textPrimary
|
||||
titleLabel.textAlignment = .center
|
||||
|
||||
let uriLabel = UILabel()
|
||||
uriLabel.text = uri
|
||||
uriLabel.font = .systemFont(ofSize: 14)
|
||||
uriLabel.textColor = AppDesign.textSecondary
|
||||
uriLabel.textAlignment = .center
|
||||
uriLabel.numberOfLines = 2
|
||||
uriLabel.isHidden = uri?.isEmpty != false
|
||||
|
||||
let stack = UIStackView(arrangedSubviews: [iconView, titleLabel, uriLabel])
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 12
|
||||
stack.alignment = .center
|
||||
view.addSubview(stack)
|
||||
stack.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
make.leading.trailing.equalToSuperview().inset(24)
|
||||
}
|
||||
iconView.snp.makeConstraints { make in
|
||||
make.width.height.equalTo(44)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,583 @@
|
||||
//
|
||||
// HomeViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 首页工作台,展示景区头部、工作状态、位置上报卡片和常用应用网格。
|
||||
final class HomeViewController: UIViewController {
|
||||
|
||||
private let viewModel = HomeViewModel()
|
||||
private let commonMenuStore = HomeCommonMenuStore()
|
||||
private var commonURIs: [String] = []
|
||||
private var isOnline = false
|
||||
private var reminderMinutes = 0
|
||||
private var secondsUntilReport = 0
|
||||
private var countdownTimer: Timer?
|
||||
|
||||
private let minimalTopRoleIds: Set<Int> = [46, 47, 52, 53, 54]
|
||||
|
||||
private lazy var scenicButton: UIButton = {
|
||||
var config = UIButton.Configuration.plain()
|
||||
config.baseForegroundColor = UIColor(hex: 0x333333)
|
||||
config.image = UIImage(systemName: "chevron.down")?.withConfiguration(
|
||||
UIImage.SymbolConfiguration(pointSize: AppMetrics.FontSize.subheadline, weight: .bold)
|
||||
)
|
||||
config.imagePlacement = .trailing
|
||||
config.imagePadding = AppMetrics.Spacing.xSmall
|
||||
config.contentInsets = .zero
|
||||
let button = UIButton(configuration: config)
|
||||
button.contentHorizontalAlignment = .leading
|
||||
button.addTarget(self, action: #selector(scenicTapped), for: .touchUpInside)
|
||||
return button
|
||||
}()
|
||||
|
||||
private lazy var tableView: UITableView = {
|
||||
let table = UITableView(frame: .zero, style: .grouped)
|
||||
table.backgroundColor = AppDesignUIKit.pageBackground
|
||||
table.separatorStyle = .none
|
||||
table.showsVerticalScrollIndicator = false
|
||||
table.dataSource = self
|
||||
table.delegate = self
|
||||
table.register(HomeMenuGridCell.self, forCellReuseIdentifier: HomeMenuGridCell.reuseID)
|
||||
table.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
|
||||
return table
|
||||
}()
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = AppDesignUIKit.pageBackground
|
||||
setupTopBar()
|
||||
setupTableView()
|
||||
bindViewModel()
|
||||
rebuildMenus()
|
||||
observeContextChanges()
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
navigationController?.setNavigationBarHidden(true, animated: animated)
|
||||
startCountdownTimerIfNeeded()
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
countdownTimer?.invalidate()
|
||||
countdownTimer = nil
|
||||
}
|
||||
|
||||
private func setupTopBar() {
|
||||
let topBar = UIView()
|
||||
topBar.backgroundColor = .white
|
||||
view.addSubview(topBar)
|
||||
topBar.addSubview(scenicButton)
|
||||
|
||||
topBar.snp.makeConstraints { make in
|
||||
make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
|
||||
make.height.equalTo(78)
|
||||
}
|
||||
scenicButton.snp.makeConstraints { make in
|
||||
make.leading.trailing.equalToSuperview().inset(AppMetrics.Spacing.pageHorizontal)
|
||||
make.centerY.equalToSuperview()
|
||||
}
|
||||
updateScenicTitle()
|
||||
}
|
||||
|
||||
private func setupTableView() {
|
||||
view.addSubview(tableView)
|
||||
tableView.snp.makeConstraints { make in
|
||||
make.top.equalTo(view.safeAreaLayoutGuide).offset(78)
|
||||
make.leading.trailing.bottom.equalToSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
private func bindViewModel() {
|
||||
viewModel.onChange = { [weak self] in
|
||||
self?.tableView.reloadData()
|
||||
}
|
||||
}
|
||||
|
||||
private func observeContextChanges() {
|
||||
let services = appServices
|
||||
services.permissionContext.onChange = { [weak self] in
|
||||
self?.rebuildMenus()
|
||||
}
|
||||
services.accountContext.onChange = { [weak self] in
|
||||
self?.updateScenicTitle()
|
||||
self?.tableView.reloadData()
|
||||
}
|
||||
}
|
||||
|
||||
private func rebuildMenus() {
|
||||
let services = appServices
|
||||
viewModel.buildMenus(
|
||||
from: services.permissionContext.rolePermissions,
|
||||
currentRoleId: services.permissionContext.currentRole?.id
|
||||
)
|
||||
commonURIs = commonMenuStore.load(menuItems: viewModel.menuItems)
|
||||
tableView.reloadData()
|
||||
}
|
||||
|
||||
private func updateScenicTitle() {
|
||||
let name = appServices.accountContext.currentScenic?.name ?? "请选择景区"
|
||||
scenicButton.configuration?.attributedTitle = AttributedString(
|
||||
name,
|
||||
attributes: AttributeContainer([
|
||||
.font: UIFont.systemFont(ofSize: AppMetrics.FontSize.title3, weight: .semibold)
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
private var currentRoleId: Int? {
|
||||
appServices.permissionContext.currentRole?.id
|
||||
}
|
||||
|
||||
private var shouldShowWorkStatus: Bool {
|
||||
guard let currentRoleId else { return true }
|
||||
return !minimalTopRoleIds.contains(currentRoleId)
|
||||
}
|
||||
|
||||
private var isStoreManager: Bool {
|
||||
currentRoleId == 46
|
||||
}
|
||||
|
||||
private var displayMenuItems: [HomeMenuItem] {
|
||||
let selected = commonURIs.compactMap { menuItem(for: $0) }
|
||||
let fallback = selected.isEmpty ? Array(viewModel.menuItems.prefix(3)) : selected
|
||||
return fallback + [HomeMenuItem(title: "更多功能", uri: "more_functions", iconSrc: nil)]
|
||||
}
|
||||
|
||||
private func menuItem(for uri: String) -> HomeMenuItem? {
|
||||
let availableURIs = Set(viewModel.menuItems.map(\.uri))
|
||||
let resolvedUri = HomeMenuRouter.canonicalURI(for: uri, availableURIs: availableURIs)
|
||||
guard let existing = viewModel.menuItems.first(where: { $0.uri == resolvedUri }) else { return nil }
|
||||
return HomeMenuItem(
|
||||
title: HomeMenuRouter.displayTitle(for: resolvedUri, fallback: existing.title),
|
||||
uri: resolvedUri,
|
||||
iconSrc: existing.iconSrc
|
||||
)
|
||||
}
|
||||
|
||||
private var countdownDisplay: String {
|
||||
let hours = secondsUntilReport / 3_600
|
||||
let minutes = (secondsUntilReport % 3_600) / 60
|
||||
let seconds = secondsUntilReport % 60
|
||||
return "\(hours):\(String(format: "%02d", minutes)):\(String(format: "%02d", seconds))"
|
||||
}
|
||||
|
||||
private var reminderText: String {
|
||||
reminderMinutes == 0 ? "不提醒" : "提前\(reminderMinutes)分钟"
|
||||
}
|
||||
|
||||
private func startCountdownTimerIfNeeded() {
|
||||
countdownTimer?.invalidate()
|
||||
guard isOnline else { return }
|
||||
countdownTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
|
||||
guard let self, self.isOnline, self.secondsUntilReport > 0 else { return }
|
||||
self.secondsUntilReport -= 1
|
||||
self.tableView.reloadSections(IndexSet(integer: 0), with: .none)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func scenicTapped() {
|
||||
HomeMenuRouting.push(.scenicSelection, from: self)
|
||||
}
|
||||
|
||||
@objc private func onlineTapped() {
|
||||
let message = isOnline
|
||||
? "是否确认切换为离线状态?离线后将暂停位置上报。"
|
||||
: "是否确认切换为在线状态?在线后将开始位置上报和计时。"
|
||||
let alert = UIAlertController(title: "切换在线状态", message: message, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
alert.addAction(UIAlertAction(title: "确定", style: .default) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
self.isOnline.toggle()
|
||||
if self.isOnline {
|
||||
self.secondsUntilReport = 7_200
|
||||
self.startCountdownTimerIfNeeded()
|
||||
} else {
|
||||
self.countdownTimer?.invalidate()
|
||||
}
|
||||
self.tableView.reloadSections(IndexSet(integer: 0), with: .none)
|
||||
})
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
@objc private func reminderTapped() {
|
||||
let sheet = UIAlertController(title: "提前提醒时间", message: nil, preferredStyle: .actionSheet)
|
||||
for minute in [0, 5, 10, 15, 30] {
|
||||
let title = minute == 0 ? "不提醒" : "\(minute)分钟"
|
||||
sheet.addAction(UIAlertAction(title: title, style: .default) { [weak self] _ in
|
||||
self?.reminderMinutes = minute
|
||||
self?.tableView.reloadSections(IndexSet(integer: 0), with: .none)
|
||||
})
|
||||
}
|
||||
sheet.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
present(sheet, animated: true)
|
||||
}
|
||||
|
||||
@objc private func locationReportTapped() {
|
||||
HomeMenuRouting.openRoute(HomeMenuRouter.resolve(uri: "location_report", title: "位置上报"), from: self)
|
||||
}
|
||||
|
||||
@objc private func paymentTapped() {
|
||||
HomeMenuRouting.openRoute(HomeMenuRouter.resolve(uri: "payment_collection", title: "立即收款"), from: self)
|
||||
}
|
||||
|
||||
@objc private func taskCreateTapped() {
|
||||
HomeMenuRouting.openRoute(HomeMenuRouter.resolve(uri: "task_create", title: "提交任务"), from: self)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UITableView
|
||||
|
||||
extension HomeViewController: UITableViewDataSource, UITableViewDelegate {
|
||||
func numberOfSections(in tableView: UITableView) -> Int {
|
||||
var count = 1
|
||||
if shouldShowWorkStatus { count += 2 }
|
||||
if isStoreManager, appServices.accountContext.currentStore != nil { count += 1 }
|
||||
return count
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
1
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||
if section == tableView.numberOfSections - 1 {
|
||||
return "常用应用"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
if indexPath.section == tableView.numberOfSections - 1 {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: HomeMenuGridCell.reuseID, for: indexPath) as! HomeMenuGridCell
|
||||
cell.configure(items: displayMenuItems) { [weak self] item in
|
||||
self.flatMap { HomeMenuRouting.openMenu(item, from: $0) }
|
||||
}
|
||||
return cell
|
||||
}
|
||||
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
|
||||
cell.selectionStyle = .none
|
||||
cell.contentView.subviews.forEach { $0.removeFromSuperview() }
|
||||
|
||||
if shouldShowWorkStatus {
|
||||
if indexPath.section == 0 {
|
||||
cell.contentView.addSubview(makeStatusCard())
|
||||
} else if indexPath.section == 1 {
|
||||
cell.contentView.addSubview(makeLocationCard())
|
||||
} else if indexPath.section == 2, isStoreManager, let store = appServices.accountContext.currentStore {
|
||||
cell.contentView.addSubview(makeStoreCard(store))
|
||||
} else {
|
||||
cell.contentView.addSubview(makeQuickActionsRow())
|
||||
}
|
||||
} else if isStoreManager, let store = appServices.accountContext.currentStore, indexPath.section == 0 {
|
||||
cell.contentView.addSubview(makeStoreCard(store))
|
||||
}
|
||||
|
||||
cell.contentView.subviews.first?.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(UIEdgeInsets(
|
||||
top: AppMetrics.Spacing.xxSmall,
|
||||
left: AppMetrics.Spacing.pageHorizontal,
|
||||
bottom: AppMetrics.Spacing.xxSmall,
|
||||
right: AppMetrics.Spacing.pageHorizontal
|
||||
))
|
||||
}
|
||||
cell.backgroundColor = .clear
|
||||
return cell
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||
if indexPath.section == tableView.numberOfSections - 1 { return 240 }
|
||||
if shouldShowWorkStatus {
|
||||
switch indexPath.section {
|
||||
case 0: return 100
|
||||
case 1: return 148
|
||||
case 2 where isStoreManager && appServices.accountContext.currentStore != nil: return 88
|
||||
default: return 118
|
||||
}
|
||||
}
|
||||
if isStoreManager, appServices.accountContext.currentStore != nil, indexPath.section == 0 { return 88 }
|
||||
return UITableView.automaticDimension
|
||||
}
|
||||
|
||||
private func makeStatusCard() -> UIView {
|
||||
let card = makeCardView()
|
||||
|
||||
let onlineButton = UIButton(type: .system)
|
||||
onlineButton.setTitle(isOnline ? "在线" : "离线", for: .normal)
|
||||
onlineButton.titleLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.caption, weight: .medium)
|
||||
onlineButton.setTitleColor(isOnline ? UIColor(hex: 0xF0FDF4) : UIColor(hex: 0x7B8EAA), for: .normal)
|
||||
onlineButton.backgroundColor = isOnline ? UIColor(hex: 0x22C55E) : UIColor(hex: 0xF4F4F4)
|
||||
onlineButton.layer.cornerRadius = 4
|
||||
onlineButton.contentEdgeInsets = UIEdgeInsets(top: 6, left: 12, bottom: 6, right: 12)
|
||||
onlineButton.addTarget(self, action: #selector(onlineTapped), for: .touchUpInside)
|
||||
|
||||
let clockLabel = UILabel()
|
||||
clockLabel.text = " \(countdownDisplay)"
|
||||
clockLabel.font = .systemFont(ofSize: AppMetrics.FontSize.body, weight: .medium)
|
||||
clockLabel.textColor = AppDesignUIKit.primary
|
||||
|
||||
let reminderButton = UIButton(type: .system)
|
||||
reminderButton.setTitle(" \(reminderText)", for: .normal)
|
||||
reminderButton.titleLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.body, weight: .medium)
|
||||
reminderButton.setTitleColor(AppDesignUIKit.primary, for: .normal)
|
||||
reminderButton.addTarget(self, action: #selector(reminderTapped), for: .touchUpInside)
|
||||
|
||||
let stack = UIStackView(arrangedSubviews: [onlineButton, clockLabel, reminderButton])
|
||||
stack.axis = .horizontal
|
||||
stack.distribution = .equalSpacing
|
||||
stack.alignment = .center
|
||||
card.addSubview(stack)
|
||||
stack.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(15)
|
||||
}
|
||||
return card
|
||||
}
|
||||
|
||||
private func makeLocationCard() -> UIView {
|
||||
let card = makeCardView()
|
||||
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.text = "立即上报"
|
||||
titleLabel.font = .systemFont(ofSize: 22, weight: .bold)
|
||||
titleLabel.textColor = UIColor(hex: 0x333333)
|
||||
|
||||
let subtitleLabel = UILabel()
|
||||
subtitleLabel.text = "您已进入打卡范围"
|
||||
subtitleLabel.font = .systemFont(ofSize: 15)
|
||||
subtitleLabel.textColor = UIColor(hex: 0x999999)
|
||||
|
||||
let textStack = UIStackView(arrangedSubviews: [titleLabel, subtitleLabel])
|
||||
textStack.axis = .vertical
|
||||
textStack.spacing = AppMetrics.Spacing.xxSmall
|
||||
|
||||
let actionButton = UIButton(type: .system)
|
||||
actionButton.setImage(UIImage(systemName: "hand.tap.fill"), for: .normal)
|
||||
actionButton.tintColor = .white
|
||||
actionButton.backgroundColor = AppDesignUIKit.primary
|
||||
actionButton.layer.cornerRadius = 46
|
||||
actionButton.addTarget(self, action: #selector(locationReportTapped), for: .touchUpInside)
|
||||
|
||||
card.addSubview(textStack)
|
||||
card.addSubview(actionButton)
|
||||
textStack.snp.makeConstraints { make in
|
||||
make.leading.top.bottom.equalToSuperview().inset(15)
|
||||
make.trailing.lessThanOrEqualTo(actionButton.snp.leading).offset(-12)
|
||||
}
|
||||
actionButton.snp.makeConstraints { make in
|
||||
make.trailing.equalToSuperview().inset(15)
|
||||
make.centerY.equalToSuperview()
|
||||
make.width.height.equalTo(92)
|
||||
}
|
||||
return card
|
||||
}
|
||||
|
||||
private func makeQuickActionsRow() -> UIView {
|
||||
let stack = UIStackView()
|
||||
stack.axis = .horizontal
|
||||
stack.spacing = AppMetrics.Spacing.small
|
||||
stack.distribution = .fillEqually
|
||||
|
||||
stack.addArrangedSubview(quickActionButton(icon: "qrcode", title: "立即收款", action: #selector(paymentTapped)))
|
||||
stack.addArrangedSubview(quickActionButton(icon: "checklist.checked", title: "提交任务", action: #selector(taskCreateTapped)))
|
||||
stack.addArrangedSubview(quickActionButton(
|
||||
icon: isOnline ? "wifi" : "wifi.slash",
|
||||
title: isOnline ? "在线" : "离线",
|
||||
action: #selector(onlineTapped),
|
||||
active: isOnline
|
||||
))
|
||||
return stack
|
||||
}
|
||||
|
||||
private func quickActionButton(icon: String, title: String, action: Selector, active: Bool = false) -> UIView {
|
||||
let card = makeCardView()
|
||||
card.backgroundColor = active ? UIColor(hex: 0xE3F2FD) : .white
|
||||
|
||||
let iconView = UIImageView(image: UIImage(systemName: icon))
|
||||
iconView.tintColor = AppDesignUIKit.primary
|
||||
iconView.contentMode = .scaleAspectFit
|
||||
|
||||
let label = UILabel()
|
||||
label.text = title
|
||||
label.font = .systemFont(ofSize: AppMetrics.FontSize.body)
|
||||
label.textAlignment = .center
|
||||
|
||||
let stack = UIStackView(arrangedSubviews: [iconView, label])
|
||||
stack.axis = .vertical
|
||||
stack.spacing = AppMetrics.Spacing.xSmall
|
||||
stack.alignment = .center
|
||||
card.addSubview(stack)
|
||||
stack.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
}
|
||||
iconView.snp.makeConstraints { make in
|
||||
make.height.equalTo(34)
|
||||
}
|
||||
|
||||
let button = UIButton(type: .custom)
|
||||
button.addTarget(self, action: action, for: .touchUpInside)
|
||||
card.addSubview(button)
|
||||
button.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
card.snp.makeConstraints { make in
|
||||
make.height.equalTo(102)
|
||||
}
|
||||
return card
|
||||
}
|
||||
|
||||
private func makeStoreCard(_ store: BusinessScope) -> UIView {
|
||||
let card = makeCardView()
|
||||
|
||||
let nameLabel = UILabel()
|
||||
nameLabel.text = store.name
|
||||
nameLabel.font = .systemFont(ofSize: AppMetrics.FontSize.title2, weight: .bold)
|
||||
|
||||
let scenicLabel = UILabel()
|
||||
scenicLabel.text = appServices.accountContext.currentScenic?.name
|
||||
scenicLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline)
|
||||
scenicLabel.textColor = UIColor(hex: 0x7B8EAA)
|
||||
scenicLabel.numberOfLines = 2
|
||||
|
||||
let statusLabel = UILabel()
|
||||
statusLabel.text = "营业中"
|
||||
statusLabel.font = .systemFont(ofSize: AppMetrics.FontSize.caption)
|
||||
statusLabel.textColor = UIColor(hex: 0x22C55E)
|
||||
statusLabel.backgroundColor = UIColor(hex: 0xF0FDF4)
|
||||
statusLabel.layer.cornerRadius = 4
|
||||
statusLabel.clipsToBounds = true
|
||||
statusLabel.textAlignment = .center
|
||||
|
||||
let textStack = UIStackView(arrangedSubviews: [nameLabel, scenicLabel])
|
||||
textStack.axis = .vertical
|
||||
textStack.spacing = AppMetrics.Spacing.xSmall
|
||||
|
||||
card.addSubview(textStack)
|
||||
card.addSubview(statusLabel)
|
||||
textStack.snp.makeConstraints { make in
|
||||
make.leading.top.bottom.equalToSuperview().inset(AppMetrics.Spacing.medium)
|
||||
make.trailing.lessThanOrEqualTo(statusLabel.snp.leading).offset(-8)
|
||||
}
|
||||
statusLabel.snp.makeConstraints { make in
|
||||
make.trailing.top.equalToSuperview().inset(AppMetrics.Spacing.medium)
|
||||
make.width.greaterThanOrEqualTo(52)
|
||||
make.height.equalTo(22)
|
||||
}
|
||||
return card
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Menu Grid Cell
|
||||
|
||||
private final class HomeMenuGridCell: UITableViewCell {
|
||||
static let reuseID = "HomeMenuGridCell"
|
||||
|
||||
private var onSelect: ((HomeMenuItem) -> Void)?
|
||||
private var items: [HomeMenuItem] = []
|
||||
|
||||
private lazy var collectionView: UICollectionView = {
|
||||
let layout = UICollectionViewFlowLayout()
|
||||
layout.minimumInteritemSpacing = 15
|
||||
layout.minimumLineSpacing = 15
|
||||
let view = UICollectionView(frame: .zero, collectionViewLayout: layout)
|
||||
view.backgroundColor = .clear
|
||||
view.isScrollEnabled = false
|
||||
view.dataSource = self
|
||||
view.delegate = self
|
||||
view.register(HomeMenuItemCell.self, forCellWithReuseIdentifier: HomeMenuItemCell.reuseID)
|
||||
return view
|
||||
}()
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
backgroundColor = .clear
|
||||
selectionStyle = .none
|
||||
contentView.addSubview(collectionView)
|
||||
collectionView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(AppMetrics.Spacing.pageHorizontal)
|
||||
make.height.equalTo(220)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func configure(items: [HomeMenuItem], onSelect: @escaping (HomeMenuItem) -> Void) {
|
||||
self.items = items
|
||||
self.onSelect = onSelect
|
||||
collectionView.reloadData()
|
||||
}
|
||||
}
|
||||
|
||||
extension HomeMenuGridCell: UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
|
||||
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
|
||||
items.count
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
|
||||
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: HomeMenuItemCell.reuseID, for: indexPath) as! HomeMenuItemCell
|
||||
cell.configure(item: items[indexPath.item])
|
||||
return cell
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||
onSelect?(items[indexPath.item])
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
|
||||
let width = (collectionView.bounds.width - 30) / 3
|
||||
return CGSize(width: width, height: 102)
|
||||
}
|
||||
}
|
||||
|
||||
private final class HomeMenuItemCell: UICollectionViewCell {
|
||||
static let reuseID = "HomeMenuItemCell"
|
||||
|
||||
private let iconView = UIImageView()
|
||||
private let titleLabel = UILabel()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
contentView.backgroundColor = .white
|
||||
contentView.layer.cornerRadius = 8
|
||||
|
||||
titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.body)
|
||||
titleLabel.textColor = UIColor(hex: 0x4B5563)
|
||||
titleLabel.textAlignment = .center
|
||||
titleLabel.numberOfLines = 1
|
||||
titleLabel.adjustsFontSizeToFitWidth = true
|
||||
|
||||
let stack = UIStackView(arrangedSubviews: [iconView, titleLabel])
|
||||
stack.axis = .vertical
|
||||
stack.spacing = AppMetrics.Spacing.small
|
||||
stack.alignment = .center
|
||||
contentView.addSubview(stack)
|
||||
stack.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
make.leading.trailing.equalToSuperview().inset(4)
|
||||
}
|
||||
iconView.snp.makeConstraints { make in
|
||||
make.width.height.equalTo(30)
|
||||
}
|
||||
iconView.contentMode = .scaleAspectFit
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func configure(item: HomeMenuItem) {
|
||||
titleLabel.text = item.title
|
||||
let symbol = UIImage(systemName: HomeIconCatalog.iconName(for: item.uri))
|
||||
iconView.loadRemoteImage(urlString: item.iconSrc, contentMode: .scaleAspectFit, placeholder: symbol)
|
||||
iconView.tintColor = AppDesign.primary
|
||||
}
|
||||
}
|
||||
115
suixinkan_ios/Features/Home/ViewModels/HomeViewModel.swift
Normal file
115
suixinkan_ios/Features/Home/ViewModels/HomeViewModel.swift
Normal file
@ -0,0 +1,115 @@
|
||||
//
|
||||
// HomeViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
/// 首页 ViewModel,根据当前角色权限构建首页菜单列表。
|
||||
final class HomeViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
private(set) var menuItems: [HomeMenuItem] = [] { didSet { onChange?() } }
|
||||
|
||||
/// 菜单排序权重,与旧工程和 Android 端保持一致。
|
||||
private let preferredOrder: [String] = [
|
||||
"space_settings",
|
||||
"album_list",
|
||||
"album_trailer",
|
||||
"wallet",
|
||||
"cloud_management",
|
||||
"cloud_storage_transit",
|
||||
"asset_management",
|
||||
"material_upload",
|
||||
"task_management",
|
||||
"schedule_management",
|
||||
"system_settings",
|
||||
"message_center",
|
||||
"checkin_points",
|
||||
"sample_management",
|
||||
"sample_upload",
|
||||
"live_stream_management",
|
||||
"scenicselection",
|
||||
"scenicapplication",
|
||||
"permission_apply",
|
||||
"permission_apply_status",
|
||||
"scenic_settlement",
|
||||
"scenic_settlement_review",
|
||||
"verification_order",
|
||||
"live_album",
|
||||
"pm",
|
||||
"location_report",
|
||||
"registration_invitation",
|
||||
"store",
|
||||
"fly",
|
||||
"pilot_cert",
|
||||
"task_management_editor",
|
||||
"deposit_order_detail",
|
||||
"deposit_order",
|
||||
"pm_manager",
|
||||
"pilot_controller",
|
||||
"/scenic-queue",
|
||||
"queue_management",
|
||||
"operating-area",
|
||||
"/scenic-order-manage",
|
||||
"photographer_orders"
|
||||
]
|
||||
|
||||
/// 从角色权限树扁平化出菜单项,按固定顺序排序并按 URI 别名去重。
|
||||
func buildMenus(from permissions: [RolePermissionResponse], currentRoleId: Int?) {
|
||||
let source: RolePermissionResponse?
|
||||
if let currentRoleId {
|
||||
source = permissions.first { $0.role.id == currentRoleId }
|
||||
} else {
|
||||
source = permissions.first
|
||||
}
|
||||
|
||||
guard let source else {
|
||||
menuItems = []
|
||||
return
|
||||
}
|
||||
|
||||
let permissionItems = flatten(source.role.permission)
|
||||
var seen = Set<String>()
|
||||
var deduplicated: [PermissionItem] = []
|
||||
for item in permissionItems where !item.uri.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
if seen.insert(HomeMenuRouter.menuAliasKey(for: item.uri)).inserted {
|
||||
deduplicated.append(item)
|
||||
}
|
||||
}
|
||||
|
||||
let preferredIndexMap = Dictionary(uniqueKeysWithValues: preferredOrder.enumerated().map { ($1, $0) })
|
||||
let sortedItems = deduplicated.sorted { lhs, rhs in
|
||||
let lhsRank = preferredIndexMap[lhs.uri] ?? Int.max
|
||||
let rhsRank = preferredIndexMap[rhs.uri] ?? Int.max
|
||||
if lhsRank != rhsRank {
|
||||
return lhsRank < rhsRank
|
||||
}
|
||||
let lhsIndex = deduplicated.firstIndex(where: { $0.id == lhs.id }) ?? Int.max
|
||||
let rhsIndex = deduplicated.firstIndex(where: { $0.id == rhs.id }) ?? Int.max
|
||||
return lhsIndex < rhsIndex
|
||||
}
|
||||
|
||||
menuItems = sortedItems.map { item in
|
||||
HomeMenuItem(
|
||||
title: item.name.isEmpty ? HomeMenuRouter.title(for: item.uri) : item.name,
|
||||
uri: item.uri,
|
||||
iconSrc: item.iconSrc
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 返回指定 URI 的展示标题。
|
||||
func title(for uri: String) -> String {
|
||||
HomeMenuRouter.title(for: uri)
|
||||
}
|
||||
|
||||
/// 递归展开权限树。
|
||||
private func flatten(_ items: [PermissionItem]) -> [PermissionItem] {
|
||||
items.flatMap { item in
|
||||
[item] + flatten(item.children)
|
||||
}
|
||||
}
|
||||
}
|
||||
48
suixinkan_ios/Features/Invite/API/InviteAPI.swift
Normal file
48
suixinkan_ios/Features/Invite/API/InviteAPI.swift
Normal file
@ -0,0 +1,48 @@
|
||||
//
|
||||
// InviteAPI.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 邀请服务协议,抽象邀请信息和邀请记录接口。
|
||||
@MainActor
|
||||
protocol InviteServing {
|
||||
/// 获取邀请二维码、邀请码和规则信息。
|
||||
func inviteInfo() async throws -> InviteInfoResponse
|
||||
|
||||
/// 获取邀请用户列表。
|
||||
func inviteUserList(page: Int, pageSize: Int) async throws -> [InviteUserItem]
|
||||
}
|
||||
|
||||
/// 邀请 API,封装摄影师邀请相关接口。
|
||||
@MainActor
|
||||
final class InviteAPI: InviteServing {
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化邀请 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
self.client = client
|
||||
}
|
||||
|
||||
/// 获取邀请二维码、邀请码和规则信息。
|
||||
func inviteInfo() async throws -> InviteInfoResponse {
|
||||
try await client.send(APIRequest(method: .get, path: "/api/yf-handset-app/photog/invite-info"))
|
||||
}
|
||||
|
||||
/// 获取邀请用户列表。
|
||||
func inviteUserList(page: Int = 1, pageSize: Int = 20) async throws -> [InviteUserItem] {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/yf-handset-app/photog/invite/user-list",
|
||||
queryItems: [
|
||||
URLQueryItem(name: "page", value: "\(max(page, 1))"),
|
||||
URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))")
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
30
suixinkan_ios/Features/Invite/Invite.md
Normal file
30
suixinkan_ios/Features/Invite/Invite.md
Normal file
@ -0,0 +1,30 @@
|
||||
# Invite 模块业务逻辑
|
||||
|
||||
## 模块职责
|
||||
|
||||
Invite 模块负责首页 `registration_invitation`、`photographer_invite` 和 `invite_record` 入口。
|
||||
|
||||
邀请页展示邀请码、邀请链接、二维码和规则;邀请记录页展示邀请用户和奖励记录。模块不保存业务状态到全局对象。
|
||||
|
||||
## 邀请页
|
||||
|
||||
`PhotographerInviteViewModel` 通过 `InviteAPI.inviteInfo()` 获取邀请信息,并使用 CoreImage 在本地生成二维码图片。二维码图片只用于当前页面展示,不落盘。
|
||||
|
||||
复制邀请链接时只由 View 层写入剪贴板,ViewModel 不关心系统剪贴板状态。
|
||||
|
||||
## 邀请记录
|
||||
|
||||
`InviteRecordViewModel` 提供“邀请记录 / 奖励记录”分段:
|
||||
- 邀请记录来自 `InviteAPI.inviteUserList`。
|
||||
- 奖励记录复用 `WalletAPI.walletEarningDetail`。
|
||||
- 顶部奖励汇总复用 `WalletAPI.walletSummary(type: 2)`。
|
||||
|
||||
分页状态、筛选分段和展示行只存在 ViewModel 内存中。
|
||||
|
||||
## 接口边界
|
||||
|
||||
`InviteAPI` 只封装邀请信息和邀请用户列表。钱包收益和提现入口继续由 Wallet 模块负责,Invite 模块只调用其公开服务协议,不持有钱包业务状态。
|
||||
|
||||
## 缓存边界
|
||||
|
||||
邀请码、邀请记录、奖励记录和二维码都不落盘。后续如果需要分享图片,应在分享流程中临时生成。
|
||||
156
suixinkan_ios/Features/Invite/Models/InviteModels.swift
Normal file
156
suixinkan_ios/Features/Invite/Models/InviteModels.swift
Normal file
@ -0,0 +1,156 @@
|
||||
//
|
||||
// InviteModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 邀请信息响应实体,表示邀请码、邀请链接和规则文案。
|
||||
struct InviteInfoResponse: Decodable, Equatable {
|
||||
let enableInvite: Bool
|
||||
let inviteCode: String
|
||||
let inviteUrl: String
|
||||
let description: [String]
|
||||
|
||||
/// 字段映射,兼容后端下划线命名。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case enableInvite = "enable_invite"
|
||||
case inviteCode = "invite_code"
|
||||
case inviteUrl = "invite_url"
|
||||
case description
|
||||
}
|
||||
|
||||
/// 宽松解码邀请信息。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
enableInvite = try container.decodeInviteLossyBool(forKey: .enableInvite) ?? true
|
||||
inviteCode = try container.decodeInviteLossyString(forKey: .inviteCode)
|
||||
inviteUrl = try container.decodeInviteLossyString(forKey: .inviteUrl)
|
||||
description = (try? container.decodeIfPresent([String].self, forKey: .description)) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
/// 邀请用户实体,表示邀请记录里的摄影师。
|
||||
struct InviteUserItem: Decodable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let realName: String
|
||||
let phone: String
|
||||
let avatar: String
|
||||
let createdAt: String
|
||||
let inviteLevel: Int
|
||||
|
||||
/// 字段映射,兼容后端下划线命名。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case realName = "real_name"
|
||||
case phone
|
||||
case avatar
|
||||
case createdAt = "created_at"
|
||||
case inviteLevel = "invite_level"
|
||||
}
|
||||
|
||||
/// 宽松解码邀请用户字段。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decodeInviteLossyInt(forKey: .id) ?? 0
|
||||
realName = try container.decodeInviteLossyString(forKey: .realName)
|
||||
phone = try container.decodeInviteLossyString(forKey: .phone)
|
||||
avatar = try container.decodeInviteLossyString(forKey: .avatar)
|
||||
createdAt = try container.decodeInviteLossyString(forKey: .createdAt)
|
||||
inviteLevel = try container.decodeInviteLossyInt(forKey: .inviteLevel) ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
/// 邀请记录页面展示行实体,统一邀请用户和奖励明细的展示字段。
|
||||
struct InviteDisplayRow: Identifiable, Equatable {
|
||||
let id: String
|
||||
let title: String
|
||||
let subtitle: String
|
||||
let amount: String
|
||||
let time: String
|
||||
let phone: String
|
||||
let avatar: String
|
||||
let inviteLevel: Int
|
||||
|
||||
/// 邀请层级文案。
|
||||
var levelText: String {
|
||||
inviteLevel == 1 ? "一级" : "二级"
|
||||
}
|
||||
|
||||
/// 头像占位文案。
|
||||
var avatarPlaceholder: String {
|
||||
if let first = title.first {
|
||||
return String(first)
|
||||
}
|
||||
if let first = phone.first {
|
||||
return String(first)
|
||||
}
|
||||
return "?"
|
||||
}
|
||||
}
|
||||
|
||||
/// 邀请记录分段类型。
|
||||
enum InviteRecordTab: CaseIterable, Equatable {
|
||||
case invite
|
||||
case reward
|
||||
|
||||
/// 分段标题。
|
||||
var title: String {
|
||||
switch self {
|
||||
case .invite: return "邀请记录"
|
||||
case .reward: return "奖励记录"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension KeyedDecodingContainer {
|
||||
/// 将 String、数字和 Bool 宽松解码为字符串。
|
||||
func decodeInviteLossyString(forKey key: Key) throws -> String {
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||
return String(value)
|
||||
}
|
||||
if let value = try? decodeIfPresent(Double.self, forKey: key) {
|
||||
return String(value)
|
||||
}
|
||||
if let value = try? decodeIfPresent(Bool.self, forKey: key) {
|
||||
return value ? "1" : "0"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
/// 将 String、Double 和 Int 宽松解码为 Int。
|
||||
func decodeInviteLossyInt(forKey key: Key) throws -> Int? {
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try? decodeIfPresent(Double.self, forKey: key) {
|
||||
return Int(value)
|
||||
}
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return Int(text) ?? Double(text).map(Int.init)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 将 String、数字和 Bool 宽松解码为 Bool。
|
||||
func decodeInviteLossyBool(forKey key: Key) throws -> Bool? {
|
||||
if let value = try? decodeIfPresent(Bool.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||
return value != 0
|
||||
}
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
let text = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
if ["1", "true", "yes"].contains(text) { return true }
|
||||
if ["0", "false", "no"].contains(text) { return false }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,148 @@
|
||||
//
|
||||
// InviteViewControllers.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/26.
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 摄影师邀请页。
|
||||
final class PhotographerInviteViewController: UIViewController {
|
||||
private let services = AppServices.shared
|
||||
private let viewModel = PhotographerInviteViewModel()
|
||||
private let scrollView = UIScrollView()
|
||||
private let contentStack = UIStackView()
|
||||
private let codeLabel = UILabel()
|
||||
private let urlLabel = UILabel()
|
||||
private let qrImageView = UIImageView()
|
||||
private let rulesLabel = UILabel()
|
||||
private let activityIndicator = UIActivityIndicatorView(style: .medium)
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = "邀请摄影师"
|
||||
view.backgroundColor = UIColor(hex: 0xF5F7FA)
|
||||
setupUI()
|
||||
viewModel.onChange = { [weak self] in self?.render() }
|
||||
Task { await viewModel.reload(api: services.inviteAPI) }
|
||||
}
|
||||
|
||||
private func setupUI() {
|
||||
navigationItem.rightBarButtonItems = [
|
||||
UIBarButtonItem(title: "复制码", style: .plain, target: self, action: #selector(copyCode)),
|
||||
UIBarButtonItem(title: "复制链接", style: .plain, target: self, action: #selector(copyURL))
|
||||
]
|
||||
|
||||
contentStack.axis = .vertical
|
||||
contentStack.spacing = 12
|
||||
scrollView.addSubview(contentStack)
|
||||
view.addSubview(scrollView)
|
||||
view.addSubview(activityIndicator)
|
||||
|
||||
scrollView.snp.makeConstraints { $0.edges.equalToSuperview() }
|
||||
contentStack.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(16)
|
||||
make.width.equalTo(scrollView.snp.width).offset(-32)
|
||||
}
|
||||
activityIndicator.snp.makeConstraints { $0.center.equalToSuperview() }
|
||||
|
||||
codeLabel.font = .systemFont(ofSize: 22, weight: .bold)
|
||||
urlLabel.font = .systemFont(ofSize: 14)
|
||||
urlLabel.numberOfLines = 0
|
||||
urlLabel.textColor = AppDesign.textSecondary
|
||||
qrImageView.contentMode = .scaleAspectFit
|
||||
qrImageView.snp.makeConstraints { $0.height.equalTo(220) }
|
||||
rulesLabel.numberOfLines = 0
|
||||
rulesLabel.font = .systemFont(ofSize: 14)
|
||||
rulesLabel.textColor = AppDesign.textSecondary
|
||||
|
||||
contentStack.addArrangedSubview(codeLabel)
|
||||
contentStack.addArrangedSubview(urlLabel)
|
||||
contentStack.addArrangedSubview(qrImageView)
|
||||
contentStack.addArrangedSubview(rulesLabel)
|
||||
}
|
||||
|
||||
private func render() {
|
||||
activityIndicator.isHidden = !viewModel.loading
|
||||
if viewModel.loading { activityIndicator.startAnimating() } else { activityIndicator.stopAnimating() }
|
||||
codeLabel.text = viewModel.inviteCode.isEmpty ? "邀请码加载中..." : "邀请码:\(viewModel.inviteCode)"
|
||||
urlLabel.text = viewModel.inviteUrl
|
||||
qrImageView.image = viewModel.qrImage
|
||||
rulesLabel.text = viewModel.rules.joined(separator: "\n")
|
||||
if let error = viewModel.errorMessage {
|
||||
rulesLabel.text = (rulesLabel.text ?? "") + "\n\n" + error
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func copyCode() { viewModel.copyInviteCode() }
|
||||
@objc private func copyURL() { viewModel.copyInviteUrl() }
|
||||
}
|
||||
|
||||
extension PhotographerInviteViewModel: ViewModelBindable {}
|
||||
|
||||
/// 邀请记录页。
|
||||
final class InviteRecordViewController: ModuleTableViewController {
|
||||
private let viewModel = InviteRecordViewModel()
|
||||
private let summaryLabel = UILabel()
|
||||
|
||||
override func viewDidLoad() {
|
||||
title = "邀请记录"
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
title: "奖励明细",
|
||||
style: .plain,
|
||||
target: self,
|
||||
action: #selector(toggleTab)
|
||||
)
|
||||
super.viewDidLoad()
|
||||
setupHeader()
|
||||
viewModel.onChange = { [weak self] in
|
||||
self?.updateSummary()
|
||||
self?.reloadTable()
|
||||
}
|
||||
}
|
||||
|
||||
private func setupHeader() {
|
||||
summaryLabel.font = .systemFont(ofSize: 14)
|
||||
summaryLabel.textColor = AppDesign.textSecondary
|
||||
summaryLabel.numberOfLines = 0
|
||||
summaryLabel.textAlignment = .center
|
||||
summaryLabel.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 56)
|
||||
tableView.tableHeaderView = summaryLabel
|
||||
}
|
||||
|
||||
override func tableRowCount() -> Int {
|
||||
viewModel.displayRows.count
|
||||
}
|
||||
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let row = viewModel.displayRows[indexPath.row]
|
||||
cell.configure(
|
||||
title: row.title,
|
||||
subtitle: row.subtitle,
|
||||
detail: row.amount.isEmpty ? row.time : "\(row.amount) · \(row.time)"
|
||||
)
|
||||
}
|
||||
|
||||
override func reloadContent() async {
|
||||
await viewModel.reload(
|
||||
inviteAPI: services.inviteAPI,
|
||||
walletAPI: services.walletAPI,
|
||||
refresh: true
|
||||
)
|
||||
updateSummary()
|
||||
}
|
||||
|
||||
private func updateSummary() {
|
||||
summaryLabel.text = "累计奖励 \(viewModel.totalRewardText) · 可提现 \(viewModel.withdrawableText)"
|
||||
navigationItem.rightBarButtonItem?.title = viewModel.tab == .invite ? "奖励明细" : "邀请用户"
|
||||
}
|
||||
|
||||
@objc private func toggleTab() {
|
||||
Task {
|
||||
let next: InviteRecordTab = viewModel.tab == .invite ? .reward : .invite
|
||||
await viewModel.selectTab(next, inviteAPI: services.inviteAPI, walletAPI: services.walletAPI)
|
||||
}
|
||||
}
|
||||
}
|
||||
203
suixinkan_ios/Features/Invite/ViewModels/InviteViewModels.swift
Normal file
203
suixinkan_ios/Features/Invite/ViewModels/InviteViewModels.swift
Normal file
@ -0,0 +1,203 @@
|
||||
//
|
||||
// InviteViewModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/24.
|
||||
//
|
||||
|
||||
import CoreImage.CIFilterBuiltins
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
/// 邀请页 ViewModel,负责加载邀请信息、生成二维码和复制内容。
|
||||
@MainActor
|
||||
final class PhotographerInviteViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
private(set) var loading = false { didSet { onChange?() } }
|
||||
private(set) var inviteCode = "" { didSet { onChange?() } }
|
||||
private(set) var inviteUrl = "" { didSet { onChange?() } }
|
||||
private(set) var rules: [String] = [] { didSet { onChange?() } }
|
||||
private(set) var qrImage: UIImage? { didSet { onChange?() } }
|
||||
var errorMessage: String? { didSet { onChange?() } }
|
||||
|
||||
private let qrContext = CIContext()
|
||||
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
|
||||
final class InviteRecordViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
var tab: InviteRecordTab = .invite { didSet { onChange?() } }
|
||||
private(set) var totalRewardText = "¥ 0.00" { didSet { onChange?() } }
|
||||
private(set) var withdrawableText = "¥ 0.00" { didSet { onChange?() } }
|
||||
private(set) var displayRows: [InviteDisplayRow] = [] { didSet { onChange?() } }
|
||||
private(set) var loading = false { didSet { onChange?() } }
|
||||
private(set) var loadingMore = false { didSet { onChange?() } }
|
||||
private(set) var hasMore = false { didSet { onChange?() } }
|
||||
var errorMessage: String? { didSet { onChange?() } }
|
||||
|
||||
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
|
||||
}()
|
||||
}
|
||||
159
suixinkan_ios/Features/Live/API/LiveAPI.swift
Normal file
159
suixinkan_ios/Features/Live/API/LiveAPI.swift
Normal file
@ -0,0 +1,159 @@
|
||||
//
|
||||
// LiveAPI.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 直播服务协议,定义直播管理和直播相册接口能力。
|
||||
@MainActor
|
||||
protocol LiveServing {
|
||||
func liveList(scenicId: Int, page: Int, pageSize: Int) async throws -> LiveListResponse
|
||||
func liveDetail(liveId: Int) async throws -> LiveEntity
|
||||
func liveCreate(_ request: LiveCreateRequest) async throws
|
||||
func liveStart(liveId: Int) async throws
|
||||
func liveStop(liveId: Int) async throws
|
||||
func liveFinish(liveId: Int) async throws
|
||||
func liveSetPushMode(liveId: Int, mode: Int) async throws
|
||||
func liveAlbumList(scenicId: Int, startTime: String?, endTime: String?, page: Int, pageSize: Int) async throws -> LiveAlbumFolderListResponse
|
||||
func liveAlbumCreateFolder(_ request: LiveAlbumCreateFolderRequest) async throws
|
||||
func liveAlbumDeleteFolder(folderId: Int) async throws
|
||||
func liveAlbumFolderDetail(folderId: Int) async throws -> LiveAlbumFolderItem
|
||||
func liveAlbumDeleteFiles(folderId: Int, fileIds: [Int]) async throws
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 直播 API,封装手动直播和直播相册网络请求。
|
||||
final class LiveAPI {
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化直播 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
self.client = client
|
||||
}
|
||||
|
||||
/// 获取直播列表。
|
||||
func liveList(scenicId: Int, page: Int = 1, pageSize: Int = 10) async throws -> LiveListResponse {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/app/manual-live/list",
|
||||
queryItems: [
|
||||
URLQueryItem(name: "scenic_id", value: String(scenicId)),
|
||||
URLQueryItem(name: "page", value: String(max(page, 1))),
|
||||
URLQueryItem(name: "page_size", value: String(max(pageSize, 1)))
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取直播详情。
|
||||
func liveDetail(liveId: Int) async throws -> LiveEntity {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/app/manual-live/detail",
|
||||
queryItems: [URLQueryItem(name: "live_id", value: String(liveId))]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 创建直播。
|
||||
func liveCreate(_ request: LiveCreateRequest) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(method: .post, path: "/api/app/manual-live/create", body: request)
|
||||
)
|
||||
}
|
||||
|
||||
/// 开始直播。
|
||||
func liveStart(liveId: Int) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(method: .post, path: "/api/app/manual-live/start", body: LiveControlRequest(liveId: liveId))
|
||||
)
|
||||
}
|
||||
|
||||
/// 暂停直播。
|
||||
func liveStop(liveId: Int) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(method: .post, path: "/api/app/manual-live/stop", body: LiveControlRequest(liveId: liveId))
|
||||
)
|
||||
}
|
||||
|
||||
/// 结束直播。
|
||||
func liveFinish(liveId: Int) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(method: .post, path: "/api/app/manual-live/finish", body: LiveControlRequest(liveId: liveId))
|
||||
)
|
||||
}
|
||||
|
||||
/// 切换直播推流模式。
|
||||
func liveSetPushMode(liveId: Int, mode: Int) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(method: .post, path: "/api/app/manual-live/set-push-mode", body: LivePushModeRequest(liveId: liveId, manualPushMode: mode))
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取直播相册列表。
|
||||
func liveAlbumList(
|
||||
scenicId: Int,
|
||||
startTime: String? = nil,
|
||||
endTime: String? = nil,
|
||||
page: Int = 1,
|
||||
pageSize: Int = 10
|
||||
) async throws -> LiveAlbumFolderListResponse {
|
||||
var queryItems = [
|
||||
URLQueryItem(name: "scenic_id", value: String(scenicId)),
|
||||
URLQueryItem(name: "page", value: String(max(page, 1))),
|
||||
URLQueryItem(name: "page_size", value: String(max(pageSize, 1)))
|
||||
]
|
||||
if let startTime = startTime?.liveTrimmed, !startTime.isEmpty {
|
||||
queryItems.append(URLQueryItem(name: "start_time", value: startTime))
|
||||
}
|
||||
if let endTime = endTime?.liveTrimmed, !endTime.isEmpty {
|
||||
queryItems.append(URLQueryItem(name: "end_time", value: endTime))
|
||||
}
|
||||
return try await client.send(
|
||||
APIRequest(method: .get, path: "/api/app/view-album/folders", queryItems: queryItems)
|
||||
)
|
||||
}
|
||||
|
||||
/// 创建直播相册。
|
||||
func liveAlbumCreateFolder(_ request: LiveAlbumCreateFolderRequest) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(method: .post, path: "/api/app/view-album/create-folder", body: request)
|
||||
)
|
||||
}
|
||||
|
||||
/// 删除直播相册。
|
||||
func liveAlbumDeleteFolder(folderId: Int) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(method: .post, path: "/api/app/view-album/delete-folder", body: LiveAlbumDeleteFolderRequest(folderId: folderId))
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取直播相册详情。
|
||||
func liveAlbumFolderDetail(folderId: Int) async throws -> LiveAlbumFolderItem {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/app/view-album/folder-detail",
|
||||
queryItems: [URLQueryItem(name: "folder_id", value: String(folderId))]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 删除直播相册内的素材。
|
||||
func liveAlbumDeleteFiles(folderId: Int, fileIds: [Int]) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/app/view-album/delete-files",
|
||||
body: LiveAlbumDeleteFilesRequest(folderId: folderId, fileIds: fileIds)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension LiveAPI: LiveServing {}
|
||||
27
suixinkan_ios/Features/Live/Live.md
Normal file
27
suixinkan_ios/Features/Live/Live.md
Normal file
@ -0,0 +1,27 @@
|
||||
# 直播模块
|
||||
|
||||
## 模块职责
|
||||
|
||||
`Features/Live` 承接首页 `live_stream_management` 和 `live_album` 权限入口,负责手动直播管理和直播相册素材管理。旧 iOS 工程未接入真推流 SDK,本模块按旧 iOS 对齐,不新增腾讯 TRTC、RTMP 或其他推流 SDK 依赖。
|
||||
|
||||
## 代码结构
|
||||
|
||||
- `LiveAPI`:封装 `/api/app/manual-live/...` 和 `/api/app/view-album/...` 接口。
|
||||
- `LiveManagementViewModel`:管理直播列表分页、创建、开始/暂停、结束和详情失败清理。
|
||||
- `LivePlaybackViewModel`:管理直播详情和直播相册视频的系统播放器 URL、播放、暂停和释放状态。
|
||||
- `LivePushReadinessViewModel`:诊断直播推流地址、相机/麦克风权限、网络状态和当前推流 SDK 接入状态,不触发本机采集推流。
|
||||
- `LiveAlbumViewModel`:管理直播相册日期筛选、分页和删除相册。
|
||||
- `LiveAlbumCreateViewModel`:管理本地图片/视频上传到 OSS 后创建直播相册。
|
||||
- `LiveAlbumPreviewViewModel`:进入相册预览时加载相册详情,并支持删除单个素材。
|
||||
|
||||
## 业务边界
|
||||
|
||||
直播详情会从 `play_url`、`pull_url`、`hls_url`、`live_url`、`flv_url` 中筛选系统播放器可处理的 http/https 播放地址;`push_url` 是给 OBS 或第三方工具使用的外部推流地址,不作为播放地址使用。只有 RTMP 推流地址时,页面展示封面、提示和复制入口。
|
||||
|
||||
直播相册视频预览使用系统 `VideoPlayer` 播放可支持的视频地址;图片仍使用 `RemoteImage` 展示。视频地址不可播放时,页面保留打开和复制原始 URL 的操作。
|
||||
|
||||
推流专项按旧 iOS 对齐,只保留诊断层和适配协议,默认 `UnsupportedLivePushAdapter` 明确提示未接入真推流 SDK;不引入腾讯/TRTC、Agora、HaishinKit、Zego 或 Android `youfun_control` 的飞控/抓娃娃直播接口。直播详情的“开始/暂停/结束”和“推流模式”只调用 `manual-live` 业务接口,不启动本机摄像头、麦克风或后台推流。
|
||||
|
||||
后续若要接入真推流 SDK,可参考 Android `youfun_control` 中腾讯 TRTC 的实现路线,但需要单独确认后端房间参数、SDK 版本、真机权限、推流源和人工联调范围。
|
||||
|
||||
直播封面创建沿用旧 iOS 的 URL 输入。直播相册素材通过 `PhotosPicker` 选择后,使用 `OSSUploadService.uploadAliveAlbumFile` 上传到 `live_albums/yyyyMMdd/scenicId/...`,再把最终 URL 提交给创建相册接口。
|
||||
415
suixinkan_ios/Features/Live/Models/LiveModels.swift
Normal file
415
suixinkan_ios/Features/Live/Models/LiveModels.swift
Normal file
@ -0,0 +1,415 @@
|
||||
//
|
||||
// LiveModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 直播列表响应实体。
|
||||
struct LiveListResponse: Decodable {
|
||||
let items: [LiveEntity]
|
||||
let total: Int
|
||||
let page: Int
|
||||
let pageSize: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case items
|
||||
case total
|
||||
case page
|
||||
case pageSize = "page_size"
|
||||
}
|
||||
|
||||
init(items: [LiveEntity] = [], total: Int = 0, page: Int = 1, pageSize: Int = 10) {
|
||||
self.items = items
|
||||
self.total = total
|
||||
self.page = page
|
||||
self.pageSize = pageSize
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
items = (try? container.decode([LiveEntity].self, forKey: .items)) ?? []
|
||||
total = try container.liveDecodeLossyInt(forKey: .total) ?? 0
|
||||
page = try container.liveDecodeLossyInt(forKey: .page) ?? 1
|
||||
pageSize = try container.liveDecodeLossyInt(forKey: .pageSize) ?? 10
|
||||
}
|
||||
}
|
||||
|
||||
/// 直播实体,表示一场手动直播。
|
||||
struct LiveEntity: Decodable, Identifiable, Equatable {
|
||||
let id: Int
|
||||
let title: String
|
||||
let coverImg: String
|
||||
let pushUrl: String
|
||||
let playUrl: String
|
||||
let pullUrl: String
|
||||
let hlsUrl: String
|
||||
let flvUrl: String
|
||||
let liveUrl: String
|
||||
let startTime: Int64
|
||||
let endTime: Int64
|
||||
let duration: Int64
|
||||
let status: Int
|
||||
let statusLabel: String
|
||||
let manualPushMode: Int
|
||||
let manualPushState: Int
|
||||
let viewsCount: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case title
|
||||
case coverImg = "cover_img"
|
||||
case pushUrl = "push_url"
|
||||
case playUrl = "play_url"
|
||||
case pullUrl = "pull_url"
|
||||
case hlsUrl = "hls_url"
|
||||
case flvUrl = "flv_url"
|
||||
case liveUrl = "live_url"
|
||||
case startTime = "start_time"
|
||||
case endTime = "end_time"
|
||||
case duration
|
||||
case status
|
||||
case statusLabel = "status_label"
|
||||
case manualPushMode = "manaul_push_mode"
|
||||
case manualPushState = "manaul_push_state"
|
||||
case viewsCount = "views_count"
|
||||
}
|
||||
|
||||
init(
|
||||
id: Int = 0,
|
||||
title: String = "",
|
||||
coverImg: String = "",
|
||||
pushUrl: String = "",
|
||||
playUrl: String = "",
|
||||
pullUrl: String = "",
|
||||
hlsUrl: String = "",
|
||||
flvUrl: String = "",
|
||||
liveUrl: String = "",
|
||||
startTime: Int64 = 0,
|
||||
endTime: Int64 = 0,
|
||||
duration: Int64 = 0,
|
||||
status: Int = 0,
|
||||
statusLabel: String = "",
|
||||
manualPushMode: Int = 1,
|
||||
manualPushState: Int = 0,
|
||||
viewsCount: Int = 0
|
||||
) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.coverImg = coverImg
|
||||
self.pushUrl = pushUrl
|
||||
self.playUrl = playUrl
|
||||
self.pullUrl = pullUrl
|
||||
self.hlsUrl = hlsUrl
|
||||
self.flvUrl = flvUrl
|
||||
self.liveUrl = liveUrl
|
||||
self.startTime = startTime
|
||||
self.endTime = endTime
|
||||
self.duration = duration
|
||||
self.status = status
|
||||
self.statusLabel = statusLabel
|
||||
self.manualPushMode = manualPushMode
|
||||
self.manualPushState = manualPushState
|
||||
self.viewsCount = viewsCount
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.liveDecodeLossyInt(forKey: .id) ?? 0
|
||||
title = try container.liveDecodeLossyString(forKey: .title)
|
||||
coverImg = try container.liveDecodeLossyString(forKey: .coverImg)
|
||||
pushUrl = try container.liveDecodeLossyString(forKey: .pushUrl)
|
||||
playUrl = try container.liveDecodeLossyString(forKey: .playUrl)
|
||||
pullUrl = try container.liveDecodeLossyString(forKey: .pullUrl)
|
||||
hlsUrl = try container.liveDecodeLossyString(forKey: .hlsUrl)
|
||||
flvUrl = try container.liveDecodeLossyString(forKey: .flvUrl)
|
||||
liveUrl = try container.liveDecodeLossyString(forKey: .liveUrl)
|
||||
startTime = Int64(try container.liveDecodeLossyInt(forKey: .startTime) ?? 0)
|
||||
endTime = Int64(try container.liveDecodeLossyInt(forKey: .endTime) ?? 0)
|
||||
duration = Int64(try container.liveDecodeLossyInt(forKey: .duration) ?? 0)
|
||||
status = try container.liveDecodeLossyInt(forKey: .status) ?? 0
|
||||
statusLabel = try container.liveDecodeLossyString(forKey: .statusLabel)
|
||||
manualPushMode = try container.liveDecodeLossyInt(forKey: .manualPushMode) ?? 1
|
||||
manualPushState = try container.liveDecodeLossyInt(forKey: .manualPushState) ?? 0
|
||||
viewsCount = try container.liveDecodeLossyInt(forKey: .viewsCount) ?? 0
|
||||
}
|
||||
|
||||
var displayStatus: String {
|
||||
statusLabel.liveNonEmpty ?? "状态\(status)"
|
||||
}
|
||||
|
||||
var playbackURLCandidates: [String] {
|
||||
[playUrl, pullUrl, hlsUrl, liveUrl, flvUrl]
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建直播请求实体。
|
||||
struct LiveCreateRequest: Encodable, Equatable {
|
||||
let scenicId: String
|
||||
let title: String
|
||||
let coverImg: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case scenicId = "scenic_id"
|
||||
case title
|
||||
case coverImg = "cover_img"
|
||||
}
|
||||
}
|
||||
|
||||
/// 直播控制请求实体。
|
||||
struct LiveControlRequest: Encodable, Equatable {
|
||||
let liveId: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case liveId = "live_id"
|
||||
}
|
||||
}
|
||||
|
||||
/// 直播推流模式切换请求实体。
|
||||
struct LivePushModeRequest: Encodable, Equatable {
|
||||
let liveId: Int
|
||||
let manualPushMode: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case liveId = "live_id"
|
||||
case manualPushMode = "manual_push_mode"
|
||||
}
|
||||
}
|
||||
|
||||
/// 直播相册列表响应实体。
|
||||
struct LiveAlbumFolderListResponse: Decodable {
|
||||
let items: [LiveAlbumFolderItem]
|
||||
let page: Int
|
||||
let pageSize: Int
|
||||
let total: Int
|
||||
let totalPages: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case items
|
||||
case page
|
||||
case pageSize = "page_size"
|
||||
case total
|
||||
case totalPages = "total_pages"
|
||||
}
|
||||
|
||||
init(items: [LiveAlbumFolderItem] = [], page: Int = 1, pageSize: Int = 10, total: Int = 0, totalPages: Int = 0) {
|
||||
self.items = items
|
||||
self.page = page
|
||||
self.pageSize = pageSize
|
||||
self.total = total
|
||||
self.totalPages = totalPages
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
items = (try? container.decode([LiveAlbumFolderItem].self, forKey: .items)) ?? []
|
||||
page = try container.liveDecodeLossyInt(forKey: .page) ?? 1
|
||||
pageSize = try container.liveDecodeLossyInt(forKey: .pageSize) ?? 10
|
||||
total = try container.liveDecodeLossyInt(forKey: .total) ?? 0
|
||||
totalPages = try container.liveDecodeLossyInt(forKey: .totalPages) ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
/// 直播相册文件夹实体。
|
||||
struct LiveAlbumFolderItem: Decodable, Identifiable, Equatable {
|
||||
let id: Int
|
||||
let albumId: Int
|
||||
let name: String
|
||||
let creator: String
|
||||
let items: [LiveAlbumFileItem]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case albumId = "album_id"
|
||||
case name
|
||||
case creator
|
||||
case items
|
||||
}
|
||||
|
||||
init(id: Int = 0, albumId: Int = 0, name: String = "", creator: String = "", items: [LiveAlbumFileItem] = []) {
|
||||
self.id = id
|
||||
self.albumId = albumId
|
||||
self.name = name
|
||||
self.creator = creator
|
||||
self.items = items
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.liveDecodeLossyInt(forKey: .id) ?? 0
|
||||
albumId = try container.liveDecodeLossyInt(forKey: .albumId) ?? 0
|
||||
name = try container.liveDecodeLossyString(forKey: .name)
|
||||
creator = try container.liveDecodeLossyString(forKey: .creator)
|
||||
items = (try? container.decode([LiveAlbumFileItem].self, forKey: .items)) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
/// 直播相册素材实体。
|
||||
struct LiveAlbumFileItem: Decodable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let url: String
|
||||
let type: Int
|
||||
let size: Int64
|
||||
let coverImg: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case url
|
||||
case type
|
||||
case size
|
||||
case coverImg = "cover_img"
|
||||
}
|
||||
|
||||
init(id: Int = 0, url: String = "", type: Int = 1, size: Int64 = 0, coverImg: String? = nil) {
|
||||
self.id = id
|
||||
self.url = url
|
||||
self.type = type
|
||||
self.size = size
|
||||
self.coverImg = coverImg
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.liveDecodeLossyInt(forKey: .id) ?? 0
|
||||
url = try container.liveDecodeLossyString(forKey: .url)
|
||||
type = try container.liveDecodeLossyInt(forKey: .type) ?? 1
|
||||
size = Int64(try container.liveDecodeLossyInt(forKey: .size) ?? 0)
|
||||
coverImg = try? container.decodeIfPresent(String.self, forKey: .coverImg)
|
||||
}
|
||||
|
||||
var isVideo: Bool { type == 2 }
|
||||
|
||||
var previewURL: String {
|
||||
if let cover = coverImg?.liveTrimmed, !cover.isEmpty {
|
||||
return cover
|
||||
}
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建直播相册请求实体。
|
||||
struct LiveAlbumCreateFolderRequest: Encodable, Equatable {
|
||||
let scenicId: String
|
||||
let name: String
|
||||
let items: [LiveAlbumCreateFileItem]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case scenicId = "scenic_id"
|
||||
case name
|
||||
case items
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建直播相册素材请求实体。
|
||||
struct LiveAlbumCreateFileItem: Encodable, Equatable {
|
||||
let url: String
|
||||
let type: Int
|
||||
let size: Int64
|
||||
let coverImg: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case url
|
||||
case type
|
||||
case size
|
||||
case coverImg = "cover_img"
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除直播相册请求实体。
|
||||
struct LiveAlbumDeleteFolderRequest: Encodable, Equatable {
|
||||
let folderId: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case folderId = "folder_id"
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除直播相册素材请求实体。
|
||||
struct LiveAlbumDeleteFilesRequest: Encodable, Equatable {
|
||||
let folderId: Int
|
||||
let fileIds: [Int]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case folderId = "folder_id"
|
||||
case fileIds = "file_ids"
|
||||
}
|
||||
}
|
||||
|
||||
/// 本地待上传直播相册素材实体。
|
||||
struct LiveAlbumLocalUploadFile: Identifiable, Equatable {
|
||||
let id: UUID
|
||||
let data: Data
|
||||
let fileName: String
|
||||
let fileType: Int
|
||||
let size: Int64
|
||||
var uploadedURL: String?
|
||||
|
||||
init(id: UUID = UUID(), data: Data, fileName: String, fileType: Int, size: Int64? = nil, uploadedURL: String? = nil) {
|
||||
self.id = id
|
||||
self.data = data
|
||||
self.fileName = fileName
|
||||
self.fileType = fileType
|
||||
self.size = size ?? Int64(data.count)
|
||||
self.uploadedURL = uploadedURL
|
||||
}
|
||||
|
||||
var isVideo: Bool { fileType == 2 }
|
||||
}
|
||||
|
||||
private extension KeyedDecodingContainer {
|
||||
func liveDecodeLossyString(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(Int64.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 ""
|
||||
}
|
||||
|
||||
func liveDecodeLossyInt(forKey key: Key) throws -> Int? {
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try? decodeIfPresent(Int64.self, forKey: key) {
|
||||
return Int(value)
|
||||
}
|
||||
if let value = try? decodeIfPresent(Double.self, forKey: key) {
|
||||
return Int(value)
|
||||
}
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let intValue = Int(trimmed) {
|
||||
return intValue
|
||||
}
|
||||
if let doubleValue = Double(trimmed) {
|
||||
return Int(doubleValue)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
extension String {
|
||||
/// 直播模块内部使用的去空白字符串。
|
||||
var liveTrimmed: String {
|
||||
trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
/// 直播模块内部使用的非空字符串兜底。
|
||||
var liveNonEmpty: String? {
|
||||
let value = liveTrimmed
|
||||
return value.isEmpty ? nil : value
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,65 @@
|
||||
//
|
||||
// LiveViewControllers.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/26.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
/// 直播管理页。
|
||||
final class LiveManagementViewController: ModuleTableViewController {
|
||||
private let viewModel = LiveManagementViewModel()
|
||||
|
||||
override func viewDidLoad() {
|
||||
title = "直播管理"
|
||||
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.title,
|
||||
subtitle: item.statusLabel,
|
||||
detail: String(item.startTime)
|
||||
)
|
||||
}
|
||||
|
||||
override func reloadContent() async {
|
||||
await viewModel.reload(api: services.liveAPI, scenicId: services.currentScenicId)
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
||||
guard indexPath.row >= viewModel.items.count - 2 else { return }
|
||||
Task { await viewModel.loadMore(api: services.liveAPI, scenicId: services.currentScenicId) }
|
||||
}
|
||||
}
|
||||
|
||||
extension LiveManagementViewModel: ViewModelBindable {}
|
||||
|
||||
/// 直播相册页。
|
||||
final class LiveAlbumViewController: ModuleTableViewController {
|
||||
private let viewModel = LiveAlbumViewModel()
|
||||
|
||||
override func viewDidLoad() {
|
||||
title = "直播相册"
|
||||
super.viewDidLoad()
|
||||
wireViewModel(viewModel) { }
|
||||
}
|
||||
|
||||
override func tableRowCount() -> Int { viewModel.folders.count }
|
||||
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let folder = viewModel.folders[indexPath.row]
|
||||
cell.configure(title: folder.name, subtitle: folder.creator, detail: "\(folder.items.count) 张")
|
||||
}
|
||||
|
||||
override func reloadContent() async {
|
||||
await viewModel.reload(api: services.liveAPI, scenicId: services.currentScenicId)
|
||||
}
|
||||
}
|
||||
|
||||
extension LiveAlbumViewModel: ViewModelBindable {}
|
||||
@ -0,0 +1,131 @@
|
||||
//
|
||||
// LivePlaybackViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/25.
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
|
||||
/// 直播播放地址解析器,避免把 RTMP 推流地址误当作播放地址。
|
||||
enum LivePlaybackURLResolver {
|
||||
static func playableURL(from live: LiveEntity) -> URL? {
|
||||
playableURL(from: live.playbackURLCandidates)
|
||||
}
|
||||
|
||||
static func playableURL(from candidates: [String]) -> URL? {
|
||||
for candidate in candidates {
|
||||
if let url = playableURL(from: candidate) {
|
||||
return url
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
static func playableURL(from rawValue: String) -> URL? {
|
||||
let value = rawValue.liveTrimmed
|
||||
guard !value.isEmpty, let url = URL(string: value) else { return nil }
|
||||
guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else { return nil }
|
||||
if url.pathExtension.lowercased() == "flv" {
|
||||
return nil
|
||||
}
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
/// 播放器状态。
|
||||
enum LivePlaybackState: Equatable {
|
||||
case empty
|
||||
case ready(URL)
|
||||
case playing(URL)
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 直播播放器 ViewModel,管理系统播放器的 URL、播放和释放状态。
|
||||
final class LivePlaybackViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
var state: LivePlaybackState = .empty { didSet { onChange?() } }
|
||||
var errorMessage: String? { didSet { onChange?() } }
|
||||
|
||||
private(set) var player: AVPlayer? { didSet { onChange?() } }
|
||||
|
||||
var playableURL: URL? {
|
||||
switch state {
|
||||
case .ready(let url), .playing(let url):
|
||||
url
|
||||
case .empty, .failed:
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
var isPlaying: Bool {
|
||||
if case .playing = state { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
init(live: LiveEntity? = nil, urlString: String? = nil) {
|
||||
if let live {
|
||||
load(live: live)
|
||||
} else if let urlString {
|
||||
load(urlString: urlString)
|
||||
}
|
||||
}
|
||||
|
||||
func load(live: LiveEntity) {
|
||||
load(url: LivePlaybackURLResolver.playableURL(from: live))
|
||||
}
|
||||
|
||||
func load(urlString: String) {
|
||||
load(url: LivePlaybackURLResolver.playableURL(from: urlString))
|
||||
}
|
||||
|
||||
func play() {
|
||||
guard let url = playableURL else { return }
|
||||
if player == nil {
|
||||
player = AVPlayer(url: url)
|
||||
}
|
||||
player?.play()
|
||||
state = .playing(url)
|
||||
}
|
||||
|
||||
func pause() {
|
||||
guard let url = playableURL else { return }
|
||||
player?.pause()
|
||||
state = .ready(url)
|
||||
}
|
||||
|
||||
func reload() {
|
||||
guard let url = playableURL else { return }
|
||||
releasePlayer()
|
||||
player = AVPlayer(url: url)
|
||||
state = .ready(url)
|
||||
}
|
||||
|
||||
func release() {
|
||||
releasePlayer()
|
||||
if let url = playableURL {
|
||||
state = .ready(url)
|
||||
} else {
|
||||
state = .empty
|
||||
}
|
||||
}
|
||||
|
||||
private func load(url: URL?) {
|
||||
releasePlayer()
|
||||
guard let url else {
|
||||
errorMessage = "暂无可播放地址"
|
||||
state = .empty
|
||||
return
|
||||
}
|
||||
errorMessage = nil
|
||||
player = AVPlayer(url: url)
|
||||
state = .ready(url)
|
||||
}
|
||||
|
||||
private func releasePlayer() {
|
||||
player?.pause()
|
||||
player = nil
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,291 @@
|
||||
//
|
||||
// LivePushReadinessViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/25.
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
/// 直播推流权限状态。
|
||||
enum LivePushPermissionState: Equatable {
|
||||
case unknown
|
||||
case granted
|
||||
case denied
|
||||
|
||||
var displayText: String {
|
||||
switch self {
|
||||
case .unknown: "未检查"
|
||||
case .granted: "已授权"
|
||||
case .denied: "未授权"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 直播推流网络状态。
|
||||
enum LivePushNetworkState: Equatable {
|
||||
case unknown
|
||||
case unavailable
|
||||
case wifi
|
||||
case cellular
|
||||
case other
|
||||
|
||||
var displayText: String {
|
||||
switch self {
|
||||
case .unknown: "检测中"
|
||||
case .unavailable: "网络不可用"
|
||||
case .wifi: "Wi-Fi"
|
||||
case .cellular: "蜂窝网络"
|
||||
case .other: "其他网络"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 推流 SDK 适配协议。后续接入真实 RTMP/RTC SDK 时替换默认实现。
|
||||
protocol LivePushAdapter {
|
||||
var name: String { get }
|
||||
var isAvailable: Bool { get }
|
||||
func prepare(pushURL: URL) async throws
|
||||
func start() async throws
|
||||
func stop() async throws
|
||||
func dispose() async
|
||||
}
|
||||
|
||||
/// 权限提供者协议,隔离 AVFoundation 便于测试。
|
||||
protocol LivePermissionProviding {
|
||||
func cameraPermission() async -> LivePushPermissionState
|
||||
func microphonePermission() async -> LivePushPermissionState
|
||||
}
|
||||
|
||||
/// 网络监听协议,隔离 NWPathMonitor 便于测试。
|
||||
protocol LiveNetworkMonitoring: AnyObject {
|
||||
var currentState: LivePushNetworkState { get }
|
||||
func start(_ onChange: @escaping @Sendable (LivePushNetworkState) -> Void)
|
||||
func stop()
|
||||
}
|
||||
|
||||
enum LivePushReadinessError: LocalizedError, Equatable {
|
||||
case missingPushURL
|
||||
case invalidPushURL
|
||||
case permissionDenied
|
||||
case networkUnavailable
|
||||
case sdkUnavailable
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .missingPushURL:
|
||||
"暂无推流地址"
|
||||
case .invalidPushURL:
|
||||
"推流地址格式无效"
|
||||
case .permissionDenied:
|
||||
"请先开启相机和麦克风权限"
|
||||
case .networkUnavailable:
|
||||
"当前网络不可用"
|
||||
case .sdkUnavailable:
|
||||
"当前版本未接入真推流 SDK"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct UnsupportedLivePushAdapter: LivePushAdapter {
|
||||
let name = "未接入推流 SDK"
|
||||
let isAvailable = false
|
||||
|
||||
func prepare(pushURL: URL) async throws {
|
||||
throw LivePushReadinessError.sdkUnavailable
|
||||
}
|
||||
|
||||
func start() async throws {
|
||||
throw LivePushReadinessError.sdkUnavailable
|
||||
}
|
||||
|
||||
func stop() async throws {}
|
||||
|
||||
func dispose() async {}
|
||||
}
|
||||
|
||||
struct SystemLivePermissionProvider: LivePermissionProviding {
|
||||
func cameraPermission() async -> LivePushPermissionState {
|
||||
await permission(for: .video)
|
||||
}
|
||||
|
||||
func microphonePermission() async -> LivePushPermissionState {
|
||||
await permission(for: .audio)
|
||||
}
|
||||
|
||||
private func permission(for mediaType: AVMediaType) async -> LivePushPermissionState {
|
||||
switch AVCaptureDevice.authorizationStatus(for: mediaType) {
|
||||
case .authorized:
|
||||
return .granted
|
||||
case .notDetermined:
|
||||
let granted = await AVCaptureDevice.requestAccess(for: mediaType)
|
||||
return granted ? .granted : .denied
|
||||
case .denied, .restricted:
|
||||
return .denied
|
||||
@unknown default:
|
||||
return .denied
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class SystemLiveNetworkMonitor: LiveNetworkMonitoring {
|
||||
|
||||
var onChange: (() -> Void)?
|
||||
private let monitor = NWPathMonitor()
|
||||
private let queue = DispatchQueue(label: "com.suixinkan.live.network")
|
||||
private(set) var currentState: LivePushNetworkState = .unknown
|
||||
|
||||
func start(_ onChange: @escaping @Sendable (LivePushNetworkState) -> Void) {
|
||||
monitor.pathUpdateHandler = { [weak self] path in
|
||||
let state = Self.state(from: path)
|
||||
self?.currentState = state
|
||||
onChange(state)
|
||||
}
|
||||
monitor.start(queue: queue)
|
||||
}
|
||||
|
||||
func stop() {
|
||||
monitor.cancel()
|
||||
}
|
||||
|
||||
private static func state(from path: NWPath) -> LivePushNetworkState {
|
||||
guard path.status == .satisfied else { return .unavailable }
|
||||
if path.usesInterfaceType(.wifi) { return .wifi }
|
||||
if path.usesInterfaceType(.cellular) { return .cellular }
|
||||
return .other
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 推流准备 ViewModel,检查权限、网络和默认 SDK 可用性。
|
||||
final class LivePushReadinessViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
var cameraPermission: LivePushPermissionState = .unknown { didSet { onChange?() } }
|
||||
var microphonePermission: LivePushPermissionState = .unknown { didSet { onChange?() } }
|
||||
var networkState: LivePushNetworkState = .unknown { didSet { onChange?() } }
|
||||
var prepared = false { didSet { onChange?() } }
|
||||
var running = false { didSet { onChange?() } }
|
||||
var errorMessage: String? { didSet { onChange?() } }
|
||||
|
||||
let adapterName: String
|
||||
let sdkStatusText: String
|
||||
|
||||
private let permissionProvider: any LivePermissionProviding
|
||||
private let networkMonitor: any LiveNetworkMonitoring
|
||||
private let adapter: any LivePushAdapter
|
||||
private var pushURL: URL?
|
||||
|
||||
init(
|
||||
permissionProvider: any LivePermissionProviding = SystemLivePermissionProvider(),
|
||||
networkMonitor: any LiveNetworkMonitoring = SystemLiveNetworkMonitor(),
|
||||
adapter: any LivePushAdapter = UnsupportedLivePushAdapter()
|
||||
) {
|
||||
self.permissionProvider = permissionProvider
|
||||
self.networkMonitor = networkMonitor
|
||||
self.adapter = adapter
|
||||
self.adapterName = adapter.name
|
||||
self.sdkStatusText = adapter.isAvailable ? "已接入" : "未接入真推流 SDK"
|
||||
self.networkState = networkMonitor.currentState
|
||||
}
|
||||
|
||||
func configure(pushURL rawValue: String) {
|
||||
let value = rawValue.liveTrimmed
|
||||
guard !value.isEmpty else {
|
||||
pushURL = nil
|
||||
errorMessage = LivePushReadinessError.missingPushURL.localizedDescription
|
||||
return
|
||||
}
|
||||
guard let url = URL(string: value), url.scheme?.isEmpty == false else {
|
||||
pushURL = nil
|
||||
errorMessage = LivePushReadinessError.invalidPushURL.localizedDescription
|
||||
return
|
||||
}
|
||||
pushURL = url
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
func startMonitoring() {
|
||||
networkState = networkMonitor.currentState
|
||||
networkMonitor.start { [weak self] state in
|
||||
Task { @MainActor in
|
||||
self?.networkState = state
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stopMonitoring() {
|
||||
networkMonitor.stop()
|
||||
}
|
||||
|
||||
func refreshPermissions() async {
|
||||
async let camera = permissionProvider.cameraPermission()
|
||||
async let microphone = permissionProvider.microphonePermission()
|
||||
cameraPermission = await camera
|
||||
microphonePermission = await microphone
|
||||
}
|
||||
|
||||
func runDiagnostics() throws {
|
||||
do {
|
||||
try validateReadiness()
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
throw error
|
||||
}
|
||||
guard adapter.isAvailable else {
|
||||
errorMessage = LivePushReadinessError.sdkUnavailable.localizedDescription
|
||||
throw LivePushReadinessError.sdkUnavailable
|
||||
}
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
func prepare() async throws {
|
||||
try validateReadiness()
|
||||
guard let pushURL else { throw LivePushReadinessError.missingPushURL }
|
||||
do {
|
||||
try await adapter.prepare(pushURL: pushURL)
|
||||
prepared = true
|
||||
errorMessage = nil
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
func startPush() async throws {
|
||||
try validateReadiness()
|
||||
guard adapter.isAvailable else {
|
||||
errorMessage = LivePushReadinessError.sdkUnavailable.localizedDescription
|
||||
throw LivePushReadinessError.sdkUnavailable
|
||||
}
|
||||
try await adapter.start()
|
||||
running = true
|
||||
prepared = true
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
func stopPush() async {
|
||||
try? await adapter.stop()
|
||||
running = false
|
||||
}
|
||||
|
||||
func dispose() async {
|
||||
stopMonitoring()
|
||||
await adapter.dispose()
|
||||
running = false
|
||||
prepared = false
|
||||
}
|
||||
|
||||
private func validateReadiness() throws {
|
||||
guard pushURL != nil else {
|
||||
throw LivePushReadinessError.missingPushURL
|
||||
}
|
||||
guard cameraPermission != .denied, microphonePermission != .denied else {
|
||||
throw LivePushReadinessError.permissionDenied
|
||||
}
|
||||
guard networkState != .unavailable else {
|
||||
throw LivePushReadinessError.networkUnavailable
|
||||
}
|
||||
}
|
||||
}
|
||||
466
suixinkan_ios/Features/Live/ViewModels/LiveViewModels.swift
Normal file
466
suixinkan_ios/Features/Live/ViewModels/LiveViewModels.swift
Normal file
@ -0,0 +1,466 @@
|
||||
//
|
||||
// LiveViewModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
/// 直播管理 ViewModel,负责直播列表、详情、创建和控制动作。
|
||||
final class LiveManagementViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
var items: [LiveEntity] = [] { didSet { onChange?() } }
|
||||
var detail: LiveEntity? { didSet { onChange?() } }
|
||||
var loading = false { didSet { onChange?() } }
|
||||
var loadingMore = false { didSet { onChange?() } }
|
||||
var hasMore = false { didSet { onChange?() } }
|
||||
var page = 1 { didSet { onChange?() } }
|
||||
var errorMessage: String? { didSet { onChange?() } }
|
||||
|
||||
private let pageSize = 10
|
||||
private var total = 0
|
||||
|
||||
/// 进行中的直播数量。
|
||||
var liveRunningCount: Int {
|
||||
items.filter { $0.status == 2 }.count
|
||||
}
|
||||
|
||||
/// 已结束的直播数量。
|
||||
var liveFinishedCount: Int {
|
||||
items.filter { $0.status == 3 }.count
|
||||
}
|
||||
|
||||
/// 重新加载直播列表。
|
||||
func reload(api: any LiveServing, scenicId: Int?, showLoading: Bool = true) async {
|
||||
guard let scenicId else {
|
||||
reset()
|
||||
return
|
||||
}
|
||||
if showLoading { loading = true }
|
||||
errorMessage = nil
|
||||
defer { loading = false }
|
||||
do {
|
||||
try await loadPage(api: api, scenicId: scenicId, page: 1)
|
||||
} catch {
|
||||
clearListAndDetail()
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载下一页直播列表。
|
||||
func loadMore(api: any LiveServing, scenicId: Int?) async {
|
||||
guard let scenicId, hasMore, !loadingMore else { return }
|
||||
loadingMore = true
|
||||
defer { loadingMore = false }
|
||||
do {
|
||||
try await loadPage(api: api, scenicId: scenicId, page: page + 1)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建直播并刷新列表。
|
||||
func create(api: any LiveServing, scenicId: Int?, title: String, coverURL: String) async throws {
|
||||
guard let scenicId else {
|
||||
throw LiveValidationError.missingScenic
|
||||
}
|
||||
let normalizedTitle = title.liveTrimmed
|
||||
let normalizedCover = coverURL.liveTrimmed
|
||||
guard !normalizedTitle.isEmpty else {
|
||||
throw LiveValidationError.emptyTitle
|
||||
}
|
||||
guard normalizedCover.liveIsHTTPURL else {
|
||||
throw LiveValidationError.invalidCoverURL
|
||||
}
|
||||
try await api.liveCreate(LiveCreateRequest(scenicId: String(scenicId), title: normalizedTitle, coverImg: normalizedCover))
|
||||
await reload(api: api, scenicId: scenicId, showLoading: false)
|
||||
}
|
||||
|
||||
/// 加载直播详情,失败时清空旧详情。
|
||||
func loadDetail(api: any LiveServing, liveId: Int) async {
|
||||
do {
|
||||
detail = try await api.liveDetail(liveId: liveId)
|
||||
} catch {
|
||||
detail = nil
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 开始或暂停直播,并刷新列表。
|
||||
func control(api: any LiveServing, item: LiveEntity, scenicId: Int?) async throws {
|
||||
if item.status == 2 {
|
||||
try await api.liveStop(liveId: item.id)
|
||||
} else if item.status != 3 {
|
||||
try await api.liveStart(liveId: item.id)
|
||||
}
|
||||
await reload(api: api, scenicId: scenicId, showLoading: false)
|
||||
}
|
||||
|
||||
/// 结束直播并刷新列表。
|
||||
func finish(api: any LiveServing, item: LiveEntity, scenicId: Int?) async throws {
|
||||
try await api.liveFinish(liveId: item.id)
|
||||
await reload(api: api, scenicId: scenicId, showLoading: false)
|
||||
}
|
||||
|
||||
private func loadPage(api: any LiveServing, scenicId: Int, page: Int) async throws {
|
||||
let response = try await api.liveList(scenicId: scenicId, page: page, pageSize: pageSize)
|
||||
if page == 1 {
|
||||
items = response.items
|
||||
} else {
|
||||
let incomingById = Dictionary(uniqueKeysWithValues: response.items.map { ($0.id, $0) })
|
||||
let kept = items.filter { incomingById[$0.id] == nil }
|
||||
items = kept + response.items
|
||||
}
|
||||
self.page = page
|
||||
total = response.total
|
||||
hasMore = items.count < total
|
||||
}
|
||||
|
||||
private func reset() {
|
||||
clearListAndDetail()
|
||||
loading = false
|
||||
loadingMore = false
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
private func clearListAndDetail() {
|
||||
items = []
|
||||
detail = nil
|
||||
page = 1
|
||||
total = 0
|
||||
hasMore = false
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 直播详情 ViewModel,负责直播详情页动作后的详情刷新。
|
||||
final class LiveDetailViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
var detail: LiveEntity { didSet { onChange?() } }
|
||||
var loading = false { didSet { onChange?() } }
|
||||
var actionInFlight = false { didSet { onChange?() } }
|
||||
var errorMessage: String? { didSet { onChange?() } }
|
||||
|
||||
init(detail: LiveEntity) {
|
||||
self.detail = detail
|
||||
}
|
||||
|
||||
/// 刷新直播详情。
|
||||
func refresh(api: any LiveServing, showLoading: Bool = true) async {
|
||||
if showLoading { loading = true }
|
||||
defer { loading = false }
|
||||
do {
|
||||
detail = try await api.liveDetail(liveId: detail.id)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 开始或暂停当前直播。
|
||||
func control(api: any LiveServing) async throws {
|
||||
guard detail.status != 3 else { return }
|
||||
actionInFlight = true
|
||||
defer { actionInFlight = false }
|
||||
if detail.status == 2 {
|
||||
try await api.liveStop(liveId: detail.id)
|
||||
} else {
|
||||
try await api.liveStart(liveId: detail.id)
|
||||
}
|
||||
await refresh(api: api, showLoading: false)
|
||||
}
|
||||
|
||||
/// 结束当前直播。
|
||||
func finish(api: any LiveServing) async throws {
|
||||
guard detail.status != 3 else { return }
|
||||
actionInFlight = true
|
||||
defer { actionInFlight = false }
|
||||
try await api.liveFinish(liveId: detail.id)
|
||||
await refresh(api: api, showLoading: false)
|
||||
}
|
||||
|
||||
/// 切换推流模式。
|
||||
func setPushMode(api: any LiveServing, mode: Int) async throws {
|
||||
guard detail.manualPushMode != mode else { return }
|
||||
actionInFlight = true
|
||||
defer { actionInFlight = false }
|
||||
try await api.liveSetPushMode(liveId: detail.id, mode: mode)
|
||||
await refresh(api: api, showLoading: false)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 直播相册 ViewModel,负责相册列表、筛选、新建和删除。
|
||||
final class LiveAlbumViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
var folders: [LiveAlbumFolderItem] = [] { didSet { onChange?() } }
|
||||
var startDate: Date? { didSet { onChange?() } }
|
||||
var endDate: Date? { didSet { onChange?() } }
|
||||
var loading = false { didSet { onChange?() } }
|
||||
var loadingMore = false { didSet { onChange?() } }
|
||||
var hasMore = false { didSet { onChange?() } }
|
||||
var page = 1 { didSet { onChange?() } }
|
||||
var errorMessage: String? { didSet { onChange?() } }
|
||||
|
||||
private let pageSize = 10
|
||||
private var total = 0
|
||||
|
||||
/// 设置开始时间并校验时间顺序。
|
||||
func setStartDate(_ date: Date) throws {
|
||||
if let endDate, Calendar.current.startOfDay(for: date) > Calendar.current.startOfDay(for: endDate) {
|
||||
throw LiveValidationError.invalidDateRange
|
||||
}
|
||||
startDate = date
|
||||
}
|
||||
|
||||
/// 设置结束时间并校验时间顺序。
|
||||
func setEndDate(_ date: Date) throws {
|
||||
if let startDate, Calendar.current.startOfDay(for: date) < Calendar.current.startOfDay(for: startDate) {
|
||||
throw LiveValidationError.invalidDateRange
|
||||
}
|
||||
endDate = date
|
||||
}
|
||||
|
||||
/// 清除日期筛选。
|
||||
func clearDateFilters() {
|
||||
startDate = nil
|
||||
endDate = nil
|
||||
}
|
||||
|
||||
/// 重新加载直播相册列表。
|
||||
func reload(api: any LiveServing, scenicId: Int?, showLoading: Bool = true) async {
|
||||
guard let scenicId else {
|
||||
reset()
|
||||
return
|
||||
}
|
||||
if showLoading { loading = true }
|
||||
errorMessage = nil
|
||||
defer { loading = false }
|
||||
do {
|
||||
try await loadPage(api: api, scenicId: scenicId, page: 1)
|
||||
} catch {
|
||||
clearFolders()
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载下一页直播相册。
|
||||
func loadMore(api: any LiveServing, scenicId: Int?) async {
|
||||
guard let scenicId, hasMore, !loadingMore else { return }
|
||||
loadingMore = true
|
||||
let nextPage = page + 1
|
||||
defer { loadingMore = false }
|
||||
do {
|
||||
try await loadPage(api: api, scenicId: scenicId, page: nextPage)
|
||||
} catch {
|
||||
page = max(1, nextPage - 1)
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除直播相册并刷新。
|
||||
func deleteFolder(api: any LiveServing, folderId: Int, scenicId: Int?) async throws {
|
||||
try await api.liveAlbumDeleteFolder(folderId: folderId)
|
||||
await reload(api: api, scenicId: scenicId, showLoading: false)
|
||||
}
|
||||
|
||||
private func loadPage(api: any LiveServing, scenicId: Int, page: Int) async throws {
|
||||
let response = try await api.liveAlbumList(
|
||||
scenicId: scenicId,
|
||||
startTime: startDate?.liveDayText,
|
||||
endTime: endDate?.liveDayText,
|
||||
page: page,
|
||||
pageSize: pageSize
|
||||
)
|
||||
if page == 1 {
|
||||
folders = response.items
|
||||
} else {
|
||||
let incomingById = Set(response.items.map(\.id))
|
||||
folders = folders.filter { !incomingById.contains($0.id) } + response.items
|
||||
}
|
||||
self.page = page
|
||||
total = response.total
|
||||
hasMore = folders.count < total
|
||||
}
|
||||
|
||||
private func reset() {
|
||||
clearFolders()
|
||||
loading = false
|
||||
loadingMore = false
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
private func clearFolders() {
|
||||
folders = []
|
||||
page = 1
|
||||
total = 0
|
||||
hasMore = false
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 新建直播相册 ViewModel,负责本地素材上传和创建相册。
|
||||
final class LiveAlbumCreateViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
var name = "" { didSet { onChange?() } }
|
||||
var localFiles: [LiveAlbumLocalUploadFile] = [] { didSet { onChange?() } }
|
||||
var submitting = false { didSet { onChange?() } }
|
||||
var uploadProgress = 0 { didSet { onChange?() } }
|
||||
var errorMessage: String? { didSet { onChange?() } }
|
||||
|
||||
/// 添加本地待上传素材。
|
||||
func addLocalFiles(_ files: [LiveAlbumLocalUploadFile]) {
|
||||
localFiles.append(contentsOf: files)
|
||||
}
|
||||
|
||||
/// 删除本地待上传素材。
|
||||
func removeLocalFile(id: UUID) {
|
||||
localFiles.removeAll { $0.id == id }
|
||||
}
|
||||
|
||||
/// 上传本地素材并创建直播相册。
|
||||
func submit(scenicId: Int?, api: any LiveServing, uploader: any OSSUploadServing) async throws {
|
||||
guard let scenicId else { throw LiveValidationError.missingScenic }
|
||||
let normalizedName = name.liveTrimmed
|
||||
guard !normalizedName.isEmpty else { throw LiveValidationError.emptyAlbumName }
|
||||
guard !localFiles.isEmpty else { throw LiveValidationError.emptyAlbumFiles }
|
||||
guard !submitting else { return }
|
||||
|
||||
submitting = true
|
||||
uploadProgress = 0
|
||||
defer { submitting = false }
|
||||
|
||||
var uploadedItems: [LiveAlbumCreateFileItem] = []
|
||||
let count = max(localFiles.count, 1)
|
||||
do {
|
||||
for index in localFiles.indices {
|
||||
let file = localFiles[index]
|
||||
let url = try await uploader.uploadAliveAlbumFile(
|
||||
data: file.data,
|
||||
fileName: file.fileName,
|
||||
fileType: file.fileType,
|
||||
scenicId: scenicId
|
||||
) { progress in
|
||||
Task { @MainActor in
|
||||
let base = Double(index) / Double(count)
|
||||
let step = Double(progress) / Double(count)
|
||||
self.uploadProgress = min(99, Int((base + step / 100) * 100))
|
||||
}
|
||||
}
|
||||
localFiles[index].uploadedURL = url
|
||||
uploadedItems.append(
|
||||
LiveAlbumCreateFileItem(url: url, type: file.fileType, size: file.size, coverImg: nil)
|
||||
)
|
||||
}
|
||||
try await api.liveAlbumCreateFolder(
|
||||
LiveAlbumCreateFolderRequest(scenicId: String(scenicId), name: normalizedName, items: uploadedItems)
|
||||
)
|
||||
name = ""
|
||||
localFiles = []
|
||||
uploadProgress = 100
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 直播相册预览 ViewModel,负责加载相册详情和删除当前素材。
|
||||
final class LiveAlbumPreviewViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
let folderId: Int
|
||||
var folder: LiveAlbumFolderItem? { didSet { onChange?() } }
|
||||
var files: [LiveAlbumFileItem] = [] { didSet { onChange?() } }
|
||||
var currentIndex: Int { didSet { onChange?() } }
|
||||
var loading = false { didSet { onChange?() } }
|
||||
var errorMessage: String? { didSet { onChange?() } }
|
||||
|
||||
init(folderId: Int, startIndex: Int = 0, summary: LiveAlbumFolderItem? = nil) {
|
||||
self.folderId = folderId
|
||||
currentIndex = max(startIndex, 0)
|
||||
folder = summary
|
||||
files = summary?.items ?? []
|
||||
}
|
||||
|
||||
/// 加载相册详情。
|
||||
func load(api: any LiveServing) async {
|
||||
loading = true
|
||||
defer { loading = false }
|
||||
do {
|
||||
let detail = try await api.liveAlbumFolderDetail(folderId: folderId)
|
||||
folder = detail
|
||||
files = detail.items
|
||||
if currentIndex >= files.count {
|
||||
currentIndex = max(files.count - 1, 0)
|
||||
}
|
||||
} catch {
|
||||
files = []
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除当前预览素材并刷新详情。
|
||||
func deleteCurrentFile(api: any LiveServing) async throws {
|
||||
guard files.indices.contains(currentIndex) else { return }
|
||||
let file = files[currentIndex]
|
||||
try await api.liveAlbumDeleteFiles(folderId: folderId, fileIds: [file.id])
|
||||
await load(api: api)
|
||||
}
|
||||
}
|
||||
|
||||
/// 直播模块校验错误。
|
||||
enum LiveValidationError: LocalizedError, Equatable {
|
||||
case missingScenic
|
||||
case emptyTitle
|
||||
case invalidCoverURL
|
||||
case invalidDateRange
|
||||
case emptyAlbumName
|
||||
case emptyAlbumFiles
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .missingScenic:
|
||||
"当前账号缺少景区信息"
|
||||
case .emptyTitle:
|
||||
"请输入直播标题"
|
||||
case .invalidCoverURL:
|
||||
"封面图地址需以 http:// 或 https:// 开头"
|
||||
case .invalidDateRange:
|
||||
"开始时间不能大于结束时间"
|
||||
case .emptyAlbumName:
|
||||
"请输入相册名称"
|
||||
case .emptyAlbumFiles:
|
||||
"请上传素材"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Date {
|
||||
/// 直播接口使用的日期格式。
|
||||
var liveDayText: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "zh_CN")
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
return formatter.string(from: self)
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
var liveIsHTTPURL: Bool {
|
||||
let lower = liveTrimmed.lowercased()
|
||||
return lower.hasPrefix("http://") || lower.hasPrefix("https://")
|
||||
}
|
||||
}
|
||||
|
||||
extension Int64 {
|
||||
/// 直播时长展示文案。
|
||||
var liveDurationText: String {
|
||||
let totalSeconds = Swift.max(Int(self), 0)
|
||||
let hours = totalSeconds / 3600
|
||||
let minutes = (totalSeconds % 3600) / 60
|
||||
let seconds = totalSeconds % 60
|
||||
return "\(hours):\(String(format: "%02d", minutes)):\(String(format: "%02d", seconds))"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,80 @@
|
||||
//
|
||||
// LocationReportAPI.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 位置上报服务协议,抽象上报和历史接口以便 ViewModel 测试替换。
|
||||
@MainActor
|
||||
protocol LocationReportServing {
|
||||
/// 提交一次位置上报。
|
||||
func reportLocation(staffId: Int, latitude: Double, longitude: Double, address: String, type: LocationReportType, scenicId: Int) async throws -> LocationReportSubmitResponse
|
||||
|
||||
/// 获取位置上报历史列表。
|
||||
func locationReportList(staffId: Int, page: Int, pageSize: Int, type: LocationReportType, startDate: String?, endDate: String?) async throws -> ListPayload<LocationReportHistoryItem>
|
||||
}
|
||||
|
||||
/// 位置上报 API,负责封装旧工程定位上报相关接口。
|
||||
@MainActor
|
||||
final class LocationReportAPI: LocationReportServing {
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化位置上报 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
self.client = client
|
||||
}
|
||||
|
||||
/// 提交一次位置上报。
|
||||
func reportLocation(
|
||||
staffId: Int,
|
||||
latitude: Double,
|
||||
longitude: Double,
|
||||
address: String,
|
||||
type: LocationReportType,
|
||||
scenicId: Int
|
||||
) async throws -> LocationReportSubmitResponse {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/yf-handset-app/photog/loacation/report",
|
||||
queryItems: [
|
||||
URLQueryItem(name: "staff_id", value: "\(staffId)"),
|
||||
URLQueryItem(name: "latitude", value: "\(latitude)"),
|
||||
URLQueryItem(name: "longitude", value: "\(longitude)"),
|
||||
URLQueryItem(name: "address", value: address),
|
||||
URLQueryItem(name: "type", value: "\(type.rawValue)"),
|
||||
URLQueryItem(name: "scenic_id", value: "\(scenicId)")
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取位置上报历史列表。
|
||||
func locationReportList(
|
||||
staffId: Int,
|
||||
page: Int = 1,
|
||||
pageSize: Int = 20,
|
||||
type: LocationReportType = .all,
|
||||
startDate: String? = nil,
|
||||
endDate: String? = nil
|
||||
) async throws -> ListPayload<LocationReportHistoryItem> {
|
||||
var query = [
|
||||
URLQueryItem(name: "staff_id", value: "\(staffId)"),
|
||||
URLQueryItem(name: "page", value: "\(max(page, 1))"),
|
||||
URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))"),
|
||||
URLQueryItem(name: "type", value: "\(max(type.rawValue, 0))")
|
||||
]
|
||||
if let startDate, !startDate.isEmpty {
|
||||
query.append(URLQueryItem(name: "start_date", value: startDate))
|
||||
}
|
||||
if let endDate, !endDate.isEmpty {
|
||||
query.append(URLQueryItem(name: "end_date", value: endDate))
|
||||
}
|
||||
return try await client.send(
|
||||
APIRequest(method: .get, path: "/api/yf-handset-app/photog/loacation/list", queryItems: query)
|
||||
)
|
||||
}
|
||||
}
|
||||
39
suixinkan_ios/Features/LocationReport/LocationReport.md
Normal file
39
suixinkan_ios/Features/LocationReport/LocationReport.md
Normal file
@ -0,0 +1,39 @@
|
||||
# LocationReport 模块业务逻辑
|
||||
|
||||
## 模块职责
|
||||
|
||||
LocationReport 模块负责首页 `location_report` 和 `location_report_history` 入口。
|
||||
|
||||
本模块包含当前位置获取、标记点、在线状态、立即上报、提醒设置、历史记录、类型筛选、日期筛选和分页。上报状态只保存在页面 ViewModel 内,不进入全局 App 状态。
|
||||
|
||||
## 数据来源
|
||||
|
||||
- 当前景区 ID 从 `AccountContext.currentScenic` 读取。
|
||||
- 上报人员 ID 从 `AccountSnapshotStore.load()?.businessUserId` 读取,避免把业务账号 ID 复制进页面全局状态。
|
||||
- 提交接口使用旧工程拼写 `/api/yf-handset-app/photog/loacation/report`。
|
||||
- 历史接口使用 `/api/yf-handset-app/photog/loacation/list`。
|
||||
|
||||
## 上报流程
|
||||
|
||||
`LocationReportViewModel` 管理当前位置、标记点、在线状态、提醒分钟数和页面内倒计时。
|
||||
|
||||
1. 页面进入时请求一次前台定位。
|
||||
2. 用户可以立即上报当前位置,也可以把当前位置设为标记点后上报。
|
||||
3. 切换到在线状态时提交一次在线状态上报。
|
||||
4. 上报成功后按服务端 `expired` 重置倒计时;服务端未返回时兜底为 2 小时。
|
||||
|
||||
## 历史流程
|
||||
|
||||
`LocationReportHistoryViewModel` 管理类型筛选、日期筛选、分页和错误状态。
|
||||
|
||||
历史记录支持 `全部`、`立即上报`、`标记点`、`在线状态` 四类筛选。分页失败只保留错误提示,不影响登录态和其他模块。
|
||||
|
||||
## 定位边界
|
||||
|
||||
当前只做前台手动定位和页面内倒计时。本轮不做后台持续定位、后台定时上报、离线持久化队列或推送提醒。
|
||||
|
||||
定位结果、在线状态、提醒倒计时和提交表单均不落盘。
|
||||
|
||||
## 测试要求
|
||||
|
||||
新增位置上报逻辑时,需要同步补充 API、ViewModel 和路由测试。测试失败时先修复问题,再继续迁移后续功能。
|
||||
@ -0,0 +1,127 @@
|
||||
//
|
||||
// LocationReportModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 位置上报类型实体,表示立即上报、标记点上报和在线状态上报。
|
||||
enum LocationReportType: Int, CaseIterable, Identifiable {
|
||||
case all = 0
|
||||
case immediate = 1
|
||||
case marked = 2
|
||||
case onlineStatus = 3
|
||||
|
||||
var id: Int { rawValue }
|
||||
|
||||
/// 上报类型展示标题。
|
||||
var title: String {
|
||||
switch self {
|
||||
case .all: "全部"
|
||||
case .immediate: "立即上报"
|
||||
case .marked: "标记点"
|
||||
case .onlineStatus: "在线状态"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 位置坐标实体,表示定位或手动选点得到的经纬度。
|
||||
struct LocationCoordinate: Equatable {
|
||||
var latitude: Double
|
||||
var longitude: Double
|
||||
}
|
||||
|
||||
/// 位置上报提交响应实体,表示服务端返回的上报人员、过期秒数和状态。
|
||||
struct LocationReportSubmitResponse: Decodable, Equatable {
|
||||
let staffId: String
|
||||
let expired: Int
|
||||
let status: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case staffId = "staff_id"
|
||||
case expired
|
||||
case status
|
||||
}
|
||||
|
||||
/// 创建位置上报提交响应,主要用于测试替身返回。
|
||||
init(staffId: String, expired: Int, status: Int) {
|
||||
self.staffId = staffId
|
||||
self.expired = expired
|
||||
self.status = status
|
||||
}
|
||||
|
||||
/// 宽松解码提交响应字段。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
staffId = try container.decodeLossyString(forKey: .staffId)
|
||||
expired = try container.decodeLossyInt(forKey: .expired) ?? 0
|
||||
status = try container.decodeLossyInt(forKey: .status) ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
/// 位置上报历史项实体,表示一次历史上报记录。
|
||||
struct LocationReportHistoryItem: Decodable, Equatable, Identifiable {
|
||||
let id: Int
|
||||
let staffId: Int
|
||||
let type: Int
|
||||
let latitude: String
|
||||
let longitude: String
|
||||
let address: String
|
||||
let ip: String
|
||||
let remark: String
|
||||
let createdAt: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case staffId = "staff_id"
|
||||
case type
|
||||
case latitude
|
||||
case longitude
|
||||
case address
|
||||
case ip
|
||||
case remark
|
||||
case createdAt = "created_at"
|
||||
}
|
||||
|
||||
/// 宽松解码历史项字段。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decodeLossyInt(forKey: .id) ?? 0
|
||||
staffId = try container.decodeLossyInt(forKey: .staffId) ?? 0
|
||||
type = try container.decodeLossyInt(forKey: .type) ?? 0
|
||||
latitude = try container.decodeLossyString(forKey: .latitude)
|
||||
longitude = try container.decodeLossyString(forKey: .longitude)
|
||||
address = try container.decodeLossyString(forKey: .address)
|
||||
ip = try container.decodeLossyString(forKey: .ip)
|
||||
remark = try container.decodeLossyString(forKey: .remark)
|
||||
createdAt = try container.decodeLossyString(forKey: .createdAt)
|
||||
}
|
||||
|
||||
/// 上报类型展示文案。
|
||||
var typeTitle: String {
|
||||
LocationReportType(rawValue: type)?.title ?? "未知"
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,119 @@
|
||||
//
|
||||
// LocationReportViewControllers.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/26.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
/// 位置上报页。
|
||||
final class LocationReportViewController: ModuleTableViewController {
|
||||
private let viewModel = LocationReportViewModel()
|
||||
|
||||
override func viewDidLoad() {
|
||||
title = "位置上报"
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
title: "上报",
|
||||
style: .done,
|
||||
target: self,
|
||||
action: #selector(submitReport)
|
||||
)
|
||||
super.viewDidLoad()
|
||||
wireViewModel(viewModel) { }
|
||||
}
|
||||
|
||||
override func numberOfSections(in tableView: UITableView) -> Int { 2 }
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
section == 0 ? 3 : 1
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||
section == 0 ? "状态" : "操作"
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(
|
||||
withIdentifier: TitleSubtitleTableViewCell.reuseIdentifier,
|
||||
for: indexPath
|
||||
) as! TitleSubtitleTableViewCell
|
||||
if indexPath.section == 0 {
|
||||
switch indexPath.row {
|
||||
case 0:
|
||||
cell.configure(title: "在线状态", subtitle: viewModel.isOnline ? "在线" : "离线")
|
||||
case 1:
|
||||
cell.configure(title: "当前地址", subtitle: viewModel.currentAddress)
|
||||
case 2:
|
||||
cell.configure(title: "倒计时", subtitle: viewModel.countdownText)
|
||||
default:
|
||||
break
|
||||
}
|
||||
} else {
|
||||
cell.configure(title: "切换在线状态", subtitle: viewModel.isOnline ? "点击离线" : "点击上线")
|
||||
}
|
||||
return cell
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
if indexPath.section == 1 {
|
||||
Task {
|
||||
_ = await viewModel.setOnline(
|
||||
!viewModel.isOnline,
|
||||
staffId: services.staffId,
|
||||
scenicId: services.currentScenicId,
|
||||
api: services.locationReportAPI
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func reloadContent() async {
|
||||
viewModel.applyCurrentLocation(latitude: 39.9, longitude: 116.4, address: "定位待接入")
|
||||
}
|
||||
|
||||
@objc private func submitReport() {
|
||||
Task {
|
||||
_ = await viewModel.submit(
|
||||
type: .immediate,
|
||||
staffId: services.staffId,
|
||||
scenicId: services.currentScenicId,
|
||||
api: services.locationReportAPI
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension LocationReportViewModel: ViewModelBindable {}
|
||||
|
||||
/// 位置上报历史页。
|
||||
final class LocationReportHistoryViewController: ModuleTableViewController {
|
||||
private let viewModel = LocationReportHistoryViewModel()
|
||||
|
||||
override func viewDidLoad() {
|
||||
title = "上报历史"
|
||||
super.viewDidLoad()
|
||||
wireViewModel(viewModel) { }
|
||||
}
|
||||
|
||||
override func tableRowCount() -> Int {
|
||||
viewModel.items.count
|
||||
}
|
||||
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let record = viewModel.items[indexPath.row]
|
||||
cell.configure(title: record.typeTitle, subtitle: record.address, detail: record.createdAt)
|
||||
}
|
||||
|
||||
override func reloadContent() async {
|
||||
await viewModel.reload(staffId: services.staffId, api: services.locationReportAPI)
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
||||
guard indexPath.row >= viewModel.items.count - 2 else { return }
|
||||
Task { await viewModel.loadMore(staffId: services.staffId, api: services.locationReportAPI) }
|
||||
}
|
||||
}
|
||||
|
||||
extension LocationReportHistoryViewModel: ViewModelBindable {}
|
||||
@ -0,0 +1,232 @@
|
||||
//
|
||||
// LocationReportViewModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 位置上报 ViewModel,负责页面内在线状态、坐标、倒计时和提交动作。
|
||||
@MainActor
|
||||
final class LocationReportViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
var isOnline = false { didSet { onChange?() } }
|
||||
var reminderMinutes = 30 { didSet { onChange?() } }
|
||||
var secondsUntilReport = 0 { didSet { onChange?() } }
|
||||
var currentCoordinate: LocationCoordinate? { didSet { onChange?() } }
|
||||
var markedCoordinate: LocationCoordinate? { didSet { onChange?() } }
|
||||
var currentAddress = "" { didSet { onChange?() } }
|
||||
var markedAddress = "" { didSet { onChange?() } }
|
||||
var lastReportText = "" { didSet { onChange?() } }
|
||||
var errorMessage: String? { didSet { onChange?() } }
|
||||
var isSubmitting = false { didSet { onChange?() } }
|
||||
|
||||
/// 倒计时展示文案。
|
||||
var countdownText: String {
|
||||
guard secondsUntilReport > 0 else { return "可立即上报" }
|
||||
let hours = secondsUntilReport / 3600
|
||||
let minutes = (secondsUntilReport % 3600) / 60
|
||||
return "\(hours)小时\(minutes)分钟后可再次提醒"
|
||||
}
|
||||
|
||||
/// 设置当前位置。
|
||||
func applyCurrentLocation(latitude: Double, longitude: Double, address: String) {
|
||||
currentCoordinate = LocationCoordinate(latitude: latitude, longitude: longitude)
|
||||
currentAddress = address
|
||||
}
|
||||
|
||||
/// 设置标记点位置。
|
||||
func applyMarkedLocation(latitude: Double, longitude: Double, address: String) {
|
||||
markedCoordinate = LocationCoordinate(latitude: latitude, longitude: longitude)
|
||||
markedAddress = address
|
||||
}
|
||||
|
||||
/// 切换在线状态;切到在线时会提交一次状态上报。
|
||||
func setOnline(
|
||||
_ value: Bool,
|
||||
staffId: Int?,
|
||||
scenicId: Int?,
|
||||
api: any LocationReportServing
|
||||
) async -> Bool {
|
||||
isOnline = value
|
||||
guard value else { return true }
|
||||
return await submit(type: .onlineStatus, staffId: staffId, scenicId: scenicId, api: api)
|
||||
}
|
||||
|
||||
/// 提交指定类型的位置上报。
|
||||
func submit(
|
||||
type: LocationReportType,
|
||||
staffId: Int?,
|
||||
scenicId: Int?,
|
||||
api: any LocationReportServing
|
||||
) async -> Bool {
|
||||
guard !isSubmitting else { return false }
|
||||
guard let staffId, staffId > 0 else {
|
||||
errorMessage = "缺少上报人员"
|
||||
return false
|
||||
}
|
||||
guard let scenicId, scenicId > 0 else {
|
||||
errorMessage = "缺少当前景区"
|
||||
return false
|
||||
}
|
||||
|
||||
let source = reportSource(for: type)
|
||||
guard let coordinate = source.coordinate else {
|
||||
errorMessage = "请先获取或选择位置"
|
||||
return false
|
||||
}
|
||||
let address = source.address.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !address.isEmpty else {
|
||||
errorMessage = "请填写位置地址"
|
||||
return false
|
||||
}
|
||||
|
||||
isSubmitting = true
|
||||
errorMessage = nil
|
||||
defer { isSubmitting = false }
|
||||
|
||||
do {
|
||||
let response = try await api.reportLocation(
|
||||
staffId: staffId,
|
||||
latitude: coordinate.latitude,
|
||||
longitude: coordinate.longitude,
|
||||
address: address,
|
||||
type: type,
|
||||
scenicId: scenicId
|
||||
)
|
||||
secondsUntilReport = response.expired > 0 ? response.expired : 7200
|
||||
lastReportText = LocationReportViewModel.displayFormatter.string(from: Date())
|
||||
return true
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// 页面内倒计时每秒递减。
|
||||
func tick() {
|
||||
guard secondsUntilReport > 0 else { return }
|
||||
secondsUntilReport -= 1
|
||||
}
|
||||
|
||||
/// 根据上报类型选择当前坐标或标记点坐标。
|
||||
private func reportSource(for type: LocationReportType) -> (coordinate: LocationCoordinate?, address: String) {
|
||||
switch type {
|
||||
case .marked:
|
||||
(markedCoordinate ?? currentCoordinate, markedAddress.isEmpty ? currentAddress : markedAddress)
|
||||
case .all, .immediate, .onlineStatus:
|
||||
(currentCoordinate, currentAddress)
|
||||
}
|
||||
}
|
||||
|
||||
private static let displayFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
|
||||
return formatter
|
||||
}()
|
||||
}
|
||||
|
||||
/// 位置上报历史 ViewModel,负责筛选、日期和分页加载。
|
||||
@MainActor
|
||||
final class LocationReportHistoryViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
var selectedType: LocationReportType = .all { didSet { onChange?() } }
|
||||
var startDate: Date? { didSet { onChange?() } }
|
||||
var endDate: Date? { didSet { onChange?() } }
|
||||
var items: [LocationReportHistoryItem] = [] { 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(staffId: Int?, api: any LocationReportServing) async {
|
||||
guard let staffId, staffId > 0 else {
|
||||
reset()
|
||||
errorMessage = "缺少上报人员"
|
||||
return
|
||||
}
|
||||
page = 1
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
defer { isLoading = false }
|
||||
|
||||
do {
|
||||
let payload = try await api.locationReportList(
|
||||
staffId: staffId,
|
||||
page: page,
|
||||
pageSize: pageSize,
|
||||
type: selectedType,
|
||||
startDate: dateString(startDate),
|
||||
endDate: dateString(endDate)
|
||||
)
|
||||
items = payload.list
|
||||
total = payload.total
|
||||
} catch {
|
||||
items = []
|
||||
total = 0
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载下一页历史记录。
|
||||
func loadMore(staffId: Int?, api: any LocationReportServing) async {
|
||||
guard let staffId, staffId > 0, hasMore, !isLoading, !isLoadingMore else { return }
|
||||
isLoadingMore = true
|
||||
let nextPage = page + 1
|
||||
defer { isLoadingMore = false }
|
||||
|
||||
do {
|
||||
let payload = try await api.locationReportList(
|
||||
staffId: staffId,
|
||||
page: nextPage,
|
||||
pageSize: pageSize,
|
||||
type: selectedType,
|
||||
startDate: dateString(startDate),
|
||||
endDate: dateString(endDate)
|
||||
)
|
||||
page = nextPage
|
||||
items.append(contentsOf: payload.list)
|
||||
total = payload.total
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 切换历史类型筛选并刷新。
|
||||
func selectType(_ type: LocationReportType, staffId: Int?, api: any LocationReportServing) async {
|
||||
guard selectedType != type else { return }
|
||||
selectedType = type
|
||||
await reload(staffId: staffId, api: api)
|
||||
}
|
||||
|
||||
/// 清空历史状态。
|
||||
func reset() {
|
||||
items = []
|
||||
total = 0
|
||||
page = 1
|
||||
isLoading = false
|
||||
isLoadingMore = false
|
||||
}
|
||||
|
||||
/// 将日期转为接口需要的 yyyy-MM-dd 字符串。
|
||||
private func dateString(_ date: Date?) -> String? {
|
||||
guard let date else { return nil }
|
||||
return Self.dateFormatter.string(from: date)
|
||||
}
|
||||
|
||||
private static let dateFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
return formatter
|
||||
}()
|
||||
}
|
||||
59
suixinkan_ios/Features/Main/Main.md
Normal file
59
suixinkan_ios/Features/Main/Main.md
Normal file
@ -0,0 +1,59 @@
|
||||
# Main 模块业务逻辑
|
||||
|
||||
## 模块职责
|
||||
|
||||
Main 模块负责登录后的主界面 Tab 容器,以及当前尚未迁移页面的占位入口。
|
||||
|
||||
主界面由 `MainTabsView` 承载:
|
||||
- 根据 `MainTabBarConfiguration.activeStyle` 选择自定义 TabBar 或系统 `TabView`。
|
||||
- 默认使用自定义 TabBar;如需切回系统实现,将配置改为 `.system`。
|
||||
- 自定义和系统两套外壳都展示首页、订单、数据、我的四个 Tab。
|
||||
- 每个 Tab 内部都包裹独立的 `NavigationStack`,并通过 `TabNavigationStackHost` 复用同一套导航构建逻辑。
|
||||
- 每个 Tab 的导航路径由 `AppRouter` 单独保存。
|
||||
|
||||
## Tab 结构
|
||||
|
||||
当前 Tab 来自 `AppTab`:
|
||||
- `home`:首页
|
||||
- `orders`:订单
|
||||
- `statistics`:数据
|
||||
- `profile`:我的
|
||||
|
||||
`HomeRootView` 已接入真实的 `HomeView`,`OrdersRootView` 已接入真实的 `OrdersView`,`StatisticsRootView` 已接入真实的 `StatisticsView`,`ProfileRootView` 已接入真实的 `ProfileView`。
|
||||
|
||||
## 导航流程
|
||||
|
||||
1. `MainTabsView` 从 Environment 读取 `AppRouter`。
|
||||
2. `MainTabsView` 根据 `MainTabBarConfiguration.activeStyle` 选择 `CustomMainTabsView` 或 `SystemMainTabsView`。
|
||||
3. 两套外壳都使用 `appRouter.selectedTab` 作为选中状态。
|
||||
4. 每个 Tab 通过 `TabNavigationStackHost` 创建自己的 `NavigationStack(path:)`。
|
||||
5. 路径绑定来自 `appRouter.binding(for:)`。
|
||||
6. Tab 内页面需要进入尚未迁移的子页面时,通过当前 Tab 注入的 `RouterPath` push 一个 `AppRoute.placeholder`。
|
||||
7. `navigationDestination` 根据 `AppRoute` 展示详情页。
|
||||
8. 子页面展示时读取 `AppRoute.hidesTabBarWhenPushed`,默认隐藏底部 TabBar。
|
||||
|
||||
## 自定义 TabBar
|
||||
|
||||
自定义 TabBar 由 `CustomMainTabsView` 和 `CustomMainTabBar` 组成:
|
||||
- `CustomMainTabsView` 负责保留已访问 Tab 页面、刷新订单角标、展示扫码页。
|
||||
- `CustomTabNavigationStackHost` 会把 `CustomMainTabBar` 拼在每个 Tab 的根页面内容下方。
|
||||
- `CustomMainTabBar` 只负责展示 UI,不直接读取账号、订单 API 或业务上下文。
|
||||
- `MainTabBadgeViewModel` 通过 `OrdersAPI.writeOffList` 获取待核销数量,失败时静默清空角标。
|
||||
- 中间扫码按钮使用订单模块已有的 `OrderScannerPage`。
|
||||
- 扫码成功后调用 `AppRouter.routeToOrderVerification(scannedCode:)`,订单页再通过 `consumePendingOrderScanCode()` 一次性消费结果。
|
||||
- 自定义 TabBar 只属于 Tab 根页面内容;当前 Tab 的导航栈 push 到二级页面后,目标页面不包含 TabBar,也不会保留底部占位高度。
|
||||
- 承载根页面和 `CustomMainTabBar` 的容器需要忽略键盘底部安全区,避免订单搜索框等输入控件唤起键盘时把自定义 TabBar 顶起。
|
||||
|
||||
系统 `TabView` 外壳由 `SystemMainTabsView` 保留。系统模式不显示中间扫码按钮,但订单页内部的扫码核销入口仍然可用。
|
||||
|
||||
## 后续迁移规则
|
||||
|
||||
迁移新页面时:
|
||||
- 优先替换对应 Tab 的 RootView。
|
||||
- 保持每个 Tab 自己的 `NavigationStack`。
|
||||
- 跨 Tab 跳转通过 `AppRouter.select` 切换 Tab。
|
||||
- 需要从首页或全局入口进入订单核销时,优先使用 `AppRouter.selectOrders(entry:)` 或 `AppRouter.routeToOrderVerification(scannedCode:)`。
|
||||
- Tab 内跳转通过当前 Tab 的 `RouterPath.navigate` 或扩展后的 `AppRoute` 处理。
|
||||
- 普通业务子页面默认隐藏 TabBar;只有确有产品需求时,才在 `AppRoute` 策略中单独放开。
|
||||
- 不要把业务状态塞进自定义 TabBar 组件;TabBar 只接收绑定、文案和动作回调。
|
||||
- 真实业务页面接入后,应同步补充该模块文档和单元测试。
|
||||
@ -0,0 +1,351 @@
|
||||
//
|
||||
// MainTabBarController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
/// 主 Tab 容器,使用自定义底部栏展示四个一级入口和中间扫码按钮。
|
||||
final class MainTabBarController: UIViewController {
|
||||
|
||||
private let services: AppServices
|
||||
private let badgeViewModel = MainTabBadgeViewModel()
|
||||
private let contentContainer = UIView()
|
||||
private let customTabBar = CustomMainTabBarView()
|
||||
|
||||
private var tabNavigationControllers: [AppTab: TabNavigationController] = [:]
|
||||
private var loadedTabs: Set<AppTab> = [.home]
|
||||
|
||||
init(services: AppServices) {
|
||||
self.services = services
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .white
|
||||
configureLayout()
|
||||
bindRouter()
|
||||
bindBadgeViewModel()
|
||||
bindAccountContext()
|
||||
switchToTab(services.appRouter.selectedTab, animated: false)
|
||||
Task { await refreshOrderBadge() }
|
||||
#if DEBUG
|
||||
Task { await AppUITestRouteDriver.applyIfNeeded(services: services) }
|
||||
#endif
|
||||
}
|
||||
|
||||
/// 控制自定义 TabBar 显隐,push 子页面时隐藏。
|
||||
func setCustomTabBarHidden(_ hidden: Bool, animated: Bool) {
|
||||
let updates = {
|
||||
self.customTabBar.alpha = hidden ? 0 : 1
|
||||
self.customTabBar.isUserInteractionEnabled = !hidden
|
||||
}
|
||||
|
||||
guard animated else {
|
||||
updates()
|
||||
return
|
||||
}
|
||||
|
||||
UIView.animate(withDuration: 0.2, delay: 0, options: [.curveEaseInOut], animations: updates)
|
||||
}
|
||||
|
||||
private func configureLayout() {
|
||||
view.addSubview(contentContainer)
|
||||
view.addSubview(customTabBar)
|
||||
|
||||
customTabBar.onTabSelected = { [weak self] tab in
|
||||
self?.services.appRouter.select(tab)
|
||||
}
|
||||
customTabBar.onScanTapped = { [weak self] in
|
||||
self?.presentScanner()
|
||||
}
|
||||
|
||||
contentContainer.snp.makeConstraints { make in
|
||||
make.top.leading.trailing.equalToSuperview()
|
||||
make.bottom.equalTo(customTabBar.snp.top)
|
||||
}
|
||||
|
||||
customTabBar.snp.makeConstraints { make in
|
||||
make.leading.trailing.equalToSuperview()
|
||||
make.bottom.equalTo(view.safeAreaLayoutGuide)
|
||||
make.height.equalTo(72)
|
||||
}
|
||||
}
|
||||
|
||||
private func bindRouter() {
|
||||
services.appRouter.onChange = { [weak self] in
|
||||
self?.handleRouterChange()
|
||||
}
|
||||
}
|
||||
|
||||
private func bindBadgeViewModel() {
|
||||
badgeViewModel.onChange = { [weak self] in
|
||||
self?.updateOrderBadge()
|
||||
}
|
||||
}
|
||||
|
||||
private func bindAccountContext() {
|
||||
services.accountContext.onChange = { [weak self] in
|
||||
Task { await self?.refreshOrderBadge() }
|
||||
}
|
||||
}
|
||||
|
||||
private func handleRouterChange() {
|
||||
switchToTab(services.appRouter.selectedTab, animated: false)
|
||||
if services.appRouter.selectedTab == .orders {
|
||||
Task { await refreshOrderBadge() }
|
||||
}
|
||||
}
|
||||
|
||||
private func switchToTab(_ tab: AppTab, animated: Bool) {
|
||||
loadedTabs.insert(tab)
|
||||
customTabBar.selectedTab = tab
|
||||
|
||||
let navigationController = navigationController(for: tab)
|
||||
for child in children where child !== navigationController {
|
||||
child.willMove(toParent: nil)
|
||||
child.view.removeFromSuperview()
|
||||
child.removeFromParent()
|
||||
}
|
||||
|
||||
addChild(navigationController)
|
||||
contentContainer.addSubview(navigationController.view)
|
||||
navigationController.view.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
navigationController.didMove(toParent: self)
|
||||
|
||||
let isRoot = navigationController.viewControllers.count <= 1
|
||||
setCustomTabBarHidden(!isRoot, animated: animated)
|
||||
}
|
||||
|
||||
private func navigationController(for tab: AppTab) -> TabNavigationController {
|
||||
if let existing = tabNavigationControllers[tab] {
|
||||
return existing
|
||||
}
|
||||
|
||||
let navigationController = TabNavigationController(tab: tab, services: services)
|
||||
navigationController.tabBarHost = self
|
||||
tabNavigationControllers[tab] = navigationController
|
||||
return navigationController
|
||||
}
|
||||
|
||||
private func updateOrderBadge() {
|
||||
guard let count = badgeViewModel.pendingWriteOffCount, count > 0 else {
|
||||
customTabBar.orderBadgeText = nil
|
||||
return
|
||||
}
|
||||
customTabBar.orderBadgeText = "\(count)"
|
||||
}
|
||||
|
||||
private func refreshOrderBadge() async {
|
||||
await badgeViewModel.refreshPendingWriteOffCount(
|
||||
api: services.ordersAPI,
|
||||
scenicId: services.accountContext.currentScenic?.id,
|
||||
storeId: services.accountContext.currentStore?.id
|
||||
)
|
||||
}
|
||||
|
||||
private func presentScanner() {
|
||||
let scanner = OrderCodeScannerViewController()
|
||||
scanner.onScanResult = { [weak self] result in
|
||||
guard let self else { return }
|
||||
switch result {
|
||||
case .success(let rawCode):
|
||||
dismiss(animated: true) {
|
||||
self.services.appRouter.routeToOrderVerification(scannedCode: rawCode)
|
||||
}
|
||||
case .failure(let error):
|
||||
showToast(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
let navigation = UINavigationController(rootViewController: scanner)
|
||||
navigation.modalPresentationStyle = .fullScreen
|
||||
present(navigation, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
/// 自定义主 TabBar,展示四个一级入口和中间扫码核销按钮。
|
||||
private final class CustomMainTabBarView: UIView {
|
||||
|
||||
var selectedTab: AppTab = .home {
|
||||
didSet { refreshSelection() }
|
||||
}
|
||||
|
||||
var orderBadgeText: String? {
|
||||
didSet { ordersBadgeLabel.text = orderBadgeText; ordersBadgeLabel.isHidden = orderBadgeText == nil }
|
||||
}
|
||||
|
||||
var onTabSelected: ((AppTab) -> Void)?
|
||||
var onScanTapped: (() -> Void)?
|
||||
|
||||
private struct TabItem {
|
||||
let tab: AppTab
|
||||
let title: String
|
||||
let selectedImage: String
|
||||
let unselectedImage: String
|
||||
}
|
||||
|
||||
private let items: [TabItem] = [
|
||||
.init(tab: .home, title: "首页", selectedImage: "TabHomeSelected", unselectedImage: "TabHomeUnselected"),
|
||||
.init(tab: .orders, title: "订单", selectedImage: "TabOrderSelected", unselectedImage: "TabOrderUnselected"),
|
||||
.init(tab: .statistics, title: "数据", selectedImage: "TabDataSelected", unselectedImage: "TabDataUnselected"),
|
||||
.init(tab: .profile, title: "我的", selectedImage: "TabProfileSelected", unselectedImage: "TabProfileUnselected")
|
||||
]
|
||||
|
||||
private var tabButtons: [AppTab: UIButton] = [:]
|
||||
private var tabTitleLabels: [AppTab: UILabel] = [:]
|
||||
private let ordersBadgeLabel = UILabel()
|
||||
private let topDivider = UIView()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .white
|
||||
configureViews()
|
||||
refreshSelection()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
nil
|
||||
}
|
||||
|
||||
private func configureViews() {
|
||||
topDivider.backgroundColor = UIColor.black.withAlphaComponent(0.04)
|
||||
addSubview(topDivider)
|
||||
topDivider.snp.makeConstraints { make in
|
||||
make.top.leading.trailing.equalToSuperview()
|
||||
make.height.equalTo(0.5)
|
||||
}
|
||||
|
||||
let leftStack = UIStackView()
|
||||
leftStack.axis = .horizontal
|
||||
leftStack.distribution = .fillEqually
|
||||
leftStack.spacing = 0
|
||||
|
||||
let rightStack = UIStackView()
|
||||
rightStack.axis = .horizontal
|
||||
rightStack.distribution = .fillEqually
|
||||
rightStack.spacing = 0
|
||||
|
||||
for (index, item) in items.enumerated() {
|
||||
let buttonContainer = makeTabButton(for: item)
|
||||
if index < 2 {
|
||||
leftStack.addArrangedSubview(buttonContainer)
|
||||
} else {
|
||||
rightStack.addArrangedSubview(buttonContainer)
|
||||
}
|
||||
}
|
||||
|
||||
let scanButton = UIButton(type: .system)
|
||||
scanButton.backgroundColor = AppDesign.primary
|
||||
scanButton.layer.cornerRadius = 29
|
||||
scanButton.tintColor = .white
|
||||
scanButton.setImage(
|
||||
UIImage(systemName: "qrcode.viewfinder")?.withConfiguration(
|
||||
UIImage.SymbolConfiguration(pointSize: 30, weight: .bold)
|
||||
),
|
||||
for: .normal
|
||||
)
|
||||
scanButton.accessibilityLabel = "扫码核销"
|
||||
scanButton.accessibilityIdentifier = "main.scan"
|
||||
scanButton.addTarget(self, action: #selector(scanTapped), for: .touchUpInside)
|
||||
|
||||
let row = UIStackView(arrangedSubviews: [leftStack, scanButton, rightStack])
|
||||
row.axis = .horizontal
|
||||
row.alignment = .center
|
||||
row.spacing = 0
|
||||
addSubview(row)
|
||||
|
||||
scanButton.snp.makeConstraints { make in
|
||||
make.width.equalTo(74)
|
||||
make.height.equalTo(64)
|
||||
}
|
||||
|
||||
row.snp.makeConstraints { make in
|
||||
make.leading.trailing.equalToSuperview().inset(22)
|
||||
make.top.bottom.equalToSuperview()
|
||||
}
|
||||
|
||||
leftStack.snp.makeConstraints { make in
|
||||
make.width.equalTo(rightStack)
|
||||
}
|
||||
}
|
||||
|
||||
private func makeTabButton(for item: TabItem) -> UIView {
|
||||
let container = UIView()
|
||||
|
||||
let button = UIButton(type: .custom)
|
||||
button.accessibilityLabel = item.title
|
||||
button.accessibilityIdentifier = "main.tab.\(item.tab.rawValue)"
|
||||
button.tag = items.firstIndex(where: { $0.tab == item.tab }) ?? 0
|
||||
button.addTarget(self, action: #selector(tabTapped(_:)), for: .touchUpInside)
|
||||
tabButtons[item.tab] = button
|
||||
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.text = item.title
|
||||
titleLabel.font = .systemFont(ofSize: 12)
|
||||
titleLabel.textAlignment = .center
|
||||
tabTitleLabels[item.tab] = titleLabel
|
||||
|
||||
container.addSubview(button)
|
||||
container.addSubview(titleLabel)
|
||||
|
||||
button.snp.makeConstraints { make in
|
||||
make.top.equalToSuperview().offset(8)
|
||||
make.centerX.equalToSuperview()
|
||||
make.width.height.equalTo(24)
|
||||
}
|
||||
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(button.snp.bottom).offset(3)
|
||||
make.centerX.equalToSuperview()
|
||||
make.bottom.lessThanOrEqualToSuperview()
|
||||
}
|
||||
|
||||
if item.tab == .orders {
|
||||
ordersBadgeLabel.font = .systemFont(ofSize: 9, weight: .bold)
|
||||
ordersBadgeLabel.textColor = .white
|
||||
ordersBadgeLabel.backgroundColor = UIColor(hex: 0xEF4444)
|
||||
ordersBadgeLabel.textAlignment = .center
|
||||
ordersBadgeLabel.layer.cornerRadius = 7.5
|
||||
ordersBadgeLabel.clipsToBounds = true
|
||||
ordersBadgeLabel.isHidden = true
|
||||
container.addSubview(ordersBadgeLabel)
|
||||
ordersBadgeLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(button).offset(-4)
|
||||
make.leading.equalTo(button.snp.trailing).offset(-6)
|
||||
make.height.equalTo(15)
|
||||
make.width.greaterThanOrEqualTo(15)
|
||||
}
|
||||
}
|
||||
|
||||
return container
|
||||
}
|
||||
|
||||
private func refreshSelection() {
|
||||
for item in items {
|
||||
let isSelected = item.tab == selectedTab
|
||||
let imageName = isSelected ? item.selectedImage : item.unselectedImage
|
||||
tabButtons[item.tab]?.setImage(UIImage(named: imageName), for: .normal)
|
||||
tabTitleLabels[item.tab]?.textColor = isSelected ? AppDesign.primary : UIColor(hex: 0x7D8DA3)
|
||||
tabTitleLabels[item.tab]?.font = .systemFont(ofSize: 12, weight: isSelected ? .medium : .regular)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func tabTapped(_ sender: UIButton) {
|
||||
guard sender.tag >= 0, sender.tag < items.count else { return }
|
||||
onTabSelected?(items[sender.tag].tab)
|
||||
}
|
||||
|
||||
@objc private func scanTapped() {
|
||||
onScanTapped?()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,41 @@
|
||||
//
|
||||
// PlaceholderViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 未知或未实现路由的占位页。
|
||||
final class PlaceholderViewController: UIViewController {
|
||||
|
||||
private let pageTitle: String
|
||||
|
||||
init(title: String) {
|
||||
pageTitle = title
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = UIColor(hex: 0xF5F7FA)
|
||||
title = pageTitle
|
||||
|
||||
let label = UILabel()
|
||||
label.text = pageTitle
|
||||
label.font = .systemFont(ofSize: AppMetrics.FontSize.title2, weight: .semibold)
|
||||
label.textColor = AppDesign.textPrimary
|
||||
label.textAlignment = .center
|
||||
label.numberOfLines = 0
|
||||
view.addSubview(label)
|
||||
label.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
make.leading.trailing.equalToSuperview().inset(AppMetrics.Spacing.large)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
//
|
||||
// MainTabBadgeViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
/// 主 Tab 角标 ViewModel,负责获取订单 Tab 待核销数量。
|
||||
final class MainTabBadgeViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
private(set) var pendingWriteOffCount: Int? { didSet { onChange?() } }
|
||||
|
||||
/// 刷新待核销订单数量,缺少景区或接口失败时清空角标。
|
||||
func refreshPendingWriteOffCount(api: OrderServing, scenicId: Int?, storeId: Int?) async {
|
||||
guard let scenicId, scenicId > 0 else {
|
||||
pendingWriteOffCount = nil
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let result = try await api.writeOffList(scenicId: scenicId, storeId: storeId, page: 1, pageSize: 1)
|
||||
pendingWriteOffCount = min(result.total, 99)
|
||||
} catch {
|
||||
pendingWriteOffCount = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,72 @@
|
||||
//
|
||||
// MessageCenterAPI.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 消息中心服务协议,定义消息列表、已读和删除能力。
|
||||
@MainActor
|
||||
protocol MessageCenterServing {
|
||||
/// 获取消息列表,使用 last_id 游标分页。
|
||||
func messageList(lastId: Int, limit: Int, unread: Int) async throws -> MessageListResponse
|
||||
|
||||
/// 标记单条消息为已读。
|
||||
func messageRead(id: Int) async throws
|
||||
|
||||
/// 删除单条消息。
|
||||
func messageDelete(id: Int) async throws
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 消息中心 API,封装消息相关网络请求。
|
||||
final class MessageCenterAPI {
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化消息中心 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
self.client = client
|
||||
}
|
||||
|
||||
/// 获取消息列表,使用 last_id 游标分页。
|
||||
func messageList(lastId: Int = 0, limit: Int = 20, unread: Int = 0) async throws -> MessageListResponse {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/app/msg/list",
|
||||
queryItems: [
|
||||
URLQueryItem(name: "last_id", value: String(max(lastId, 0))),
|
||||
URLQueryItem(name: "limit", value: String(max(limit, 1))),
|
||||
URLQueryItem(name: "unread", value: String(max(unread, 0)))
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 标记单条消息为已读。
|
||||
func messageRead(id: Int) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/app/msg/read",
|
||||
queryItems: [URLQueryItem(name: "id", value: String(id))],
|
||||
body: EmptyPayload()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 删除单条消息。
|
||||
func messageDelete(id: Int) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/app/msg/delete",
|
||||
body: MessageDeleteRequest(id: id)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension MessageCenterAPI: MessageCenterServing {}
|
||||
18
suixinkan_ios/Features/MessageCenter/MessageCenter.md
Normal file
18
suixinkan_ios/Features/MessageCenter/MessageCenter.md
Normal file
@ -0,0 +1,18 @@
|
||||
# 消息中心模块
|
||||
|
||||
## 模块职责
|
||||
|
||||
`Features/MessageCenter` 承接首页 `message_center` 权限入口,负责消息列表、未读筛选、标记已读、全部已读、详情展示和删除。
|
||||
|
||||
## 代码结构
|
||||
|
||||
- `MessageCenterAPI`:封装 `/api/app/msg/list`、`/api/app/msg/read`、`/api/app/msg/delete`。
|
||||
- `MessageCenterViewModel`:管理 `last_id` 游标分页、筛选、去重排序、已读和删除后的本地状态同步。
|
||||
- `MessageCenterView`:提供统计卡、全部/未读筛选、列表、失败重试、空态、详情和删除确认。
|
||||
|
||||
## 数据与边界
|
||||
|
||||
- 消息详情不请求独立详情接口,直接使用列表返回的标题、内容、类型和推送时间。
|
||||
- 点击消息时先调用已读接口,再进入详情,并将本地未读状态同步为已读。
|
||||
- 全部已读沿用旧 iOS 能力,逐条调用已读接口;任一失败时保留原状态并透出错误。
|
||||
- 首屏刷新失败会清空旧数据和分页状态,加载更多失败保留当前列表。
|
||||
@ -0,0 +1,226 @@
|
||||
//
|
||||
// MessageCenterModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
/// 消息中心列表响应,使用 last_id 游标分页。
|
||||
struct MessageListResponse: Decodable, Equatable {
|
||||
let hasMore: Bool
|
||||
let lastId: Int
|
||||
let items: [MessageEntity]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case hasMore = "has_more"
|
||||
case lastId = "last_id"
|
||||
case items
|
||||
}
|
||||
|
||||
/// 创建空消息响应,主要用于测试和失败兜底。
|
||||
init(hasMore: Bool = false, lastId: Int = 0, items: [MessageEntity] = []) {
|
||||
self.hasMore = hasMore
|
||||
self.lastId = lastId
|
||||
self.items = items
|
||||
}
|
||||
|
||||
/// 宽松解码分页状态,兼容后端数字/字符串/布尔类型不稳定。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
hasMore = try container.decodeLossyBool(forKey: .hasMore) ?? false
|
||||
lastId = try container.decodeLossyInt(forKey: .lastId) ?? 0
|
||||
items = try container.decodeIfPresent([MessageEntity].self, forKey: .items) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
/// 后端消息实体。
|
||||
struct MessageEntity: Decodable, Equatable, Identifiable {
|
||||
let id: Int
|
||||
let type: Int
|
||||
let typeName: String
|
||||
let title: String
|
||||
let content: String
|
||||
let pushAt: String
|
||||
let createdAt: String
|
||||
let isRead: Bool
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case type
|
||||
case typeName = "type_name"
|
||||
case title
|
||||
case content
|
||||
case pushAt = "push_at"
|
||||
case createdAt = "created_at"
|
||||
case isRead = "is_read"
|
||||
}
|
||||
|
||||
/// 创建消息实体,主要用于测试。
|
||||
init(
|
||||
id: Int,
|
||||
type: Int,
|
||||
typeName: String = "",
|
||||
title: String = "",
|
||||
content: String = "",
|
||||
pushAt: String = "",
|
||||
createdAt: String = "",
|
||||
isRead: Bool = false
|
||||
) {
|
||||
self.id = id
|
||||
self.type = type
|
||||
self.typeName = typeName
|
||||
self.title = title
|
||||
self.content = content
|
||||
self.pushAt = pushAt
|
||||
self.createdAt = createdAt
|
||||
self.isRead = isRead
|
||||
}
|
||||
|
||||
/// 宽松解码消息字段,避免单条异常字段导致整页失败。
|
||||
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)
|
||||
title = try container.decodeLossyString(forKey: .title)
|
||||
content = try container.decodeLossyString(forKey: .content)
|
||||
pushAt = try container.decodeLossyString(forKey: .pushAt)
|
||||
createdAt = try container.decodeLossyString(forKey: .createdAt)
|
||||
isRead = try container.decodeLossyBool(forKey: .isRead) ?? false
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除消息请求体。
|
||||
struct MessageDeleteRequest: Encodable {
|
||||
let id: Int
|
||||
}
|
||||
|
||||
/// 页面展示用消息类型。
|
||||
enum MessageType: Equatable {
|
||||
case order
|
||||
case writeOff
|
||||
case system
|
||||
|
||||
/// 消息类型图标。
|
||||
var iconName: String {
|
||||
switch self {
|
||||
case .order:
|
||||
return "cart.fill"
|
||||
case .writeOff:
|
||||
return "qrcode.viewfinder"
|
||||
case .system:
|
||||
return "bell.badge.fill"
|
||||
}
|
||||
}
|
||||
|
||||
/// 消息类型标题。
|
||||
var title: String {
|
||||
switch self {
|
||||
case .order:
|
||||
return "订单"
|
||||
case .writeOff:
|
||||
return "核销"
|
||||
case .system:
|
||||
return "系统"
|
||||
}
|
||||
}
|
||||
|
||||
/// 消息类型颜色。
|
||||
var color: UIColor {
|
||||
switch self {
|
||||
case .order:
|
||||
return AppDesign.primary
|
||||
case .writeOff:
|
||||
return AppDesign.success
|
||||
case .system:
|
||||
return AppDesign.warning
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 页面展示用消息项。
|
||||
struct MessageItem: Identifiable, Equatable {
|
||||
let id: String
|
||||
let msgId: Int?
|
||||
let title: String
|
||||
let detail: String
|
||||
let time: String
|
||||
var isRead: Bool
|
||||
let type: MessageType
|
||||
}
|
||||
|
||||
/// 消息筛选项。
|
||||
enum MessageFilter: Int, CaseIterable, Identifiable {
|
||||
case all = 0
|
||||
case unread = 1
|
||||
|
||||
var id: Int { rawValue }
|
||||
|
||||
/// 筛选标题。
|
||||
var title: String {
|
||||
switch self {
|
||||
case .all:
|
||||
return "全部"
|
||||
case .unread:
|
||||
return "未读"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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、数字和 Bool 宽松解码为 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),
|
||||
case let text = value.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!text.isEmpty {
|
||||
return Int(text) ?? Int(Double(text) ?? 0)
|
||||
}
|
||||
if let value = try? decodeIfPresent(Bool.self, forKey: key) {
|
||||
return value ? 1 : 0
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 将 String、数字和 Bool 宽松解码为 Bool。
|
||||
func decodeLossyBool(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),
|
||||
case let text = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(),
|
||||
!text.isEmpty {
|
||||
if ["1", "true", "yes"].contains(text) { return true }
|
||||
if ["0", "false", "no"].contains(text) { return false }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,69 @@
|
||||
//
|
||||
// MessageCenterViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/26.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
/// 消息中心列表页。
|
||||
final class MessageCenterViewController: ModuleTableViewController {
|
||||
private let viewModel = MessageCenterViewModel()
|
||||
|
||||
override func viewDidLoad() {
|
||||
title = "消息中心"
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
title: "全部已读",
|
||||
style: .plain,
|
||||
target: self,
|
||||
action: #selector(markAllRead)
|
||||
)
|
||||
super.viewDidLoad()
|
||||
wireViewModel(viewModel) { [weak self] in self?.updateBarButtons() }
|
||||
}
|
||||
|
||||
override func tableRowCount() -> Int {
|
||||
viewModel.messages.count
|
||||
}
|
||||
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let item = viewModel.messages[indexPath.row]
|
||||
cell.configure(
|
||||
title: item.title,
|
||||
subtitle: item.detail,
|
||||
detail: item.isRead ? item.time : "未读 · \(item.time)"
|
||||
)
|
||||
cell.accessoryType = item.isRead ? .none : .disclosureIndicator
|
||||
}
|
||||
|
||||
override func didSelectTableRow(at indexPath: IndexPath) {
|
||||
let item = viewModel.messages[indexPath.row]
|
||||
Task {
|
||||
try? await viewModel.markAsRead(api: services.messageCenterAPI, item: item)
|
||||
showAlert(title: item.title, message: item.detail)
|
||||
}
|
||||
}
|
||||
|
||||
override func reloadContent() async {
|
||||
isLoading = viewModel.messages.isEmpty
|
||||
await viewModel.reloadFirstPage(api: services.messageCenterAPI)
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
@objc private func markAllRead() {
|
||||
Task { try? await viewModel.markAllAsRead(api: services.messageCenterAPI) }
|
||||
}
|
||||
|
||||
private func updateBarButtons() {
|
||||
navigationItem.rightBarButtonItem?.isEnabled = viewModel.unreadCount > 0 && !viewModel.messages.isEmpty
|
||||
}
|
||||
|
||||
private func showAlert(title: String, message: String) {
|
||||
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "知道了", style: .default))
|
||||
present(alert, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
extension MessageCenterViewModel: ViewModelBindable {}
|
||||
@ -0,0 +1,169 @@
|
||||
//
|
||||
// MessageCenterViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
/// 消息中心 ViewModel,负责消息分页、筛选、已读和删除。
|
||||
final class MessageCenterViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
var messages: [MessageItem] = [] { didSet { onChange?() } }
|
||||
var selectedFilter: MessageFilter = .all { didSet { onChange?() } }
|
||||
var isLoading = false { didSet { onChange?() } }
|
||||
var isLoadingMore = false { didSet { onChange?() } }
|
||||
var loadFailed = false { didSet { onChange?() } }
|
||||
var loadFailureReason: String? { didSet { onChange?() } }
|
||||
var message: String? { didSet { onChange?() } }
|
||||
|
||||
private(set) var hasMoreMessages = false { didSet { onChange?() } }
|
||||
private(set) var lastId = 0 { didSet { onChange?() } }
|
||||
private let pageSize = 20
|
||||
|
||||
/// 未读消息数量。
|
||||
var unreadCount: Int {
|
||||
messages.filter { !$0.isRead }.count
|
||||
}
|
||||
|
||||
/// 当前筛选说明。
|
||||
var filterDescription: String {
|
||||
selectedFilter == .all ? "显示全部消息" : "仅显示未读消息"
|
||||
}
|
||||
|
||||
/// 切换筛选并重新加载首屏。
|
||||
func selectFilter(_ filter: MessageFilter, api: any MessageCenterServing) async {
|
||||
guard selectedFilter != filter else { return }
|
||||
selectedFilter = filter
|
||||
await reloadFirstPage(api: api)
|
||||
}
|
||||
|
||||
/// 重新加载首屏消息,失败时清空旧数据。
|
||||
func reloadFirstPage(api: any MessageCenterServing) async {
|
||||
isLoading = true
|
||||
loadFailed = false
|
||||
loadFailureReason = nil
|
||||
message = nil
|
||||
defer { isLoading = false }
|
||||
|
||||
do {
|
||||
let response = try await api.messageList(lastId: 0, limit: pageSize, unread: selectedFilter.rawValue)
|
||||
messages = mapMessages(response.items)
|
||||
hasMoreMessages = response.hasMore
|
||||
lastId = response.lastId
|
||||
} catch {
|
||||
clearMessages()
|
||||
loadFailed = true
|
||||
loadFailureReason = error.localizedDescription
|
||||
message = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载更多消息,失败时保留当前数据和分页状态。
|
||||
func loadMore(api: any MessageCenterServing) async {
|
||||
guard hasMoreMessages, !isLoadingMore, !isLoading else { return }
|
||||
isLoadingMore = true
|
||||
message = nil
|
||||
defer { isLoadingMore = false }
|
||||
|
||||
do {
|
||||
let response = try await api.messageList(lastId: lastId, limit: pageSize, unread: selectedFilter.rawValue)
|
||||
messages.append(contentsOf: mapMessages(response.items))
|
||||
messages = deduplicatedAndSorted(messages)
|
||||
hasMoreMessages = response.hasMore
|
||||
lastId = response.lastId
|
||||
} catch {
|
||||
message = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 标记单条消息为已读,接口成功后再更新本地状态。
|
||||
func markAsRead(api: any MessageCenterServing, item: MessageItem) async throws {
|
||||
guard !item.isRead, let msgId = item.msgId else { return }
|
||||
try await api.messageRead(id: msgId)
|
||||
guard let index = messages.firstIndex(where: { $0.id == item.id }) else { return }
|
||||
messages[index].isRead = true
|
||||
}
|
||||
|
||||
/// 将当前列表中的未读消息逐条标记为已读。
|
||||
func markAllAsRead(api: any MessageCenterServing) async throws {
|
||||
let original = messages
|
||||
do {
|
||||
for item in messages where !item.isRead {
|
||||
if let msgId = item.msgId {
|
||||
try await api.messageRead(id: msgId)
|
||||
}
|
||||
}
|
||||
messages = messages.map { item in
|
||||
var next = item
|
||||
next.isRead = true
|
||||
return next
|
||||
}
|
||||
} catch {
|
||||
messages = original
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除消息,接口成功后再移除本地列表。
|
||||
func deleteMessage(api: any MessageCenterServing, item: MessageItem) async throws {
|
||||
if let msgId = item.msgId {
|
||||
try await api.messageDelete(id: msgId)
|
||||
}
|
||||
messages.removeAll { $0.id == item.id }
|
||||
}
|
||||
|
||||
/// 将后端实体转换为页面展示实体。
|
||||
func mapMessages(_ source: [MessageEntity]) -> [MessageItem] {
|
||||
source.map { item in
|
||||
MessageItem(
|
||||
id: "msg_\(item.id)",
|
||||
msgId: item.id,
|
||||
title: item.title.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
?? item.typeName.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
?? "系统通知",
|
||||
detail: item.content,
|
||||
time: item.pushAt.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
?? item.createdAt.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
?? "0000-00-00 00:00",
|
||||
isRead: item.isRead,
|
||||
type: Self.messageType(from: item.type)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 将后端类型映射为页面消息类型。
|
||||
static func messageType(from value: Int) -> MessageType {
|
||||
switch value {
|
||||
case 1:
|
||||
return .order
|
||||
case 2:
|
||||
return .writeOff
|
||||
default:
|
||||
return .system
|
||||
}
|
||||
}
|
||||
|
||||
private func deduplicatedAndSorted(_ source: [MessageItem]) -> [MessageItem] {
|
||||
var unique: [String: MessageItem] = [:]
|
||||
for item in source {
|
||||
unique[item.id] = item
|
||||
}
|
||||
return unique.values.sorted { $0.time > $1.time }
|
||||
}
|
||||
|
||||
private func clearMessages() {
|
||||
messages = []
|
||||
hasMoreMessages = false
|
||||
lastId = 0
|
||||
isLoadingMore = false
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
var nonEmpty: String? {
|
||||
isEmpty ? nil : self
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,50 @@
|
||||
//
|
||||
// OperatingAreaAPI.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 运营区域服务协议,定义店铺和景区管理员两类围栏数据接口。
|
||||
@MainActor
|
||||
protocol OperatingAreaServing {
|
||||
func storeBusinessArea(storeId: Int) async throws -> ListPayload<OperatingAreaItem>
|
||||
func scenicAdminBusinessArea(scenicId: Int) async throws -> ListPayload<OperatingAreaItem>
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 运营区域 API,封装运营区域只读围栏接口。
|
||||
final class OperatingAreaAPI {
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化运营区域 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
self.client = client
|
||||
}
|
||||
|
||||
/// 获取当前店铺的运营区域围栏。
|
||||
func storeBusinessArea(storeId: Int) async throws -> ListPayload<OperatingAreaItem> {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/app/store/business-area",
|
||||
queryItems: [URLQueryItem(name: "store_id", value: String(storeId))]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取景区管理员视角下的运营区域围栏。
|
||||
func scenicAdminBusinessArea(scenicId: Int) async throws -> ListPayload<OperatingAreaItem> {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/app/scenic-admin/business-area",
|
||||
queryItems: [URLQueryItem(name: "scenic_id", value: String(scenicId))]
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension OperatingAreaAPI: OperatingAreaServing {}
|
||||
@ -0,0 +1,374 @@
|
||||
//
|
||||
// OperatingAreaModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 运营区域入口模式,区分店铺管理员与景区管理员两条接口链路。
|
||||
enum OperatingAreaEntryMode: Equatable {
|
||||
case storeAdmin(storeId: Int)
|
||||
case scenicAdmin(scenicId: Int)
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .storeAdmin:
|
||||
"店铺运营区域"
|
||||
case .scenicAdmin:
|
||||
"景区运营区域"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 运营区域接口单项,表示一个门店或区域围栏。
|
||||
struct OperatingAreaItem: Decodable, Equatable, Identifiable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let businessMapArea: OperatingMapArea?
|
||||
let statusText: String
|
||||
let typeText: String
|
||||
let auditStatusText: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case name
|
||||
case businessMapArea = "business_map_area"
|
||||
case statusText = "status_text"
|
||||
case typeText = "type_text"
|
||||
case auditStatusText = "audit_status_text"
|
||||
}
|
||||
|
||||
init(
|
||||
id: Int = 0,
|
||||
name: String = "",
|
||||
businessMapArea: OperatingMapArea? = nil,
|
||||
statusText: String = "",
|
||||
typeText: String = "",
|
||||
auditStatusText: String = ""
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.businessMapArea = businessMapArea
|
||||
self.statusText = statusText
|
||||
self.typeText = typeText
|
||||
self.auditStatusText = auditStatusText
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.operatingDecodeLossyInt(forKey: .id) ?? 0
|
||||
name = try container.operatingDecodeLossyString(forKey: .name)
|
||||
businessMapArea = try container.decodeIfPresent(OperatingMapArea.self, forKey: .businessMapArea)
|
||||
statusText = try container.operatingDecodeLossyString(forKey: .statusText)
|
||||
typeText = try container.operatingDecodeLossyString(forKey: .typeText)
|
||||
auditStatusText = try container.operatingDecodeLossyString(forKey: .auditStatusText)
|
||||
}
|
||||
}
|
||||
|
||||
/// 运营区域原始 JSON 值,用于保留并解析后端不稳定的 `business_map_area`。
|
||||
enum OperatingMapArea: Decodable, Equatable {
|
||||
case null
|
||||
case bool(Bool)
|
||||
case number(Double)
|
||||
case string(String)
|
||||
case array([OperatingMapArea])
|
||||
case object([String: OperatingMapArea])
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
if container.decodeNil() {
|
||||
self = .null
|
||||
} else if let value = try? container.decode(Bool.self) {
|
||||
self = .bool(value)
|
||||
} else if let value = try? container.decode(Double.self) {
|
||||
self = .number(value)
|
||||
} else if let value = try? container.decode(String.self) {
|
||||
self = .string(value)
|
||||
} else if let value = try? container.decode([OperatingMapArea].self) {
|
||||
self = .array(value)
|
||||
} else {
|
||||
let keyed = try decoder.container(keyedBy: OperatingDynamicCodingKey.self)
|
||||
var object: [String: OperatingMapArea] = [:]
|
||||
for key in keyed.allKeys {
|
||||
object[key.stringValue] = try keyed.decode(OperatingMapArea.self, forKey: key)
|
||||
}
|
||||
self = .object(object)
|
||||
}
|
||||
}
|
||||
|
||||
/// 将 JSON 字符串解析为运营区域 JSON 值。
|
||||
static func parseString(_ text: String) -> OperatingMapArea? {
|
||||
guard let data = text.data(using: .utf8),
|
||||
let raw = try? JSONSerialization.jsonObject(with: data)
|
||||
else { return nil }
|
||||
return fromJSONObject(raw)
|
||||
}
|
||||
|
||||
private static func fromJSONObject(_ value: Any) -> OperatingMapArea {
|
||||
switch value {
|
||||
case is NSNull:
|
||||
return .null
|
||||
case let value as Bool:
|
||||
return .bool(value)
|
||||
case let value as NSNumber:
|
||||
return .number(value.doubleValue)
|
||||
case let value as String:
|
||||
return .string(value)
|
||||
case let value as [Any]:
|
||||
return .array(value.map(fromJSONObject))
|
||||
case let value as [String: Any]:
|
||||
return .object(value.mapValues(fromJSONObject))
|
||||
default:
|
||||
return .null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 运营区域地理坐标点。
|
||||
struct OperatingGeoPoint: Equatable, Hashable {
|
||||
let latitude: Double
|
||||
let longitude: Double
|
||||
}
|
||||
|
||||
/// 运营区域围栏环,用于地图绘制和列表展示。
|
||||
struct OperatingFenceRing: Identifiable, Equatable {
|
||||
let id: String
|
||||
let itemId: Int
|
||||
let regionName: String
|
||||
let points: [OperatingGeoPoint]
|
||||
let isCurrentStore: Bool
|
||||
|
||||
init(itemId: Int, regionName: String, points: [OperatingGeoPoint], isCurrentStore: Bool, ringIndex: Int) {
|
||||
self.id = "\(itemId)-\(ringIndex)"
|
||||
self.itemId = itemId
|
||||
self.regionName = regionName
|
||||
self.points = points
|
||||
self.isCurrentStore = isCurrentStore
|
||||
}
|
||||
}
|
||||
|
||||
/// 运营区域阻断原因,用于缺少上下文或数据不可展示时给出稳定提示。
|
||||
enum OperatingAreaBlockReason: Equatable {
|
||||
case missingStore
|
||||
case missingScenic
|
||||
case unsupportedRole
|
||||
case emptyAreaList
|
||||
case noParsableFenceData
|
||||
case backendMessage(String)
|
||||
|
||||
var message: String {
|
||||
switch self {
|
||||
case .missingStore:
|
||||
"请先选择店铺"
|
||||
case .missingScenic:
|
||||
"请先选择景区"
|
||||
case .unsupportedRole:
|
||||
"当前账号暂不支持运营区域"
|
||||
case .emptyAreaList:
|
||||
"当前景区无区域数据"
|
||||
case .noParsableFenceData:
|
||||
"有区域记录,但未解析出有效围栏,请检查坐标数据"
|
||||
case .backendMessage(let message):
|
||||
message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "运营区域加载失败" : message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 运营区域围栏解析器,兼容 GeoJSON、数组、字符串 JSON 和常见路径字段。
|
||||
enum OperatingAreaParser {
|
||||
/// 将后端 `business_map_area` 解析为若干闭合多边形环。
|
||||
static func parseToRings(_ area: OperatingMapArea?) -> [[OperatingGeoPoint]] {
|
||||
guard let area else { return [] }
|
||||
return parseElement(area)
|
||||
}
|
||||
|
||||
private static func parseElement(_ value: OperatingMapArea) -> [[OperatingGeoPoint]] {
|
||||
switch value {
|
||||
case .null, .bool, .number:
|
||||
return []
|
||||
case .string(let text):
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty, let parsed = OperatingMapArea.parseString(trimmed) else { return [] }
|
||||
return parseElement(parsed)
|
||||
case .array(let array):
|
||||
return parseArrayRoot(array)
|
||||
case .object(let object):
|
||||
return parseObject(object)
|
||||
}
|
||||
}
|
||||
|
||||
private static func parseObject(_ object: [String: OperatingMapArea]) -> [[OperatingGeoPoint]] {
|
||||
let type: String?
|
||||
if case let .string(rawType)? = object["type"] {
|
||||
type = rawType.lowercased()
|
||||
} else {
|
||||
type = nil
|
||||
}
|
||||
|
||||
switch type {
|
||||
case "polygon":
|
||||
guard case let .array(coordinates)? = object["coordinates"],
|
||||
case let .array(outer)? = coordinates.first
|
||||
else { return [] }
|
||||
return parseRing(outer).map { [$0] } ?? []
|
||||
case "multipolygon":
|
||||
guard case let .array(polygons)? = object["coordinates"] else { return [] }
|
||||
return polygons.compactMap { polygon -> [OperatingGeoPoint]? in
|
||||
guard case let .array(rings) = polygon,
|
||||
case let .array(firstRing)? = rings.first
|
||||
else { return nil }
|
||||
return parseRing(firstRing)
|
||||
}
|
||||
default:
|
||||
for key in ["path", "paths", "points", "coordinates"] {
|
||||
if let nested = object[key] {
|
||||
return parseElement(nested)
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private static func parseArrayRoot(_ array: [OperatingMapArea]) -> [[OperatingGeoPoint]] {
|
||||
guard let first = array.first else { return [] }
|
||||
switch first {
|
||||
case .object:
|
||||
return parseObjectRing(array).map { [$0] } ?? []
|
||||
case .array(let nested):
|
||||
guard let sample = nested.first else { return [] }
|
||||
switch sample {
|
||||
case .array(let pointCandidate):
|
||||
if pointCandidate.count >= 2, pointCandidate[0].numberValue != nil, pointCandidate[1].numberValue != nil {
|
||||
return parseRing(nested).map { [$0] } ?? []
|
||||
}
|
||||
return array.compactMap { element -> [OperatingGeoPoint]? in
|
||||
guard case let .array(segment) = element else { return nil }
|
||||
return parseRing(segment)
|
||||
}
|
||||
case .object:
|
||||
return parseObjectRing(nested).map { [$0] } ?? []
|
||||
default:
|
||||
return parseRing(nested).map { [$0] } ?? []
|
||||
}
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private static func parseObjectRing(_ array: [OperatingMapArea]) -> [OperatingGeoPoint]? {
|
||||
let points = array.compactMap { element -> OperatingGeoPoint? in
|
||||
guard case let .object(object) = element else { return nil }
|
||||
return pointFromObject(object)
|
||||
}
|
||||
return points.count >= 3 ? points : nil
|
||||
}
|
||||
|
||||
private static func parseRing(_ array: [OperatingMapArea]) -> [OperatingGeoPoint]? {
|
||||
let points = array.compactMap { element -> OperatingGeoPoint? in
|
||||
switch element {
|
||||
case .array(let pair):
|
||||
guard pair.count >= 2,
|
||||
let a = pair[0].numberValue,
|
||||
let b = pair[1].numberValue
|
||||
else { return nil }
|
||||
return normalizePair(a, b)
|
||||
case .object(let object):
|
||||
return pointFromObject(object)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return points.count >= 3 ? points : nil
|
||||
}
|
||||
|
||||
private static func pointFromObject(_ object: [String: OperatingMapArea]) -> OperatingGeoPoint? {
|
||||
let lat = object["lat"]?.numberValue ?? object["latitude"]?.numberValue
|
||||
let lng = object["lng"]?.numberValue ?? object["lon"]?.numberValue ?? object["longitude"]?.numberValue
|
||||
guard let lat, let lng else { return nil }
|
||||
return OperatingGeoPoint(latitude: lat, longitude: lng)
|
||||
}
|
||||
|
||||
private static func normalizePair(_ a: Double, _ b: Double) -> OperatingGeoPoint {
|
||||
if looksLikeLngLatPair(lng: a, lat: b) {
|
||||
return OperatingGeoPoint(latitude: b, longitude: a)
|
||||
}
|
||||
if looksLikeLatLngPair(lat: a, lng: b) {
|
||||
return OperatingGeoPoint(latitude: a, longitude: b)
|
||||
}
|
||||
if abs(a) > 90 {
|
||||
return OperatingGeoPoint(latitude: b, longitude: a)
|
||||
}
|
||||
return OperatingGeoPoint(latitude: a, longitude: b)
|
||||
}
|
||||
|
||||
private static func looksLikeLngLatPair(lng: Double, lat: Double) -> Bool {
|
||||
(-90.0...90.0).contains(lat) && abs(lng) <= 180.0 && abs(lng) > abs(lat)
|
||||
}
|
||||
|
||||
private static func looksLikeLatLngPair(lat: Double, lng: Double) -> Bool {
|
||||
(-90.0...90.0).contains(lat) && abs(lng) <= 180.0 && abs(lat) <= abs(lng)
|
||||
}
|
||||
}
|
||||
|
||||
private struct OperatingDynamicCodingKey: CodingKey {
|
||||
let stringValue: String
|
||||
let intValue: Int?
|
||||
|
||||
init?(stringValue: String) {
|
||||
self.stringValue = stringValue
|
||||
intValue = nil
|
||||
}
|
||||
|
||||
init?(intValue: Int) {
|
||||
stringValue = String(intValue)
|
||||
self.intValue = intValue
|
||||
}
|
||||
}
|
||||
|
||||
private extension OperatingMapArea {
|
||||
var numberValue: Double? {
|
||||
switch self {
|
||||
case .number(let value):
|
||||
return value
|
||||
case .string(let text):
|
||||
return Double(text.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension KeyedDecodingContainer {
|
||||
func operatingDecodeLossyString(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 ? "true" : "false"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func operatingDecodeLossyInt(forKey key: Key) throws -> Int? {
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let intValue = Int(text) { return intValue }
|
||||
if let doubleValue = Double(text) { return Int(doubleValue) }
|
||||
}
|
||||
if let value = try? decodeIfPresent(Double.self, forKey: key) {
|
||||
return Int(value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
17
suixinkan_ios/Features/OperatingArea/OperatingArea.md
Normal file
17
suixinkan_ios/Features/OperatingArea/OperatingArea.md
Normal file
@ -0,0 +1,17 @@
|
||||
# OperatingArea 模块业务逻辑
|
||||
|
||||
## 模块职责
|
||||
|
||||
OperatingArea 负责首页 `operating-area` 入口的运营区域展示。模块按当前角色和业务上下文进入店铺管理员或景区管理员模式,读取后端围栏数据并只读展示。
|
||||
|
||||
## 核心流程
|
||||
|
||||
- `OperatingAreaView` 从环境读取 `AccountContext`、`PermissionContext` 和 `OperatingAreaAPI`。
|
||||
- `OperatingAreaViewModel` 根据角色名称和当前景区/门店解析入口模式。
|
||||
- 店铺管理员调用 `/api/app/store/business-area`,景区管理员调用 `/api/app/scenic-admin/business-area`。
|
||||
- `OperatingAreaParser` 解析 `business_map_area`,兼容 GeoJSON、坐标数组和字符串 JSON。
|
||||
- 真机构建启用高德地图时绘制 polygon;模拟器或未启用高德时展示坐标摘要兜底。
|
||||
|
||||
## 边界
|
||||
|
||||
本模块只展示已有区域和围栏,不提供新建、编辑、删除或绘制保存能力。缺少景区/门店、角色不支持、空列表和不可解析围栏都会进入明确空态或阻断提示。
|
||||
@ -0,0 +1,38 @@
|
||||
//
|
||||
// OperatingAreaViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/26.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
/// 运营区域列表页。
|
||||
final class OperatingAreaViewController: ModuleTableViewController {
|
||||
private let viewModel = OperatingAreaViewModel()
|
||||
|
||||
override func viewDidLoad() {
|
||||
title = "运营区域"
|
||||
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.typeText) · \(item.statusText)", detail: "围栏 \(viewModel.fenceRings.count) 组")
|
||||
}
|
||||
|
||||
override func reloadContent() async {
|
||||
await viewModel.reload(
|
||||
api: services.operatingAreaAPI,
|
||||
accountContext: services.accountContext,
|
||||
permissionContext: services.permissionContext
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension OperatingAreaViewModel: ViewModelBindable {}
|
||||
@ -0,0 +1,153 @@
|
||||
//
|
||||
// OperatingAreaViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
/// 运营区域 ViewModel,负责角色分流、围栏加载、解析和错误状态管理。
|
||||
final class OperatingAreaViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
private(set) var mode: OperatingAreaEntryMode? { didSet { onChange?() } }
|
||||
private(set) var loading = false { didSet { onChange?() } }
|
||||
private(set) var items: [OperatingAreaItem] = [] { didSet { onChange?() } }
|
||||
private(set) var fenceRings: [OperatingFenceRing] = [] { didSet { onChange?() } }
|
||||
private(set) var blockReason: OperatingAreaBlockReason? { didSet { onChange?() } }
|
||||
private(set) var errorMessage: String? { didSet { onChange?() } }
|
||||
|
||||
/// 按当前账号和角色刷新运营区域。
|
||||
func reload(
|
||||
api: any OperatingAreaServing,
|
||||
accountContext: AccountContext,
|
||||
permissionContext: PermissionContext
|
||||
) async {
|
||||
guard !loading else { return }
|
||||
guard let nextMode = resolveMode(accountContext: accountContext, permissionContext: permissionContext) else {
|
||||
return
|
||||
}
|
||||
|
||||
mode = nextMode
|
||||
loading = true
|
||||
errorMessage = nil
|
||||
blockReason = nil
|
||||
defer { loading = false }
|
||||
|
||||
do {
|
||||
let response: ListPayload<OperatingAreaItem>
|
||||
switch nextMode {
|
||||
case .storeAdmin(let storeId):
|
||||
response = try await api.storeBusinessArea(storeId: storeId)
|
||||
case .scenicAdmin(let scenicId):
|
||||
response = try await api.scenicAdminBusinessArea(scenicId: scenicId)
|
||||
}
|
||||
apply(items: response.list, mode: nextMode)
|
||||
} catch {
|
||||
resetLoadedData()
|
||||
let message = error.localizedDescription
|
||||
errorMessage = message
|
||||
blockReason = .backendMessage(message)
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据当前上下文解析运营区域入口模式。
|
||||
@discardableResult
|
||||
func resolveMode(accountContext: AccountContext, permissionContext: PermissionContext) -> OperatingAreaEntryMode? {
|
||||
let roleName = permissionContext.currentRole?.name.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let lowercaseRoleName = roleName.lowercased()
|
||||
let isStoreRole = roleName.contains("店铺") || roleName.contains("门店") || lowercaseRoleName.contains("store")
|
||||
let isScenicRole = roleName.contains("景区") || lowercaseRoleName.contains("scenic")
|
||||
|
||||
if isStoreRole {
|
||||
guard let storeId = accountContext.currentStore?.id, storeId > 0 else {
|
||||
setBlocked(.missingStore)
|
||||
return nil
|
||||
}
|
||||
mode = .storeAdmin(storeId: storeId)
|
||||
blockReason = nil
|
||||
return mode
|
||||
}
|
||||
|
||||
if isScenicRole {
|
||||
guard let scenicId = accountContext.currentScenic?.id, scenicId > 0 else {
|
||||
setBlocked(.missingScenic)
|
||||
return nil
|
||||
}
|
||||
mode = .scenicAdmin(scenicId: scenicId)
|
||||
blockReason = nil
|
||||
return mode
|
||||
}
|
||||
|
||||
if let storeId = accountContext.currentStore?.id, storeId > 0 {
|
||||
mode = .storeAdmin(storeId: storeId)
|
||||
blockReason = nil
|
||||
return mode
|
||||
}
|
||||
|
||||
if let scenicId = accountContext.currentScenic?.id, scenicId > 0 {
|
||||
mode = .scenicAdmin(scenicId: scenicId)
|
||||
blockReason = nil
|
||||
return mode
|
||||
}
|
||||
|
||||
setBlocked(.unsupportedRole)
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 根据状态生成统计展示。
|
||||
var summary: (areaCount: Int, fenceCount: Int, currentStoreFenceCount: Int) {
|
||||
(
|
||||
areaCount: items.count,
|
||||
fenceCount: fenceRings.count,
|
||||
currentStoreFenceCount: fenceRings.filter(\.isCurrentStore).count
|
||||
)
|
||||
}
|
||||
|
||||
/// 当前页面标题。
|
||||
var title: String {
|
||||
mode?.title ?? "运营区域"
|
||||
}
|
||||
|
||||
private func apply(items: [OperatingAreaItem], mode: OperatingAreaEntryMode) {
|
||||
self.items = items
|
||||
if items.isEmpty {
|
||||
fenceRings = []
|
||||
blockReason = .emptyAreaList
|
||||
return
|
||||
}
|
||||
|
||||
let currentStoreId: Int? = {
|
||||
guard case .storeAdmin(let storeId) = mode else { return nil }
|
||||
return storeId
|
||||
}()
|
||||
|
||||
let parsed = items.flatMap { item -> [OperatingFenceRing] in
|
||||
OperatingAreaParser.parseToRings(item.businessMapArea).enumerated().compactMap { index, points in
|
||||
guard points.count >= 3 else { return nil }
|
||||
return OperatingFenceRing(
|
||||
itemId: item.id,
|
||||
regionName: item.name,
|
||||
points: points,
|
||||
isCurrentStore: currentStoreId == item.id,
|
||||
ringIndex: index
|
||||
)
|
||||
}
|
||||
}
|
||||
fenceRings = parsed
|
||||
blockReason = parsed.isEmpty ? .noParsableFenceData : nil
|
||||
}
|
||||
|
||||
private func setBlocked(_ reason: OperatingAreaBlockReason) {
|
||||
resetLoadedData()
|
||||
blockReason = reason
|
||||
errorMessage = nil
|
||||
loading = false
|
||||
}
|
||||
|
||||
private func resetLoadedData() {
|
||||
items = []
|
||||
fenceRings = []
|
||||
}
|
||||
}
|
||||
258
suixinkan_ios/Features/Orders/API/OrdersAPI.swift
Normal file
258
suixinkan_ios/Features/Orders/API/OrdersAPI.swift
Normal file
@ -0,0 +1,258 @@
|
||||
//
|
||||
// OrdersAPI.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
/// 订单服务协议,抽象订单列表、核销列表和核销操作以便测试替换。
|
||||
protocol OrderServing {
|
||||
/// 获取订单管理列表。
|
||||
func orderList(
|
||||
scenicId: Int,
|
||||
page: Int,
|
||||
pageSize: Int,
|
||||
orderStatus: Int?,
|
||||
userPhone: String?,
|
||||
startTime: String?,
|
||||
endTime: String?,
|
||||
isRefined: Int?,
|
||||
isScenicAdmin: Bool
|
||||
) async throws -> ListPayload<OrderEntity>
|
||||
|
||||
/// 获取核销订单列表。
|
||||
func writeOffList(scenicId: Int, storeId: Int?, page: Int, pageSize: Int) async throws -> ListPayload<WriteOffOrderItem>
|
||||
|
||||
/// 核销指定订单号。
|
||||
func writeOff(orderNumber: String) async throws
|
||||
|
||||
/// 获取门店订单详情。
|
||||
func storeOrderDetail(storeId: Int, orderNumber: String) async throws -> StoreOrderDetailResponse
|
||||
|
||||
/// 获取押金订单列表。
|
||||
func depositOrderList(scenicId: Int, page: Int, pageSize: Int) async throws -> ListPayload<DepositOrderListItem>
|
||||
|
||||
/// 核销押金订单。
|
||||
func depositOrderWriteOff(orderNumber: String) async throws
|
||||
|
||||
/// 申请押金订单退款。
|
||||
func depositOrderRefund(orderNumber: String, refundReason: String) async throws
|
||||
|
||||
/// 获取订单某个拍摄点的底片、成片和评价信息。
|
||||
func storeOrderShootingDetail(storeId: Int, orderNumber: String, scenicSpotId: Int, photogUid: Int) async throws -> StoreOrderShootingDetailResponse
|
||||
|
||||
/// 申请普通订单退款。
|
||||
func orderRefund(orderNumber: String, refundType: OrderRefundMode, refundAmount: String, refundReason: String) async throws
|
||||
|
||||
/// 获取订单历史拍摄信息。
|
||||
func multiTravelShootHistory(orderNumber: String) async throws -> MultiTravelShootHistoryResponse
|
||||
|
||||
/// 获取多点旅拍订单已核销打卡点列表。
|
||||
func multiTravelVerifiedScenicSpotList(orderNumber: String) async throws -> [MultiTravelVerifiedScenicSpotItem]
|
||||
|
||||
/// 提交多点旅拍任务素材。
|
||||
func multiTravelUploadMaterial(_ request: MultiTravelUploadMaterialRequest) async throws
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 订单 API,封装订单管理、核销订单和订单核销接口。
|
||||
final class OrdersAPI: OrderServing {
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化订单 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
self.client = client
|
||||
}
|
||||
|
||||
/// 获取订单管理列表。
|
||||
func orderList(
|
||||
scenicId: Int,
|
||||
page: Int = 1,
|
||||
pageSize: Int = 20,
|
||||
orderStatus: Int? = nil,
|
||||
userPhone: String? = nil,
|
||||
startTime: String? = nil,
|
||||
endTime: String? = nil,
|
||||
isRefined: Int? = nil,
|
||||
isScenicAdmin: Bool = false
|
||||
) async throws -> ListPayload<OrderEntity> {
|
||||
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 orderStatus, orderStatus > 0 {
|
||||
query.append(URLQueryItem(name: "order_status", value: "\(orderStatus)"))
|
||||
}
|
||||
if let userPhone, !userPhone.isEmpty {
|
||||
query.append(URLQueryItem(name: "user_phone", value: userPhone))
|
||||
}
|
||||
if let startTime, !startTime.isEmpty {
|
||||
query.append(URLQueryItem(name: "start_time", value: startTime))
|
||||
}
|
||||
if let endTime, !endTime.isEmpty {
|
||||
query.append(URLQueryItem(name: "end_time", value: endTime))
|
||||
}
|
||||
if let isRefined, isRefined > 0 {
|
||||
query.append(URLQueryItem(name: "is_refined", value: "\(isRefined)"))
|
||||
}
|
||||
|
||||
return try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: isScenicAdmin ? "/api/app/scenic-admin/order/list" : "/api/yf-handset-app/photog/order/listv2",
|
||||
queryItems: query
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取核销订单列表。
|
||||
func writeOffList(scenicId: Int, storeId: Int? = nil, page: Int = 1, pageSize: Int = 20) async throws -> ListPayload<WriteOffOrderItem> {
|
||||
var queryItems = [
|
||||
URLQueryItem(name: "scenic_id", value: "\(scenicId)"),
|
||||
URLQueryItem(name: "page", value: "\(max(page, 1))"),
|
||||
URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))")
|
||||
]
|
||||
if let storeId, storeId > 0 {
|
||||
queryItems.append(URLQueryItem(name: "store_id", value: "\(storeId)"))
|
||||
}
|
||||
|
||||
return try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/yf-handset-app/photog/order/order-verification-list",
|
||||
queryItems: queryItems
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 核销指定订单号。
|
||||
func writeOff(orderNumber: String) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/yf-handset-app/photog/order/order-verify",
|
||||
body: WriteOffRequest(orderNumber: orderNumber)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取门店订单详情。
|
||||
func storeOrderDetail(storeId: Int, orderNumber: String) async throws -> StoreOrderDetailResponse {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/app/store/order/detail",
|
||||
queryItems: [
|
||||
URLQueryItem(name: "store_id", value: "\(storeId)"),
|
||||
URLQueryItem(name: "order_number", value: orderNumber)
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取押金订单列表。
|
||||
func depositOrderList(scenicId: Int, page: Int = 1, pageSize: Int = 10) async throws -> ListPayload<DepositOrderListItem> {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/yf-handset-app/photog/order/deposit-list",
|
||||
queryItems: [
|
||||
URLQueryItem(name: "scenic_id", value: "\(scenicId)"),
|
||||
URLQueryItem(name: "page", value: "\(max(page, 1))"),
|
||||
URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))")
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 核销押金订单。
|
||||
func depositOrderWriteOff(orderNumber: String) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/yf-handset-app/photog/order/deposit-writeoff",
|
||||
body: DepositOrderWriteOffRequest(orderNumber: orderNumber)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 申请押金订单退款。
|
||||
func depositOrderRefund(orderNumber: String, refundReason: String) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/yf-handset-app/photog/order/deposit-refund",
|
||||
body: DepositOrderRefundRequest(orderNumber: orderNumber, refundReason: refundReason)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取订单某个拍摄点的底片、成片和评价信息。
|
||||
func storeOrderShootingDetail(storeId: Int, orderNumber: String, scenicSpotId: Int, photogUid: Int) async throws -> StoreOrderShootingDetailResponse {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/app/store/order/shooting-detail",
|
||||
queryItems: [
|
||||
URLQueryItem(name: "store_id", value: "\(storeId)"),
|
||||
URLQueryItem(name: "order_number", value: orderNumber),
|
||||
URLQueryItem(name: "scenic_spot_id", value: "\(scenicSpotId)"),
|
||||
URLQueryItem(name: "photog_uid", value: "\(photogUid)")
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 申请普通订单退款。
|
||||
func orderRefund(orderNumber: String, refundType: OrderRefundMode, refundAmount: String, refundReason: String) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/yf-handset-app/photog/order/refund",
|
||||
body: OrderRefundRequest(
|
||||
orderNumber: orderNumber,
|
||||
refundType: refundType.rawValue,
|
||||
refundAmount: refundAmount,
|
||||
refundReason: refundReason
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取订单历史拍摄信息。
|
||||
func multiTravelShootHistory(orderNumber: String) async throws -> MultiTravelShootHistoryResponse {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/yf-handset-app/photog/order/multi-travel/shoot-history",
|
||||
queryItems: [URLQueryItem(name: "order_number", value: orderNumber)]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取多点旅拍订单已核销打卡点列表。
|
||||
func multiTravelVerifiedScenicSpotList(orderNumber: String) async throws -> [MultiTravelVerifiedScenicSpotItem] {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/yf-handset-app/photog/order/multi-travel/verified-scenic-spot-list",
|
||||
queryItems: [URLQueryItem(name: "order_number", value: orderNumber)]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 提交多点旅拍任务素材。
|
||||
func multiTravelUploadMaterial(_ request: MultiTravelUploadMaterialRequest) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/yf-handset-app/photog/order/multi-travel/upload-material",
|
||||
body: request
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
835
suixinkan_ios/Features/Orders/Models/OrderModels.swift
Normal file
835
suixinkan_ios/Features/Orders/Models/OrderModels.swift
Normal file
@ -0,0 +1,835 @@
|
||||
//
|
||||
// OrderModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 订单 Tab 内的子入口,表示订单管理或核销订单。
|
||||
enum OrdersEntry: Hashable {
|
||||
case storeOrders
|
||||
case verificationOrders
|
||||
}
|
||||
|
||||
/// 订单状态筛选实体,表示订单管理列表顶部的状态过滤项。
|
||||
struct OrderStatusFilter: Identifiable, Hashable {
|
||||
let id: Int
|
||||
let title: String
|
||||
}
|
||||
|
||||
/// 订单筛选常量,集中维护旧工程同步过来的订单状态。
|
||||
enum OrderFilters {
|
||||
static let statusFilters: [OrderStatusFilter] = [
|
||||
.init(id: 0, title: "全部"),
|
||||
.init(id: 18, title: "已付定金"),
|
||||
.init(id: 30, title: "已完成"),
|
||||
.init(id: 50, title: "已退款"),
|
||||
.init(id: -1, title: "需要精修"),
|
||||
.init(id: -2, title: "无需精修")
|
||||
]
|
||||
}
|
||||
|
||||
/// 订单管理列表实体,承载订单卡片展示和筛选判断所需字段。
|
||||
struct OrderEntity: Decodable, Identifiable, Equatable, Hashable {
|
||||
var id: String { orderNumber }
|
||||
|
||||
let photogUid: Int
|
||||
let orderNumber: String
|
||||
let remark: String
|
||||
let scenicAreaId: Int
|
||||
let payTime: String
|
||||
let completeTime: String
|
||||
let orderStatus: Int
|
||||
let orderStatusName: String
|
||||
let storeId: Int?
|
||||
let userId: Int
|
||||
let projectId: Int
|
||||
let phone: String
|
||||
let orderAmount: String
|
||||
let actualPayAmount: String
|
||||
let actualRefundAmount: String
|
||||
let refundAmount: String
|
||||
let payTypeName: String
|
||||
let payType: Int
|
||||
let projectName: String
|
||||
let orderType: Int
|
||||
let orderTypeLabel: String
|
||||
let depositPayTime: String
|
||||
let createdAt: String
|
||||
let photoTravel: OrderPhotoTravel?
|
||||
let isNeedEdit: Bool
|
||||
let isRefined: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case photogUid = "photog_uid"
|
||||
case orderNumber = "order_number"
|
||||
case remark
|
||||
case scenicAreaId = "scenic_area_id"
|
||||
case payTime = "pay_time"
|
||||
case completeTime = "complete_time"
|
||||
case orderStatus = "order_status"
|
||||
case orderStatusName = "order_status_name"
|
||||
case storeId = "store_id"
|
||||
case userId = "user_id"
|
||||
case projectId = "project_id"
|
||||
case phone
|
||||
case orderAmount = "order_amount"
|
||||
case actualPayAmount = "actual_pay_amount"
|
||||
case actualRefundAmount = "actual_refund_amount"
|
||||
case refundAmount = "refund_amount"
|
||||
case payTypeName = "pay_type_name"
|
||||
case payType = "pay_type"
|
||||
case projectName = "project_name"
|
||||
case orderType = "order_type"
|
||||
case orderTypeLabel = "order_type_label"
|
||||
case depositPayTime = "deposit_pay_time"
|
||||
case createdAt = "created_at"
|
||||
case photoTravel = "photo_travel"
|
||||
case isNeedEdit
|
||||
case isRefined = "is_refined"
|
||||
}
|
||||
|
||||
/// 宽松解码订单字段,兼容后端数字和字符串混用。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
photogUid = try container.decodeLossyInt(forKey: .photogUid) ?? 0
|
||||
orderNumber = try container.decodeLossyString(forKey: .orderNumber)
|
||||
remark = try container.decodeLossyString(forKey: .remark)
|
||||
scenicAreaId = try container.decodeLossyInt(forKey: .scenicAreaId) ?? 0
|
||||
payTime = try container.decodeLossyString(forKey: .payTime)
|
||||
completeTime = try container.decodeLossyString(forKey: .completeTime)
|
||||
orderStatus = try container.decodeLossyInt(forKey: .orderStatus) ?? 0
|
||||
orderStatusName = try container.decodeLossyString(forKey: .orderStatusName)
|
||||
storeId = try container.decodeLossyInt(forKey: .storeId)
|
||||
userId = try container.decodeLossyInt(forKey: .userId) ?? 0
|
||||
projectId = try container.decodeLossyInt(forKey: .projectId) ?? 0
|
||||
phone = try container.decodeLossyString(forKey: .phone)
|
||||
orderAmount = try container.decodeLossyString(forKey: .orderAmount)
|
||||
actualPayAmount = try container.decodeLossyString(forKey: .actualPayAmount)
|
||||
actualRefundAmount = try container.decodeLossyString(forKey: .actualRefundAmount)
|
||||
refundAmount = try container.decodeLossyString(forKey: .refundAmount)
|
||||
payTypeName = try container.decodeLossyString(forKey: .payTypeName)
|
||||
payType = try container.decodeLossyInt(forKey: .payType) ?? 0
|
||||
projectName = try container.decodeLossyString(forKey: .projectName)
|
||||
orderType = try container.decodeLossyInt(forKey: .orderType) ?? 0
|
||||
orderTypeLabel = try container.decodeLossyString(forKey: .orderTypeLabel)
|
||||
depositPayTime = try container.decodeLossyString(forKey: .depositPayTime)
|
||||
createdAt = try container.decodeLossyString(forKey: .createdAt)
|
||||
photoTravel = try container.decodeIfPresent(OrderPhotoTravel.self, forKey: .photoTravel)
|
||||
isNeedEdit = try container.decodeLossyBool(forKey: .isNeedEdit) ?? true
|
||||
isRefined = try container.decodeLossyInt(forKey: .isRefined) ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
/// 旅拍订单附加信息实体,表示修图、视频和核销相关数量。
|
||||
struct OrderPhotoTravel: Decodable, Equatable, Hashable {
|
||||
let orderPhotoNum: Int
|
||||
let orderVideoNum: Int
|
||||
let checkInTime: String
|
||||
let retouchGiftPhotoNum: Int
|
||||
let retouchGiftVideoNum: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case orderPhotoNum = "order_photo_num"
|
||||
case orderVideoNum = "order_video_num"
|
||||
case checkInTime = "check_in_time"
|
||||
case retouchGiftPhotoNum = "retouch_gift_photo_num"
|
||||
case retouchGiftVideoNum = "retouch_gift_video_num"
|
||||
}
|
||||
|
||||
/// 宽松解码旅拍附加信息。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
orderPhotoNum = try container.decodeLossyInt(forKey: .orderPhotoNum) ?? 0
|
||||
orderVideoNum = try container.decodeLossyInt(forKey: .orderVideoNum) ?? 0
|
||||
checkInTime = try container.decodeLossyString(forKey: .checkInTime)
|
||||
retouchGiftPhotoNum = try container.decodeLossyInt(forKey: .retouchGiftPhotoNum) ?? 0
|
||||
retouchGiftVideoNum = try container.decodeLossyInt(forKey: .retouchGiftVideoNum) ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
/// 核销订单列表实体,表示可核销订单卡片的展示和操作状态。
|
||||
struct WriteOffOrderItem: Decodable, Identifiable, Equatable, Hashable {
|
||||
var id: String { orderNumber }
|
||||
|
||||
let orderNumber: String
|
||||
let orderVerificationStatus: String
|
||||
let payTime: String
|
||||
let projectName: String
|
||||
let userPhone: String
|
||||
let orderAmount: String
|
||||
let orderStatusName: String
|
||||
let orderVerificationTime: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case orderNumber = "order_number"
|
||||
case orderVerificationStatus = "order_verification_status"
|
||||
case payTime = "pay_time"
|
||||
case projectName = "project_name"
|
||||
case userPhone = "user_phone"
|
||||
case orderAmount = "order_amount"
|
||||
case orderStatusName = "order_status_name"
|
||||
case orderVerificationTime = "order_verification_time"
|
||||
}
|
||||
|
||||
/// 宽松解码核销订单字段。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
orderNumber = try container.decodeLossyString(forKey: .orderNumber)
|
||||
orderVerificationStatus = try container.decodeLossyString(forKey: .orderVerificationStatus)
|
||||
payTime = try container.decodeLossyString(forKey: .payTime)
|
||||
projectName = try container.decodeLossyString(forKey: .projectName)
|
||||
userPhone = try container.decodeLossyString(forKey: .userPhone)
|
||||
orderAmount = try container.decodeLossyString(forKey: .orderAmount)
|
||||
orderStatusName = try container.decodeLossyString(forKey: .orderStatusName)
|
||||
orderVerificationTime = try container.decodeLossyString(forKey: .orderVerificationTime)
|
||||
}
|
||||
}
|
||||
|
||||
/// 核销订单请求实体,提交手动输入的订单号。
|
||||
struct WriteOffRequest: Encodable {
|
||||
let orderNumber: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case orderNumber = "order_number"
|
||||
}
|
||||
}
|
||||
|
||||
/// 押金订单列表实体,表示首页押金订单卡片的基础信息和操作状态。
|
||||
struct DepositOrderListItem: Decodable, Identifiable, Equatable, Hashable {
|
||||
let id: Int
|
||||
let orderNumber: String
|
||||
let userPhone: String
|
||||
let amount: String
|
||||
let status: Int
|
||||
let statusName: String
|
||||
let createdAt: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case orderNumber = "order_number"
|
||||
case userPhone = "user_phone"
|
||||
case amount
|
||||
case status
|
||||
case statusName = "status_name"
|
||||
case createdAt = "created_at"
|
||||
}
|
||||
|
||||
/// 宽松解码押金订单字段,兼容后端数字和字符串混用。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decodeLossyInt(forKey: .id) ?? 0
|
||||
orderNumber = try container.decodeLossyString(forKey: .orderNumber)
|
||||
userPhone = try container.decodeLossyString(forKey: .userPhone)
|
||||
amount = try container.decodeLossyString(forKey: .amount)
|
||||
status = try container.decodeLossyInt(forKey: .status) ?? 0
|
||||
statusName = try container.decodeLossyString(forKey: .statusName)
|
||||
createdAt = try container.decodeLossyString(forKey: .createdAt)
|
||||
}
|
||||
}
|
||||
|
||||
/// 押金订单核销请求实体。
|
||||
struct DepositOrderWriteOffRequest: Encodable, Equatable {
|
||||
let orderNumber: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case orderNumber = "order_number"
|
||||
}
|
||||
}
|
||||
|
||||
/// 押金订单退款请求实体。
|
||||
struct DepositOrderRefundRequest: Encodable, Equatable {
|
||||
let orderNumber: String
|
||||
let refundReason: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case orderNumber = "order_number"
|
||||
case refundReason = "refund_reason"
|
||||
}
|
||||
}
|
||||
|
||||
/// 普通订单退款模式,表示全额退款或部分退款。
|
||||
enum OrderRefundMode: Int, CaseIterable, Identifiable, Hashable {
|
||||
case full = 1
|
||||
case partial = 3
|
||||
|
||||
var id: Int { rawValue }
|
||||
|
||||
/// 退款模式展示文案。
|
||||
var title: String {
|
||||
switch self {
|
||||
case .full:
|
||||
return "全额退款"
|
||||
case .partial:
|
||||
return "部分退款"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 普通订单退款请求实体。
|
||||
struct OrderRefundRequest: Encodable, Equatable {
|
||||
let orderNumber: String
|
||||
let refundType: Int
|
||||
let refundAmount: String
|
||||
let refundReason: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case orderNumber = "order_number"
|
||||
case refundType = "refund_type"
|
||||
case refundAmount = "refund_amount"
|
||||
case refundReason = "refund_reason"
|
||||
}
|
||||
}
|
||||
|
||||
/// 订单详情路由实体,表示订单模块内部可 push 的二级页面。
|
||||
enum OrdersRoute: Hashable {
|
||||
case storeDetail(OrderEntity)
|
||||
case writeOffDetail(WriteOffOrderItem)
|
||||
case depositDetail(orderNumber: String)
|
||||
case depositShootingInfo(orderNumber: String, scenicSpotId: Int, photogUid: Int)
|
||||
case historicalShooting(orderNumber: String)
|
||||
case multiTravelTaskUpload(orderNumber: String)
|
||||
case orderTrailer(orderNumber: String, title: String)
|
||||
}
|
||||
|
||||
/// 订单详情接口响应实体,承载门店订单详情页的完整展示数据。
|
||||
struct StoreOrderDetailResponse: Decodable, Equatable, Hashable {
|
||||
let orderNumber: String
|
||||
let orderType: Int
|
||||
let orderTypeLabel: String
|
||||
let createdAt: String
|
||||
let orderStatus: Int
|
||||
let orderStatusName: String
|
||||
let actualPayAmount: String
|
||||
let actualRefundAmount: String
|
||||
let payTypeName: String
|
||||
let payTime: String
|
||||
let completeTime: String
|
||||
let userId: Int
|
||||
let phone: String
|
||||
let projectId: Int
|
||||
let projectName: String
|
||||
let remark: String
|
||||
let multiTravel: StoreOrderMultiTravelInfo?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case orderNumber = "order_number"
|
||||
case orderType = "order_type"
|
||||
case orderTypeLabel = "order_type_label"
|
||||
case createdAt = "created_at"
|
||||
case orderStatus = "order_status"
|
||||
case orderStatusName = "order_status_name"
|
||||
case actualPayAmount = "actual_pay_amount"
|
||||
case actualRefundAmount = "actual_refund_amount"
|
||||
case payTypeName = "pay_type_name"
|
||||
case payTime = "pay_time"
|
||||
case completeTime = "complete_time"
|
||||
case userId = "user_id"
|
||||
case phone
|
||||
case projectId = "project_id"
|
||||
case projectName = "project_name"
|
||||
case remark
|
||||
case multiTravel = "multi_travel"
|
||||
}
|
||||
|
||||
/// 宽松解码订单详情字段,兼容后端数字和字符串混用。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
orderNumber = try container.decodeLossyString(forKey: .orderNumber)
|
||||
orderType = try container.decodeLossyInt(forKey: .orderType) ?? 0
|
||||
orderTypeLabel = try container.decodeLossyString(forKey: .orderTypeLabel)
|
||||
createdAt = try container.decodeLossyString(forKey: .createdAt)
|
||||
orderStatus = try container.decodeLossyInt(forKey: .orderStatus) ?? 0
|
||||
orderStatusName = try container.decodeLossyString(forKey: .orderStatusName)
|
||||
actualPayAmount = try container.decodeLossyString(forKey: .actualPayAmount)
|
||||
actualRefundAmount = try container.decodeLossyString(forKey: .actualRefundAmount)
|
||||
payTypeName = try container.decodeLossyString(forKey: .payTypeName)
|
||||
payTime = try container.decodeLossyString(forKey: .payTime)
|
||||
completeTime = try container.decodeLossyString(forKey: .completeTime)
|
||||
userId = try container.decodeLossyInt(forKey: .userId) ?? 0
|
||||
phone = try container.decodeLossyString(forKey: .phone)
|
||||
projectId = try container.decodeLossyInt(forKey: .projectId) ?? 0
|
||||
projectName = try container.decodeLossyString(forKey: .projectName)
|
||||
remark = try container.decodeLossyString(forKey: .remark)
|
||||
multiTravel = try container.decodeIfPresent(StoreOrderMultiTravelInfo.self, forKey: .multiTravel)
|
||||
}
|
||||
}
|
||||
|
||||
/// 多景点旅拍详情实体,表示项目配置和各拍摄点状态。
|
||||
struct StoreOrderMultiTravelInfo: Decodable, Equatable, Hashable {
|
||||
let projectInfo: StoreOrderProjectInfo?
|
||||
let shootingList: [StoreOrderShootingListItem]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case projectInfo = "project_info"
|
||||
case shootingList = "shooting_list"
|
||||
}
|
||||
|
||||
/// 宽松解码多景点旅拍信息。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
projectInfo = try container.decodeIfPresent(StoreOrderProjectInfo.self, forKey: .projectInfo)
|
||||
shootingList = try container.decodeIfPresent([StoreOrderShootingListItem].self, forKey: .shootingList) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
/// 订单项目配置实体,表示打卡点数量和单点素材数量。
|
||||
struct StoreOrderProjectInfo: Decodable, Equatable, Hashable {
|
||||
let settleSpotNum: Int
|
||||
let singleSpotMaterialNum: Int
|
||||
let singleSpotPhotoNum: Int
|
||||
let singleSpotVideoNum: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case settleSpotNum = "settle_spot_num"
|
||||
case singleSpotMaterialNum = "single_spot_material_num"
|
||||
case singleSpotPhotoNum = "single_spot_photo_num"
|
||||
case singleSpotVideoNum = "single_spot_video_num"
|
||||
}
|
||||
|
||||
/// 宽松解码项目配置字段。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
settleSpotNum = try container.decodeLossyInt(forKey: .settleSpotNum) ?? 0
|
||||
singleSpotMaterialNum = try container.decodeLossyInt(forKey: .singleSpotMaterialNum) ?? 0
|
||||
singleSpotPhotoNum = try container.decodeLossyInt(forKey: .singleSpotPhotoNum) ?? 0
|
||||
singleSpotVideoNum = try container.decodeLossyInt(forKey: .singleSpotVideoNum) ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
/// 拍摄点状态实体,表示订单在某个打卡点的拍摄人员和完成情况。
|
||||
struct StoreOrderShootingListItem: Decodable, Identifiable, Equatable, Hashable {
|
||||
var id: String { "\(scenicSpotId)-\(photogUid)" }
|
||||
|
||||
let scenicSpotId: Int
|
||||
let photogUid: Int
|
||||
let scenicSpotName: String
|
||||
let staffName: String
|
||||
let photogName: String
|
||||
let status: Int
|
||||
let startAvg: Double
|
||||
let start: Double
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case scenicSpotId = "scenic_spot_id"
|
||||
case photogUid = "photog_uid"
|
||||
case scenicSpotName = "scenic_spot_name"
|
||||
case staffName = "staff_name"
|
||||
case photogName = "photog_name"
|
||||
case status
|
||||
case startAvg = "start_avg"
|
||||
case start
|
||||
}
|
||||
|
||||
/// 宽松解码拍摄点状态字段。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
scenicSpotId = try container.decodeLossyInt(forKey: .scenicSpotId) ?? 0
|
||||
photogUid = try container.decodeLossyInt(forKey: .photogUid) ?? 0
|
||||
scenicSpotName = try container.decodeLossyString(forKey: .scenicSpotName)
|
||||
staffName = try container.decodeLossyString(forKey: .staffName)
|
||||
photogName = try container.decodeLossyString(forKey: .photogName)
|
||||
status = try container.decodeLossyInt(forKey: .status) ?? 0
|
||||
startAvg = try container.decodeLossyDouble(forKey: .startAvg) ?? 0
|
||||
start = try container.decodeLossyDouble(forKey: .start) ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
/// 订单拍摄信息响应实体,表示某个打卡点的评价、底片和成片。
|
||||
struct StoreOrderShootingDetailResponse: Decodable, Equatable, Hashable {
|
||||
let scenicSpotId: Int
|
||||
let scenicSpotName: String
|
||||
let orderType: Int
|
||||
let orderTypeName: String
|
||||
let orderComment: StoreOrderComment?
|
||||
let materialList: [OrderMediaFile]
|
||||
let completeList: [OrderMediaFile]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case scenicSpotId = "scenic_spot_id"
|
||||
case scenicSpotName = "scenic_spot_name"
|
||||
case orderType = "order_type"
|
||||
case orderTypeName = "order_type_name"
|
||||
case orderComment = "order_comment"
|
||||
case materialList = "material_list"
|
||||
case completeList = "complete_list"
|
||||
}
|
||||
|
||||
/// 宽松解码拍摄信息详情。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
scenicSpotId = try container.decodeLossyInt(forKey: .scenicSpotId) ?? 0
|
||||
scenicSpotName = try container.decodeLossyString(forKey: .scenicSpotName)
|
||||
orderType = try container.decodeLossyInt(forKey: .orderType) ?? 0
|
||||
orderTypeName = try container.decodeLossyString(forKey: .orderTypeName)
|
||||
orderComment = try container.decodeIfPresent(StoreOrderComment.self, forKey: .orderComment)
|
||||
materialList = try container.decodeIfPresent([OrderMediaFile].self, forKey: .materialList) ?? []
|
||||
completeList = try container.decodeIfPresent([OrderMediaFile].self, forKey: .completeList) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
/// 订单评价实体,表示拍摄、修图、景区、服务和相机评分。
|
||||
struct StoreOrderComment: Decodable, Equatable, Hashable {
|
||||
let starShooting: Int
|
||||
let starRetouching: Int
|
||||
let starScenery: Int
|
||||
let starService: Int
|
||||
let starCamera: Int
|
||||
let content: String
|
||||
let createdAt: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case starShooting = "star_shooting"
|
||||
case starRetouching = "star_retouching"
|
||||
case starScenery = "star_scenery"
|
||||
case starService = "star_service"
|
||||
case starCamera = "star_camera"
|
||||
case content
|
||||
case createdAt = "created_at"
|
||||
}
|
||||
|
||||
/// 宽松解码评价字段。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
starShooting = try container.decodeLossyInt(forKey: .starShooting) ?? 0
|
||||
starRetouching = try container.decodeLossyInt(forKey: .starRetouching) ?? 0
|
||||
starScenery = try container.decodeLossyInt(forKey: .starScenery) ?? 0
|
||||
starService = try container.decodeLossyInt(forKey: .starService) ?? 0
|
||||
starCamera = try container.decodeLossyInt(forKey: .starCamera) ?? 0
|
||||
content = try container.decodeLossyString(forKey: .content)
|
||||
createdAt = try container.decodeLossyString(forKey: .createdAt)
|
||||
}
|
||||
}
|
||||
|
||||
/// 订单媒体文件实体,表示底片、成片或历史拍摄媒体。
|
||||
struct OrderMediaFile: Decodable, Identifiable, Equatable, Hashable {
|
||||
var id: String { "\(fileUrl)-\(fileName)-\(uploadTime)" }
|
||||
|
||||
let fileName: String
|
||||
let fileUrl: String
|
||||
let fileType: Int
|
||||
let fileSize: Int64
|
||||
let coverUrl: String
|
||||
let uploadTime: String
|
||||
|
||||
var isVideo: Bool {
|
||||
fileType == 1
|
||||
}
|
||||
|
||||
var previewURLString: String {
|
||||
coverUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? fileUrl : coverUrl
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case fileName = "file_name"
|
||||
case fileUrl = "file_url"
|
||||
case fileType = "file_type"
|
||||
case fileSize = "file_size"
|
||||
case coverUrl = "cover_url"
|
||||
case uploadTime = "upload_time"
|
||||
}
|
||||
|
||||
/// 宽松解码媒体文件字段。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
fileName = try container.decodeLossyString(forKey: .fileName)
|
||||
fileUrl = try container.decodeLossyString(forKey: .fileUrl)
|
||||
fileType = try container.decodeLossyInt(forKey: .fileType) ?? 0
|
||||
fileSize = Int64(try container.decodeLossyInt(forKey: .fileSize) ?? 0)
|
||||
coverUrl = try container.decodeLossyString(forKey: .coverUrl)
|
||||
uploadTime = try container.decodeLossyString(forKey: .uploadTime)
|
||||
}
|
||||
}
|
||||
|
||||
/// 历史拍摄响应实体,表示订单多点位历史拍摄媒体。
|
||||
struct MultiTravelShootHistoryResponse: Decodable, Equatable, Hashable {
|
||||
let projectName: String
|
||||
let projectType: Int
|
||||
let projectTypeName: String
|
||||
let photogSpotList: [PhotogSpotItem]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case projectName = "project_name"
|
||||
case projectType = "project_type"
|
||||
case projectTypeName = "project_type_name"
|
||||
case photogSpotList = "photog_spot_list"
|
||||
}
|
||||
|
||||
/// 宽松解码历史拍摄响应。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
projectName = try container.decodeLossyString(forKey: .projectName)
|
||||
projectType = try container.decodeLossyInt(forKey: .projectType) ?? 0
|
||||
projectTypeName = try container.decodeLossyString(forKey: .projectTypeName)
|
||||
photogSpotList = try container.decodeIfPresent([PhotogSpotItem].self, forKey: .photogSpotList) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
/// 历史拍摄打卡点实体,表示某个摄影师在打卡点上传过的媒体。
|
||||
struct PhotogSpotItem: Decodable, Identifiable, Equatable, Hashable {
|
||||
var id: String { "\(scenicSpotId)-\(photogUid)" }
|
||||
|
||||
let scenicSpotId: Int
|
||||
let photogUid: Int
|
||||
let scenicSpotName: String
|
||||
let photogNickname: String
|
||||
let photogName: String
|
||||
let files: [OrderMediaFile]
|
||||
|
||||
var photographerDisplayName: String {
|
||||
let nickname = photogNickname.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return nickname.isEmpty ? photogName : nickname
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case scenicSpotId = "scenic_spot_id"
|
||||
case photogUid = "photog_uid"
|
||||
case scenicSpotName = "scenic_spot_name"
|
||||
case photogNickname = "photog_nickname"
|
||||
case photogName = "photog_name"
|
||||
case files
|
||||
}
|
||||
|
||||
/// 宽松解码历史拍摄打卡点。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
scenicSpotId = try container.decodeLossyInt(forKey: .scenicSpotId) ?? 0
|
||||
photogUid = try container.decodeLossyInt(forKey: .photogUid) ?? 0
|
||||
scenicSpotName = try container.decodeLossyString(forKey: .scenicSpotName)
|
||||
photogNickname = try container.decodeLossyString(forKey: .photogNickname)
|
||||
photogName = try container.decodeLossyString(forKey: .photogName)
|
||||
files = try container.decodeIfPresent([OrderMediaFile].self, forKey: .files) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
/// 多点旅拍已核销打卡点实体,用于任务上传时选择目标打卡点。
|
||||
struct MultiTravelVerifiedScenicSpotItem: Decodable, Identifiable, Equatable, 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.decodeLossyInt(forKey: .id) ?? 0
|
||||
name = try container.decodeLossyString(forKey: .name)
|
||||
}
|
||||
}
|
||||
|
||||
/// 多点旅拍任务素材提交请求实体。
|
||||
struct MultiTravelUploadMaterialRequest: Encodable, Equatable {
|
||||
let orderNumber: String
|
||||
let scenicSpotId: Int
|
||||
let cloudFile: [MultiTravelCloudFileItem]
|
||||
let uploadFile: [MultiTravelUploadFileItem]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case orderNumber = "order_number"
|
||||
case scenicSpotId = "scenic_spot_id"
|
||||
case cloudFile = "cloud_file"
|
||||
case uploadFile = "upload_file"
|
||||
}
|
||||
}
|
||||
|
||||
/// 多点旅拍云盘素材提交项。
|
||||
struct MultiTravelCloudFileItem: Encodable, Equatable {
|
||||
let fileId: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case fileId = "file_id"
|
||||
}
|
||||
}
|
||||
|
||||
/// 多点旅拍本地上传素材提交项。
|
||||
struct MultiTravelUploadFileItem: Encodable, Equatable {
|
||||
let fileName: String
|
||||
let fileUrl: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case fileName = "file_name"
|
||||
case fileUrl = "file_url"
|
||||
}
|
||||
}
|
||||
|
||||
/// 订单详情展示适配实体,统一列表摘要和详情接口响应的读取方式。
|
||||
struct StoreOrderDetailDisplay: Equatable {
|
||||
let orderNumber: String
|
||||
let orderTypeLabel: String
|
||||
let createdAt: String
|
||||
let orderStatusName: String
|
||||
let actualPayAmount: String
|
||||
let actualRefundAmount: String
|
||||
let payTypeName: String
|
||||
let payTime: String
|
||||
let completeTime: String
|
||||
let userId: Int
|
||||
let phone: String
|
||||
let projectId: Int
|
||||
let projectName: String
|
||||
let remark: String
|
||||
|
||||
/// 使用订单列表项构建兜底展示数据。
|
||||
init(item: OrderEntity) {
|
||||
orderNumber = item.orderNumber
|
||||
orderTypeLabel = item.orderTypeLabel
|
||||
createdAt = item.createdAt
|
||||
orderStatusName = item.orderStatusName
|
||||
actualPayAmount = item.actualPayAmount
|
||||
actualRefundAmount = item.actualRefundAmount.isEmpty ? item.refundAmount : item.actualRefundAmount
|
||||
payTypeName = item.payTypeName
|
||||
payTime = item.payTime
|
||||
completeTime = item.completeTime
|
||||
userId = item.userId
|
||||
phone = item.phone
|
||||
projectId = item.projectId
|
||||
projectName = item.projectName
|
||||
remark = item.remark
|
||||
}
|
||||
|
||||
/// 使用详情接口响应构建完整展示数据。
|
||||
init(detail: StoreOrderDetailResponse) {
|
||||
orderNumber = detail.orderNumber
|
||||
orderTypeLabel = detail.orderTypeLabel
|
||||
createdAt = detail.createdAt
|
||||
orderStatusName = detail.orderStatusName
|
||||
actualPayAmount = detail.actualPayAmount
|
||||
actualRefundAmount = detail.actualRefundAmount
|
||||
payTypeName = detail.payTypeName
|
||||
payTime = detail.payTime
|
||||
completeTime = detail.completeTime
|
||||
userId = detail.userId
|
||||
phone = detail.phone
|
||||
projectId = detail.projectId
|
||||
projectName = detail.projectName
|
||||
remark = detail.remark
|
||||
}
|
||||
}
|
||||
|
||||
/// 订单号解析器,负责从扫码内容或手动输入中提取可核销订单号。
|
||||
enum OrderNumberParser {
|
||||
/// 从原始文本中解析订单号,兼容 URL、键值格式和纯订单号。
|
||||
static func parse(_ raw: String) -> String? {
|
||||
let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else { return nil }
|
||||
|
||||
if let url = URL(string: text),
|
||||
let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
||||
let queryItems = components.queryItems,
|
||||
let value = queryItems.first(where: { $0.name.lowercased() == "order_number" })?.value,
|
||||
!value.isEmpty {
|
||||
return value
|
||||
}
|
||||
|
||||
let patterns = [
|
||||
"order[_-]?number[=:]([A-Za-z0-9_-]+)",
|
||||
"([A-Za-z0-9_-]{8,})"
|
||||
]
|
||||
for pattern in patterns {
|
||||
if let match = firstCapture(in: text, pattern: pattern) {
|
||||
return match
|
||||
}
|
||||
}
|
||||
|
||||
if !text.contains(" "), text.count >= 6 {
|
||||
return text
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 返回第一个正则捕获组。
|
||||
private static func firstCapture(in text: String, pattern: String) -> String? {
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else {
|
||||
return nil
|
||||
}
|
||||
let range = NSRange(text.startIndex..<text.endIndex, in: text)
|
||||
guard let match = regex.firstMatch(in: text, options: [], range: range),
|
||||
match.numberOfRanges > 1,
|
||||
let captureRange = Range(match.range(at: 1), in: text) else {
|
||||
return nil
|
||||
}
|
||||
return String(text[captureRange])
|
||||
}
|
||||
}
|
||||
|
||||
private extension KeyedDecodingContainer {
|
||||
/// 将任意常见 JSON 值宽松解码成字符串。
|
||||
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 ""
|
||||
}
|
||||
|
||||
/// 将 Int、Double、Bool 或数字字符串宽松解码成整数。
|
||||
func decodeLossyInt(forKey key: Key) throws -> Int? {
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let intValue = Int(text) {
|
||||
return intValue
|
||||
}
|
||||
if let doubleValue = Double(text) {
|
||||
return Int(doubleValue)
|
||||
}
|
||||
}
|
||||
if let value = try? decodeIfPresent(Double.self, forKey: key) {
|
||||
return Int(value)
|
||||
}
|
||||
if let value = try? decodeIfPresent(Bool.self, forKey: key) {
|
||||
return value ? 1 : 0
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 将 Bool、Int 或字符串宽松解码成布尔值。
|
||||
func decodeLossyBool(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) {
|
||||
switch value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
|
||||
case "1", "true", "yes":
|
||||
return true
|
||||
case "0", "false", "no":
|
||||
return false
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 将 Double、Int 或数字字符串宽松解码成浮点数。
|
||||
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
|
||||
}
|
||||
}
|
||||
49
suixinkan_ios/Features/Orders/Orders.md
Normal file
49
suixinkan_ios/Features/Orders/Orders.md
Normal file
@ -0,0 +1,49 @@
|
||||
# Orders 模块业务逻辑
|
||||
|
||||
## 模块职责
|
||||
|
||||
Orders 模块负责登录后的订单 Tab。当前已覆盖订单管理、核销订单和押金订单相关入口,提供列表展示、筛选、分页、刷新、订单详情、扫码核销、手动核销、押金核销、押金退款、普通订单退款、历史拍摄、任务上传和尾片入口闭环。
|
||||
|
||||
当前仍不迁移独立视频播放器增强、尾片审核进度、上传后自动关联订单尾片等后端未体现的能力;这些能力后续按明确接口再补。
|
||||
|
||||
## 核心对象
|
||||
|
||||
- `OrdersView`:订单 Tab 根视图,读取 `AccountContext`、`PermissionContext`、`AppRouter` 和 `OrdersAPI`。
|
||||
- `OrdersViewModel`:管理订单子入口、筛选条件、分页状态、加载状态和手动核销状态。
|
||||
- `OrderDetailViewModel`:管理订单详情接口加载、错误提示和列表摘要兜底展示。
|
||||
- `DepositOrderListViewModel`:管理押金订单分页、核销、退款和操作后刷新。
|
||||
- `DepositOrderDetailViewModel`:管理押金订单详情加载,缺少门店时不发起请求。
|
||||
- `DepositOrderShootingInfoViewModel`:管理押金订单单个打卡点的底片、成片和评价。
|
||||
- `OrderRefundViewModel`:管理普通订单退款入口判断、全额/部分退款金额校验和提交保护。
|
||||
- `HistoricalShootingInfoViewModel`:管理多点位订单历史拍摄媒体展示。
|
||||
- `MultiTravelTaskUploadViewModel`:管理多点旅拍任务上传的订单号、已核销打卡点、云盘附件、本地附件、OSS 上传和提交。
|
||||
- `OrdersAPI`:封装订单列表、核销订单列表和订单核销接口。
|
||||
- `OrdersEntry`:表示订单 Tab 内部入口,包含订单管理和核销订单。
|
||||
- `OrdersRoute`:表示订单模块二级页面路由,包含订单详情和核销订单详情。
|
||||
- `OrderNumberParser`:负责从扫码内容中解析订单号,支持 URL query、键值文本和纯订单号。
|
||||
|
||||
## 数据流程
|
||||
|
||||
订单页面从 `AccountContext.currentScenic` 读取当前景区 ID,从 `PermissionContext.currentRole` 读取角色 ID。景区管理员角色 `roleId == 53` 使用景区管理员订单接口,其他角色使用摄影师订单接口。
|
||||
|
||||
订单管理支持状态筛选、手机号搜索、开始/结束日期筛选。筛选变更后从第一页重新加载,滚动到列表底部时加载下一页。
|
||||
|
||||
订单管理点击订单卡片进入订单详情。详情页优先用 `/api/app/store/order/detail` 按 `store_id + order_number` 拉取完整数据;缺少门店 ID 或接口失败时,保留列表卡片摘要作为兜底展示,并提示当前详情不完整。
|
||||
|
||||
核销订单支持扫码和手动输入订单号。扫码使用 AVFoundation,不缓存扫码结果;扫码成功后先用 `OrderNumberParser` 提取订单号,命中当前核销列表时滚动定位并高亮卡片,未命中时填入订单号并允许继续核销。核销前统一弹确认框,确认后调用核销接口。核销成功后重新拉取第一页核销订单,并重置分页状态;核销失败只清理提交状态,不刷新列表。
|
||||
|
||||
押金订单入口由首页 `deposit_order_detail`、`deposit_order`、`deposit_order_shooting_info` 进入。没有订单上下文时先展示押金订单页,用户可以手输订单号进入详情,也可以从列表进入详情。押金详情复用门店订单详情接口,按当前门店 ID 和订单号加载;拍摄点列表进入拍摄信息页,展示评价、底片和成片。
|
||||
|
||||
普通订单退款只在 `orderStatus == 18 || orderStatus == 30`、`orderType != 19` 且可退金额大于 0 时展示入口。全额退款使用可退金额,部分退款要求金额大于 0、最多两位小数且不能超过可退金额。押金退款要求填写退款原因,普通退款和押金退款都不保存表单数据,提交成功后刷新对应订单数据。
|
||||
|
||||
历史拍摄按订单号请求 `/api/yf-handset-app/photog/order/multi-travel/shoot-history`,仅展示项目、拍摄点和已有媒体,不做上传、下载、删除或编辑。
|
||||
|
||||
任务上传仅对 `orderType == 19` 的多点旅拍订单展示。页面先按订单号请求 `/api/yf-handset-app/photog/order/multi-travel/verified-scenic-spot-list` 获取已核销打卡点,再支持选择云盘素材和本地图片/视频;本地素材先通过 `OSSUploadService.uploadTaskFile` 上传,成功后和云盘文件一起提交到 `/api/yf-handset-app/photog/order/multi-travel/upload-material`。
|
||||
|
||||
视频预告和尾片上传按旧工程现状统一进入 `OrderTrailerView`,该页只做订单尾片流程引导,并打开已迁移的 `AlbumTrailerEntryView` 完成相册预览上传,不新增独立订单尾片接口。
|
||||
|
||||
## 路由边界
|
||||
|
||||
首页 `photographer_orders` 和 `/scenic-order-manage` 会进入订单管理,`verification_order` 会进入核销订单,`deposit_order_detail`、`deposit_order`、`deposit_order_shooting_info` 会进入押金订单页。订单详情、核销订单详情、押金详情、押金拍摄信息、历史拍摄、任务上传和订单尾片已接入 `AppRoute.orders` 真实路由,push 后继续隐藏 TabBar。
|
||||
|
||||
未明确接口的订单后续能力继续进入占位页,避免入口崩溃。
|
||||
@ -0,0 +1,360 @@
|
||||
//
|
||||
// DepositOrderViewControllers.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 押金订单列表页,支持查询、分页、核销和退款。
|
||||
final class DepositOrderListViewController: UIViewController {
|
||||
|
||||
private let viewModel = DepositOrderListViewModel()
|
||||
private var orderNumberField = UITextField()
|
||||
|
||||
private lazy var tableView: UITableView = {
|
||||
let table = UITableView(frame: .zero, style: .insetGrouped)
|
||||
table.dataSource = self
|
||||
table.delegate = self
|
||||
table.register(DepositOrderCell.self, forCellReuseIdentifier: DepositOrderCell.reuseID)
|
||||
return table
|
||||
}()
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = "押金订单"
|
||||
view.backgroundColor = AppDesignUIKit.pageBackground
|
||||
view.addSubview(tableView)
|
||||
tableView.snp.makeConstraints { make in make.edges.equalToSuperview() }
|
||||
|
||||
viewModel.onChange = { [weak self] in self?.tableView.reloadData() }
|
||||
setupHeader()
|
||||
Task { await reload(showLoading: true) }
|
||||
}
|
||||
|
||||
private func setupHeader() {
|
||||
let header = UIView(frame: CGRect(x: 0, y: 0, width: view.bounds.width, height: 120))
|
||||
orderNumberField.placeholder = "请输入押金订单号"
|
||||
orderNumberField.borderStyle = .roundedRect
|
||||
orderNumberField.autocorrectionType = .no
|
||||
orderNumberField.autocapitalizationType = .none
|
||||
|
||||
let detailButton = makePrimaryButton(title: "查看订单详情")
|
||||
detailButton.addTarget(self, action: #selector(openDetail), for: .touchUpInside)
|
||||
|
||||
header.addSubview(orderNumberField)
|
||||
header.addSubview(detailButton)
|
||||
orderNumberField.snp.makeConstraints { make in
|
||||
make.top.leading.trailing.equalToSuperview().inset(16)
|
||||
make.height.equalTo(44)
|
||||
}
|
||||
detailButton.snp.makeConstraints { make in
|
||||
make.top.equalTo(orderNumberField.snp.bottom).offset(12)
|
||||
make.leading.trailing.equalToSuperview().inset(16)
|
||||
}
|
||||
tableView.tableHeaderView = header
|
||||
}
|
||||
|
||||
@objc private func openDetail() {
|
||||
let text = orderNumberField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !text.isEmpty else { return }
|
||||
HomeMenuRouting.pushOrders(.depositDetail(orderNumber: text), from: self)
|
||||
}
|
||||
|
||||
private func reload(showLoading: Bool) async {
|
||||
if showLoading {
|
||||
await appServices.globalLoading.withLoading {
|
||||
await viewModel.reload(api: appServices.ordersAPI, scenicId: appServices.accountContext.currentScenic?.id, reset: true)
|
||||
}
|
||||
} else {
|
||||
await viewModel.reload(api: appServices.ordersAPI, scenicId: appServices.accountContext.currentScenic?.id, reset: true)
|
||||
}
|
||||
if let message = viewModel.errorMessage { showToast(message) }
|
||||
}
|
||||
|
||||
private func writeOff(_ item: DepositOrderListItem) {
|
||||
let alert = UIAlertController(title: "确认核销该押金订单?", message: item.orderNumber, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
alert.addAction(UIAlertAction(title: "确认核销", style: .default) { [weak self] _ in
|
||||
Task {
|
||||
guard let self else { return }
|
||||
let success = await self.viewModel.writeOff(api: self.appServices.ordersAPI, scenicId: self.appServices.accountContext.currentScenic?.id, orderNumber: item.orderNumber)
|
||||
if success { self.showToast("核销成功") }
|
||||
else if let message = self.viewModel.errorMessage { self.showToast(message) }
|
||||
}
|
||||
})
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func refund(_ item: DepositOrderListItem) {
|
||||
let alert = UIAlertController(title: "申请退款", message: "请填写退款原因", preferredStyle: .alert)
|
||||
alert.addTextField { $0.placeholder = "退款原因" }
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
alert.addAction(UIAlertAction(title: "提交", style: .default) { [weak self] _ in
|
||||
Task {
|
||||
guard let self else { return }
|
||||
let reason = alert.textFields?.first?.text ?? ""
|
||||
let success = await self.viewModel.refund(api: self.appServices.ordersAPI, scenicId: self.appServices.accountContext.currentScenic?.id, orderNumber: item.orderNumber, reason: reason)
|
||||
if success { self.showToast("退款申请已提交") }
|
||||
else if let message = self.viewModel.errorMessage { self.showToast(message) }
|
||||
}
|
||||
})
|
||||
present(alert, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
extension DepositOrderListViewController: UITableViewDataSource, UITableViewDelegate {
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
max(viewModel.orders.count, 1)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||
"押金订单列表 \(viewModel.orders.count)/\(viewModel.total)"
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
guard !viewModel.orders.isEmpty else {
|
||||
let cell = UITableViewCell()
|
||||
cell.textLabel?.text = viewModel.loading ? "加载中..." : "暂无押金订单"
|
||||
cell.selectionStyle = .none
|
||||
return cell
|
||||
}
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: DepositOrderCell.reuseID, for: indexPath) as! DepositOrderCell
|
||||
let item = viewModel.orders[indexPath.row]
|
||||
cell.configure(item: item, isOperating: viewModel.operatingOrderNumber == item.orderNumber)
|
||||
cell.onDetail = { [weak self] in
|
||||
self.flatMap { HomeMenuRouting.pushOrders(.depositDetail(orderNumber: item.orderNumber), from: $0) }
|
||||
}
|
||||
cell.onWriteOff = { [weak self] in self?.writeOff(item) }
|
||||
cell.onRefund = { [weak self] in self?.refund(item) }
|
||||
return cell
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
||||
guard indexPath.row == viewModel.orders.count - 1, viewModel.hasMore else { return }
|
||||
Task {
|
||||
await viewModel.reload(api: appServices.ordersAPI, scenicId: appServices.accountContext.currentScenic?.id, reset: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class DepositOrderCell: UITableViewCell {
|
||||
static let reuseID = "DepositOrderCell"
|
||||
var onDetail: (() -> Void)?
|
||||
var onWriteOff: (() -> Void)?
|
||||
var onRefund: (() -> Void)?
|
||||
|
||||
private let titleLabel = UILabel()
|
||||
private let statusLabel = UILabel()
|
||||
private let amountLabel = UILabel()
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
selectionStyle = .none
|
||||
titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline, weight: .semibold)
|
||||
statusLabel.font = .systemFont(ofSize: AppMetrics.FontSize.caption)
|
||||
amountLabel.font = .systemFont(ofSize: AppMetrics.FontSize.body, weight: .semibold)
|
||||
amountLabel.textColor = AppDesignUIKit.primary
|
||||
|
||||
let detailButton = UIButton(type: .system)
|
||||
detailButton.setTitle("详情", for: .normal)
|
||||
detailButton.addTarget(self, action: #selector(detailTapped), for: .touchUpInside)
|
||||
let writeOffButton = UIButton(type: .system)
|
||||
writeOffButton.setTitle("核销", for: .normal)
|
||||
writeOffButton.addTarget(self, action: #selector(writeOffTapped), for: .touchUpInside)
|
||||
let refundButton = UIButton(type: .system)
|
||||
refundButton.setTitle("退款", for: .normal)
|
||||
refundButton.addTarget(self, action: #selector(refundTapped), for: .touchUpInside)
|
||||
|
||||
let buttonRow = UIStackView(arrangedSubviews: [detailButton, writeOffButton, refundButton])
|
||||
buttonRow.axis = .horizontal
|
||||
buttonRow.distribution = .fillEqually
|
||||
|
||||
let stack = UIStackView(arrangedSubviews: [titleLabel, amountLabel, buttonRow])
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 8
|
||||
contentView.addSubview(stack)
|
||||
contentView.addSubview(statusLabel)
|
||||
stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(12) }
|
||||
statusLabel.snp.makeConstraints { make in make.trailing.top.equalToSuperview().inset(12) }
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
func configure(item: DepositOrderListItem, isOperating: Bool) {
|
||||
titleLabel.text = item.orderNumber
|
||||
statusLabel.text = isOperating ? "处理中" : item.statusName
|
||||
amountLabel.text = "¥\(item.amount.isEmpty ? "0.00" : item.amount)"
|
||||
}
|
||||
|
||||
@objc private func detailTapped() { onDetail?() }
|
||||
@objc private func writeOffTapped() { onWriteOff?() }
|
||||
@objc private func refundTapped() { onRefund?() }
|
||||
}
|
||||
|
||||
/// 押金订单详情页。
|
||||
final class DepositOrderDetailViewController: UIViewController {
|
||||
|
||||
private let orderNumber: String
|
||||
private let viewModel = DepositOrderDetailViewModel()
|
||||
|
||||
private lazy var tableView: UITableView = {
|
||||
let table = UITableView(frame: .zero, style: .insetGrouped)
|
||||
table.dataSource = self
|
||||
table.backgroundColor = AppDesignUIKit.pageBackground
|
||||
return table
|
||||
}()
|
||||
|
||||
init(orderNumber: String) {
|
||||
self.orderNumber = orderNumber
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = "押金订单详情"
|
||||
view.addSubview(tableView)
|
||||
tableView.snp.makeConstraints { make in make.edges.equalToSuperview() }
|
||||
Task { await loadDetail() }
|
||||
}
|
||||
|
||||
private func loadDetail() async {
|
||||
await appServices.globalLoading.withLoading {
|
||||
await viewModel.load(
|
||||
api: appServices.ordersAPI,
|
||||
storeId: appServices.accountContext.currentStore?.id,
|
||||
orderNumber: orderNumber
|
||||
)
|
||||
}
|
||||
tableView.reloadData()
|
||||
if let message = viewModel.errorMessage { showToast(message) }
|
||||
}
|
||||
}
|
||||
|
||||
extension DepositOrderDetailViewController: UITableViewDataSource {
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
viewModel.detail == nil ? 1 : 8
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
|
||||
cell.selectionStyle = .none
|
||||
guard let detail = viewModel.detail else {
|
||||
cell.textLabel?.text = viewModel.errorMessage ?? (viewModel.loading ? "加载中..." : "暂无详情")
|
||||
return cell
|
||||
}
|
||||
let rows: [(String, String)] = [
|
||||
("订单号", detail.orderNumber),
|
||||
("状态", detail.orderStatusName),
|
||||
("类型", detail.orderTypeLabel),
|
||||
("付款金额", detail.actualPayAmount),
|
||||
("手机号", detail.phone),
|
||||
("项目", detail.projectName),
|
||||
("创建时间", detail.createdAt),
|
||||
("备注", detail.remark)
|
||||
]
|
||||
cell.textLabel?.text = rows[indexPath.row].0
|
||||
cell.detailTextLabel?.text = rows[indexPath.row].1
|
||||
return cell
|
||||
}
|
||||
}
|
||||
|
||||
/// 押金拍摄信息页。
|
||||
final class DepositOrderShootingInfoViewController: UIViewController {
|
||||
|
||||
private let orderNumber: String
|
||||
private let scenicSpotId: Int
|
||||
private let photogUid: Int
|
||||
private let viewModel = DepositOrderShootingInfoViewModel()
|
||||
|
||||
private lazy var tableView: UITableView = {
|
||||
let table = UITableView(frame: .zero, style: .insetGrouped)
|
||||
table.dataSource = self
|
||||
return table
|
||||
}()
|
||||
|
||||
init(orderNumber: String, scenicSpotId: Int, photogUid: Int) {
|
||||
self.orderNumber = orderNumber
|
||||
self.scenicSpotId = scenicSpotId
|
||||
self.photogUid = photogUid
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = "押金拍摄信息"
|
||||
view.addSubview(tableView)
|
||||
tableView.snp.makeConstraints { make in make.edges.equalToSuperview() }
|
||||
Task { await loadDetail() }
|
||||
}
|
||||
|
||||
private func loadDetail() async {
|
||||
await appServices.globalLoading.withLoading {
|
||||
await viewModel.load(
|
||||
api: appServices.ordersAPI,
|
||||
storeId: appServices.accountContext.currentStore?.id,
|
||||
orderNumber: orderNumber,
|
||||
scenicSpotId: scenicSpotId,
|
||||
photogUid: photogUid
|
||||
)
|
||||
}
|
||||
tableView.reloadData()
|
||||
if let message = viewModel.errorMessage { showToast(message) }
|
||||
}
|
||||
}
|
||||
|
||||
extension DepositOrderShootingInfoViewController: UITableViewDataSource {
|
||||
func numberOfSections(in tableView: UITableView) -> Int {
|
||||
viewModel.detail == nil ? 1 : 3
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
guard let detail = viewModel.detail else { return 1 }
|
||||
switch section {
|
||||
case 0: return 2
|
||||
case 1: return detail.materialList.count
|
||||
default: return detail.completeList.count
|
||||
}
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||
guard viewModel.detail != nil else { return nil }
|
||||
switch section {
|
||||
case 1: return "素材"
|
||||
case 2: return "成片"
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
guard let detail = viewModel.detail else {
|
||||
let cell = UITableViewCell()
|
||||
cell.textLabel?.text = viewModel.errorMessage ?? "加载中..."
|
||||
return cell
|
||||
}
|
||||
if indexPath.section == 0 {
|
||||
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
|
||||
if indexPath.row == 0 {
|
||||
cell.textLabel?.text = "打卡点"
|
||||
cell.detailTextLabel?.text = detail.scenicSpotName
|
||||
} else {
|
||||
cell.textLabel?.text = "拍摄评分"
|
||||
cell.detailTextLabel?.text = detail.orderComment.map { "\($0.starShooting) 星" } ?? "--"
|
||||
}
|
||||
return cell
|
||||
}
|
||||
let files = indexPath.section == 1 ? detail.materialList : detail.completeList
|
||||
let media = files[indexPath.row]
|
||||
let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil)
|
||||
cell.textLabel?.text = media.fileName
|
||||
cell.detailTextLabel?.text = media.fileUrl
|
||||
return cell
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,301 @@
|
||||
//
|
||||
// OrderCodeScannerViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 订单扫码错误实体,描述相机不可用、权限拒绝和元数据异常。
|
||||
enum OrderScannerError: LocalizedError {
|
||||
case cameraUnavailable
|
||||
case permissionDenied
|
||||
case invalidMetadata
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .cameraUnavailable:
|
||||
"当前设备不可用相机,无法扫码。"
|
||||
case .permissionDenied:
|
||||
"未开启相机权限,请在系统设置中允许后重试。"
|
||||
case .invalidMetadata:
|
||||
"未识别到有效条码,请重试。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 订单扫码页,包装 AVFoundation 扫码控制器并提供关闭与结果回调。
|
||||
final class OrderCodeScannerViewController: UIViewController {
|
||||
|
||||
var onScanResult: ((Result<String, Error>) -> Void)?
|
||||
|
||||
private let scannerController = OrderScannerCaptureViewController()
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .black
|
||||
title = "扫码核销"
|
||||
navigationItem.leftBarButtonItem = UIBarButtonItem(title: "关闭", style: .plain, target: self, action: #selector(closeTapped))
|
||||
|
||||
#if targetEnvironment(simulator)
|
||||
showUnavailablePlaceholder()
|
||||
#else
|
||||
if AppUITestLaunchState.isRunningUITests || AVCaptureDevice.default(for: .video) == nil {
|
||||
showUnavailablePlaceholder()
|
||||
} else {
|
||||
embedScanner()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func embedScanner() {
|
||||
scannerController.onScanResult = { [weak self] result in
|
||||
self?.onScanResult?(result)
|
||||
}
|
||||
addChild(scannerController)
|
||||
view.addSubview(scannerController.view)
|
||||
scannerController.view.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
scannerController.didMove(toParent: self)
|
||||
|
||||
let hintLabel = UILabel()
|
||||
hintLabel.text = "请将二维码/条码放入取景框"
|
||||
hintLabel.font = .systemFont(ofSize: AppMetrics.FontSize.footnote, weight: .medium)
|
||||
hintLabel.textColor = .white
|
||||
hintLabel.backgroundColor = UIColor.black.withAlphaComponent(0.55)
|
||||
hintLabel.layer.cornerRadius = 16
|
||||
hintLabel.clipsToBounds = true
|
||||
hintLabel.textAlignment = .center
|
||||
view.addSubview(hintLabel)
|
||||
hintLabel.snp.makeConstraints { make in
|
||||
make.centerX.equalToSuperview()
|
||||
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(AppMetrics.Spacing.xxLarge)
|
||||
make.height.equalTo(32)
|
||||
make.width.greaterThanOrEqualTo(220)
|
||||
}
|
||||
}
|
||||
|
||||
private func showUnavailablePlaceholder() {
|
||||
let stack = UIStackView()
|
||||
stack.axis = .vertical
|
||||
stack.spacing = AppMetrics.Spacing.small
|
||||
stack.alignment = .center
|
||||
|
||||
let icon = UIImageView(image: UIImage(systemName: "camera.viewfinder"))
|
||||
icon.tintColor = .white
|
||||
icon.contentMode = .scaleAspectFit
|
||||
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.text = "当前环境不可用相机"
|
||||
titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.title3, weight: .semibold)
|
||||
titleLabel.textColor = .white
|
||||
|
||||
let messageLabel = UILabel()
|
||||
messageLabel.text = "请在真机上使用扫码核销"
|
||||
messageLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline)
|
||||
messageLabel.textColor = UIColor.white.withAlphaComponent(0.72)
|
||||
|
||||
stack.addArrangedSubview(icon)
|
||||
stack.addArrangedSubview(titleLabel)
|
||||
stack.addArrangedSubview(messageLabel)
|
||||
view.addSubview(stack)
|
||||
icon.snp.makeConstraints { make in
|
||||
make.width.height.equalTo(48)
|
||||
}
|
||||
stack.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func closeTapped() {
|
||||
dismiss(animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
/// AVFoundation 扫码控制器,管理相机权限、预览层、识别类型和手电筒。
|
||||
final class OrderScannerCaptureViewController: UIViewController, AVCaptureMetadataOutputObjectsDelegate {
|
||||
|
||||
var onScanResult: ((Result<String, Error>) -> Void)?
|
||||
|
||||
private let session = AVCaptureSession()
|
||||
private var previewLayer: AVCaptureVideoPreviewLayer?
|
||||
private var videoDevice: AVCaptureDevice?
|
||||
private var hasFinishedScan = false
|
||||
|
||||
private let scanFrameView: UIView = {
|
||||
let view = UIView()
|
||||
view.layer.borderColor = UIColor.systemBlue.cgColor
|
||||
view.layer.borderWidth = 2
|
||||
view.layer.cornerRadius = 12
|
||||
view.backgroundColor = .clear
|
||||
return view
|
||||
}()
|
||||
|
||||
private lazy var torchButton: UIButton = {
|
||||
let button = Self.makeScannerButton(title: "手电筒")
|
||||
button.addTarget(self, action: #selector(toggleTorch), for: .touchUpInside)
|
||||
return button
|
||||
}()
|
||||
|
||||
private lazy var retryButton: UIButton = {
|
||||
let button = Self.makeScannerButton(title: "重新扫描")
|
||||
button.addTarget(self, action: #selector(restartScan), for: .touchUpInside)
|
||||
return button
|
||||
}()
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .black
|
||||
setupControls()
|
||||
checkPermissionAndStart()
|
||||
}
|
||||
|
||||
override func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
previewLayer?.frame = view.layer.bounds
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
updateTorch(enabled: false)
|
||||
stopSession()
|
||||
}
|
||||
|
||||
private func checkPermissionAndStart() {
|
||||
switch AVCaptureDevice.authorizationStatus(for: .video) {
|
||||
case .authorized:
|
||||
startSession()
|
||||
case .notDetermined:
|
||||
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
|
||||
DispatchQueue.main.async {
|
||||
guard let self else { return }
|
||||
if granted {
|
||||
self.startSession()
|
||||
} else {
|
||||
self.onScanResult?(.failure(OrderScannerError.permissionDenied))
|
||||
}
|
||||
}
|
||||
}
|
||||
case .denied, .restricted:
|
||||
onScanResult?(.failure(OrderScannerError.permissionDenied))
|
||||
@unknown default:
|
||||
onScanResult?(.failure(OrderScannerError.permissionDenied))
|
||||
}
|
||||
}
|
||||
|
||||
private func startSession() {
|
||||
hasFinishedScan = false
|
||||
guard !session.isRunning else { return }
|
||||
do {
|
||||
try configureSession()
|
||||
} catch {
|
||||
onScanResult?(.failure(error))
|
||||
return
|
||||
}
|
||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||
self?.session.startRunning()
|
||||
}
|
||||
}
|
||||
|
||||
private func stopSession() {
|
||||
guard session.isRunning else { return }
|
||||
session.stopRunning()
|
||||
}
|
||||
|
||||
private static func makeScannerButton(title: String) -> UIButton {
|
||||
var config = UIButton.Configuration.filled()
|
||||
config.title = title
|
||||
config.baseForegroundColor = .white
|
||||
config.baseBackgroundColor = UIColor.black.withAlphaComponent(0.42)
|
||||
config.cornerStyle = .capsule
|
||||
return UIButton(configuration: config)
|
||||
}
|
||||
|
||||
private func configureSession() throws {
|
||||
if previewLayer != nil { return }
|
||||
guard let videoDevice = AVCaptureDevice.default(for: .video) else {
|
||||
throw OrderScannerError.cameraUnavailable
|
||||
}
|
||||
self.videoDevice = videoDevice
|
||||
|
||||
let videoInput = try AVCaptureDeviceInput(device: videoDevice)
|
||||
guard session.canAddInput(videoInput) else { throw OrderScannerError.cameraUnavailable }
|
||||
session.addInput(videoInput)
|
||||
|
||||
let metadataOutput = AVCaptureMetadataOutput()
|
||||
guard session.canAddOutput(metadataOutput) else { throw OrderScannerError.invalidMetadata }
|
||||
session.addOutput(metadataOutput)
|
||||
|
||||
metadataOutput.setMetadataObjectsDelegate(self, queue: .main)
|
||||
let desiredTypes: [AVMetadataObject.ObjectType] = [.qr, .ean8, .ean13, .pdf417, .code39, .code93, .code128, .dataMatrix, .aztec, .itf14, .upce]
|
||||
let supportedTypes = desiredTypes.filter { metadataOutput.availableMetadataObjectTypes.contains($0) }
|
||||
guard !supportedTypes.isEmpty else { throw OrderScannerError.invalidMetadata }
|
||||
metadataOutput.metadataObjectTypes = supportedTypes
|
||||
|
||||
let preview = AVCaptureVideoPreviewLayer(session: session)
|
||||
preview.videoGravity = .resizeAspectFill
|
||||
preview.frame = view.layer.bounds
|
||||
view.layer.insertSublayer(preview, at: 0)
|
||||
previewLayer = preview
|
||||
torchButton.isEnabled = videoDevice.hasTorch
|
||||
}
|
||||
|
||||
func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) {
|
||||
guard !hasFinishedScan else { return }
|
||||
guard let metadataObject = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
|
||||
let code = metadataObject.stringValue,
|
||||
!code.isEmpty else { return }
|
||||
hasFinishedScan = true
|
||||
stopSession()
|
||||
UINotificationFeedbackGenerator().notificationOccurred(.success)
|
||||
onScanResult?(.success(code))
|
||||
}
|
||||
|
||||
private func setupControls() {
|
||||
view.addSubview(scanFrameView)
|
||||
view.addSubview(torchButton)
|
||||
view.addSubview(retryButton)
|
||||
|
||||
scanFrameView.snp.makeConstraints { make in
|
||||
make.centerX.equalToSuperview()
|
||||
make.centerY.equalToSuperview().offset(-40)
|
||||
make.width.height.equalTo(240)
|
||||
}
|
||||
torchButton.snp.makeConstraints { make in
|
||||
make.leading.equalTo(view.safeAreaLayoutGuide).inset(16)
|
||||
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(18)
|
||||
}
|
||||
retryButton.snp.makeConstraints { make in
|
||||
make.trailing.equalTo(view.safeAreaLayoutGuide).inset(16)
|
||||
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(18)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func toggleTorch() {
|
||||
updateTorch(enabled: !(videoDevice?.isTorchActive ?? false))
|
||||
}
|
||||
|
||||
private func updateTorch(enabled: Bool) {
|
||||
guard let device = videoDevice, device.hasTorch else { return }
|
||||
do {
|
||||
try device.lockForConfiguration()
|
||||
if enabled {
|
||||
try device.setTorchModeOn(level: AVCaptureDevice.maxAvailableTorchLevel)
|
||||
} else {
|
||||
device.torchMode = .off
|
||||
}
|
||||
device.unlockForConfiguration()
|
||||
torchButton.configuration?.title = enabled ? "关闭手电" : "手电筒"
|
||||
} catch {
|
||||
torchButton.configuration?.title = "手电筒不可用"
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func restartScan() {
|
||||
hasFinishedScan = false
|
||||
updateTorch(enabled: false)
|
||||
startSession()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,252 @@
|
||||
//
|
||||
// OrderDetailViewControllers.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 门店订单详情页,展示订单接口补全后的支付、客户、项目和拍摄点信息。
|
||||
final class StoreOrderDetailViewController: UIViewController {
|
||||
|
||||
private let item: OrderEntity
|
||||
private let viewModel: OrderDetailViewModel
|
||||
private let refundViewModel = OrderRefundViewModel()
|
||||
|
||||
private lazy var tableView: UITableView = {
|
||||
let table = UITableView(frame: .zero, style: .insetGrouped)
|
||||
table.dataSource = self
|
||||
table.delegate = self
|
||||
table.backgroundColor = AppDesignUIKit.pageBackground
|
||||
return table
|
||||
}()
|
||||
|
||||
init(item: OrderEntity) {
|
||||
self.item = item
|
||||
self.viewModel = OrderDetailViewModel(item: item)
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = "订单详情"
|
||||
view.backgroundColor = AppDesignUIKit.pageBackground
|
||||
view.addSubview(tableView)
|
||||
tableView.snp.makeConstraints { make in make.edges.equalToSuperview() }
|
||||
|
||||
viewModel.onChange = { [weak self] in self?.tableView.reloadData() }
|
||||
Task { await loadDetail() }
|
||||
}
|
||||
|
||||
private func loadDetail() async {
|
||||
await appServices.globalLoading.withLoading(message: "加载详情中...") {
|
||||
await viewModel.load(api: appServices.ordersAPI, fallbackStoreId: appServices.accountContext.currentStore?.id)
|
||||
}
|
||||
if let error = viewModel.errorMessage {
|
||||
showToast(error)
|
||||
}
|
||||
}
|
||||
|
||||
private func copyOrderNumber() {
|
||||
UIPasteboard.general.string = viewModel.display.orderNumber
|
||||
showToast("订单号已复制")
|
||||
}
|
||||
|
||||
private func presentRefund() {
|
||||
refundViewModel.begin(item: item)
|
||||
let alert = UIAlertController(title: "订单退款", message: "可退 ¥\(refundViewModel.availableAmountText(for: item))", preferredStyle: .alert)
|
||||
alert.addTextField { $0.placeholder = "退款原因" }
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
alert.addAction(UIAlertAction(title: "提交", style: .default) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
self.refundViewModel.reason = alert.textFields?.first?.text ?? ""
|
||||
Task {
|
||||
let success = await self.refundViewModel.submit(api: self.appServices.ordersAPI, item: self.item)
|
||||
if success {
|
||||
self.showToast("退款申请已提交")
|
||||
await self.viewModel.load(api: self.appServices.ordersAPI, fallbackStoreId: self.appServices.accountContext.currentStore?.id, forceReload: true)
|
||||
} else if let message = self.refundViewModel.errorMessage {
|
||||
self.showToast(message)
|
||||
}
|
||||
}
|
||||
})
|
||||
present(alert, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
extension StoreOrderDetailViewController: UITableViewDataSource, UITableViewDelegate {
|
||||
func numberOfSections(in tableView: UITableView) -> Int { 6 }
|
||||
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
switch section {
|
||||
case 0: return viewModel.contextMessage == nil ? 0 : 1
|
||||
case 1: return 7
|
||||
case 2: return 4
|
||||
case 3: return 4
|
||||
case 4: return viewModel.shootingList.count
|
||||
case 5: return 5
|
||||
default: return 0
|
||||
}
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||
switch section {
|
||||
case 1: "订单信息"
|
||||
case 2: "支付信息"
|
||||
case 3: "客户与项目"
|
||||
case 4 where !viewModel.shootingList.isEmpty: "拍摄点"
|
||||
case 5: "后续功能"
|
||||
default: nil
|
||||
}
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
|
||||
cell.selectionStyle = .none
|
||||
let display = viewModel.display
|
||||
|
||||
switch indexPath.section {
|
||||
case 0:
|
||||
cell.textLabel?.text = viewModel.contextMessage
|
||||
cell.textLabel?.numberOfLines = 0
|
||||
case 1:
|
||||
let rows = ["订单号", "状态", "类型", "创建时间", "付款时间", "完成时间", "复制订单号"]
|
||||
cell.textLabel?.text = rows[indexPath.row]
|
||||
switch indexPath.row {
|
||||
case 0: cell.detailTextLabel?.text = display.orderNumber
|
||||
case 1: cell.detailTextLabel?.text = display.orderStatusName
|
||||
case 2: cell.detailTextLabel?.text = display.orderTypeLabel
|
||||
case 3: cell.detailTextLabel?.text = display.createdAt
|
||||
case 4: cell.detailTextLabel?.text = displayPayTime
|
||||
case 5: cell.detailTextLabel?.text = display.completeTime
|
||||
default:
|
||||
cell.textLabel?.textColor = AppDesignUIKit.primary
|
||||
cell.selectionStyle = .default
|
||||
}
|
||||
case 2:
|
||||
let rows = ["付款金额", "退款金额", "付款方式", "用户 UID"]
|
||||
cell.textLabel?.text = rows[indexPath.row]
|
||||
switch indexPath.row {
|
||||
case 0: cell.detailTextLabel?.text = "¥\(emptyToZero(display.actualPayAmount))"
|
||||
case 1: cell.detailTextLabel?.text = "¥\(emptyToZero(display.actualRefundAmount))"
|
||||
case 2: cell.detailTextLabel?.text = display.payTypeName
|
||||
default: cell.detailTextLabel?.text = "\(display.userId)"
|
||||
}
|
||||
case 3:
|
||||
let rows = ["手机号", "关联项目", "项目 ID", "备注"]
|
||||
cell.textLabel?.text = rows[indexPath.row]
|
||||
switch indexPath.row {
|
||||
case 0: cell.detailTextLabel?.text = display.phone
|
||||
case 1: cell.detailTextLabel?.text = display.projectName
|
||||
case 2: cell.detailTextLabel?.text = "\(display.projectId)"
|
||||
default: cell.detailTextLabel?.text = display.remark
|
||||
}
|
||||
case 4:
|
||||
let shooting = viewModel.shootingList[indexPath.row]
|
||||
cell.textLabel?.text = shooting.scenicSpotName
|
||||
cell.detailTextLabel?.text = shooting.staffName.isEmpty ? "状态 \(shooting.status)" : shooting.staffName
|
||||
case 5:
|
||||
let actions = ["历史拍摄", "任务上传", "视频预告", "尾片上传", "退款"]
|
||||
cell.textLabel?.text = actions[indexPath.row]
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
cell.selectionStyle = .default
|
||||
if indexPath.row == 4, !refundViewModel.canRefund(item) {
|
||||
cell.isUserInteractionEnabled = false
|
||||
cell.textLabel?.textColor = AppDesignUIKit.textSecondary
|
||||
}
|
||||
default: break
|
||||
}
|
||||
return cell
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
if indexPath.section == 1, indexPath.row == 6 { copyOrderNumber(); return }
|
||||
guard indexPath.section == 5 else { return }
|
||||
let orderNumber = viewModel.display.orderNumber
|
||||
switch indexPath.row {
|
||||
case 0:
|
||||
HomeMenuRouting.pushOrders(.historicalShooting(orderNumber: orderNumber), from: self)
|
||||
case 1 where item.orderType == 19:
|
||||
HomeMenuRouting.pushOrders(.multiTravelTaskUpload(orderNumber: orderNumber), from: self)
|
||||
case 2:
|
||||
HomeMenuRouting.pushOrders(.orderTrailer(orderNumber: orderNumber, title: "视频预告"), from: self)
|
||||
case 3:
|
||||
HomeMenuRouting.pushOrders(.orderTrailer(orderNumber: orderNumber, title: "尾片上传"), from: self)
|
||||
case 4:
|
||||
presentRefund()
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
private var displayPayTime: String {
|
||||
let payTime = viewModel.display.payTime
|
||||
if payTime.isEmpty || payTime == "0" || payTime.hasPrefix("1970") { return item.depositPayTime }
|
||||
return payTime
|
||||
}
|
||||
|
||||
private func emptyToZero(_ value: String) -> String {
|
||||
value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "0" : value
|
||||
}
|
||||
}
|
||||
|
||||
/// 核销订单详情页,展示核销列表项摘要信息。
|
||||
final class WriteOffOrderDetailViewController: UIViewController {
|
||||
|
||||
private let item: WriteOffOrderItem
|
||||
|
||||
private lazy var tableView: UITableView = {
|
||||
let table = UITableView(frame: .zero, style: .insetGrouped)
|
||||
table.dataSource = self
|
||||
table.backgroundColor = AppDesignUIKit.pageBackground
|
||||
return table
|
||||
}()
|
||||
|
||||
init(item: WriteOffOrderItem) {
|
||||
self.item = item
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = "核销详情"
|
||||
view.addSubview(tableView)
|
||||
tableView.snp.makeConstraints { make in make.edges.equalToSuperview() }
|
||||
}
|
||||
}
|
||||
|
||||
extension WriteOffOrderDetailViewController: UITableViewDataSource {
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 6 }
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
|
||||
cell.selectionStyle = .none
|
||||
switch indexPath.row {
|
||||
case 0:
|
||||
cell.textLabel?.text = "订单号"
|
||||
cell.detailTextLabel?.text = item.orderNumber
|
||||
case 1:
|
||||
cell.textLabel?.text = "项目"
|
||||
cell.detailTextLabel?.text = item.projectName
|
||||
case 2:
|
||||
cell.textLabel?.text = "手机号"
|
||||
cell.detailTextLabel?.text = item.userPhone
|
||||
case 3:
|
||||
cell.textLabel?.text = "金额"
|
||||
cell.detailTextLabel?.text = "¥\(item.orderAmount)"
|
||||
case 4:
|
||||
cell.textLabel?.text = "状态"
|
||||
cell.detailTextLabel?.text = item.orderStatusName
|
||||
default:
|
||||
cell.textLabel?.text = "核销时间"
|
||||
cell.detailTextLabel?.text = item.orderVerificationTime
|
||||
}
|
||||
return cell
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,268 @@
|
||||
//
|
||||
// OrderTailUploadViewControllers.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import PhotosUI
|
||||
import SnapKit
|
||||
import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
/// 多点旅拍任务上传页,负责选择打卡点和素材后提交到订单接口。
|
||||
final class MultiTravelTaskUploadViewController: UIViewController {
|
||||
|
||||
private let viewModel: MultiTravelTaskUploadViewModel
|
||||
|
||||
private let orderField = UITextField()
|
||||
private let spotButton = UIButton(type: .system)
|
||||
private let cloudListLabel = UILabel()
|
||||
private let localListLabel = UILabel()
|
||||
private let submitButton = UIButton(type: .system)
|
||||
|
||||
init(initialOrderNumber: String) {
|
||||
self.viewModel = MultiTravelTaskUploadViewModel(initialOrderNumber: initialOrderNumber)
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = "任务上传"
|
||||
view.backgroundColor = UIColor(hex: 0xF5F7FA)
|
||||
setupForm()
|
||||
bindViewModel()
|
||||
Task { await viewModel.loadSpots(api: appServices.ordersAPI) }
|
||||
}
|
||||
|
||||
private func bindViewModel() {
|
||||
viewModel.onChange = { [weak self] in
|
||||
self?.applyViewModel()
|
||||
}
|
||||
}
|
||||
|
||||
private func setupForm() {
|
||||
orderField.borderStyle = .roundedRect
|
||||
orderField.placeholder = "关联订单号"
|
||||
orderField.autocorrectionType = .no
|
||||
orderField.autocapitalizationType = .none
|
||||
orderField.text = viewModel.orderNumber
|
||||
orderField.addTarget(self, action: #selector(orderChanged), for: .editingChanged)
|
||||
|
||||
spotButton.setTitle("选择打卡点", for: .normal)
|
||||
spotButton.addTarget(self, action: #selector(selectSpot), for: .touchUpInside)
|
||||
|
||||
cloudListLabel.numberOfLines = 0
|
||||
cloudListLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline)
|
||||
localListLabel.numberOfLines = 0
|
||||
localListLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline)
|
||||
|
||||
submitButton.configuration = .filled()
|
||||
submitButton.configuration?.title = "保存任务素材"
|
||||
submitButton.configuration?.baseBackgroundColor = AppDesignUIKit.primary
|
||||
submitButton.addTarget(self, action: #selector(submitTapped), for: .touchUpInside)
|
||||
|
||||
let refreshButton = UIButton(type: .system)
|
||||
refreshButton.setTitle("刷新打卡点", for: .normal)
|
||||
refreshButton.addTarget(self, action: #selector(refreshSpots), for: .touchUpInside)
|
||||
|
||||
let pickLocalButton = UIButton(type: .system)
|
||||
pickLocalButton.setTitle("选择图片/视频", for: .normal)
|
||||
pickLocalButton.addTarget(self, action: #selector(pickLocalFiles), for: .touchUpInside)
|
||||
|
||||
let stack = UIStackView(arrangedSubviews: [
|
||||
labeledRow("订单号", orderField),
|
||||
refreshButton,
|
||||
spotButton,
|
||||
labeledRow("云盘素材", cloudListLabel),
|
||||
pickLocalButton,
|
||||
labeledRow("本地素材", localListLabel),
|
||||
submitButton
|
||||
])
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 12
|
||||
view.addSubview(stack)
|
||||
stack.snp.makeConstraints { make in
|
||||
make.top.equalTo(view.safeAreaLayoutGuide).offset(16)
|
||||
make.leading.trailing.equalToSuperview().inset(16)
|
||||
}
|
||||
applyViewModel()
|
||||
}
|
||||
|
||||
private func labeledRow(_ title: String, _ content: UIView) -> UIStackView {
|
||||
let label = UILabel()
|
||||
label.text = title
|
||||
label.font = .systemFont(ofSize: AppMetrics.FontSize.body, weight: .semibold)
|
||||
let row = UIStackView(arrangedSubviews: [label, content])
|
||||
row.axis = .vertical
|
||||
row.spacing = 8
|
||||
return row
|
||||
}
|
||||
|
||||
private func applyViewModel() {
|
||||
orderField.text = viewModel.orderNumber
|
||||
spotButton.setTitle(viewModel.isLoadingSpots ? "加载中..." : viewModel.selectedSpotName, for: .normal)
|
||||
spotButton.isEnabled = !viewModel.isLoadingSpots
|
||||
|
||||
if viewModel.selectedCloudFiles.isEmpty {
|
||||
cloudListLabel.text = "未选择云盘文件"
|
||||
} else {
|
||||
cloudListLabel.text = viewModel.selectedCloudFiles.map(\.fileName).joined(separator: "\n")
|
||||
}
|
||||
|
||||
if viewModel.selectedLocalFiles.isEmpty {
|
||||
localListLabel.text = "未选择本地文件"
|
||||
} else {
|
||||
localListLabel.text = viewModel.selectedLocalFiles.map { file in
|
||||
"\(file.fileName) \(file.statusText)"
|
||||
}.joined(separator: "\n")
|
||||
}
|
||||
|
||||
submitButton.isEnabled = viewModel.canSubmit
|
||||
submitButton.configuration?.title = viewModel.isSubmitting ? "保存中..." : "保存任务素材"
|
||||
}
|
||||
|
||||
@objc private func orderChanged() {
|
||||
viewModel.orderNumber = orderField.text ?? ""
|
||||
}
|
||||
|
||||
@objc private func refreshSpots() {
|
||||
Task { await viewModel.loadSpots(api: appServices.ordersAPI) }
|
||||
}
|
||||
|
||||
@objc private func selectSpot() {
|
||||
guard !viewModel.spots.isEmpty else { return }
|
||||
let sheet = UIAlertController(title: "选择打卡点", message: nil, preferredStyle: .actionSheet)
|
||||
for spot in viewModel.spots {
|
||||
let title = spot.name.isEmpty ? "打卡点 \(spot.id)" : spot.name
|
||||
sheet.addAction(UIAlertAction(title: title, style: .default) { [weak self] _ in
|
||||
self?.viewModel.selectSpot(id: spot.id)
|
||||
})
|
||||
}
|
||||
sheet.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
present(sheet, animated: true)
|
||||
}
|
||||
|
||||
@objc private func pickLocalFiles() {
|
||||
var config = PHPickerConfiguration()
|
||||
config.selectionLimit = 9
|
||||
config.filter = .any(of: [.images, .videos])
|
||||
let picker = PHPickerViewController(configuration: config)
|
||||
picker.delegate = self
|
||||
present(picker, animated: true)
|
||||
}
|
||||
|
||||
@objc private func submitTapped() {
|
||||
Task {
|
||||
let success = await viewModel.submit(
|
||||
api: appServices.ordersAPI,
|
||||
uploadService: appServices.ossUploadService,
|
||||
scenicId: appServices.accountContext.currentScenic?.id
|
||||
)
|
||||
if success {
|
||||
showToast("提交成功")
|
||||
navigationController?.popViewController(animated: true)
|
||||
} else if let message = viewModel.errorMessage {
|
||||
showToast(message)
|
||||
}
|
||||
applyViewModel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension MultiTravelTaskUploadViewController: PHPickerViewControllerDelegate {
|
||||
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
|
||||
picker.dismiss(animated: true)
|
||||
guard !results.isEmpty else { return }
|
||||
for result in results {
|
||||
let provider = result.itemProvider
|
||||
if provider.hasItemConformingToTypeIdentifier(UTType.movie.identifier) {
|
||||
provider.loadFileRepresentation(forTypeIdentifier: UTType.movie.identifier) { [weak self] url, _ in
|
||||
guard let self, let url else { return }
|
||||
let data = (try? Data(contentsOf: url)) ?? Data()
|
||||
DispatchQueue.main.async {
|
||||
self.viewModel.addLocalFile(data: data, fileName: url.lastPathComponent)
|
||||
self.applyViewModel()
|
||||
}
|
||||
}
|
||||
} else if provider.canLoadObject(ofClass: UIImage.self) {
|
||||
provider.loadObject(ofClass: UIImage.self) { [weak self] object, _ in
|
||||
guard let self, let image = object as? UIImage, let data = image.jpegData(compressionQuality: 0.9) else { return }
|
||||
DispatchQueue.main.async {
|
||||
self.viewModel.addLocalFile(data: data, fileName: "upload_\(Int(Date().timeIntervalSince1970)).jpg")
|
||||
self.applyViewModel()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 历史拍摄信息页。
|
||||
final class HistoricalShootingInfoViewController: UIViewController {
|
||||
|
||||
private let orderNumber: String
|
||||
private let viewModel = HistoricalShootingInfoViewModel()
|
||||
|
||||
private lazy var tableView: UITableView = {
|
||||
let table = UITableView(frame: .zero, style: .insetGrouped)
|
||||
table.dataSource = self
|
||||
table.delegate = self
|
||||
return table
|
||||
}()
|
||||
|
||||
init(orderNumber: String) {
|
||||
self.orderNumber = orderNumber
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = "历史拍摄"
|
||||
view.addSubview(tableView)
|
||||
tableView.snp.makeConstraints { make in make.edges.equalToSuperview() }
|
||||
viewModel.onChange = { [weak self] in self?.tableView.reloadData() }
|
||||
Task { await loadData() }
|
||||
}
|
||||
|
||||
private func loadData() async {
|
||||
await appServices.globalLoading.withLoading {
|
||||
await viewModel.load(api: appServices.ordersAPI, orderNumber: orderNumber)
|
||||
}
|
||||
if let message = viewModel.errorMessage { showToast(message) }
|
||||
}
|
||||
}
|
||||
|
||||
extension HistoricalShootingInfoViewController: UITableViewDataSource, UITableViewDelegate {
|
||||
func numberOfSections(in tableView: UITableView) -> Int { 2 }
|
||||
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
section == 0 ? 2 : max(viewModel.spots.count, 1)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||
section == 1 ? "拍摄点位" : nil
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
|
||||
cell.selectionStyle = .none
|
||||
if indexPath.section == 0 {
|
||||
cell.textLabel?.text = indexPath.row == 0 ? "项目" : "项目类型"
|
||||
cell.detailTextLabel?.text = indexPath.row == 0 ? viewModel.projectName : viewModel.projectTypeName
|
||||
return cell
|
||||
}
|
||||
guard !viewModel.spots.isEmpty else {
|
||||
cell.textLabel?.text = viewModel.errorMessage ?? "暂无历史拍摄"
|
||||
return cell
|
||||
}
|
||||
let spot = viewModel.spots[indexPath.row]
|
||||
cell.textLabel?.text = spot.scenicSpotName
|
||||
cell.detailTextLabel?.text = "\(spot.files.count) 个文件 · \(spot.photographerDisplayName)"
|
||||
return cell
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,604 @@
|
||||
//
|
||||
// OrdersViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 订单 Tab 根页面,展示订单管理和核销订单两个子入口及对应列表。
|
||||
final class OrdersViewController: UIViewController {
|
||||
|
||||
private let viewModel = OrdersViewModel()
|
||||
private var manualOrderNumber = ""
|
||||
private var scanHintMessage: String?
|
||||
|
||||
private lazy var tableView: UITableView = {
|
||||
let table = UITableView(frame: .zero, style: .grouped)
|
||||
table.backgroundColor = AppDesignUIKit.pageBackground
|
||||
table.separatorStyle = .none
|
||||
table.dataSource = self
|
||||
table.delegate = self
|
||||
table.register(OrderEntityCell.self, forCellReuseIdentifier: OrderEntityCell.reuseID)
|
||||
table.register(WriteOffOrderCell.self, forCellReuseIdentifier: WriteOffOrderCell.reuseID)
|
||||
table.register(OrdersHeaderCell.self, forCellReuseIdentifier: OrdersHeaderCell.reuseID)
|
||||
table.register(OrdersFilterCell.self, forCellReuseIdentifier: OrdersFilterCell.reuseID)
|
||||
table.register(OrdersWriteOffActionCell.self, forCellReuseIdentifier: OrdersWriteOffActionCell.reuseID)
|
||||
return table
|
||||
}()
|
||||
|
||||
private lazy var refreshControl = UIRefreshControl()
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = "订单"
|
||||
view.backgroundColor = AppDesignUIKit.pageBackground
|
||||
|
||||
view.addSubview(tableView)
|
||||
tableView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
refreshControl.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
|
||||
tableView.refreshControl = refreshControl
|
||||
|
||||
viewModel.onChange = { [weak self] in
|
||||
self?.tableView.reloadData()
|
||||
}
|
||||
|
||||
appServices.appRouter.onChange = { [weak self] in
|
||||
guard let self else { return }
|
||||
if self.viewModel.selectedEntry != self.appServices.appRouter.selectedOrdersEntry {
|
||||
self.viewModel.selectedEntry = self.appServices.appRouter.selectedOrdersEntry
|
||||
Task { await self.reload(showLoading: true) }
|
||||
}
|
||||
Task { await self.consumePendingScanCodeIfNeeded() }
|
||||
}
|
||||
appServices.accountContext.onChange = { [weak self] in
|
||||
Task { await self?.reload(showLoading: true) }
|
||||
}
|
||||
|
||||
viewModel.selectedEntry = appServices.appRouter.selectedOrdersEntry
|
||||
Task {
|
||||
await reload(showLoading: true)
|
||||
await consumePendingScanCodeIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func refreshPulled() {
|
||||
Task {
|
||||
await reload(showLoading: false)
|
||||
refreshControl.endRefreshing()
|
||||
}
|
||||
}
|
||||
|
||||
private var currentScenicId: Int? { appServices.accountContext.currentScenic?.id }
|
||||
private var currentStoreId: Int? { appServices.accountContext.currentStore?.id }
|
||||
private var currentRoleId: Int? { appServices.permissionContext.currentRole?.id }
|
||||
|
||||
private func reload(showLoading: Bool) async {
|
||||
do {
|
||||
try await appServices.globalLoading.withOptionalLoading(showLoading, message: "加载订单...") {
|
||||
try await self.viewModel.reload(
|
||||
api: self.appServices.ordersAPI,
|
||||
scenicId: self.currentScenicId,
|
||||
storeId: self.currentStoreId,
|
||||
roleId: self.currentRoleId,
|
||||
showLoading: false
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
showToast(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadMore() async {
|
||||
do {
|
||||
if viewModel.selectedEntry == .storeOrders {
|
||||
try await viewModel.loadMoreStoreOrders(
|
||||
api: appServices.ordersAPI,
|
||||
scenicId: currentScenicId,
|
||||
roleId: currentRoleId
|
||||
)
|
||||
} else {
|
||||
try await viewModel.loadMoreWriteOffOrders(
|
||||
api: appServices.ordersAPI,
|
||||
scenicId: currentScenicId,
|
||||
storeId: currentStoreId
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
showToast(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func switchEntry(_ entry: OrdersEntry) {
|
||||
guard viewModel.selectedEntry != entry else { return }
|
||||
viewModel.selectedEntry = entry
|
||||
appServices.appRouter.selectedOrdersEntry = entry
|
||||
Task { await reload(showLoading: true) }
|
||||
}
|
||||
|
||||
private func verify(orderNumber: String) async {
|
||||
guard let scenicId = currentScenicId else { return }
|
||||
do {
|
||||
try await appServices.globalLoading.withLoading(message: "核销中...") {
|
||||
try await self.viewModel.verify(
|
||||
api: self.appServices.ordersAPI,
|
||||
scenicId: scenicId,
|
||||
storeId: self.currentStoreId,
|
||||
orderNumber: orderNumber
|
||||
)
|
||||
}
|
||||
showToast("核销成功")
|
||||
} catch {
|
||||
showToast(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func consumePendingScanCodeIfNeeded() async {
|
||||
guard viewModel.selectedEntry == .verificationOrders,
|
||||
let code = appServices.appRouter.consumePendingOrderScanCode() else { return }
|
||||
handleScanResult(code)
|
||||
}
|
||||
|
||||
private func handleScanResult(_ rawCode: String) {
|
||||
guard let parsed = viewModel.matchedWriteOffOrder(for: rawCode) else {
|
||||
showToast("未识别到有效订单号")
|
||||
return
|
||||
}
|
||||
if parsed.matched != nil {
|
||||
confirmVerify(orderNumber: parsed.orderNumber)
|
||||
} else {
|
||||
scanHintMessage = "扫码成功,当前列表未找到该订单"
|
||||
tableView.reloadData()
|
||||
}
|
||||
}
|
||||
|
||||
private func confirmVerify(orderNumber: String) {
|
||||
let alert = UIAlertController(title: "确认核销该订单?", message: orderNumber, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
alert.addAction(UIAlertAction(title: "确认核销", style: .default) { [weak self] _ in
|
||||
Task { await self?.verify(orderNumber: orderNumber) }
|
||||
})
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func presentScanner() {
|
||||
let scanner = OrderCodeScannerViewController()
|
||||
scanner.onScanResult = { [weak self, weak scanner] result in
|
||||
scanner?.dismiss(animated: true)
|
||||
guard let self else { return }
|
||||
switch result {
|
||||
case .success(let code):
|
||||
self.handleScanResult(code)
|
||||
case .failure(let error):
|
||||
self.showToast(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
let nav = UINavigationController(rootViewController: scanner)
|
||||
nav.modalPresentationStyle = .fullScreen
|
||||
present(nav, animated: true)
|
||||
}
|
||||
|
||||
private func presentDateFilter() {
|
||||
let alert = UIAlertController(title: "时间筛选", message: "选择开始和结束日期", preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "清除筛选", style: .destructive) { [weak self] _ in
|
||||
self?.viewModel.filterStartDate = nil
|
||||
self?.viewModel.filterEndDate = nil
|
||||
Task { await self?.reload(showLoading: true) }
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "近7天", style: .default) { [weak self] _ in
|
||||
let end = Date()
|
||||
let start = Calendar.current.date(byAdding: .day, value: -6, to: end) ?? end
|
||||
self?.viewModel.filterStartDate = start
|
||||
self?.viewModel.filterEndDate = end
|
||||
Task { await self?.reload(showLoading: true) }
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func presentStatusFilter() {
|
||||
let sheet = UIAlertController(title: "订单状态", message: nil, preferredStyle: .actionSheet)
|
||||
for filter in OrderFilters.statusFilters {
|
||||
sheet.addAction(UIAlertAction(title: filter.title, style: .default) { [weak self] _ in
|
||||
self?.viewModel.selectedStatus = filter.id
|
||||
Task { await self?.reload(showLoading: true) }
|
||||
})
|
||||
}
|
||||
sheet.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
present(sheet, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
extension OrdersViewController: UITableViewDataSource, UITableViewDelegate {
|
||||
func numberOfSections(in tableView: UITableView) -> Int {
|
||||
guard currentScenicId != nil else { return 1 }
|
||||
return viewModel.selectedEntry == .storeOrders ? 3 : 3
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
if section == 0 { return 1 }
|
||||
if section == 1 { return viewModel.selectedEntry == .storeOrders ? 1 : 1 }
|
||||
if currentScenicId == nil { return 1 }
|
||||
if viewModel.selectedEntry == .storeOrders {
|
||||
return max(viewModel.storeOrders.count, viewModel.loading && viewModel.storeOrders.isEmpty ? 0 : 1)
|
||||
}
|
||||
return max(viewModel.writeOffOrders.count, viewModel.loading && viewModel.writeOffOrders.isEmpty ? 0 : 1)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
if indexPath.section == 0 {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: OrdersHeaderCell.reuseID, for: indexPath) as! OrdersHeaderCell
|
||||
let services = appServices
|
||||
cell.configure(
|
||||
selectedEntry: viewModel.selectedEntry,
|
||||
scenicName: services.accountContext.currentScenic?.name ?? "--",
|
||||
storeTotal: viewModel.storeTotal,
|
||||
writeOffTotal: viewModel.writeOffTotal,
|
||||
onSelectEntry: { [weak self] entry in self?.switchEntry(entry) }
|
||||
)
|
||||
return cell
|
||||
}
|
||||
|
||||
if currentScenicId == nil {
|
||||
let cell = UITableViewCell()
|
||||
cell.selectionStyle = .none
|
||||
cell.backgroundColor = .clear
|
||||
cell.contentView.subviews.forEach { $0.removeFromSuperview() }
|
||||
let empty = makeEmptyStateView(title: "缺少经营上下文", message: "请先在首页选择景区后查看订单。", systemImage: "mountain.2")
|
||||
cell.contentView.addSubview(empty)
|
||||
empty.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
make.height.equalTo(360)
|
||||
}
|
||||
return cell
|
||||
}
|
||||
|
||||
if indexPath.section == 1 {
|
||||
if viewModel.selectedEntry == .storeOrders {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: OrdersFilterCell.reuseID, for: indexPath) as! OrdersFilterCell
|
||||
let statusTitle = OrderFilters.statusFilters.first(where: { $0.id == viewModel.selectedStatus })?.title ?? "全部"
|
||||
cell.configure(
|
||||
statusTitle: statusTitle,
|
||||
phone: viewModel.searchPhone,
|
||||
onStatus: { [weak self] in self?.presentStatusFilter() },
|
||||
onDate: { [weak self] in self?.presentDateFilter() },
|
||||
onPhoneChange: { [weak self] text in self?.viewModel.searchPhone = text },
|
||||
onSearch: { [weak self] in Task { await self?.reload(showLoading: true) } }
|
||||
)
|
||||
return cell
|
||||
}
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: OrdersWriteOffActionCell.reuseID, for: indexPath) as! OrdersWriteOffActionCell
|
||||
cell.configure(
|
||||
manualOrderNumber: manualOrderNumber,
|
||||
isVerifying: viewModel.isVerifying,
|
||||
hint: scanHintMessage,
|
||||
onScan: { [weak self] in self?.presentScanner() },
|
||||
onManualChange: { [weak self] text in self?.manualOrderNumber = text },
|
||||
onVerify: { [weak self] in
|
||||
guard let self, !self.manualOrderNumber.isEmpty else { return }
|
||||
self.confirmVerify(orderNumber: self.manualOrderNumber)
|
||||
}
|
||||
)
|
||||
return cell
|
||||
}
|
||||
|
||||
if viewModel.selectedEntry == .storeOrders {
|
||||
if viewModel.storeOrders.isEmpty {
|
||||
let cell = UITableViewCell()
|
||||
cell.selectionStyle = .none
|
||||
cell.backgroundColor = .clear
|
||||
cell.contentView.subviews.forEach { $0.removeFromSuperview() }
|
||||
let empty = makeEmptyStateView(title: "暂无订单", message: "可切换筛选条件或下拉刷新。", systemImage: "tray")
|
||||
cell.contentView.addSubview(empty)
|
||||
empty.snp.makeConstraints { make in make.edges.equalToSuperview(); make.height.equalTo(260) }
|
||||
return cell
|
||||
}
|
||||
let item = viewModel.storeOrders[indexPath.row]
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: OrderEntityCell.reuseID, for: indexPath) as! OrderEntityCell
|
||||
cell.configure(item: item)
|
||||
return cell
|
||||
}
|
||||
|
||||
if viewModel.writeOffOrders.isEmpty {
|
||||
let cell = UITableViewCell()
|
||||
cell.selectionStyle = .none
|
||||
cell.backgroundColor = .clear
|
||||
cell.contentView.subviews.forEach { $0.removeFromSuperview() }
|
||||
let empty = makeEmptyStateView(title: "暂无核销订单", message: "可下拉刷新或切换景区查看。", systemImage: "tray")
|
||||
cell.contentView.addSubview(empty)
|
||||
empty.snp.makeConstraints { make in make.edges.equalToSuperview(); make.height.equalTo(260) }
|
||||
return cell
|
||||
}
|
||||
let item = viewModel.writeOffOrders[indexPath.row]
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: WriteOffOrderCell.reuseID, for: indexPath) as! WriteOffOrderCell
|
||||
cell.configure(item: item, isVerifying: viewModel.currentVerifyingOrderNumber == item.orderNumber)
|
||||
return cell
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
guard indexPath.section == 2 else { return }
|
||||
if viewModel.selectedEntry == .storeOrders, indexPath.row < viewModel.storeOrders.count {
|
||||
HomeMenuRouting.pushOrders(.storeDetail(viewModel.storeOrders[indexPath.row]), from: self)
|
||||
} else if viewModel.selectedEntry == .verificationOrders, indexPath.row < viewModel.writeOffOrders.count {
|
||||
HomeMenuRouting.pushOrders(.writeOffDetail(viewModel.writeOffOrders[indexPath.row]), from: self)
|
||||
}
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
||||
guard indexPath.section == 2 else { return }
|
||||
let isLast: Bool
|
||||
if viewModel.selectedEntry == .storeOrders {
|
||||
isLast = indexPath.row == viewModel.storeOrders.count - 1
|
||||
} else {
|
||||
isLast = indexPath.row == viewModel.writeOffOrders.count - 1
|
||||
}
|
||||
if isLast { Task { await loadMore() } }
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||
if indexPath.section == 0 { return 120 }
|
||||
if indexPath.section == 1 { return viewModel.selectedEntry == .storeOrders ? 130 : 150 }
|
||||
return UITableView.automaticDimension
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cells
|
||||
|
||||
private final class OrdersHeaderCell: UITableViewCell {
|
||||
static let reuseID = "OrdersHeaderCell"
|
||||
private var onSelectEntry: ((OrdersEntry) -> Void)?
|
||||
|
||||
private let storeButton = UIButton(type: .system)
|
||||
private let verifyButton = UIButton(type: .system)
|
||||
private let leftPill = UILabel()
|
||||
private let rightPill = UILabel()
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
selectionStyle = .none
|
||||
backgroundColor = .clear
|
||||
let card = UIView()
|
||||
card.backgroundColor = .white
|
||||
card.layer.cornerRadius = 8
|
||||
contentView.addSubview(card)
|
||||
card.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 16, bottom: 4, right: 16))
|
||||
}
|
||||
|
||||
let segmentBackground = UIView()
|
||||
segmentBackground.backgroundColor = UIColor(hex: 0xF3F4F6)
|
||||
segmentBackground.layer.cornerRadius = 8
|
||||
card.addSubview(segmentBackground)
|
||||
storeButton.addTarget(self, action: #selector(storeTapped), for: .touchUpInside)
|
||||
verifyButton.addTarget(self, action: #selector(verifyTapped), for: .touchUpInside)
|
||||
segmentBackground.addSubview(storeButton)
|
||||
segmentBackground.addSubview(verifyButton)
|
||||
|
||||
[leftPill, rightPill].forEach {
|
||||
$0.font = .systemFont(ofSize: AppMetrics.FontSize.caption)
|
||||
$0.numberOfLines = 2
|
||||
card.addSubview($0)
|
||||
}
|
||||
|
||||
segmentBackground.snp.makeConstraints { make in
|
||||
make.top.leading.trailing.equalToSuperview().inset(12)
|
||||
make.height.equalTo(40)
|
||||
}
|
||||
storeButton.snp.makeConstraints { make in
|
||||
make.leading.top.bottom.equalToSuperview().inset(2)
|
||||
make.width.equalToSuperview().multipliedBy(0.5)
|
||||
}
|
||||
verifyButton.snp.makeConstraints { make in
|
||||
make.trailing.top.bottom.equalToSuperview().inset(2)
|
||||
make.width.equalToSuperview().multipliedBy(0.5)
|
||||
}
|
||||
leftPill.snp.makeConstraints { make in
|
||||
make.leading.bottom.equalToSuperview().inset(12)
|
||||
make.top.equalTo(segmentBackground.snp.bottom).offset(10)
|
||||
make.width.equalToSuperview().multipliedBy(0.45)
|
||||
}
|
||||
rightPill.snp.makeConstraints { make in
|
||||
make.trailing.bottom.equalToSuperview().inset(12)
|
||||
make.top.equalTo(leftPill)
|
||||
make.width.equalToSuperview().multipliedBy(0.45)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
func configure(selectedEntry: OrdersEntry, scenicName: String, storeTotal: Int, writeOffTotal: Int, onSelectEntry: @escaping (OrdersEntry) -> Void) {
|
||||
self.onSelectEntry = onSelectEntry
|
||||
updateSegment(storeButton, title: "订单管理", selected: selectedEntry == .storeOrders)
|
||||
updateSegment(verifyButton, title: "核销订单", selected: selectedEntry == .verificationOrders)
|
||||
if selectedEntry == .storeOrders {
|
||||
leftPill.text = "当前景区\n\(scenicName)"
|
||||
rightPill.text = "订单总数\n\(storeTotal)"
|
||||
} else {
|
||||
leftPill.text = "核销订单\n\(writeOffTotal)"
|
||||
rightPill.text = "当前景区\n\(scenicName)"
|
||||
}
|
||||
}
|
||||
|
||||
private func updateSegment(_ button: UIButton, title: String, selected: Bool) {
|
||||
button.setTitle(title, for: .normal)
|
||||
button.titleLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline, weight: selected ? .semibold : .medium)
|
||||
button.setTitleColor(selected ? AppDesignUIKit.primary : AppDesignUIKit.textSecondary, for: .normal)
|
||||
button.backgroundColor = selected ? .white : .clear
|
||||
button.layer.cornerRadius = 6
|
||||
}
|
||||
|
||||
@objc private func storeTapped() { onSelectEntry?(.storeOrders) }
|
||||
@objc private func verifyTapped() { onSelectEntry?(.verificationOrders) }
|
||||
}
|
||||
|
||||
private final class OrdersFilterCell: UITableViewCell, UITextFieldDelegate {
|
||||
static let reuseID = "OrdersFilterCell"
|
||||
private let phoneField = UITextField()
|
||||
private var onPhoneChange: ((String) -> Void)?
|
||||
private var onSearch: (() -> Void)?
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
selectionStyle = .none
|
||||
backgroundColor = .clear
|
||||
let card = UIView()
|
||||
card.backgroundColor = .white
|
||||
card.layer.cornerRadius = 8
|
||||
contentView.addSubview(card)
|
||||
card.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 16, bottom: 4, right: 16))
|
||||
}
|
||||
phoneField.delegate = self
|
||||
phoneField.keyboardType = .phonePad
|
||||
phoneField.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline)
|
||||
phoneField.backgroundColor = UIColor(hex: 0xF4F4F4)
|
||||
phoneField.layer.cornerRadius = 6
|
||||
phoneField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 1))
|
||||
phoneField.leftViewMode = .always
|
||||
card.addSubview(phoneField)
|
||||
phoneField.snp.makeConstraints { make in
|
||||
make.leading.bottom.equalToSuperview().inset(12)
|
||||
make.height.equalTo(40)
|
||||
make.trailing.equalToSuperview().inset(100)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
func configure(statusTitle: String, phone: String, onStatus: @escaping () -> Void, onDate: @escaping () -> Void, onPhoneChange: @escaping (String) -> Void, onSearch: @escaping () -> Void) {
|
||||
phoneField.text = phone
|
||||
self.onPhoneChange = onPhoneChange
|
||||
self.onSearch = onSearch
|
||||
}
|
||||
|
||||
func textFieldDidChangeSelection(_ textField: UITextField) {
|
||||
onPhoneChange?(textField.text ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
private final class OrdersWriteOffActionCell: UITableViewCell, UITextFieldDelegate {
|
||||
static let reuseID = "OrdersWriteOffActionCell"
|
||||
private let manualField = UITextField()
|
||||
private var onManualChange: ((String) -> Void)?
|
||||
private var onVerify: (() -> Void)?
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
selectionStyle = .none
|
||||
backgroundColor = .clear
|
||||
let card = UIView()
|
||||
card.backgroundColor = .white
|
||||
card.layer.cornerRadius = 8
|
||||
contentView.addSubview(card)
|
||||
card.snp.makeConstraints { make in make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 16, bottom: 4, right: 16)) }
|
||||
|
||||
manualField.delegate = self
|
||||
manualField.placeholder = "手动输入订单号"
|
||||
manualField.autocorrectionType = .no
|
||||
manualField.autocapitalizationType = .none
|
||||
manualField.backgroundColor = UIColor(hex: 0xF4F4F4)
|
||||
manualField.layer.cornerRadius = 8
|
||||
manualField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 1))
|
||||
manualField.leftViewMode = .always
|
||||
card.addSubview(manualField)
|
||||
manualField.snp.makeConstraints { make in
|
||||
make.leading.bottom.equalToSuperview().inset(12)
|
||||
make.height.equalTo(42)
|
||||
make.trailing.equalToSuperview().inset(90)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
func configure(manualOrderNumber: String, isVerifying: Bool, hint: String?, onScan: @escaping () -> Void, onManualChange: @escaping (String) -> Void, onVerify: @escaping () -> Void) {
|
||||
manualField.text = manualOrderNumber
|
||||
self.onManualChange = onManualChange
|
||||
self.onVerify = onVerify
|
||||
}
|
||||
|
||||
func textFieldDidChangeSelection(_ textField: UITextField) {
|
||||
onManualChange?(textField.text ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
private final class OrderEntityCell: UITableViewCell {
|
||||
static let reuseID = "OrderEntityCell"
|
||||
private let titleLabel = UILabel()
|
||||
private let statusLabel = UILabel()
|
||||
private let amountLabel = UILabel()
|
||||
private let phoneLabel = UILabel()
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
selectionStyle = .default
|
||||
backgroundColor = .clear
|
||||
let card = UIView()
|
||||
card.backgroundColor = .white
|
||||
card.layer.cornerRadius = 8
|
||||
contentView.addSubview(card)
|
||||
card.snp.makeConstraints { make in make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 16, bottom: 4, right: 16)) }
|
||||
|
||||
titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline, weight: .semibold)
|
||||
statusLabel.font = .systemFont(ofSize: AppMetrics.FontSize.caption, weight: .medium)
|
||||
amountLabel.font = .systemFont(ofSize: AppMetrics.FontSize.body, weight: .semibold)
|
||||
amountLabel.textColor = AppDesignUIKit.primary
|
||||
phoneLabel.font = .systemFont(ofSize: AppMetrics.FontSize.caption)
|
||||
phoneLabel.textColor = AppDesignUIKit.textSecondary
|
||||
|
||||
let stack = UIStackView(arrangedSubviews: [titleLabel, phoneLabel, amountLabel])
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 4
|
||||
card.addSubview(stack)
|
||||
card.addSubview(statusLabel)
|
||||
stack.snp.makeConstraints { make in make.leading.top.bottom.equalToSuperview().inset(12) }
|
||||
statusLabel.snp.makeConstraints { make in make.trailing.top.equalToSuperview().inset(12) }
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
func configure(item: OrderEntity) {
|
||||
titleLabel.text = item.orderNumber
|
||||
statusLabel.text = item.orderStatusName
|
||||
amountLabel.text = "¥\(item.actualPayAmount.isEmpty ? item.orderAmount : item.actualPayAmount)"
|
||||
phoneLabel.text = item.phone
|
||||
}
|
||||
}
|
||||
|
||||
private final class WriteOffOrderCell: UITableViewCell {
|
||||
static let reuseID = "WriteOffOrderCell"
|
||||
private let titleLabel = UILabel()
|
||||
private let subtitleLabel = UILabel()
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
backgroundColor = .clear
|
||||
let card = UIView()
|
||||
card.backgroundColor = .white
|
||||
card.layer.cornerRadius = 8
|
||||
contentView.addSubview(card)
|
||||
card.snp.makeConstraints { make in make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 16, bottom: 4, right: 16)) }
|
||||
titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline, weight: .semibold)
|
||||
subtitleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.caption)
|
||||
subtitleLabel.textColor = AppDesignUIKit.textSecondary
|
||||
let stack = UIStackView(arrangedSubviews: [titleLabel, subtitleLabel])
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 4
|
||||
card.addSubview(stack)
|
||||
stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(12) }
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
func configure(item: WriteOffOrderItem, isVerifying: Bool) {
|
||||
titleLabel.text = item.orderNumber
|
||||
subtitleLabel.text = isVerifying ? "核销中..." : (item.projectName.isEmpty ? item.userPhone : item.projectName)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,75 @@
|
||||
//
|
||||
// OrderDetailViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
/// 订单详情 ViewModel,负责加载门店订单详情并保留列表摘要兜底展示。
|
||||
final class OrderDetailViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
private(set) var detail: StoreOrderDetailResponse? { didSet { onChange?() } }
|
||||
private(set) var loading = false { didSet { onChange?() } }
|
||||
private(set) var errorMessage: String? { didSet { onChange?() } }
|
||||
private(set) var contextMessage: String? { didSet { onChange?() } }
|
||||
|
||||
/// 当前页面用于展示的订单信息,详情接口成功时优先使用详情数据。
|
||||
var display: StoreOrderDetailDisplay {
|
||||
if let detail {
|
||||
return StoreOrderDetailDisplay(detail: detail)
|
||||
}
|
||||
return StoreOrderDetailDisplay(item: item)
|
||||
}
|
||||
|
||||
/// 详情中的项目配置。
|
||||
var projectInfo: StoreOrderProjectInfo? {
|
||||
detail?.multiTravel?.projectInfo
|
||||
}
|
||||
|
||||
/// 详情中的拍摄点列表。
|
||||
var shootingList: [StoreOrderShootingListItem] {
|
||||
detail?.multiTravel?.shootingList ?? []
|
||||
}
|
||||
|
||||
private let item: OrderEntity
|
||||
|
||||
/// 初始化详情 ViewModel,并注入列表订单作为兜底数据。
|
||||
init(item: OrderEntity) {
|
||||
self.item = item
|
||||
}
|
||||
|
||||
/// 加载门店订单详情,缺少门店 ID 时只保留列表摘要展示。
|
||||
func load(api: OrderServing, fallbackStoreId: Int?, forceReload: Bool = false) async {
|
||||
guard !loading else { return }
|
||||
if forceReload {
|
||||
detail = nil
|
||||
} else {
|
||||
guard detail == nil else { return }
|
||||
}
|
||||
|
||||
guard let storeId = item.storeId ?? fallbackStoreId, storeId > 0 else {
|
||||
contextMessage = "缺少门店上下文,当前展示订单摘要。"
|
||||
return
|
||||
}
|
||||
|
||||
loading = true
|
||||
errorMessage = nil
|
||||
contextMessage = nil
|
||||
defer { loading = false }
|
||||
|
||||
do {
|
||||
detail = try await api.storeOrderDetail(storeId: storeId, orderNumber: item.orderNumber)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
contextMessage = "详情加载失败,当前展示订单摘要。"
|
||||
}
|
||||
}
|
||||
|
||||
/// 清理当前错误提示。
|
||||
func clearError() {
|
||||
errorMessage = nil
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,608 @@
|
||||
//
|
||||
// OrderLongTailViewModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
/// 押金订单列表 ViewModel,负责分页、核销、退款和操作后刷新。
|
||||
final class DepositOrderListViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
private(set) var orders: [DepositOrderListItem] = [] { didSet { onChange?() } }
|
||||
private(set) var total = 0 { didSet { onChange?() } }
|
||||
private(set) var page = 1 { didSet { onChange?() } }
|
||||
private(set) var hasMore = false { didSet { onChange?() } }
|
||||
private(set) var loading = false { didSet { onChange?() } }
|
||||
private(set) var loadingMore = false { didSet { onChange?() } }
|
||||
private(set) var operatingOrderNumber: String? { didSet { onChange?() } }
|
||||
var errorMessage: String? { didSet { onChange?() } }
|
||||
|
||||
private let pageSize = 10
|
||||
|
||||
/// 加载押金订单列表,缺少景区时清空旧数据且不请求接口。
|
||||
func reload(api: OrderServing, scenicId: Int?, reset: Bool) async {
|
||||
guard let scenicId, scenicId > 0 else {
|
||||
resetState()
|
||||
return
|
||||
}
|
||||
|
||||
if reset {
|
||||
page = 1
|
||||
total = 0
|
||||
orders = []
|
||||
loading = true
|
||||
} else {
|
||||
guard !loadingMore, hasMore else { return }
|
||||
loadingMore = true
|
||||
}
|
||||
|
||||
defer {
|
||||
loading = false
|
||||
loadingMore = false
|
||||
}
|
||||
|
||||
do {
|
||||
let result = try await api.depositOrderList(scenicId: scenicId, page: page, pageSize: pageSize)
|
||||
total = result.total
|
||||
if page == 1 {
|
||||
orders = result.list
|
||||
} else {
|
||||
orders.append(contentsOf: result.list)
|
||||
}
|
||||
page += 1
|
||||
hasMore = orders.count < result.total && !result.list.isEmpty
|
||||
errorMessage = nil
|
||||
} catch {
|
||||
if reset {
|
||||
orders = []
|
||||
total = 0
|
||||
page = 1
|
||||
hasMore = false
|
||||
}
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 核销押金订单,成功后刷新第一页。
|
||||
func writeOff(api: OrderServing, scenicId: Int?, orderNumber: String) async -> Bool {
|
||||
let normalized = orderNumber.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalized.isEmpty, operatingOrderNumber == nil else { return false }
|
||||
guard let scenicId, scenicId > 0 else {
|
||||
errorMessage = "缺少景区上下文"
|
||||
return false
|
||||
}
|
||||
|
||||
operatingOrderNumber = normalized
|
||||
defer { operatingOrderNumber = nil }
|
||||
|
||||
do {
|
||||
try await api.depositOrderWriteOff(orderNumber: normalized)
|
||||
await reload(api: api, scenicId: scenicId, reset: true)
|
||||
return true
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// 申请押金订单退款,原因必填,成功后刷新第一页。
|
||||
func refund(api: OrderServing, scenicId: Int?, orderNumber: String, reason: String) async -> Bool {
|
||||
let normalizedOrderNumber = orderNumber.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let normalizedReason = reason.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedOrderNumber.isEmpty, operatingOrderNumber == nil else { return false }
|
||||
guard !normalizedReason.isEmpty else {
|
||||
errorMessage = "请填写退款原因"
|
||||
return false
|
||||
}
|
||||
guard let scenicId, scenicId > 0 else {
|
||||
errorMessage = "缺少景区上下文"
|
||||
return false
|
||||
}
|
||||
|
||||
operatingOrderNumber = normalizedOrderNumber
|
||||
defer { operatingOrderNumber = nil }
|
||||
|
||||
do {
|
||||
try await api.depositOrderRefund(orderNumber: normalizedOrderNumber, refundReason: normalizedReason)
|
||||
await reload(api: api, scenicId: scenicId, reset: true)
|
||||
return true
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// 清理错误文案。
|
||||
func clearError() {
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
private func resetState() {
|
||||
orders = []
|
||||
total = 0
|
||||
page = 1
|
||||
hasMore = false
|
||||
loading = false
|
||||
loadingMore = false
|
||||
operatingOrderNumber = nil
|
||||
errorMessage = nil
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 押金订单详情 ViewModel,负责按门店和订单号加载详情。
|
||||
final class DepositOrderDetailViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
private(set) var detail: StoreOrderDetailResponse? { didSet { onChange?() } }
|
||||
private(set) var loading = false { didSet { onChange?() } }
|
||||
var errorMessage: String? { didSet { onChange?() } }
|
||||
|
||||
/// 加载押金订单详情,缺少门店或订单号时不请求接口。
|
||||
func load(api: OrderServing, storeId: Int?, orderNumber: String) async {
|
||||
let normalized = orderNumber.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let storeId, storeId > 0 else {
|
||||
reset(message: "当前账号缺少门店信息")
|
||||
return
|
||||
}
|
||||
guard !normalized.isEmpty else {
|
||||
reset(message: "订单号不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
detail = nil
|
||||
loading = true
|
||||
defer { loading = false }
|
||||
|
||||
do {
|
||||
detail = try await api.storeOrderDetail(storeId: storeId, orderNumber: normalized)
|
||||
errorMessage = nil
|
||||
} catch {
|
||||
detail = nil
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 清理错误文案。
|
||||
func clearError() {
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
private func reset(message: String) {
|
||||
detail = nil
|
||||
loading = false
|
||||
errorMessage = message
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 押金拍摄信息 ViewModel,负责加载单个打卡点的媒体和评分。
|
||||
final class DepositOrderShootingInfoViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
private(set) var detail: StoreOrderShootingDetailResponse? { didSet { onChange?() } }
|
||||
private(set) var loading = false { didSet { onChange?() } }
|
||||
var errorMessage: String? { didSet { onChange?() } }
|
||||
|
||||
/// 加载押金订单拍摄信息,缺少门店或订单号时不请求接口。
|
||||
func load(api: OrderServing, storeId: Int?, orderNumber: String, scenicSpotId: Int, photogUid: Int) async {
|
||||
let normalized = orderNumber.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let storeId, storeId > 0 else {
|
||||
reset(message: "当前账号缺少门店信息")
|
||||
return
|
||||
}
|
||||
guard !normalized.isEmpty else {
|
||||
reset(message: "订单号不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
detail = nil
|
||||
loading = true
|
||||
defer { loading = false }
|
||||
|
||||
do {
|
||||
detail = try await api.storeOrderShootingDetail(
|
||||
storeId: storeId,
|
||||
orderNumber: normalized,
|
||||
scenicSpotId: scenicSpotId,
|
||||
photogUid: photogUid
|
||||
)
|
||||
errorMessage = nil
|
||||
} catch {
|
||||
detail = nil
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 清理错误文案。
|
||||
func clearError() {
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
private func reset(message: String) {
|
||||
detail = nil
|
||||
loading = false
|
||||
errorMessage = message
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 普通订单退款 ViewModel,负责退款入口判断、金额校验和提交保护。
|
||||
final class OrderRefundViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
var mode: OrderRefundMode = .full { didSet { onChange?() } }
|
||||
var amount = "" { didSet { onChange?() } }
|
||||
var reason = "" { didSet { onChange?() } }
|
||||
private(set) var submitting = false { didSet { onChange?() } }
|
||||
var errorMessage: String? { didSet { onChange?() } }
|
||||
|
||||
/// 判断订单是否允许展示普通退款入口。
|
||||
func canRefund(_ item: OrderEntity) -> Bool {
|
||||
(item.orderStatus == 18 || item.orderStatus == 30) &&
|
||||
item.orderType != 19 &&
|
||||
availableAmount(for: item) > 0
|
||||
}
|
||||
|
||||
/// 打开退款表单时初始化默认值。
|
||||
func begin(item: OrderEntity) {
|
||||
mode = .full
|
||||
amount = availableAmountText(for: item)
|
||||
reason = ""
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
/// 提交普通订单退款。
|
||||
func submit(api: OrderServing, item: OrderEntity) async -> Bool {
|
||||
guard !submitting else { return false }
|
||||
guard canRefund(item) else {
|
||||
errorMessage = "当前订单不可退款"
|
||||
return false
|
||||
}
|
||||
let normalizedReason = reason.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedReason.isEmpty else {
|
||||
errorMessage = "请输入退款原因"
|
||||
return false
|
||||
}
|
||||
|
||||
let refundAmount: String
|
||||
switch mode {
|
||||
case .full:
|
||||
refundAmount = availableAmountText(for: item)
|
||||
case .partial:
|
||||
guard let normalized = Self.normalizedMoney(amount) else {
|
||||
errorMessage = "请输入有效的退款金额"
|
||||
return false
|
||||
}
|
||||
guard (Decimal(string: normalized) ?? 0) <= availableAmount(for: item) else {
|
||||
errorMessage = "退款金额不能大于可退金额"
|
||||
return false
|
||||
}
|
||||
refundAmount = normalized
|
||||
}
|
||||
|
||||
submitting = true
|
||||
errorMessage = nil
|
||||
defer { submitting = false }
|
||||
|
||||
do {
|
||||
try await api.orderRefund(
|
||||
orderNumber: item.orderNumber,
|
||||
refundType: mode,
|
||||
refundAmount: refundAmount,
|
||||
refundReason: normalizedReason
|
||||
)
|
||||
return true
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// 计算普通订单可退金额。
|
||||
func availableAmount(for item: OrderEntity) -> Decimal {
|
||||
let paid = Self.decimalValue(item.actualPayAmount)
|
||||
if paid > 0 { return paid }
|
||||
return Self.decimalValue(item.actualRefundAmount.isEmpty ? item.refundAmount : item.actualRefundAmount)
|
||||
}
|
||||
|
||||
/// 返回可退金额展示和提交文案。
|
||||
func availableAmountText(for item: OrderEntity) -> String {
|
||||
Self.moneyText(availableAmount(for: item))
|
||||
}
|
||||
|
||||
/// 将输入金额规范成两位小数,空值、非法值和超过两位小数返回 nil。
|
||||
static func normalizedMoney(_ rawValue: String) -> String? {
|
||||
let text = rawValue.trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: ",", with: "")
|
||||
guard !text.isEmpty else { return nil }
|
||||
let pattern = #"^\d+(\.\d{1,2})?$"#
|
||||
guard text.range(of: pattern, options: .regularExpression) != nil,
|
||||
let value = Decimal(string: text),
|
||||
value > 0 else {
|
||||
return nil
|
||||
}
|
||||
return moneyText(value)
|
||||
}
|
||||
|
||||
private static func decimalValue(_ text: String) -> Decimal {
|
||||
Decimal(string: text.trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: ",", with: "")) ?? 0
|
||||
}
|
||||
|
||||
private static func moneyText(_ value: Decimal) -> String {
|
||||
let number = NSDecimalNumber(decimal: value)
|
||||
let handler = NSDecimalNumberHandler(
|
||||
roundingMode: .plain,
|
||||
scale: 2,
|
||||
raiseOnExactness: false,
|
||||
raiseOnOverflow: false,
|
||||
raiseOnUnderflow: false,
|
||||
raiseOnDivideByZero: false
|
||||
)
|
||||
return String(format: "%.2f", number.rounding(accordingToBehavior: handler).doubleValue)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 历史拍摄 ViewModel,负责按订单号加载多点位历史拍摄媒体。
|
||||
final class HistoricalShootingInfoViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
private(set) var projectName = "" { didSet { onChange?() } }
|
||||
private(set) var projectTypeName = "" { didSet { onChange?() } }
|
||||
private(set) var spots: [PhotogSpotItem] = [] { didSet { onChange?() } }
|
||||
private(set) var loading = false { didSet { onChange?() } }
|
||||
var errorMessage: String? { didSet { onChange?() } }
|
||||
|
||||
/// 加载历史拍摄信息,订单号为空时清空旧数据且不请求接口。
|
||||
func load(api: OrderServing, orderNumber: String) async {
|
||||
let normalized = orderNumber.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalized.isEmpty else {
|
||||
reset(message: "订单号不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
loading = true
|
||||
defer { loading = false }
|
||||
|
||||
do {
|
||||
let result = try await api.multiTravelShootHistory(orderNumber: normalized)
|
||||
projectName = result.projectName
|
||||
projectTypeName = result.projectTypeName
|
||||
spots = result.photogSpotList
|
||||
errorMessage = nil
|
||||
} catch {
|
||||
reset(message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
/// 清理错误文案。
|
||||
func clearError() {
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
private func reset(message: String) {
|
||||
projectName = ""
|
||||
projectTypeName = ""
|
||||
spots = []
|
||||
loading = false
|
||||
errorMessage = message
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 多点旅拍任务上传 ViewModel,负责订单号、打卡点、云盘附件、本地附件和素材提交。
|
||||
final class MultiTravelTaskUploadViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
var orderNumber = "" { didSet { onChange?() } }
|
||||
var selectedSpotId: Int? { didSet { onChange?() } }
|
||||
private(set) var spots: [MultiTravelVerifiedScenicSpotItem] = [] { didSet { onChange?() } }
|
||||
var selectedCloudFiles: [TaskCloudSelectionItem] = [] { didSet { onChange?() } }
|
||||
var selectedLocalFiles: [TaskLocalUploadItem] = [] { didSet { onChange?() } }
|
||||
private(set) var isLoadingSpots = false { didSet { onChange?() } }
|
||||
private(set) var isSubmitting = false { didSet { onChange?() } }
|
||||
private(set) var didSubmitSuccessfully = false { didSet { onChange?() } }
|
||||
var errorMessage: String? { didSet { onChange?() } }
|
||||
private var loadedSpotOrderNumber = ""
|
||||
|
||||
/// 当前选中打卡点展示名。
|
||||
var selectedSpotName: String {
|
||||
spots.first(where: { $0.id == selectedSpotId })?.name ?? "请选择打卡点"
|
||||
}
|
||||
|
||||
/// 判断提交按钮是否满足基础启用条件。
|
||||
var canSubmit: Bool {
|
||||
!isSubmitting &&
|
||||
selectedSpotId != nil &&
|
||||
(!selectedCloudFiles.isEmpty || !selectedLocalFiles.isEmpty) &&
|
||||
!selectedLocalFiles.contains(where: \.isUploading)
|
||||
}
|
||||
|
||||
/// 初始化任务上传表单订单号。
|
||||
init(initialOrderNumber: String = "") {
|
||||
orderNumber = initialOrderNumber
|
||||
}
|
||||
|
||||
/// 加载当前订单已核销打卡点,订单号为空时不请求接口。
|
||||
func loadSpots(api: OrderServing) async {
|
||||
let normalized = orderNumber.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalized.isEmpty else {
|
||||
resetSpotSelection()
|
||||
return
|
||||
}
|
||||
|
||||
isLoadingSpots = true
|
||||
defer { isLoadingSpots = false }
|
||||
|
||||
do {
|
||||
let result = try await api.multiTravelVerifiedScenicSpotList(orderNumber: normalized)
|
||||
spots = result
|
||||
loadedSpotOrderNumber = normalized
|
||||
if !spots.contains(where: { $0.id == selectedSpotId }) {
|
||||
selectedSpotId = spots.first?.id
|
||||
}
|
||||
errorMessage = nil
|
||||
} catch {
|
||||
resetSpotSelection()
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 切换选中的打卡点。
|
||||
func selectSpot(id: Int?) {
|
||||
selectedSpotId = id
|
||||
}
|
||||
|
||||
/// 合并云盘附件,按文件 ID 去重。
|
||||
func mergeCloudFiles(_ files: [TaskCloudSelectionItem]) {
|
||||
var next = selectedCloudFiles
|
||||
for file in files where !next.contains(where: { $0.id == file.id }) {
|
||||
next.append(file)
|
||||
}
|
||||
selectedCloudFiles = next
|
||||
}
|
||||
|
||||
/// 移除指定云盘附件。
|
||||
func removeCloudFile(id: Int) {
|
||||
selectedCloudFiles.removeAll { $0.id == id }
|
||||
}
|
||||
|
||||
/// 添加本地图片或视频附件。
|
||||
func addLocalFile(data: Data, fileName: String) {
|
||||
selectedLocalFiles.append(
|
||||
TaskLocalUploadItem(
|
||||
id: UUID(),
|
||||
data: data,
|
||||
fileName: fileName,
|
||||
fileType: Self.fileType(for: fileName),
|
||||
remark: "",
|
||||
uploadedURL: nil,
|
||||
progress: 0,
|
||||
errorMessage: nil
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 移除指定本地附件。
|
||||
func removeLocalFile(id: UUID) {
|
||||
selectedLocalFiles.removeAll { $0.id == id }
|
||||
}
|
||||
|
||||
/// 提交多点旅拍任务素材;本地附件先上传 OSS,再提交最终 URL。
|
||||
@discardableResult
|
||||
func submit(api: OrderServing, uploadService: any OSSUploadServing, scenicId: Int?) async -> Bool {
|
||||
guard !isSubmitting else { return false }
|
||||
guard let requestContext = validateBeforeSubmit(scenicId: scenicId) else { return false }
|
||||
|
||||
isSubmitting = true
|
||||
didSubmitSuccessfully = false
|
||||
errorMessage = nil
|
||||
defer { isSubmitting = false }
|
||||
|
||||
do {
|
||||
let uploadFiles = try await uploadLocalFiles(uploadService: uploadService, scenicId: requestContext.scenicId)
|
||||
try await api.multiTravelUploadMaterial(
|
||||
MultiTravelUploadMaterialRequest(
|
||||
orderNumber: requestContext.orderNumber,
|
||||
scenicSpotId: requestContext.scenicSpotId,
|
||||
cloudFile: selectedCloudFiles.map { MultiTravelCloudFileItem(fileId: $0.id) },
|
||||
uploadFile: uploadFiles
|
||||
)
|
||||
)
|
||||
didSubmitSuccessfully = true
|
||||
return true
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// 清理错误文案。
|
||||
func clearError() {
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
private func validateBeforeSubmit(scenicId: Int?) -> SubmitContext? {
|
||||
let normalizedOrderNumber = orderNumber.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let scenicId, scenicId > 0 else {
|
||||
errorMessage = "请先选择景区"
|
||||
return nil
|
||||
}
|
||||
guard !normalizedOrderNumber.isEmpty else {
|
||||
errorMessage = "请先填写订单号"
|
||||
return nil
|
||||
}
|
||||
guard loadedSpotOrderNumber == normalizedOrderNumber else {
|
||||
errorMessage = "请先刷新打卡点"
|
||||
return nil
|
||||
}
|
||||
guard let selectedSpotId else {
|
||||
errorMessage = "请选择打卡点"
|
||||
return nil
|
||||
}
|
||||
guard !selectedCloudFiles.isEmpty || !selectedLocalFiles.isEmpty else {
|
||||
errorMessage = "请至少选择一个素材文件"
|
||||
return nil
|
||||
}
|
||||
guard !selectedLocalFiles.contains(where: \.isUploading) else {
|
||||
errorMessage = "文件上传中,请稍后提交"
|
||||
return nil
|
||||
}
|
||||
return SubmitContext(scenicId: scenicId, orderNumber: normalizedOrderNumber, scenicSpotId: selectedSpotId)
|
||||
}
|
||||
|
||||
private func uploadLocalFiles(uploadService: any OSSUploadServing, scenicId: Int) async throws -> [MultiTravelUploadFileItem] {
|
||||
var uploadFiles: [MultiTravelUploadFileItem] = []
|
||||
for index in selectedLocalFiles.indices {
|
||||
if let uploadedURL = selectedLocalFiles[index].uploadedURL {
|
||||
uploadFiles.append(MultiTravelUploadFileItem(fileName: selectedLocalFiles[index].fileName, fileUrl: uploadedURL))
|
||||
continue
|
||||
}
|
||||
|
||||
selectedLocalFiles[index].errorMessage = nil
|
||||
let fileID = selectedLocalFiles[index].id
|
||||
do {
|
||||
let url = try await uploadService.uploadTaskFile(
|
||||
data: selectedLocalFiles[index].data,
|
||||
fileName: selectedLocalFiles[index].fileName,
|
||||
fileType: selectedLocalFiles[index].fileType,
|
||||
scenicId: scenicId,
|
||||
onProgress: { [weak self] progress in
|
||||
Task { @MainActor in
|
||||
self?.updateLocalFileProgress(id: fileID, progress: progress)
|
||||
}
|
||||
}
|
||||
)
|
||||
selectedLocalFiles[index].uploadedURL = url
|
||||
selectedLocalFiles[index].progress = 100
|
||||
uploadFiles.append(MultiTravelUploadFileItem(fileName: selectedLocalFiles[index].fileName, fileUrl: url))
|
||||
} catch {
|
||||
selectedLocalFiles[index].progress = 0
|
||||
selectedLocalFiles[index].errorMessage = error.localizedDescription
|
||||
throw error
|
||||
}
|
||||
}
|
||||
return uploadFiles
|
||||
}
|
||||
|
||||
private func updateLocalFileProgress(id: UUID, progress: Int) {
|
||||
guard let index = selectedLocalFiles.firstIndex(where: { $0.id == id }) else { return }
|
||||
selectedLocalFiles[index].progress = progress
|
||||
}
|
||||
|
||||
private func resetSpotSelection() {
|
||||
spots = []
|
||||
selectedSpotId = nil
|
||||
loadedSpotOrderNumber = ""
|
||||
isLoadingSpots = false
|
||||
}
|
||||
|
||||
private static func fileType(for fileName: String) -> Int {
|
||||
let ext = URL(fileURLWithPath: fileName).pathExtension.lowercased()
|
||||
return ["mp4", "mov", "m4v", "avi"].contains(ext) ? 1 : 2
|
||||
}
|
||||
|
||||
private struct SubmitContext {
|
||||
let scenicId: Int
|
||||
let orderNumber: String
|
||||
let scenicSpotId: Int
|
||||
}
|
||||
}
|
||||
213
suixinkan_ios/Features/Orders/ViewModels/OrdersViewModel.swift
Normal file
213
suixinkan_ios/Features/Orders/ViewModels/OrdersViewModel.swift
Normal file
@ -0,0 +1,213 @@
|
||||
//
|
||||
// OrdersViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
/// 订单页面 ViewModel,管理订单列表、核销列表、筛选条件和手动核销流程。
|
||||
final class OrdersViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
var selectedEntry: OrdersEntry = .storeOrders { didSet { onChange?() } }
|
||||
var selectedStatus = 0 { didSet { onChange?() } }
|
||||
var searchPhone = "" { didSet { onChange?() } }
|
||||
var filterStartDate: Date? { didSet { onChange?() } }
|
||||
var filterEndDate: Date? { didSet { onChange?() } }
|
||||
private(set) var loading = false { didSet { onChange?() } }
|
||||
private(set) var loadingMore = false { didSet { onChange?() } }
|
||||
private(set) var isVerifying = false { didSet { onChange?() } }
|
||||
private(set) var currentVerifyingOrderNumber: String? { didSet { onChange?() } }
|
||||
|
||||
private(set) var storeOrders: [OrderEntity] = [] { didSet { onChange?() } }
|
||||
private(set) var storeTotal = 0 { didSet { onChange?() } }
|
||||
private(set) var storePage = 1 { didSet { onChange?() } }
|
||||
private(set) var storeHasMore = false { didSet { onChange?() } }
|
||||
|
||||
private(set) var writeOffOrders: [WriteOffOrderItem] = [] { didSet { onChange?() } }
|
||||
private(set) var writeOffTotal = 0 { didSet { onChange?() } }
|
||||
private(set) var writeOffPage = 1 { didSet { onChange?() } }
|
||||
private(set) var writeOffHasMore = false { didSet { onChange?() } }
|
||||
|
||||
/// 返回去除首尾空白后的手机号搜索值。
|
||||
var normalizedPhone: String? {
|
||||
let text = searchPhone.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return text.isEmpty ? nil : text
|
||||
}
|
||||
|
||||
/// 根据当前子入口刷新对应列表。
|
||||
func reload(api: OrderServing, scenicId: Int?, storeId: Int? = nil, roleId: Int?, showLoading: Bool = true) async throws {
|
||||
switch selectedEntry {
|
||||
case .storeOrders:
|
||||
try await reloadStoreOrders(api: api, scenicId: scenicId, roleId: roleId, showLoading: showLoading)
|
||||
case .verificationOrders:
|
||||
try await reloadWriteOffOrders(api: api, scenicId: scenicId, storeId: storeId, showLoading: showLoading)
|
||||
}
|
||||
}
|
||||
|
||||
/// 刷新订单管理列表,缺少景区时清空旧数据。
|
||||
func reloadStoreOrders(api: OrderServing, scenicId: Int?, roleId: Int?, showLoading: Bool = true) async throws {
|
||||
guard let scenicId else {
|
||||
storeOrders = []
|
||||
storeTotal = 0
|
||||
storePage = 1
|
||||
storeHasMore = false
|
||||
return
|
||||
}
|
||||
|
||||
if showLoading { loading = true }
|
||||
defer { if showLoading { loading = false } }
|
||||
|
||||
let result = try await api.orderList(
|
||||
scenicId: scenicId,
|
||||
page: 1,
|
||||
pageSize: pageSize,
|
||||
orderStatus: orderStatusQuery,
|
||||
userPhone: normalizedPhone,
|
||||
startTime: startTimeQuery,
|
||||
endTime: endTimeQuery,
|
||||
isRefined: refinedQuery,
|
||||
isScenicAdmin: roleId == 53
|
||||
)
|
||||
storeOrders = result.list
|
||||
storeTotal = result.total
|
||||
storePage = 1
|
||||
storeHasMore = storeOrders.count < result.total && !result.list.isEmpty
|
||||
}
|
||||
|
||||
/// 加载订单管理下一页。
|
||||
func loadMoreStoreOrders(api: OrderServing, scenicId: Int?, roleId: Int?) async throws {
|
||||
guard !loadingMore, storeHasMore, let scenicId else { return }
|
||||
loadingMore = true
|
||||
defer { loadingMore = false }
|
||||
|
||||
let nextPage = storePage + 1
|
||||
let result = try await api.orderList(
|
||||
scenicId: scenicId,
|
||||
page: nextPage,
|
||||
pageSize: pageSize,
|
||||
orderStatus: orderStatusQuery,
|
||||
userPhone: normalizedPhone,
|
||||
startTime: startTimeQuery,
|
||||
endTime: endTimeQuery,
|
||||
isRefined: refinedQuery,
|
||||
isScenicAdmin: roleId == 53
|
||||
)
|
||||
storeOrders.append(contentsOf: result.list)
|
||||
storeTotal = result.total
|
||||
storePage = nextPage
|
||||
storeHasMore = storeOrders.count < result.total && !result.list.isEmpty
|
||||
}
|
||||
|
||||
/// 刷新核销订单列表,缺少景区时清空旧数据。
|
||||
func reloadWriteOffOrders(api: OrderServing, scenicId: Int?, storeId: Int? = nil, showLoading: Bool = true) async throws {
|
||||
guard let scenicId else {
|
||||
writeOffOrders = []
|
||||
writeOffTotal = 0
|
||||
writeOffPage = 1
|
||||
writeOffHasMore = false
|
||||
return
|
||||
}
|
||||
|
||||
if showLoading { loading = true }
|
||||
defer { if showLoading { loading = false } }
|
||||
|
||||
let result = try await api.writeOffList(scenicId: scenicId, storeId: storeId, page: 1, pageSize: pageSize)
|
||||
writeOffOrders = result.list
|
||||
writeOffTotal = result.total
|
||||
writeOffPage = 1
|
||||
writeOffHasMore = writeOffOrders.count < result.total && !result.list.isEmpty
|
||||
}
|
||||
|
||||
/// 加载核销订单下一页。
|
||||
func loadMoreWriteOffOrders(api: OrderServing, scenicId: Int?, storeId: Int? = nil) async throws {
|
||||
guard !loadingMore, writeOffHasMore, let scenicId else { return }
|
||||
loadingMore = true
|
||||
defer { loadingMore = false }
|
||||
|
||||
let nextPage = writeOffPage + 1
|
||||
let result = try await api.writeOffList(scenicId: scenicId, storeId: storeId, page: nextPage, pageSize: pageSize)
|
||||
writeOffOrders.append(contentsOf: result.list)
|
||||
writeOffTotal = result.total
|
||||
writeOffPage = nextPage
|
||||
writeOffHasMore = writeOffOrders.count < result.total && !result.list.isEmpty
|
||||
}
|
||||
|
||||
/// 手动核销订单,并在成功后刷新核销列表。
|
||||
func verify(api: OrderServing, scenicId: Int, storeId: Int? = nil, orderNumber: String) async throws {
|
||||
guard !isVerifying else { return }
|
||||
|
||||
isVerifying = true
|
||||
currentVerifyingOrderNumber = orderNumber
|
||||
defer {
|
||||
isVerifying = false
|
||||
currentVerifyingOrderNumber = nil
|
||||
}
|
||||
|
||||
try await api.writeOff(orderNumber: orderNumber)
|
||||
let refreshed = try await api.writeOffList(scenicId: scenicId, storeId: storeId, page: 1, pageSize: pageSize)
|
||||
writeOffOrders = refreshed.list
|
||||
writeOffTotal = refreshed.total
|
||||
writeOffPage = 1
|
||||
writeOffHasMore = writeOffOrders.count < refreshed.total && !refreshed.list.isEmpty
|
||||
}
|
||||
|
||||
/// 解析扫码结果并返回当前核销列表中的匹配订单。
|
||||
func matchedWriteOffOrder(for rawCode: String) -> (orderNumber: String, matched: WriteOffOrderItem?)? {
|
||||
guard let orderNumber = OrderNumberParser.parse(rawCode) else {
|
||||
return nil
|
||||
}
|
||||
let matched = writeOffOrders.first {
|
||||
$0.orderNumber.caseInsensitiveCompare(orderNumber) == .orderedSame
|
||||
}
|
||||
return (matched?.orderNumber ?? orderNumber, matched)
|
||||
}
|
||||
|
||||
/// 根据订单号查找当前核销列表中的订单。
|
||||
func writeOffOrder(matching orderNumber: String) -> WriteOffOrderItem? {
|
||||
writeOffOrders.first {
|
||||
$0.orderNumber.caseInsensitiveCompare(orderNumber) == .orderedSame
|
||||
}
|
||||
}
|
||||
|
||||
/// 用于测试重复提交场景的内部状态设置。
|
||||
func markVerifyingForTests(orderNumber: String) {
|
||||
isVerifying = true
|
||||
currentVerifyingOrderNumber = orderNumber
|
||||
}
|
||||
|
||||
private let pageSize = 20
|
||||
|
||||
private var orderStatusQuery: Int? {
|
||||
selectedStatus > 0 ? selectedStatus : nil
|
||||
}
|
||||
|
||||
private var startTimeQuery: String? {
|
||||
filterStartDate.map { Self.dayFormatter.string(from: $0) }
|
||||
}
|
||||
|
||||
private var endTimeQuery: String? {
|
||||
filterEndDate.map { Self.dayFormatter.string(from: $0) }
|
||||
}
|
||||
|
||||
private var refinedQuery: Int? {
|
||||
switch selectedStatus {
|
||||
case -1:
|
||||
return 1
|
||||
case -2:
|
||||
return 2
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private static let dayFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.calendar = Calendar(identifier: .gregorian)
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
return formatter
|
||||
}()
|
||||
}
|
||||
53
suixinkan_ios/Features/Payment/API/PaymentAPI.swift
Normal file
53
suixinkan_ios/Features/Payment/API/PaymentAPI.swift
Normal file
@ -0,0 +1,53 @@
|
||||
//
|
||||
// PaymentAPI.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 收款模块服务协议,定义收款码和收款记录接口能力。
|
||||
@MainActor
|
||||
protocol PaymentServing {
|
||||
/// 获取当前景区的静态收款码和动态收款码基础 URL。
|
||||
func payCode(scenicId: Int) async throws -> PayCodeResponse
|
||||
|
||||
/// 获取当前景区的扫码收款记录。
|
||||
func paymentCollectionRecords(scenicId: Int) async throws -> PaymentCollectionRecordResponse
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 收款 API,封装首页“收款”模块需要的网络请求。
|
||||
final class PaymentAPI {
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化收款 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
self.client = client
|
||||
}
|
||||
|
||||
/// 获取当前景区的静态收款码和动态收款码基础 URL。
|
||||
func payCode(scenicId: Int) async throws -> PayCodeResponse {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/yf-handset-app/photog/pay-code",
|
||||
queryItems: [URLQueryItem(name: "scenic_id", value: String(scenicId))]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取当前景区的扫码收款记录。
|
||||
func paymentCollectionRecords(scenicId: Int) async throws -> PaymentCollectionRecordResponse {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/yf-handset-app/photog/order/photo-scan-order-list",
|
||||
queryItems: [URLQueryItem(name: "scenic_id", value: String(scenicId))]
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension PaymentAPI: PaymentServing {}
|
||||
182
suixinkan_ios/Features/Payment/Models/PaymentModels.swift
Normal file
182
suixinkan_ios/Features/Payment/Models/PaymentModels.swift
Normal file
@ -0,0 +1,182 @@
|
||||
//
|
||||
// PaymentModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 收款码响应实体,表示静态收款码 URL 和动态收款码基础 URL。
|
||||
struct PayCodeResponse: Decodable, Equatable {
|
||||
let staticPayUrl: String
|
||||
let dynamicPayUrl: String
|
||||
|
||||
/// 收款码 JSON 字段映射。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case staticPayUrl = "static_pay_url"
|
||||
case dynamicPayUrl = "dynamic_pay_url"
|
||||
}
|
||||
|
||||
/// 创建收款码响应实体,主要用于测试替身。
|
||||
init(staticPayUrl: String = "", dynamicPayUrl: String = "") {
|
||||
self.staticPayUrl = staticPayUrl
|
||||
self.dynamicPayUrl = dynamicPayUrl
|
||||
}
|
||||
|
||||
/// 自定义解码,兼容后端把 URL 字段返回为数字或空值。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
staticPayUrl = try container.decodeLossyString(forKey: .staticPayUrl)
|
||||
dynamicPayUrl = try container.decodeLossyString(forKey: .dynamicPayUrl)
|
||||
}
|
||||
}
|
||||
|
||||
/// 收款记录响应实体,包含后端日汇总和原始记录列表。
|
||||
struct PaymentCollectionRecordResponse: Decodable, Equatable {
|
||||
let analyse: [PaymentCollectionRecordAnalyseItem]
|
||||
let list: [PaymentCollectionRecordItem]
|
||||
|
||||
/// 收款记录响应 JSON 字段映射。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case analyse
|
||||
case list
|
||||
}
|
||||
|
||||
/// 创建收款记录响应实体,主要用于测试替身。
|
||||
init(analyse: [PaymentCollectionRecordAnalyseItem] = [], list: [PaymentCollectionRecordItem] = []) {
|
||||
self.analyse = analyse
|
||||
self.list = list
|
||||
}
|
||||
|
||||
/// 自定义解码,兼容后端字段缺失。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
analyse = try container.decodeIfPresent([PaymentCollectionRecordAnalyseItem].self, forKey: .analyse) ?? []
|
||||
list = try container.decodeIfPresent([PaymentCollectionRecordItem].self, forKey: .list) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
/// 收款日汇总实体,表示某一天的收款笔数和金额。
|
||||
struct PaymentCollectionRecordAnalyseItem: Decodable, Equatable, Identifiable {
|
||||
let date: String
|
||||
let orderCount: Int
|
||||
let orderAmountSum: String
|
||||
|
||||
var id: String { date }
|
||||
|
||||
/// 收款日汇总 JSON 字段映射。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case date
|
||||
case orderCount = "order_count"
|
||||
case orderAmountSum = "order_amount_sum"
|
||||
}
|
||||
|
||||
/// 创建收款日汇总实体,主要用于列表兜底和测试。
|
||||
init(date: String, orderCount: Int, orderAmountSum: String) {
|
||||
self.date = date
|
||||
self.orderCount = orderCount
|
||||
self.orderAmountSum = orderAmountSum
|
||||
}
|
||||
|
||||
/// 自定义解码,兼容金额和数量字段类型不稳定。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
date = try container.decodeLossyString(forKey: .date)
|
||||
orderCount = try container.decodeLossyInt(forKey: .orderCount) ?? 0
|
||||
orderAmountSum = try container.decodeLossyString(forKey: .orderAmountSum)
|
||||
}
|
||||
}
|
||||
|
||||
/// 收款记录实体,表示一次扫码收款订单。
|
||||
struct PaymentCollectionRecordItem: Decodable, Equatable, Identifiable {
|
||||
let orderNumber: String
|
||||
let userPhone: String
|
||||
let orderAmount: String
|
||||
let createDate: String
|
||||
let createTime: String
|
||||
|
||||
var id: String {
|
||||
orderNumber.isEmpty ? "\(createDate)-\(createTime)-\(orderAmount)" : orderNumber
|
||||
}
|
||||
|
||||
/// 收款记录 JSON 字段映射。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case orderNumber = "order_number"
|
||||
case userPhone = "user_phone"
|
||||
case orderAmount = "order_amount"
|
||||
case createDate = "create_date"
|
||||
case createTime = "create_time"
|
||||
}
|
||||
|
||||
/// 创建收款记录实体,主要用于测试替身。
|
||||
init(orderNumber: String, userPhone: String, orderAmount: String, createDate: String, createTime: String) {
|
||||
self.orderNumber = orderNumber
|
||||
self.userPhone = userPhone
|
||||
self.orderAmount = orderAmount
|
||||
self.createDate = createDate
|
||||
self.createTime = createTime
|
||||
}
|
||||
|
||||
/// 自定义解码,兼容订单号、手机号和金额字段类型不稳定。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
orderNumber = try container.decodeLossyString(forKey: .orderNumber)
|
||||
userPhone = try container.decodeLossyString(forKey: .userPhone)
|
||||
orderAmount = try container.decodeLossyString(forKey: .orderAmount)
|
||||
createDate = try container.decodeLossyString(forKey: .createDate)
|
||||
createTime = try container.decodeLossyString(forKey: .createTime)
|
||||
}
|
||||
}
|
||||
|
||||
/// 收款记录分组实体,表示一个日期下的汇总和明细。
|
||||
struct PaymentCollectionRecordGroup: Equatable, Identifiable {
|
||||
let analyse: PaymentCollectionRecordAnalyseItem
|
||||
let items: [PaymentCollectionRecordItem]
|
||||
|
||||
var id: String { analyse.id }
|
||||
}
|
||||
|
||||
/// 收款状态实体,表示当前动态收款轮询结果。
|
||||
enum PaymentCollectionStatus: Equatable {
|
||||
case idle
|
||||
case waiting
|
||||
case success(PaymentCollectionRecordItem)
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
private extension KeyedDecodingContainer {
|
||||
/// 将字符串、数字或空值宽松解码为字符串。
|
||||
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 value.truncatingRemainder(dividingBy: 1) == 0 ? String(Int(value)) : String(value)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
/// 将字符串或数字宽松解码为整数。
|
||||
func decodeLossyInt(forKey key: Key) throws -> Int? {
|
||||
if let value = try decodeIfPresent(Int.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try decodeIfPresent(String.self, forKey: key) {
|
||||
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let intValue = Int(text) {
|
||||
return intValue
|
||||
}
|
||||
if let doubleValue = Double(text) {
|
||||
return Int(doubleValue)
|
||||
}
|
||||
}
|
||||
if let value = try decodeIfPresent(Double.self, forKey: key) {
|
||||
return Int(value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
25
suixinkan_ios/Features/Payment/Payment.md
Normal file
25
suixinkan_ios/Features/Payment/Payment.md
Normal file
@ -0,0 +1,25 @@
|
||||
# 收款模块
|
||||
|
||||
## 模块职责
|
||||
|
||||
`Features/Payment` 承接首页 `payment_collection`、`payment_qr`、`payment_code` 权限入口,负责当前景区下的收款码、动态金额二维码、收款记录和到账轮询。
|
||||
|
||||
## 代码结构
|
||||
|
||||
- `PaymentAPI`:封装收款码和扫码收款记录接口。
|
||||
- `PaymentCollectionViewModel`:管理收款码加载、金额校验、动态二维码生成和到账轮询。
|
||||
- `PaymentCollectionRecordViewModel`:管理收款记录加载和按日期分组。
|
||||
- `PaymentCollectionView`:展示收款码、金额弹窗、保存二维码和收款状态。
|
||||
- `PaymentCollectionRecordView`:展示收款记录明细。
|
||||
|
||||
## 数据流
|
||||
|
||||
1. 页面读取 `AccountContext.currentScenic`。
|
||||
2. 有景区时调用 `/api/yf-handset-app/photog/pay-code?scenic_id=...` 获取收款码。
|
||||
3. 设置金额后基于动态收款码 URL 追加 `amount` 和可选 `remark`,生成二维码。
|
||||
4. 开始轮询 `/api/yf-handset-app/photog/order/photo-scan-order-list?scenic_id=...`,最多 60 次,每 2 秒一次。
|
||||
5. 命中新收款记录后进入成功态,并使用系统语音播报到账金额。
|
||||
|
||||
## 缓存边界
|
||||
|
||||
收款码 URL、付款状态、轮询结果不落盘。保存二维码只写入系统相册,App 不额外缓存图片文件或 Data。
|
||||
@ -0,0 +1,81 @@
|
||||
//
|
||||
// PaymentCollectionViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/26.
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 收款页。
|
||||
final class PaymentCollectionViewController: UIViewController {
|
||||
private let services = AppServices.shared
|
||||
private let viewModel = PaymentCollectionViewModel()
|
||||
private let qrImageView = UIImageView()
|
||||
private let statusLabel = UILabel()
|
||||
private let amountField = UITextField()
|
||||
private let remarkField = UITextField()
|
||||
private let activityIndicator = UIActivityIndicatorView(style: .large)
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = "收款"
|
||||
view.backgroundColor = UIColor(hex: 0xF5F7FA)
|
||||
setupUI()
|
||||
viewModel.onChange = { [weak self] in self?.render() }
|
||||
Task { await viewModel.loadPayCode(api: services.paymentAPI, scenicId: services.currentScenicId) }
|
||||
}
|
||||
|
||||
private func setupUI() {
|
||||
qrImageView.contentMode = .scaleAspectFit
|
||||
statusLabel.numberOfLines = 0
|
||||
statusLabel.textAlignment = .center
|
||||
statusLabel.textColor = AppDesign.textSecondary
|
||||
amountField.placeholder = "动态金额"
|
||||
amountField.borderStyle = .roundedRect
|
||||
amountField.keyboardType = .decimalPad
|
||||
remarkField.placeholder = "备注"
|
||||
remarkField.borderStyle = .roundedRect
|
||||
|
||||
let stack = UIStackView(arrangedSubviews: [qrImageView, statusLabel, amountField, remarkField])
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 12
|
||||
view.addSubview(stack)
|
||||
view.addSubview(activityIndicator)
|
||||
stack.snp.makeConstraints { make in
|
||||
make.top.equalTo(view.safeAreaLayoutGuide).offset(24)
|
||||
make.leading.trailing.equalToSuperview().inset(24)
|
||||
}
|
||||
qrImageView.snp.makeConstraints { $0.height.equalTo(240) }
|
||||
activityIndicator.snp.makeConstraints { $0.center.equalTo(qrImageView) }
|
||||
|
||||
navigationItem.rightBarButtonItems = [
|
||||
UIBarButtonItem(title: "生成", style: .plain, target: self, action: #selector(applyAmount)),
|
||||
UIBarButtonItem(title: "轮询", style: .plain, target: self, action: #selector(togglePolling))
|
||||
]
|
||||
}
|
||||
|
||||
private func render() {
|
||||
qrImageView.image = viewModel.qrImage
|
||||
statusLabel.text = viewModel.errorMessage ?? String(describing: viewModel.status)
|
||||
if viewModel.isLoading { activityIndicator.startAnimating() } else { activityIndicator.stopAnimating() }
|
||||
}
|
||||
|
||||
@objc private func applyAmount() {
|
||||
viewModel.amountText = amountField.text ?? ""
|
||||
viewModel.remarkText = remarkField.text ?? ""
|
||||
_ = viewModel.applyDynamicAmount()
|
||||
}
|
||||
|
||||
@objc private func togglePolling() {
|
||||
Task {
|
||||
await viewModel.pollUntilPaymentDetected(
|
||||
api: services.paymentAPI,
|
||||
scenicId: services.currentScenicId
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension PaymentCollectionViewModel: ViewModelBindable {}
|
||||
245
suixinkan_ios/Features/Payment/ViewModels/PaymentViewModel.swift
Normal file
245
suixinkan_ios/Features/Payment/ViewModels/PaymentViewModel.swift
Normal file
@ -0,0 +1,245 @@
|
||||
//
|
||||
// PaymentViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import CoreImage
|
||||
import CoreImage.CIFilterBuiltins
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
/// 收款页 ViewModel,管理收款码加载、动态金额二维码和到账轮询。
|
||||
final class PaymentCollectionViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
var staticPayUrl = "" { didSet { onChange?() } }
|
||||
var dynamicPayUrl = "" { didSet { onChange?() } }
|
||||
var amountText = "" { didSet { onChange?() } }
|
||||
var remarkText = "" { didSet { onChange?() } }
|
||||
var currentPayUrl = "" { didSet { onChange?() } }
|
||||
var qrImage: UIImage? { didSet { onChange?() } }
|
||||
var isLoading = false { didSet { onChange?() } }
|
||||
var isPolling = false { didSet { onChange?() } }
|
||||
var errorMessage: String? { didSet { onChange?() } }
|
||||
var status: PaymentCollectionStatus = .idle { didSet { onChange?() } }
|
||||
|
||||
private var knownRecordIDs = Set<String>()
|
||||
private let context = CIContext()
|
||||
private let qrFilter = CIFilter.qrCodeGenerator()
|
||||
|
||||
/// 当前静态收款码是否可展示。
|
||||
var hasStaticPayCode: Bool {
|
||||
!staticPayUrl.paymentTrimmed.isEmpty
|
||||
}
|
||||
|
||||
/// 加载当前景区收款码,无景区时清空收款状态。
|
||||
func loadPayCode(api: PaymentServing, scenicId: Int?) async {
|
||||
guard let scenicId else {
|
||||
resetPayCode()
|
||||
errorMessage = "请先选择景区"
|
||||
return
|
||||
}
|
||||
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
defer { isLoading = false }
|
||||
|
||||
do {
|
||||
let response = try await api.payCode(scenicId: scenicId)
|
||||
staticPayUrl = response.staticPayUrl
|
||||
dynamicPayUrl = response.dynamicPayUrl
|
||||
currentPayUrl = response.staticPayUrl
|
||||
qrImage = makeQRCode(from: currentPayUrl)
|
||||
status = .idle
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据输入金额和备注生成动态二维码 URL。
|
||||
@discardableResult
|
||||
func applyDynamicAmount() -> Bool {
|
||||
guard let amount = Self.normalizedMoney(amountText), Decimal(string: amount) ?? 0 > 0 else {
|
||||
errorMessage = "请输入有效收款金额"
|
||||
return false
|
||||
}
|
||||
guard !dynamicPayUrl.paymentTrimmed.isEmpty else {
|
||||
errorMessage = "动态收款码暂不可用"
|
||||
return false
|
||||
}
|
||||
|
||||
var components = URLComponents(string: dynamicPayUrl)
|
||||
var queryItems = components?.queryItems ?? []
|
||||
queryItems.removeAll { $0.name == "amount" || $0.name == "remark" }
|
||||
queryItems.append(URLQueryItem(name: "amount", value: amount))
|
||||
if !remarkText.paymentTrimmed.isEmpty {
|
||||
queryItems.append(URLQueryItem(name: "remark", value: remarkText.paymentTrimmed))
|
||||
}
|
||||
components?.queryItems = queryItems
|
||||
currentPayUrl = components?.url?.absoluteString ?? "\(dynamicPayUrl)&amount=\(amount)"
|
||||
qrImage = makeQRCode(from: currentPayUrl)
|
||||
amountText = amount
|
||||
status = .waiting
|
||||
return true
|
||||
}
|
||||
|
||||
/// 记录当前已知收款记录,轮询时只把新增记录判定为本次到账。
|
||||
func primePaymentRecords(api: PaymentServing, scenicId: Int?) async {
|
||||
guard let scenicId else { return }
|
||||
if let response = try? await api.paymentCollectionRecords(scenicId: scenicId) {
|
||||
knownRecordIDs = Set(response.list.map(\.id))
|
||||
}
|
||||
}
|
||||
|
||||
/// 轮询收款记录,命中新记录后进入成功态。
|
||||
func pollUntilPaymentDetected(
|
||||
api: PaymentServing,
|
||||
scenicId: Int?,
|
||||
maxAttempts: Int = 60,
|
||||
intervalNanoseconds: UInt64 = 2_000_000_000
|
||||
) async {
|
||||
guard let scenicId else {
|
||||
status = .failed("请先选择景区")
|
||||
return
|
||||
}
|
||||
guard maxAttempts > 0 else { return }
|
||||
|
||||
isPolling = true
|
||||
defer { isPolling = false }
|
||||
|
||||
for attempt in 0 ..< maxAttempts {
|
||||
if Task.isCancelled { return }
|
||||
do {
|
||||
let response = try await api.paymentCollectionRecords(scenicId: scenicId)
|
||||
if let item = matchedNewRecord(in: response.list) {
|
||||
knownRecordIDs.insert(item.id)
|
||||
status = .success(item)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
status = .failed(error.localizedDescription)
|
||||
return
|
||||
}
|
||||
if attempt < maxAttempts - 1 {
|
||||
try? await Task.sleep(nanoseconds: intervalNanoseconds)
|
||||
}
|
||||
}
|
||||
status = .failed("暂未查询到到账记录,请稍后刷新收款记录")
|
||||
}
|
||||
|
||||
/// 清空收款码和轮询状态。
|
||||
func resetPayCode() {
|
||||
staticPayUrl = ""
|
||||
dynamicPayUrl = ""
|
||||
currentPayUrl = ""
|
||||
qrImage = nil
|
||||
knownRecordIDs = []
|
||||
status = .idle
|
||||
}
|
||||
|
||||
/// 将输入金额规范成最多两位小数。
|
||||
static func normalizedMoney(_ rawValue: String) -> String? {
|
||||
let text = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else { return nil }
|
||||
let sanitized = text.replacingOccurrences(of: ",", with: "")
|
||||
guard let decimal = Decimal(string: sanitized), decimal > 0 else { return nil }
|
||||
let number = NSDecimalNumber(decimal: decimal)
|
||||
let handler = NSDecimalNumberHandler(
|
||||
roundingMode: .plain,
|
||||
scale: 2,
|
||||
raiseOnExactness: false,
|
||||
raiseOnOverflow: false,
|
||||
raiseOnUnderflow: false,
|
||||
raiseOnDivideByZero: false
|
||||
)
|
||||
return number.rounding(accordingToBehavior: handler).stringValue
|
||||
}
|
||||
|
||||
/// 从收款记录中找到本次动态收款对应的新记录。
|
||||
private func matchedNewRecord(in records: [PaymentCollectionRecordItem]) -> PaymentCollectionRecordItem? {
|
||||
let newRecords = records.filter { !knownRecordIDs.contains($0.id) }
|
||||
guard !newRecords.isEmpty else { return nil }
|
||||
guard let amount = Self.normalizedMoney(amountText) else {
|
||||
return newRecords.first
|
||||
}
|
||||
return newRecords.first { Self.normalizedMoney($0.orderAmount) == amount } ?? newRecords.first
|
||||
}
|
||||
|
||||
/// 生成二维码图片,保存相册和页面预览都使用同一张图。
|
||||
private func makeQRCode(from text: String) -> UIImage? {
|
||||
guard !text.paymentTrimmed.isEmpty,
|
||||
let data = text.data(using: .utf8) else {
|
||||
return nil
|
||||
}
|
||||
qrFilter.setValue(data, forKey: "inputMessage")
|
||||
qrFilter.setValue("M", forKey: "inputCorrectionLevel")
|
||||
guard let output = qrFilter.outputImage else { return nil }
|
||||
let transformed = output.transformed(by: CGAffineTransform(scaleX: 10, y: 10))
|
||||
guard let cgImage = context.createCGImage(transformed, from: transformed.extent) else { return nil }
|
||||
return UIImage(cgImage: cgImage)
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
/// 收款模块内部使用的去空白文本,避免依赖其他文件的 fileprivate 扩展。
|
||||
var paymentTrimmed: String {
|
||||
trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 收款记录 ViewModel,管理收款记录加载、日期分组和汇总兜底。
|
||||
final class PaymentCollectionRecordViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
var groups: [PaymentCollectionRecordGroup] = [] { didSet { onChange?() } }
|
||||
var isLoading = false { didSet { onChange?() } }
|
||||
var errorMessage: String? { didSet { onChange?() } }
|
||||
|
||||
/// 加载当前景区收款记录,无景区时不请求接口。
|
||||
func load(api: PaymentServing, scenicId: Int?) async {
|
||||
guard let scenicId else {
|
||||
groups = []
|
||||
errorMessage = "请先选择景区"
|
||||
return
|
||||
}
|
||||
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
defer { isLoading = false }
|
||||
|
||||
do {
|
||||
let response = try await api.paymentCollectionRecords(scenicId: scenicId)
|
||||
groups = Self.makeGroups(from: response)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 将后端响应转换为可展示日期分组,缺少 analyse 时从 list 兜底生成汇总。
|
||||
static func makeGroups(from response: PaymentCollectionRecordResponse) -> [PaymentCollectionRecordGroup] {
|
||||
if !response.analyse.isEmpty {
|
||||
return response.analyse.map { analyse in
|
||||
PaymentCollectionRecordGroup(
|
||||
analyse: analyse,
|
||||
items: response.list.filter { $0.createDate == analyse.date }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
let grouped = Dictionary(grouping: response.list, by: \.createDate)
|
||||
return grouped.keys.sorted(by: >).map { date in
|
||||
let items = grouped[date] ?? []
|
||||
let total = items.reduce(Decimal(0)) { partial, item in
|
||||
partial + (Decimal(string: item.orderAmount) ?? 0)
|
||||
}
|
||||
let analyse = PaymentCollectionRecordAnalyseItem(
|
||||
date: date,
|
||||
orderCount: items.count,
|
||||
orderAmountSum: NSDecimalNumber(decimal: total).stringValue
|
||||
)
|
||||
return PaymentCollectionRecordGroup(analyse: analyse, items: items)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,56 @@
|
||||
//
|
||||
// PilotCertificationAPI.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 飞手认证服务协议,定义认证详情、短信、提交和编辑接口。
|
||||
@MainActor
|
||||
protocol PilotCertificationServing {
|
||||
func flyerDetail() async throws -> FlyerDetailResponse
|
||||
func flyerSendCode(phone: String) async throws
|
||||
func flyerApply(_ request: FlyerApplyRequest) async throws
|
||||
func flyerEdit(_ request: FlyerEditRequest) async throws
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 飞手认证 API,封装 `/api/app/flyer` 认证相关接口。
|
||||
final class PilotCertificationAPI {
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化飞手认证 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
self.client = client
|
||||
}
|
||||
|
||||
/// 获取飞手认证详情。
|
||||
func flyerDetail() async throws -> FlyerDetailResponse {
|
||||
try await client.send(APIRequest(method: .get, path: "/api/app/flyer/detail"))
|
||||
}
|
||||
|
||||
/// 发送飞手认证短信验证码。
|
||||
func flyerSendCode(phone: String) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(method: .post, path: "/api/app/flyer/send-code", body: FlyerSendCodeRequest(phone: phone))
|
||||
)
|
||||
}
|
||||
|
||||
/// 首次提交飞手认证申请。
|
||||
func flyerApply(_ request: FlyerApplyRequest) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(method: .post, path: "/api/app/flyer/apply", body: request)
|
||||
)
|
||||
}
|
||||
|
||||
/// 驳回后编辑并重新提交飞手认证申请。
|
||||
func flyerEdit(_ request: FlyerEditRequest) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(method: .post, path: "/api/app/flyer/edit", body: request)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension PilotCertificationAPI: PilotCertificationServing {}
|
||||
@ -0,0 +1,310 @@
|
||||
//
|
||||
// PilotCertificationModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 飞手证件类型。
|
||||
enum PilotCertType: Int, CaseIterable, Identifiable {
|
||||
case idCard = 1
|
||||
case caac = 2
|
||||
|
||||
var id: Int { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .idCard:
|
||||
"身份证"
|
||||
case .caac:
|
||||
"民用无人机驾驶员执照"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 飞手认证详情响应。
|
||||
struct FlyerDetailResponse: Decodable, Equatable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let accountId: String
|
||||
let realnameStatus: Int
|
||||
let status: Int
|
||||
let statusName: String
|
||||
let submitTime: String
|
||||
let updatedAt: String
|
||||
let auditPerson: String
|
||||
let auditTime: String
|
||||
let auditNote: String
|
||||
let certificateType: Int
|
||||
let certificateNo: String
|
||||
let certificateStartDate: String
|
||||
let certificateEndDate: String
|
||||
let certificateImage: String
|
||||
let droneModel: String
|
||||
let droneSn: String
|
||||
let contactPhone: String
|
||||
let realnameStatusText: String
|
||||
let certificationLogs: [FlyerCertificationLogItem]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case name = "flyer_nickname"
|
||||
case accountId = "account_id"
|
||||
case realnameStatus = "realname_status"
|
||||
case status
|
||||
case statusName = "status_text"
|
||||
case submitTime = "created_at"
|
||||
case updatedAt = "updated_at"
|
||||
case auditPerson = "reviewer"
|
||||
case auditTime = "review_time"
|
||||
case auditNote = "reject_reason"
|
||||
case certificateType = "certificate_type"
|
||||
case certificateNo = "certificate_no"
|
||||
case certificateStartDate = "certificate_start_date"
|
||||
case certificateEndDate = "certificate_end_date"
|
||||
case certificateImage = "certificate_image"
|
||||
case droneModel = "drone_model"
|
||||
case droneSn = "drone_sn"
|
||||
case contactPhone = "contact_phone"
|
||||
case realnameStatusText = "realname_status_text"
|
||||
case certificationLogs = "flyers_certification_logs"
|
||||
}
|
||||
|
||||
init(
|
||||
id: Int = 0,
|
||||
name: String = "",
|
||||
accountId: String = "",
|
||||
realnameStatus: Int = 2,
|
||||
status: Int = 0,
|
||||
statusName: String = "",
|
||||
submitTime: String = "",
|
||||
updatedAt: String = "",
|
||||
auditPerson: String = "",
|
||||
auditTime: String = "",
|
||||
auditNote: String = "",
|
||||
certificateType: Int = 2,
|
||||
certificateNo: String = "",
|
||||
certificateStartDate: String = "",
|
||||
certificateEndDate: String = "",
|
||||
certificateImage: String = "",
|
||||
droneModel: String = "",
|
||||
droneSn: String = "",
|
||||
contactPhone: String = "",
|
||||
realnameStatusText: String = "",
|
||||
certificationLogs: [FlyerCertificationLogItem] = []
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.accountId = accountId
|
||||
self.realnameStatus = realnameStatus
|
||||
self.status = status
|
||||
self.statusName = statusName
|
||||
self.submitTime = submitTime
|
||||
self.updatedAt = updatedAt
|
||||
self.auditPerson = auditPerson
|
||||
self.auditTime = auditTime
|
||||
self.auditNote = auditNote
|
||||
self.certificateType = certificateType
|
||||
self.certificateNo = certificateNo
|
||||
self.certificateStartDate = certificateStartDate
|
||||
self.certificateEndDate = certificateEndDate
|
||||
self.certificateImage = certificateImage
|
||||
self.droneModel = droneModel
|
||||
self.droneSn = droneSn
|
||||
self.contactPhone = contactPhone
|
||||
self.realnameStatusText = realnameStatusText
|
||||
self.certificationLogs = certificationLogs
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.pilotDecodeLossyInt(forKey: .id) ?? 0
|
||||
name = try container.pilotDecodeLossyString(forKey: .name)
|
||||
accountId = try container.pilotDecodeLossyString(forKey: .accountId)
|
||||
realnameStatus = try container.pilotDecodeLossyInt(forKey: .realnameStatus) ?? 2
|
||||
status = try container.pilotDecodeLossyInt(forKey: .status) ?? 0
|
||||
statusName = try container.pilotDecodeLossyString(forKey: .statusName)
|
||||
submitTime = try container.pilotDecodeLossyString(forKey: .submitTime)
|
||||
updatedAt = try container.pilotDecodeLossyString(forKey: .updatedAt)
|
||||
auditPerson = try container.pilotDecodeLossyString(forKey: .auditPerson)
|
||||
auditTime = try container.pilotDecodeLossyString(forKey: .auditTime)
|
||||
auditNote = try container.pilotDecodeLossyString(forKey: .auditNote)
|
||||
certificateType = try container.pilotDecodeLossyInt(forKey: .certificateType) ?? 2
|
||||
certificateNo = try container.pilotDecodeLossyString(forKey: .certificateNo)
|
||||
certificateStartDate = try container.pilotDecodeLossyString(forKey: .certificateStartDate)
|
||||
certificateEndDate = try container.pilotDecodeLossyString(forKey: .certificateEndDate)
|
||||
certificateImage = try container.pilotDecodeLossyString(forKey: .certificateImage)
|
||||
droneModel = try container.pilotDecodeLossyString(forKey: .droneModel)
|
||||
droneSn = try container.pilotDecodeLossyString(forKey: .droneSn)
|
||||
contactPhone = try container.pilotDecodeLossyString(forKey: .contactPhone)
|
||||
realnameStatusText = try container.pilotDecodeLossyString(forKey: .realnameStatusText)
|
||||
certificationLogs = try container.decodeIfPresent([FlyerCertificationLogItem].self, forKey: .certificationLogs) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
/// 飞手认证审核日志项。
|
||||
struct FlyerCertificationLogItem: Decodable, Identifiable, Equatable {
|
||||
let id: Int
|
||||
let flyerId: Int
|
||||
let operatorName: String
|
||||
let action: Int
|
||||
let actionText: String
|
||||
let rejectReason: String
|
||||
let remark: String
|
||||
let createdAt: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case flyerId = "flyer_id"
|
||||
case operatorName = "operator"
|
||||
case action
|
||||
case actionText = "action_text"
|
||||
case rejectReason = "reject_reason"
|
||||
case remark
|
||||
case createdAt = "created_at"
|
||||
}
|
||||
|
||||
init(
|
||||
id: Int = 0,
|
||||
flyerId: Int = 0,
|
||||
operatorName: String = "",
|
||||
action: Int = 0,
|
||||
actionText: String = "",
|
||||
rejectReason: String = "",
|
||||
remark: String = "",
|
||||
createdAt: String = ""
|
||||
) {
|
||||
self.id = id
|
||||
self.flyerId = flyerId
|
||||
self.operatorName = operatorName
|
||||
self.action = action
|
||||
self.actionText = actionText
|
||||
self.rejectReason = rejectReason
|
||||
self.remark = remark
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.pilotDecodeLossyInt(forKey: .id) ?? 0
|
||||
flyerId = try container.pilotDecodeLossyInt(forKey: .flyerId) ?? 0
|
||||
operatorName = try container.pilotDecodeLossyString(forKey: .operatorName)
|
||||
action = try container.pilotDecodeLossyInt(forKey: .action) ?? 0
|
||||
actionText = try container.pilotDecodeLossyString(forKey: .actionText)
|
||||
rejectReason = try container.pilotDecodeLossyString(forKey: .rejectReason)
|
||||
remark = try container.pilotDecodeLossyString(forKey: .remark)
|
||||
createdAt = try container.pilotDecodeLossyString(forKey: .createdAt)
|
||||
}
|
||||
}
|
||||
|
||||
/// 飞手认证短信验证码请求。
|
||||
struct FlyerSendCodeRequest: Encodable, Equatable {
|
||||
let phone: String
|
||||
}
|
||||
|
||||
/// 飞手首次认证申请请求。
|
||||
struct FlyerApplyRequest: Encodable, Equatable {
|
||||
let name: String
|
||||
let realnameStatus: Int
|
||||
let certificateType: Int
|
||||
let certificateNo: String
|
||||
let certificateStartDate: String
|
||||
let certificateEndDate: String
|
||||
let certificateImage: String
|
||||
let droneModel: String
|
||||
let droneSn: String
|
||||
let contactPhone: String
|
||||
let code: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case name
|
||||
case realnameStatus = "realname_status"
|
||||
case certificateType = "certificate_type"
|
||||
case certificateNo = "certificate_no"
|
||||
case certificateStartDate = "certificate_start_date"
|
||||
case certificateEndDate = "certificate_end_date"
|
||||
case certificateImage = "certificate_image"
|
||||
case droneModel = "drone_model"
|
||||
case droneSn = "drone_sn"
|
||||
case contactPhone = "contact_phone"
|
||||
case code
|
||||
}
|
||||
}
|
||||
|
||||
/// 飞手认证驳回后编辑请求。
|
||||
struct FlyerEditRequest: Encodable, Equatable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let realnameStatus: Int
|
||||
let certificateType: Int
|
||||
let certificateNo: String
|
||||
let certificateStartDate: String
|
||||
let certificateEndDate: String
|
||||
let certificateImage: String
|
||||
let droneModel: String
|
||||
let droneSn: String
|
||||
let contactPhone: String
|
||||
let code: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case name
|
||||
case realnameStatus = "realname_status"
|
||||
case certificateType = "certificate_type"
|
||||
case certificateNo = "certificate_no"
|
||||
case certificateStartDate = "certificate_start_date"
|
||||
case certificateEndDate = "certificate_end_date"
|
||||
case certificateImage = "certificate_image"
|
||||
case droneModel = "drone_model"
|
||||
case droneSn = "drone_sn"
|
||||
case contactPhone = "contact_phone"
|
||||
case code
|
||||
}
|
||||
}
|
||||
|
||||
/// 飞手认证校验错误。
|
||||
enum PilotCertificationValidationError: LocalizedError, Equatable {
|
||||
case message(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .message(let message):
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension KeyedDecodingContainer {
|
||||
func pilotDecodeLossyString(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 ? "true" : "false"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func pilotDecodeLossyInt(forKey key: Key) throws -> Int? {
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let intValue = Int(text) { return intValue }
|
||||
if let doubleValue = Double(text) { return Int(doubleValue) }
|
||||
}
|
||||
if let value = try? decodeIfPresent(Double.self, forKey: key) {
|
||||
return Int(value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
# PilotCertification 模块业务逻辑
|
||||
|
||||
## 模块职责
|
||||
|
||||
PilotCertification 负责首页 `pilot_cert` 入口的飞手认证申请。模块展示实名状态、飞手证件、无人机信息、联系方式和审核记录,并支持未提交或驳回状态下重新提交。
|
||||
|
||||
## 核心流程
|
||||
|
||||
- `PilotCertificationView` 从环境读取 `ProfileAPI`、`PilotCertificationAPI`、`OSSUploadService` 和当前景区。
|
||||
- `PilotCertificationViewModel` 并行加载实名认证状态和飞手认证详情,单通道失败不清空另一通道数据。
|
||||
- 用户选择证件图片后先保存在本地状态,提交时通过 OSS 上传并写入 `certificate_image`。
|
||||
- 未提交状态调用 `/api/app/flyer/apply`,驳回且有飞手记录 ID 时调用 `/api/app/flyer/edit`。
|
||||
- 验证码通过 `/api/app/flyer/send-code` 发送,成功后进入 60 秒本地倒计时。
|
||||
|
||||
## 边界
|
||||
|
||||
审核通过后页面只读。本模块不包含 DJI/飞控 SDK、无人机连接、飞行控制或横屏飞控流程。
|
||||
@ -0,0 +1,111 @@
|
||||
//
|
||||
// PilotCertificationViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/26.
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 飞手认证页。
|
||||
final class PilotCertificationViewController: ModuleTableViewController {
|
||||
private let viewModel = PilotCertificationViewModel()
|
||||
private let statusLabel = UILabel()
|
||||
|
||||
override func viewDidLoad() {
|
||||
title = "飞手认证"
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
title: "提交",
|
||||
style: .done,
|
||||
target: self,
|
||||
action: #selector(submit)
|
||||
)
|
||||
super.viewDidLoad()
|
||||
setupHeader()
|
||||
wireViewModel(viewModel) { [weak self] in self?.updateHeader() }
|
||||
}
|
||||
|
||||
private func setupHeader() {
|
||||
statusLabel.numberOfLines = 0
|
||||
statusLabel.font = .systemFont(ofSize: 14)
|
||||
statusLabel.textColor = AppDesign.textSecondary
|
||||
statusLabel.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 80)
|
||||
tableView.tableHeaderView = statusLabel
|
||||
}
|
||||
|
||||
override func numberOfSections(in tableView: UITableView) -> Int { 2 }
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
section == 0 ? formRows.count : 1
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||
section == 0 ? "认证信息" : "操作"
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(
|
||||
withIdentifier: TitleSubtitleTableViewCell.reuseIdentifier,
|
||||
for: indexPath
|
||||
) as! TitleSubtitleTableViewCell
|
||||
if indexPath.section == 0 {
|
||||
let row = formRows[indexPath.row]
|
||||
cell.configure(title: row.title, subtitle: row.value)
|
||||
} else {
|
||||
cell.configure(title: "发送验证码", subtitle: viewModel.phone)
|
||||
}
|
||||
return cell
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
if indexPath.section == 1 {
|
||||
Task {
|
||||
do {
|
||||
try await viewModel.sendCode(api: services.pilotCertificationAPI)
|
||||
showToast("验证码已发送")
|
||||
} catch {
|
||||
showToast(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func reloadContent() async {
|
||||
await viewModel.load(api: services.pilotCertificationAPI, realNameAPI: services.profileAPI)
|
||||
updateHeader()
|
||||
}
|
||||
|
||||
@objc private func submit() {
|
||||
Task {
|
||||
do {
|
||||
try await viewModel.submit(
|
||||
api: services.pilotCertificationAPI,
|
||||
uploader: services.ossUploadService,
|
||||
scenicId: services.currentScenicId
|
||||
)
|
||||
services.toastCenter.show("提交成功")
|
||||
} catch {
|
||||
services.toastCenter.show(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var formRows: [(title: String, value: String)] {
|
||||
[
|
||||
("姓名", viewModel.name),
|
||||
("证件号", viewModel.certNo),
|
||||
("手机号", viewModel.phone),
|
||||
("无人机型号", viewModel.droneModel),
|
||||
("序列号", viewModel.droneSerialNo)
|
||||
]
|
||||
}
|
||||
|
||||
private func updateHeader() {
|
||||
let status = viewModel.auditStatusText
|
||||
statusLabel.text = "状态:\(status)\n\(viewModel.statusMessage ?? "")"
|
||||
}
|
||||
}
|
||||
|
||||
extension PilotCertificationViewModel: ViewModelBindable {}
|
||||
@ -0,0 +1,281 @@
|
||||
//
|
||||
// PilotCertificationViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
/// 飞手认证实名认证读取协议,便于 ViewModel 单测替换。
|
||||
protocol PilotRealNameServing {
|
||||
func realNameInfo() async throws -> RealNameInfoResponse
|
||||
}
|
||||
|
||||
extension ProfileAPI: PilotRealNameServing {}
|
||||
|
||||
@MainActor
|
||||
/// 飞手认证 ViewModel,管理审核状态、表单、验证码、证件图上传和提交。
|
||||
final class PilotCertificationViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
private(set) var flyer: FlyerDetailResponse? { didSet { onChange?() } }
|
||||
private(set) var realNameInfo: RealNameInfo? { didSet { onChange?() } }
|
||||
var name = "" { didSet { onChange?() } }
|
||||
var certType: PilotCertType = .caac { didSet { onChange?() } }
|
||||
var certNo = "" { didSet { onChange?() } }
|
||||
var certImageUrl = "" { didSet { onChange?() } }
|
||||
var startDate = Date() { didSet { onChange?() } }
|
||||
var endDate = Calendar.current.date(byAdding: .year, value: 1, to: Date()) ?? Date() { didSet { onChange?() } }
|
||||
var droneModel = "" { didSet { onChange?() } }
|
||||
var droneSerialNo = "" { didSet { onChange?() } }
|
||||
var phone = "" { didSet { onChange?() } }
|
||||
var verifyCode = "" { didSet { onChange?() } }
|
||||
private(set) var pendingCertificateImageData: Data? { didSet { onChange?() } }
|
||||
private(set) var pendingCertificateFileName: String? { didSet { onChange?() } }
|
||||
private(set) var uploadProgress: Int? { didSet { onChange?() } }
|
||||
private(set) var loading = false { didSet { onChange?() } }
|
||||
private(set) var sendingCode = false { didSet { onChange?() } }
|
||||
private(set) var submitting = false { didSet { onChange?() } }
|
||||
private(set) var countdown = 0 { didSet { onChange?() } }
|
||||
var statusMessage: String? { didSet { onChange?() } }
|
||||
|
||||
/// 加载实名状态和飞手认证详情,单通道失败不清空另一通道数据。
|
||||
func load(api: any PilotCertificationServing, realNameAPI: any PilotRealNameServing) async {
|
||||
guard !loading else { return }
|
||||
loading = true
|
||||
defer { loading = false }
|
||||
|
||||
async let realNameTask = realNameAPI.realNameInfo()
|
||||
async let flyerTask = api.flyerDetail()
|
||||
var messages: [String] = []
|
||||
|
||||
do {
|
||||
let response = try await realNameTask
|
||||
realNameInfo = response.realNameInfo
|
||||
} catch {
|
||||
messages.append(error.localizedDescription)
|
||||
}
|
||||
|
||||
do {
|
||||
apply(try await flyerTask)
|
||||
} catch {
|
||||
messages.append(error.localizedDescription)
|
||||
}
|
||||
|
||||
statusMessage = messages.isEmpty ? nil : messages.joined(separator: "\n")
|
||||
}
|
||||
|
||||
/// 选择待上传证件图。
|
||||
func prepareCertificateImage(data: Data, fileName: String) {
|
||||
pendingCertificateImageData = data
|
||||
pendingCertificateFileName = fileName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
? "pilot_cert_\(Int(Date().timeIntervalSince1970)).jpg"
|
||||
: fileName
|
||||
certImageUrl = ""
|
||||
uploadProgress = nil
|
||||
}
|
||||
|
||||
/// 发送验证码,成功后进入 60 秒倒计时。
|
||||
func sendCode(api: any PilotCertificationServing) async throws {
|
||||
guard canSendCode else {
|
||||
throw PilotCertificationValidationError.message("请输入有效的手机号码")
|
||||
}
|
||||
sendingCode = true
|
||||
defer { sendingCode = false }
|
||||
|
||||
try await api.flyerSendCode(phone: AppFormValidator.normalizedPhoneNumber(phone))
|
||||
countdown = 60
|
||||
statusMessage = "验证码已发送"
|
||||
}
|
||||
|
||||
/// 倒计时递减,由页面定时器驱动。
|
||||
func tickCountdown() {
|
||||
if countdown > 0 {
|
||||
countdown -= 1
|
||||
}
|
||||
}
|
||||
|
||||
/// 提交飞手认证,必要时先上传证件图。
|
||||
func submit(api: any PilotCertificationServing, uploader: any OSSUploadServing, scenicId: Int?) async throws {
|
||||
if let validationMessage {
|
||||
throw PilotCertificationValidationError.message(validationMessage)
|
||||
}
|
||||
|
||||
submitting = true
|
||||
defer { submitting = false }
|
||||
|
||||
try await uploadPendingCertificateImage(uploader: uploader, scenicId: scenicId)
|
||||
if auditStatus == 3, flyerId > 0 {
|
||||
try await api.flyerEdit(makeEditRequest())
|
||||
statusMessage = "修改已提交"
|
||||
} else {
|
||||
try await api.flyerApply(makeApplyRequest())
|
||||
statusMessage = "认证申请已提交"
|
||||
}
|
||||
await load(api: api, realNameAPI: PilotEmptyRealNameServing(info: realNameInfo))
|
||||
}
|
||||
|
||||
/// 审核状态编码。
|
||||
var auditStatus: Int {
|
||||
flyer?.status ?? 0
|
||||
}
|
||||
|
||||
/// 飞手认证记录 ID。
|
||||
var flyerId: Int {
|
||||
flyer?.id ?? 0
|
||||
}
|
||||
|
||||
/// 审核通过后表单只读。
|
||||
var isReadOnly: Bool {
|
||||
auditStatus == 2
|
||||
}
|
||||
|
||||
/// 实名是否已通过。
|
||||
var isRealNameVerified: Bool {
|
||||
realNameInfo?.verified == true || flyer?.realnameStatus == 2
|
||||
}
|
||||
|
||||
/// 是否允许发送验证码。
|
||||
var canSendCode: Bool {
|
||||
!sendingCode && countdown == 0 && (auditStatus == 0 || auditStatus == 3) && AppFormValidator.isValidMainlandPhoneNumber(phone)
|
||||
}
|
||||
|
||||
/// 当前表单校验错误。
|
||||
var validationMessage: String? {
|
||||
if isReadOnly { return "认证已通过,无需重复提交" }
|
||||
if auditStatus != 0 && auditStatus != 3 { return "认证审核中,请等待审核结果" }
|
||||
if name.trimmedForPilot.isEmpty { return "请输入飞手昵称" }
|
||||
if certNo.trimmedForPilot.isEmpty { return "请输入证件号码" }
|
||||
if certImageUrl.trimmedForPilot.isEmpty && pendingCertificateImageData == nil { return "请选择证件图片" }
|
||||
if endDate < startDate { return "截至日期不能早于起始日期" }
|
||||
if droneModel.trimmedForPilot.isEmpty { return "请输入无人机型号" }
|
||||
if droneSerialNo.trimmedForPilot.isEmpty { return "请输入无人机序列号" }
|
||||
if phone.trimmedForPilot.isEmpty { return "请输入手机号码" }
|
||||
if !AppFormValidator.isValidMainlandPhoneNumber(phone) { return "请输入有效的手机号码" }
|
||||
if verifyCode.trimmedForPilot.isEmpty { return "请输入验证码" }
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 审核状态展示文本。
|
||||
var auditStatusText: String {
|
||||
flyer?.statusName.trimmedForPilot.nonEmptyForPilot ?? Self.statusName(for: auditStatus)
|
||||
}
|
||||
|
||||
/// 最近审核日志。
|
||||
var latestAuditLog: FlyerCertificationLogItem? {
|
||||
flyer?.certificationLogs.last { $0.action == 2 || $0.action == 3 }
|
||||
}
|
||||
|
||||
private func apply(_ flyer: FlyerDetailResponse) {
|
||||
self.flyer = flyer
|
||||
name = flyer.name
|
||||
certType = PilotCertType(rawValue: flyer.certificateType) ?? .caac
|
||||
certNo = flyer.certificateNo
|
||||
certImageUrl = flyer.certificateImage
|
||||
startDate = Self.date(from: flyer.certificateStartDate) ?? startDate
|
||||
endDate = Self.date(from: flyer.certificateEndDate) ?? endDate
|
||||
droneModel = flyer.droneModel
|
||||
droneSerialNo = flyer.droneSn
|
||||
phone = flyer.contactPhone
|
||||
pendingCertificateImageData = nil
|
||||
pendingCertificateFileName = nil
|
||||
uploadProgress = nil
|
||||
}
|
||||
|
||||
private func uploadPendingCertificateImage(uploader: any OSSUploadServing, scenicId: Int?) async throws {
|
||||
guard let data = pendingCertificateImageData else { return }
|
||||
guard let scenicId, scenicId > 0 else {
|
||||
throw PilotCertificationValidationError.message("请先选择景区")
|
||||
}
|
||||
uploadProgress = 1
|
||||
defer { uploadProgress = nil }
|
||||
certImageUrl = try await uploader.uploadPilotCertificateImage(
|
||||
data: data,
|
||||
fileName: pendingCertificateFileName ?? "pilot_cert_\(Int(Date().timeIntervalSince1970)).jpg",
|
||||
scenicId: scenicId
|
||||
) { [weak self] progress in
|
||||
Task { @MainActor in
|
||||
self?.uploadProgress = progress
|
||||
}
|
||||
}
|
||||
pendingCertificateImageData = nil
|
||||
pendingCertificateFileName = nil
|
||||
}
|
||||
|
||||
private func makeApplyRequest() -> FlyerApplyRequest {
|
||||
FlyerApplyRequest(
|
||||
name: name.trimmedForPilot,
|
||||
realnameStatus: isRealNameVerified ? 1 : 2,
|
||||
certificateType: certType.rawValue,
|
||||
certificateNo: certNo.trimmedForPilot,
|
||||
certificateStartDate: Self.dateFormatter.string(from: startDate),
|
||||
certificateEndDate: Self.dateFormatter.string(from: endDate),
|
||||
certificateImage: certImageUrl.trimmedForPilot,
|
||||
droneModel: droneModel.trimmedForPilot,
|
||||
droneSn: droneSerialNo.trimmedForPilot,
|
||||
contactPhone: AppFormValidator.normalizedPhoneNumber(phone),
|
||||
code: verifyCode.trimmedForPilot
|
||||
)
|
||||
}
|
||||
|
||||
private func makeEditRequest() -> FlyerEditRequest {
|
||||
let apply = makeApplyRequest()
|
||||
return FlyerEditRequest(
|
||||
id: flyerId,
|
||||
name: apply.name,
|
||||
realnameStatus: apply.realnameStatus,
|
||||
certificateType: apply.certificateType,
|
||||
certificateNo: apply.certificateNo,
|
||||
certificateStartDate: apply.certificateStartDate,
|
||||
certificateEndDate: apply.certificateEndDate,
|
||||
certificateImage: apply.certificateImage,
|
||||
droneModel: apply.droneModel,
|
||||
droneSn: apply.droneSn,
|
||||
contactPhone: apply.contactPhone,
|
||||
code: apply.code
|
||||
)
|
||||
}
|
||||
|
||||
private static func statusName(for status: Int) -> String {
|
||||
switch status {
|
||||
case 1: "审核中"
|
||||
case 2: "已通过"
|
||||
case 3: "审核失败"
|
||||
default: "未提交"
|
||||
}
|
||||
}
|
||||
|
||||
private static let dateFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.calendar = Calendar(identifier: .gregorian)
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
return formatter
|
||||
}()
|
||||
|
||||
private static func date(from value: String) -> Date? {
|
||||
let text = String(value.prefix(10))
|
||||
guard !text.isEmpty else { return nil }
|
||||
return dateFormatter.date(from: text)
|
||||
}
|
||||
}
|
||||
|
||||
private struct PilotEmptyRealNameServing: PilotRealNameServing {
|
||||
let info: RealNameInfo?
|
||||
|
||||
func realNameInfo() async throws -> RealNameInfoResponse {
|
||||
RealNameInfoResponse(realNameInfo: info)
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
var trimmedForPilot: String {
|
||||
trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
var nonEmptyForPilot: String? {
|
||||
let value = trimmedForPilot
|
||||
return value.isEmpty ? nil : value
|
||||
}
|
||||
}
|
||||
96
suixinkan_ios/Features/Profile/API/ProfileAPI.swift
Normal file
96
suixinkan_ios/Features/Profile/API/ProfileAPI.swift
Normal file
@ -0,0 +1,96 @@
|
||||
//
|
||||
// ProfileAPI.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/20.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
/// 个人信息 API,封装“我的”页面需要的用户资料和资料更新接口。
|
||||
final class ProfileAPI {
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化个人信息 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
self.client = client
|
||||
}
|
||||
|
||||
/// 拉取当前登录用户的基础资料。
|
||||
func userInfo() async throws -> UserInfoResponse {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/yf-handset-app/userinfo"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 拉取当前登录账号可切换的景区账号和门店账号。
|
||||
func switchableAccounts() async throws -> V9AuthResponse {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/app/v9/accounts"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 拉取当前登录用户的实名认证状态。
|
||||
func realNameInfo() async throws -> RealNameInfoResponse {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/yf-handset-app/photog/real-name/info"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 更新用户昵称、密码或头像地址。
|
||||
func updateUserInfo(nickname: String? = nil, password: String? = nil, avatar: String? = nil) async throws {
|
||||
let request = UpdateInfoRequest(nickname: nickname, password: password, avatar: avatar)
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/yf-handset-app/userinfo-update",
|
||||
body: request
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 单独更新用户头像 URL,供 OSS 上传完成后回写服务端。
|
||||
func updateUserAvatarURL(_ fileURL: String) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/yf-handset-app/userinfo-update-avatar-url",
|
||||
queryItems: [URLQueryItem(name: "file_url", value: fileURL)]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 发送实名认证短信验证码。
|
||||
func realNameSmsVerifyCode() async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/yf-handset-app/photog/real-name/sms-verify-code",
|
||||
body: EmptyPayload()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 提交实名认证资料。
|
||||
func realNameSubmit(_ request: RealNameAuthRequest) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/yf-handset-app/photog/real-name/submit",
|
||||
body: request
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension ProfileAPI: UserProfileServing {}
|
||||
48
suixinkan_ios/Features/Profile/Models/AgreementPage.swift
Normal file
48
suixinkan_ios/Features/Profile/Models/AgreementPage.swift
Normal file
@ -0,0 +1,48 @@
|
||||
//
|
||||
// AgreementPage.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 协议和说明页面枚举,描述设置中心可打开的 H5 页面。
|
||||
enum AgreementPage: Hashable, Identifiable {
|
||||
case about
|
||||
case userAgreement
|
||||
case privacyPolicy
|
||||
case walletUserNotice
|
||||
case walletPrivacy
|
||||
|
||||
var id: String { title }
|
||||
|
||||
/// 页面导航标题。
|
||||
var title: String {
|
||||
switch self {
|
||||
case .about: "关于我们"
|
||||
case .userAgreement: "用户协议"
|
||||
case .privacyPolicy: "隐私政策"
|
||||
case .walletUserNotice: "钱包用户须知"
|
||||
case .walletPrivacy: "钱包隐私政策"
|
||||
}
|
||||
}
|
||||
|
||||
/// 页面对应的 H5 地址。
|
||||
var url: URL {
|
||||
let path = switch self {
|
||||
case .about: "/h5/app/about-us"
|
||||
case .userAgreement: "/h5/app/user-agreement"
|
||||
case .privacyPolicy: "/h5/app/privacy-policy"
|
||||
case .walletUserNotice: "/h5/app/wallet-user-notice"
|
||||
case .walletPrivacy: "/h5/app/wallet-privacy"
|
||||
}
|
||||
return APIEnvironment.current.baseURL.appending(path: path)
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置展示策略,集中处理版本号等纯展示逻辑。
|
||||
enum SettingsDisplayPolicy {
|
||||
/// 生成页面显示的版本号。
|
||||
nonisolated static func versionText(infoDictionary: [String: Any]? = Bundle.main.infoDictionary) -> String {
|
||||
AppClientInfo.appVersion(infoDictionary: infoDictionary)
|
||||
}
|
||||
}
|
||||
217
suixinkan_ios/Features/Profile/Models/ProfileModels.swift
Normal file
217
suixinkan_ios/Features/Profile/Models/ProfileModels.swift
Normal file
@ -0,0 +1,217 @@
|
||||
//
|
||||
// ProfileModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/20.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 用户资料响应实体,表示“我的”页面展示的账号基础信息。
|
||||
struct UserInfoResponse: Decodable, Equatable {
|
||||
let avatar: String
|
||||
let realName: String
|
||||
let phone: String
|
||||
let nickname: String
|
||||
let roleName: String
|
||||
let status: Int
|
||||
let statusName: String
|
||||
|
||||
/// 用户资料响应的 JSON 字段映射。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case avatar
|
||||
case realName = "real_name"
|
||||
case phone
|
||||
case nickname
|
||||
case roleName = "role_name"
|
||||
case status
|
||||
case statusName = "status_name"
|
||||
}
|
||||
|
||||
/// 创建用户资料实体,主要用于本地更新和预览默认值。
|
||||
init(
|
||||
avatar: String = "",
|
||||
realName: String = "",
|
||||
phone: String = "",
|
||||
nickname: String = "",
|
||||
roleName: String = "",
|
||||
status: Int = 0,
|
||||
statusName: String = ""
|
||||
) {
|
||||
self.avatar = avatar
|
||||
self.realName = realName
|
||||
self.phone = phone
|
||||
self.nickname = nickname
|
||||
self.roleName = roleName
|
||||
self.status = status
|
||||
self.statusName = statusName
|
||||
}
|
||||
|
||||
/// 自定义解码,兼容后端字段类型不稳定的情况。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
avatar = try container.decodeLossyString(forKey: .avatar)
|
||||
realName = try container.decodeLossyString(forKey: .realName)
|
||||
phone = try container.decodeLossyString(forKey: .phone)
|
||||
nickname = try container.decodeLossyString(forKey: .nickname)
|
||||
roleName = try container.decodeLossyString(forKey: .roleName)
|
||||
status = try container.decodeLossyInt(forKey: .status) ?? 0
|
||||
statusName = try container.decodeLossyString(forKey: .statusName)
|
||||
}
|
||||
}
|
||||
|
||||
/// 用户资料更新请求实体,表示昵称、密码和头像修改参数。
|
||||
struct UpdateInfoRequest: Encodable {
|
||||
let nickname: String?
|
||||
let password: String?
|
||||
let avatar: String?
|
||||
}
|
||||
|
||||
/// 实名认证响应实体,包裹当前用户的认证详情。
|
||||
struct RealNameInfoResponse: Decodable, Equatable {
|
||||
let realNameInfo: RealNameInfo?
|
||||
|
||||
/// 实名认证响应的 JSON 字段映射。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case realNameInfo = "real_name_info"
|
||||
}
|
||||
}
|
||||
|
||||
/// 实名认证信息实体,表示当前用户实名审核状态和失败原因。
|
||||
struct RealNameInfo: Decodable, Equatable {
|
||||
let realName: String
|
||||
let idCardNo: String
|
||||
let auditStatus: Int
|
||||
let auditStatusText: String?
|
||||
let rejectReason: String?
|
||||
let startDate: String?
|
||||
let endDate: String?
|
||||
let isLongValid: Bool
|
||||
let frontUrl: String?
|
||||
let backUrl: String?
|
||||
let auditorId: Int?
|
||||
let auditor: RealNameAuditor?
|
||||
let auditAt: String?
|
||||
|
||||
/// 当前认证是否已审核通过。
|
||||
var verified: Bool {
|
||||
auditStatus == 2
|
||||
}
|
||||
|
||||
/// 实名认证信息的 JSON 字段映射。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case realName = "real_name"
|
||||
case idCardNo = "id_card_no"
|
||||
case auditStatus = "audit_status"
|
||||
case auditStatusText = "audit_status_text"
|
||||
case rejectReason = "reject_reason"
|
||||
case startDate = "start_date"
|
||||
case endDate = "end_date"
|
||||
case isLongValid = "is_long_valid"
|
||||
case frontUrl = "front_url"
|
||||
case backUrl = "back_url"
|
||||
case auditorId = "auditor_id"
|
||||
case auditor
|
||||
case auditAt = "audit_at"
|
||||
}
|
||||
|
||||
/// 自定义解码,兼容审核状态字段类型不稳定的情况。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
realName = try container.decodeLossyString(forKey: .realName)
|
||||
idCardNo = try container.decodeLossyString(forKey: .idCardNo)
|
||||
auditStatus = try container.decodeLossyInt(forKey: .auditStatus) ?? 0
|
||||
auditStatusText = try container.decodeIfPresent(String.self, forKey: .auditStatusText)
|
||||
rejectReason = try container.decodeIfPresent(String.self, forKey: .rejectReason)
|
||||
startDate = try container.decodeIfPresent(String.self, forKey: .startDate)
|
||||
endDate = try container.decodeIfPresent(String.self, forKey: .endDate)
|
||||
isLongValid = (try container.decodeLossyInt(forKey: .isLongValid) ?? 0) == 1
|
||||
frontUrl = try container.decodeIfPresent(String.self, forKey: .frontUrl)
|
||||
backUrl = try container.decodeIfPresent(String.self, forKey: .backUrl)
|
||||
auditorId = try container.decodeLossyInt(forKey: .auditorId)
|
||||
auditor = try container.decodeIfPresent(RealNameAuditor.self, forKey: .auditor)
|
||||
auditAt = try container.decodeIfPresent(String.self, forKey: .auditAt)
|
||||
}
|
||||
}
|
||||
|
||||
/// 实名认证审核人实体,表示后台审核人的基础展示信息。
|
||||
struct RealNameAuditor: Decodable, Equatable {
|
||||
let id: Int
|
||||
let name: String
|
||||
|
||||
/// 审核人 JSON 字段映射。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case name
|
||||
}
|
||||
|
||||
/// 自定义解码,兼容审核人 ID 类型不稳定。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decodeLossyInt(forKey: .id) ?? 0
|
||||
name = try container.decodeLossyString(forKey: .name)
|
||||
}
|
||||
}
|
||||
|
||||
/// 实名认证提交请求实体,表示用户填写的姓名、证件号、短信和证件图片。
|
||||
struct RealNameAuthRequest: Encodable, Equatable {
|
||||
let realName: String
|
||||
let idCardNo: String
|
||||
let smsVerifyCode: String
|
||||
let startDate: String
|
||||
let endDate: String
|
||||
let isLongValid: Int
|
||||
let frontUrl: String
|
||||
let backUrl: String
|
||||
|
||||
/// 实名认证提交请求 JSON 字段映射。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case realName = "real_name"
|
||||
case idCardNo = "id_card_no"
|
||||
case smsVerifyCode = "sms_verify_code"
|
||||
case startDate = "start_date"
|
||||
case endDate = "end_date"
|
||||
case isLongValid = "is_long_valid"
|
||||
case frontUrl = "front_url"
|
||||
case backUrl = "back_url"
|
||||
}
|
||||
}
|
||||
|
||||
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 ? "true" : "false"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
/// 将 Int、Double 或数字字符串宽松解码为整数。
|
||||
func decodeLossyInt(forKey key: Key) throws -> Int? {
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let intValue = Int(text) {
|
||||
return intValue
|
||||
}
|
||||
if let doubleValue = Double(text) {
|
||||
return Int(doubleValue)
|
||||
}
|
||||
}
|
||||
if let value = try? decodeIfPresent(Double.self, forKey: key) {
|
||||
return Int(value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
122
suixinkan_ios/Features/Profile/Profile.md
Normal file
122
suixinkan_ios/Features/Profile/Profile.md
Normal file
@ -0,0 +1,122 @@
|
||||
# Profile 模块业务逻辑
|
||||
|
||||
## 模块职责
|
||||
|
||||
Profile 模块负责“我的/个人信息”页面及其二级页面,包括用户资料展示、昵称编辑、账号切换、密码修改、实名认证、系统设置、协议页和退出登录。
|
||||
|
||||
该模块聚焦个人资料业务:
|
||||
- 拉取用户基础资料。
|
||||
- 拉取实名认证信息。
|
||||
- 展示头像、昵称、UID、手机号、账号状态和实名认证状态。
|
||||
- 修改昵称和头像。
|
||||
- 修改密码。
|
||||
- 切换当前景区/门店业务账号。
|
||||
- 提交实名认证基础资料。
|
||||
- 展示系统设置、版本号、下载链接和协议 H5 页面。
|
||||
- 退出登录入口。
|
||||
|
||||
登录态清理和缓存清理由 App 模块的 `AuthSessionCoordinator` 统一处理。
|
||||
|
||||
DEBUG 构建下,“我的”列表会额外展示“首页调试”入口,进入 `DebugHomeMenuPreviewView` 后可查看并跳转所有已知首页菜单,不读取角色权限。该入口只用于迁移预览和调试,不进入 Release 包。
|
||||
|
||||
## 核心对象
|
||||
|
||||
- `ProfileView`:个人信息页 UI,负责展示资料、编辑入口、密码弹窗和退出确认。
|
||||
- `ProfileViewModel`:维护资料加载状态、编辑状态、保存状态和展示文案。
|
||||
- `ProfileRoute`:个人中心二级页面路由。
|
||||
- `AccountSwitchView` / `AccountSwitchViewModel`:账号切换页面和状态逻辑。
|
||||
- `RealNameAuthView` / `RealNameAuthViewModel`:实名认证页面和表单逻辑。
|
||||
- `SettingsCenterView` / `AgreementView`:设置中心和协议 H5 页面。
|
||||
- `DebugHomeMenuPreviewView`:DEBUG 专用首页菜单预览页,不参与正式业务流程。
|
||||
- `ProfileAPI`:封装用户资料、实名认证和资料更新接口。
|
||||
- `OSSUploadService`:上传头像和实名认证证件图片,返回可提交给业务接口的 OSS URL。
|
||||
- `UserInfoResponse`:用户基础资料。
|
||||
- `RealNameInfoResponse` / `RealNameInfo`:实名认证信息。
|
||||
- `UpdateInfoRequest`:昵称、密码或头像更新请求体。
|
||||
- `RealNameAuthRequest`:实名认证提交请求体。
|
||||
|
||||
## 加载流程
|
||||
|
||||
1. `ProfileView.task` 进入页面时调用 `reloadProfile(showToast: false)`。
|
||||
2. 下拉刷新时调用 `reloadProfile(showToast: true)`。
|
||||
3. `ProfileViewModel.reload` 并发请求:
|
||||
- `ProfileAPI.userInfo`
|
||||
- `ProfileAPI.realNameInfo`
|
||||
4. 请求成功后更新 `userInfo` 和 `realNameInfo`。
|
||||
5. `ProfileView` 将最新 `userInfo` 交给 `AuthSessionCoordinator.refreshCachedProfile`。
|
||||
6. 协调器更新 `AccountContext.profile`,并同步刷新账号快照。
|
||||
|
||||
## 资料编辑流程
|
||||
|
||||
1. 用户点击编辑按钮。
|
||||
2. `ProfileViewModel.beginEditing` 进入编辑状态,并把当前昵称写入编辑框。
|
||||
3. 用户点击头像时通过 `PhotosPicker` 选择本地图片。
|
||||
4. `AvatarImageProcessor` 将头像压缩为 JPEG,并暂存在内存中。
|
||||
5. 用户提交后,`ProfileViewModel.saveProfile` 校验昵称非空。
|
||||
6. 头像变化时先调用 `OSSUploadService.uploadUserAvatar` 上传到 OSS。
|
||||
7. 头像上传成功后调用 `ProfileAPI.updateUserAvatarURL` 回写头像 URL。
|
||||
8. 昵称变化时调用 `ProfileAPI.updateUserInfo(nickname:)`。
|
||||
9. 接口成功后本地更新 `userInfo.nickname` 和 `userInfo.avatar`。
|
||||
10. `ProfileView` 刷新全局账号资料和账号快照。
|
||||
|
||||
## 密码修改流程
|
||||
|
||||
1. 用户打开 `PasswordUpdateSheet`。
|
||||
2. 输入新密码并点击完成。
|
||||
3. `ProfileViewModel.updatePassword` 校验密码至少 6 位。
|
||||
4. 校验通过后调用 `ProfileAPI.updateUserInfo(password:)`。
|
||||
5. 成功后关闭弹窗并展示 Toast。
|
||||
|
||||
密码不会写入本地缓存。
|
||||
|
||||
## 账号切换流程
|
||||
|
||||
1. 用户点击“当前账号”进入 `AccountSwitchView`。
|
||||
2. `AccountSwitchViewModel.load` 调用 `ProfileAPI.switchableAccounts`,读取 `/api/app/v9/accounts`。
|
||||
3. 页面展示景区账号和门店账号,优先选中后端标记的当前账号,否则选中第一项。
|
||||
4. 用户确认后调用 `AuthAPI.setUser`,使用 `AccountSwitchAccount.toSetUserRequest()` 生成请求体。
|
||||
5. 切换成功后调用 `AuthSessionCoordinator.completeLogin`,重新写入正式 token、账号快照、权限、景区和门店上下文。
|
||||
6. `AppRouter.reset()` 清空旧路由栈并回到首页。
|
||||
|
||||
## 实名认证流程
|
||||
|
||||
1. 用户点击“认证状态”进入 `RealNameAuthView`。
|
||||
2. `RealNameAuthViewModel.load` 调用 `ProfileAPI.realNameInfo` 并回填姓名、身份证号、证件有效期、图片 URL 和审核状态。
|
||||
3. 用户可发送短信验证码,接口为 `/api/yf-handset-app/photog/real-name/sms-verify-code`。
|
||||
4. 用户通过图片卡片选择身份证人像面和国徽面。
|
||||
5. `RealNameImageProcessor` 将证件图片压缩为 JPEG,并暂存在内存中。
|
||||
6. 提交前校验姓名、身份证号、短信验证码、证件图片和有效期。
|
||||
7. 校验通过后先调用 `OSSUploadService.uploadRealNameImage` 上传证件图片。
|
||||
8. 上传成功后把 OSS URL 写入 `RealNameAuthRequest.frontUrl/backUrl`。
|
||||
9. 调用 `/api/yf-handset-app/photog/real-name/submit` 提交实名资料。
|
||||
10. 提交成功后重新加载实名认证信息。
|
||||
|
||||
证件图片的原始 Data、压缩 Data 和上传进度只保存在内存中,不写入本地缓存。
|
||||
|
||||
## 设置和协议流程
|
||||
|
||||
1. 用户点击“系统设置”进入 `SettingsCenterView`。
|
||||
2. 设置中心展示关于我们、系统版本、App 下载链接、用户协议和隐私政策。
|
||||
3. App 下载点击后复制 `/h5/app/download` 链接到剪贴板。
|
||||
4. 协议页使用 `AgreementView` 加载 `APIEnvironment.current.baseURL` 下的 H5 页面。
|
||||
|
||||
## 退出登录流程
|
||||
|
||||
1. 用户点击退出登录。
|
||||
2. `ProfileView` 展示确认弹窗。
|
||||
3. 用户确认后调用 `AuthSessionCoordinator.logout`。
|
||||
4. 协调器清空正式 token、账号快照、账号上下文、路由栈和 Toast。
|
||||
5. `AppSession` 切换为 `loggedOut`,根视图回到登录页。
|
||||
6. 上次手机号和协议状态等非敏感偏好保留。
|
||||
|
||||
## 状态展示规则
|
||||
|
||||
- 昵称为空时展示“未设置昵称”。
|
||||
- 真实姓名、手机号和账号状态为空时展示 `--`。
|
||||
- 实名认证状态:
|
||||
- `auditStatus == 2`:已实名认证。
|
||||
- `auditStatus == 3`:审核不通过。
|
||||
- 其他状态:审核中。
|
||||
- 无实名认证信息时:点击去实名认证。
|
||||
- 账号切换失败只展示 Toast,不清空登录态。
|
||||
- 实名认证失败只影响实名认证页面,不影响登录态和账号上下文。
|
||||
19
suixinkan_ios/Features/Profile/Routing/ProfileRoute.swift
Normal file
19
suixinkan_ios/Features/Profile/Routing/ProfileRoute.swift
Normal file
@ -0,0 +1,19 @@
|
||||
//
|
||||
// ProfileRoute.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 个人中心二级页面路由,集中声明“我的”Tab 可以 push 的页面。
|
||||
enum ProfileRoute: Hashable {
|
||||
case accountSwitch
|
||||
case realNameAuth
|
||||
case settings
|
||||
case agreement(AgreementPage)
|
||||
#if DEBUG
|
||||
case debugHomeMenus
|
||||
#endif
|
||||
}
|
||||
@ -0,0 +1,263 @@
|
||||
//
|
||||
// AccountSwitchViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 账号切换页面,展示当前登录主体下可进入的景区账号和门店账号。
|
||||
final class AccountSwitchViewController: UIViewController {
|
||||
|
||||
private let viewModel = AccountSwitchViewModel()
|
||||
|
||||
private lazy var tableView: UITableView = {
|
||||
let table = UITableView(frame: .zero, style: .plain)
|
||||
table.backgroundColor = UIColor(hex: 0xF5F7FB)
|
||||
table.separatorStyle = .none
|
||||
table.dataSource = self
|
||||
table.delegate = self
|
||||
table.register(AccountSwitchCell.self, forCellReuseIdentifier: AccountSwitchCell.reuseID)
|
||||
return table
|
||||
}()
|
||||
|
||||
private lazy var confirmButton: UIButton = {
|
||||
let button = makePrimaryButton(title: "确认切换")
|
||||
button.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
|
||||
return button
|
||||
}()
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = "账号切换"
|
||||
view.backgroundColor = UIColor(hex: 0xF5F7FB)
|
||||
|
||||
view.addSubview(tableView)
|
||||
view.addSubview(confirmButton)
|
||||
tableView.snp.makeConstraints { make in
|
||||
make.top.leading.trailing.equalToSuperview()
|
||||
make.bottom.equalTo(confirmButton.snp.top).offset(-12)
|
||||
}
|
||||
confirmButton.snp.makeConstraints { make in
|
||||
make.leading.trailing.equalToSuperview().inset(16)
|
||||
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(14)
|
||||
make.height.equalTo(50)
|
||||
}
|
||||
|
||||
viewModel.onChange = { [weak self] in
|
||||
self?.tableView.reloadData()
|
||||
self?.updateConfirmButton()
|
||||
}
|
||||
Task { await loadAccounts() }
|
||||
}
|
||||
|
||||
private func updateConfirmButton() {
|
||||
let enabled = viewModel.selectedAccount != nil && !viewModel.loading && !viewModel.switching
|
||||
confirmButton.isEnabled = enabled
|
||||
confirmButton.alpha = enabled ? 1 : 0.5
|
||||
}
|
||||
|
||||
private func loadAccounts(force: Bool = false) async {
|
||||
do {
|
||||
try await appServices.globalLoading.withOptionalLoading(!force && viewModel.accounts.isEmpty, message: "加载中...") {
|
||||
try await self.viewModel.load(
|
||||
api: self.appServices.profileAPI,
|
||||
force: force,
|
||||
currentAccountId: self.currentAccountId
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
showToast(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private var currentAccountId: String? {
|
||||
guard let current = appServices.accountContext.profile else { return nil }
|
||||
if let store = appServices.accountContext.currentStore {
|
||||
return "\(V9StoreUser.accountTypeValue)_\(store.id)"
|
||||
}
|
||||
if let scenic = appServices.accountContext.currentScenic {
|
||||
return "\(V9ScenicUser.accountTypeValue)_\(scenic.id)"
|
||||
}
|
||||
return current.userId.isEmpty ? nil : current.userId
|
||||
}
|
||||
|
||||
private func isCurrentAccount(_ account: AccountSwitchAccount) -> Bool {
|
||||
viewModel.isCurrent(account, currentAccountId: currentAccountId)
|
||||
}
|
||||
|
||||
@objc private func confirmTapped() {
|
||||
guard let account = viewModel.selectedAccount else { return }
|
||||
if account.isCurrent || isCurrentAccount(account) {
|
||||
navigationController?.popViewController(animated: true)
|
||||
return
|
||||
}
|
||||
Task { await switchAccount(account) }
|
||||
}
|
||||
|
||||
private func switchAccount(_ account: AccountSwitchAccount) async {
|
||||
do {
|
||||
let response = try await appServices.globalLoading.withLoading(message: "切换中...") {
|
||||
try await self.viewModel.switchAccount(account, api: self.appServices.authAPI)
|
||||
}
|
||||
let username = nonEmpty(appServices.accountContext.profile?.phone)
|
||||
?? nonEmpty(account.phone)
|
||||
?? ""
|
||||
try await appServices.authSessionCoordinator.completeLogin(
|
||||
with: response,
|
||||
username: username,
|
||||
privacyAgreementAccepted: appServices.authSessionCoordinator.loginPreferences().privacyAgreementAccepted,
|
||||
appSession: appServices.appSession,
|
||||
accountContext: appServices.accountContext,
|
||||
permissionContext: appServices.permissionContext,
|
||||
profileAPI: appServices.profileAPI,
|
||||
accountContextAPI: appServices.accountContextAPI
|
||||
)
|
||||
appServices.appRouter.reset()
|
||||
showToast("账号已切换")
|
||||
navigationController?.popToRootViewController(animated: true)
|
||||
} catch {
|
||||
showToast(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func nonEmpty(_ value: String?) -> String? {
|
||||
let text = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return text.isEmpty ? nil : text
|
||||
}
|
||||
}
|
||||
|
||||
extension AccountSwitchViewController: UITableViewDataSource, UITableViewDelegate {
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
viewModel.accounts.isEmpty && !viewModel.loading ? 1 : viewModel.accounts.count
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
guard !viewModel.accounts.isEmpty else {
|
||||
let cell = UITableViewCell()
|
||||
cell.selectionStyle = .none
|
||||
cell.backgroundColor = .clear
|
||||
cell.contentView.subviews.forEach { $0.removeFromSuperview() }
|
||||
let empty = makeEmptyStateView(
|
||||
title: "暂无可切换账号",
|
||||
message: "当前登录账号下没有其他可切换账号。",
|
||||
systemImage: "person.crop.circle.badge.exclamationmark"
|
||||
)
|
||||
cell.contentView.addSubview(empty)
|
||||
empty.snp.makeConstraints { make in make.edges.equalToSuperview(); make.height.equalTo(280) }
|
||||
return cell
|
||||
}
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: AccountSwitchCell.reuseID, for: indexPath) as! AccountSwitchCell
|
||||
let account = viewModel.accounts[indexPath.row]
|
||||
cell.configure(
|
||||
account: account,
|
||||
selected: viewModel.selectedAccountId == account.id,
|
||||
isCurrent: account.isCurrent || isCurrentAccount(account)
|
||||
)
|
||||
return cell
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
guard indexPath.row < viewModel.accounts.count else { return }
|
||||
viewModel.select(viewModel.accounts[indexPath.row])
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||
viewModel.accounts.isEmpty ? 280 : 92
|
||||
}
|
||||
}
|
||||
|
||||
private final class AccountSwitchCell: UITableViewCell {
|
||||
static let reuseID = "AccountSwitchCell"
|
||||
|
||||
private let avatarView = UIImageView()
|
||||
private let titleLabel = UILabel()
|
||||
private let subtitleLabel = UILabel()
|
||||
private let phoneLabel = UILabel()
|
||||
private let tagLabel = UILabel()
|
||||
private let checkView = UIImageView()
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
selectionStyle = .none
|
||||
backgroundColor = .clear
|
||||
|
||||
let card = UIView()
|
||||
card.backgroundColor = .white
|
||||
card.layer.cornerRadius = 12
|
||||
contentView.addSubview(card)
|
||||
|
||||
avatarView.layer.cornerRadius = 25
|
||||
avatarView.clipsToBounds = true
|
||||
avatarView.contentMode = .scaleAspectFill
|
||||
|
||||
titleLabel.font = .systemFont(ofSize: 16, weight: .semibold)
|
||||
subtitleLabel.font = .systemFont(ofSize: 13)
|
||||
subtitleLabel.textColor = AppDesignUIKit.textSecondary
|
||||
phoneLabel.font = .systemFont(ofSize: 12)
|
||||
phoneLabel.textColor = UIColor(hex: 0x9AA1AA)
|
||||
tagLabel.font = .systemFont(ofSize: 11, weight: .semibold)
|
||||
tagLabel.textAlignment = .center
|
||||
tagLabel.layer.cornerRadius = 4
|
||||
tagLabel.clipsToBounds = true
|
||||
|
||||
card.addSubview(avatarView)
|
||||
card.addSubview(titleLabel)
|
||||
card.addSubview(subtitleLabel)
|
||||
card.addSubview(phoneLabel)
|
||||
card.addSubview(tagLabel)
|
||||
card.addSubview(checkView)
|
||||
|
||||
card.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 6, left: 16, bottom: 6, right: 16))
|
||||
}
|
||||
avatarView.snp.makeConstraints { make in
|
||||
make.leading.centerY.equalToSuperview().inset(14)
|
||||
make.width.height.equalTo(50)
|
||||
}
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.leading.equalTo(avatarView.snp.trailing).offset(13)
|
||||
make.top.equalTo(avatarView).offset(2)
|
||||
make.trailing.lessThanOrEqualTo(tagLabel.snp.leading).offset(-8)
|
||||
}
|
||||
subtitleLabel.snp.makeConstraints { make in
|
||||
make.leading.equalTo(titleLabel)
|
||||
make.top.equalTo(titleLabel.snp.bottom).offset(4)
|
||||
}
|
||||
phoneLabel.snp.makeConstraints { make in
|
||||
make.leading.equalTo(titleLabel)
|
||||
make.top.equalTo(subtitleLabel.snp.bottom).offset(4)
|
||||
}
|
||||
tagLabel.snp.makeConstraints { make in
|
||||
make.trailing.equalToSuperview().inset(14)
|
||||
make.top.equalToSuperview().offset(14)
|
||||
make.height.equalTo(22)
|
||||
make.width.greaterThanOrEqualTo(40)
|
||||
}
|
||||
checkView.snp.makeConstraints { make in
|
||||
make.trailing.equalToSuperview().inset(14)
|
||||
make.bottom.equalToSuperview().inset(14)
|
||||
make.width.height.equalTo(22)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
func configure(account: AccountSwitchAccount, selected: Bool, isCurrent: Bool) {
|
||||
let title = account.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
titleLabel.text = title.isEmpty ? account.accountTypeLabel : title
|
||||
subtitleLabel.text = account.subtitle
|
||||
phoneLabel.text = account.phone
|
||||
tagLabel.text = account.isStoreUser ? "门店" : "景区"
|
||||
tagLabel.textColor = account.isStoreUser ? UIColor(hex: 0x0F9F6E) : UIColor(hex: 0x7C3AED)
|
||||
tagLabel.backgroundColor = account.isStoreUser ? UIColor(hex: 0xE8F8F1) : UIColor(hex: 0xF3ECFF)
|
||||
checkView.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle")
|
||||
checkView.tintColor = selected ? AppDesignUIKit.primary : UIColor(hex: 0xB6BECA)
|
||||
avatarView.loadRemoteAvatar(urlString: account.avatar)
|
||||
if isCurrent {
|
||||
titleLabel.text = (titleLabel.text ?? "") + " (当前)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,278 @@
|
||||
//
|
||||
// ProfileViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import PhotosUI
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 个人信息页,展示用户资料、账号状态和设置入口。
|
||||
final class ProfileViewController: UIViewController {
|
||||
|
||||
private let viewModel = ProfileViewModel()
|
||||
|
||||
private let avatarImageView = UIImageView()
|
||||
private let nicknameLabel = UILabel()
|
||||
private let uidLabel = UILabel()
|
||||
private let editButton = UIButton(type: .system)
|
||||
|
||||
private lazy var tableView: UITableView = {
|
||||
let table = UITableView(frame: .zero, style: .insetGrouped)
|
||||
table.dataSource = self
|
||||
table.delegate = self
|
||||
return table
|
||||
}()
|
||||
|
||||
private lazy var refreshControl = UIRefreshControl()
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = "个人信息"
|
||||
view.backgroundColor = UIColor(hex: 0xF7FAFF)
|
||||
setupHeader()
|
||||
view.addSubview(tableView)
|
||||
tableView.snp.makeConstraints { make in
|
||||
make.top.equalTo(view.safeAreaLayoutGuide).offset(150)
|
||||
make.leading.trailing.bottom.equalToSuperview()
|
||||
}
|
||||
refreshControl.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
|
||||
tableView.refreshControl = refreshControl
|
||||
|
||||
viewModel.onChange = { [weak self] in self?.applyViewModel() }
|
||||
editButton.addTarget(self, action: #selector(editTapped), for: .touchUpInside)
|
||||
|
||||
let avatarTap = UITapGestureRecognizer(target: self, action: #selector(pickAvatar))
|
||||
avatarImageView.isUserInteractionEnabled = true
|
||||
avatarImageView.addGestureRecognizer(avatarTap)
|
||||
|
||||
Task { await reloadProfile(showToast: false) }
|
||||
}
|
||||
|
||||
private func setupHeader() {
|
||||
let header = UIView()
|
||||
header.backgroundColor = .white
|
||||
header.layer.cornerRadius = 17
|
||||
view.addSubview(header)
|
||||
|
||||
avatarImageView.layer.cornerRadius = 43
|
||||
avatarImageView.clipsToBounds = true
|
||||
avatarImageView.contentMode = .scaleAspectFill
|
||||
avatarImageView.backgroundColor = AppDesignUIKit.primarySoft
|
||||
|
||||
nicknameLabel.font = .systemFont(ofSize: 24, weight: .semibold)
|
||||
nicknameLabel.textColor = UIColor(hex: 0x252525)
|
||||
uidLabel.font = .systemFont(ofSize: 18, weight: .semibold)
|
||||
uidLabel.textColor = UIColor(hex: 0x606A7A)
|
||||
|
||||
editButton.setImage(UIImage(systemName: "pencil"), for: .normal)
|
||||
editButton.tintColor = .white
|
||||
editButton.backgroundColor = AppDesignUIKit.primary
|
||||
editButton.layer.cornerRadius = 22
|
||||
|
||||
header.addSubview(avatarImageView)
|
||||
header.addSubview(nicknameLabel)
|
||||
header.addSubview(uidLabel)
|
||||
header.addSubview(editButton)
|
||||
|
||||
header.snp.makeConstraints { make in
|
||||
make.top.equalTo(view.safeAreaLayoutGuide).offset(16)
|
||||
make.leading.trailing.equalToSuperview().inset(19)
|
||||
make.height.equalTo(132)
|
||||
}
|
||||
avatarImageView.snp.makeConstraints { make in
|
||||
make.leading.equalToSuperview().offset(18)
|
||||
make.centerY.equalToSuperview()
|
||||
make.width.height.equalTo(86)
|
||||
}
|
||||
nicknameLabel.snp.makeConstraints { make in
|
||||
make.leading.equalTo(avatarImageView.snp.trailing).offset(14)
|
||||
make.trailing.lessThanOrEqualTo(editButton.snp.leading).offset(-8)
|
||||
make.top.equalTo(avatarImageView).offset(8)
|
||||
}
|
||||
uidLabel.snp.makeConstraints { make in
|
||||
make.leading.equalTo(nicknameLabel)
|
||||
make.top.equalTo(nicknameLabel.snp.bottom).offset(8)
|
||||
}
|
||||
editButton.snp.makeConstraints { make in
|
||||
make.trailing.equalToSuperview().inset(18)
|
||||
make.centerY.equalToSuperview()
|
||||
make.width.height.equalTo(44)
|
||||
}
|
||||
}
|
||||
|
||||
private func applyViewModel() {
|
||||
nicknameLabel.text = viewModel.displayNickname
|
||||
uidLabel.text = "UID: \(appServices.accountContext.profile?.userId ?? "--")"
|
||||
if let data = viewModel.pendingAvatarData, let image = UIImage(data: data) {
|
||||
avatarImageView.image = image
|
||||
} else {
|
||||
avatarImageView.loadRemoteAvatar(urlString: viewModel.displayAvatarURL)
|
||||
}
|
||||
editButton.setImage(UIImage(systemName: viewModel.isEditingProfile ? "checkmark" : "pencil"), for: .normal)
|
||||
editButton.isEnabled = !viewModel.isSaving
|
||||
tableView.reloadData()
|
||||
}
|
||||
|
||||
@objc private func refreshPulled() {
|
||||
Task {
|
||||
await reloadProfile(showToast: true)
|
||||
refreshControl.endRefreshing()
|
||||
}
|
||||
}
|
||||
|
||||
private func reloadProfile(showToast: Bool) async {
|
||||
do {
|
||||
try await appServices.globalLoading.withOptionalLoading(!showToast && viewModel.userInfo == nil, message: "加载资料...") {
|
||||
try await self.viewModel.reload(api: self.appServices.profileAPI)
|
||||
}
|
||||
} catch {
|
||||
if showToast { self.showToast(error.localizedDescription) }
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func editTapped() {
|
||||
if viewModel.isEditingProfile {
|
||||
Task { await saveProfileEdits() }
|
||||
} else {
|
||||
viewModel.beginEditing()
|
||||
presentNicknameEditor()
|
||||
}
|
||||
}
|
||||
|
||||
private func presentNicknameEditor() {
|
||||
let alert = UIAlertController(title: "编辑昵称", message: nil, preferredStyle: .alert)
|
||||
alert.addTextField { [weak self] field in
|
||||
field.text = self?.viewModel.editingNickname
|
||||
field.placeholder = "请输入昵称"
|
||||
}
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel) { [weak self] _ in
|
||||
self?.viewModel.cancelEditing()
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "保存", style: .default) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
self.viewModel.editingNickname = alert.textFields?.first?.text ?? ""
|
||||
Task { await self.saveProfileEdits() }
|
||||
})
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func saveProfileEdits() async {
|
||||
guard let scenicId = appServices.accountContext.currentScenic?.id else {
|
||||
showToast("请先选择景区")
|
||||
return
|
||||
}
|
||||
do {
|
||||
try await appServices.globalLoading.withLoading(message: "保存中...") {
|
||||
try await self.viewModel.saveProfile(
|
||||
api: self.appServices.profileAPI,
|
||||
uploader: self.appServices.ossUploadService,
|
||||
scenicId: scenicId
|
||||
)
|
||||
}
|
||||
showToast("资料已更新")
|
||||
} catch {
|
||||
showToast(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func pickAvatar() {
|
||||
var config = PHPickerConfiguration()
|
||||
config.filter = .images
|
||||
config.selectionLimit = 1
|
||||
let picker = PHPickerViewController(configuration: config)
|
||||
picker.delegate = self
|
||||
present(picker, animated: true)
|
||||
}
|
||||
|
||||
@objc private func logoutTapped() {
|
||||
let alert = UIAlertController(title: "确认退出当前账号?", message: nil, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
alert.addAction(UIAlertAction(title: "退出登录", style: .destructive) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
self.appServices.authSessionCoordinator.logout(
|
||||
appSession: self.appServices.appSession,
|
||||
accountContext: self.appServices.accountContext,
|
||||
permissionContext: self.appServices.permissionContext,
|
||||
scenicSpotContext: self.appServices.scenicSpotContext,
|
||||
appRouter: self.appServices.appRouter,
|
||||
toastCenter: self.appServices.toastCenter
|
||||
)
|
||||
})
|
||||
present(alert, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
extension ProfileViewController: UITableViewDataSource, UITableViewDelegate {
|
||||
func numberOfSections(in tableView: UITableView) -> Int { 2 }
|
||||
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
section == 0 ? 5 : 1
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
|
||||
if indexPath.section == 1 {
|
||||
cell.textLabel?.text = "退出登录"
|
||||
cell.textLabel?.textColor = UIColor(hex: 0xEF4444)
|
||||
cell.textLabel?.textAlignment = .center
|
||||
return cell
|
||||
}
|
||||
switch indexPath.row {
|
||||
case 0:
|
||||
cell.textLabel?.text = "姓名"
|
||||
cell.detailTextLabel?.text = viewModel.displayRealName
|
||||
case 1:
|
||||
cell.textLabel?.text = "当前账号"
|
||||
cell.detailTextLabel?.text = appServices.accountContext.profile?.displayName ?? "--"
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
case 2:
|
||||
cell.textLabel?.text = "手机号"
|
||||
cell.detailTextLabel?.text = viewModel.displayPhone
|
||||
case 3:
|
||||
cell.textLabel?.text = "实名认证"
|
||||
cell.detailTextLabel?.text = viewModel.realNameStatusText
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
default:
|
||||
cell.textLabel?.text = "设置"
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
}
|
||||
cell.selectionStyle = indexPath.row == 0 || indexPath.row == 2 ? .none : .default
|
||||
return cell
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
if indexPath.section == 1 {
|
||||
logoutTapped()
|
||||
return
|
||||
}
|
||||
switch indexPath.row {
|
||||
case 1:
|
||||
HomeMenuRouting.pushProfile(.accountSwitch, from: self)
|
||||
case 3:
|
||||
HomeMenuRouting.pushProfile(.realNameAuth, from: self)
|
||||
case 4:
|
||||
HomeMenuRouting.pushProfile(.settings, from: self)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ProfileViewController: PHPickerViewControllerDelegate {
|
||||
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
|
||||
picker.dismiss(animated: true)
|
||||
guard let provider = results.first?.itemProvider, provider.canLoadObject(ofClass: UIImage.self) else { return }
|
||||
provider.loadObject(ofClass: UIImage.self) { [weak self] object, _ in
|
||||
guard let self, let image = object as? UIImage, let data = image.jpegData(compressionQuality: 0.9) else { return }
|
||||
DispatchQueue.main.async {
|
||||
do {
|
||||
try self.viewModel.prepareAvatarImage(data: data)
|
||||
} catch {
|
||||
self.showToast(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,261 @@
|
||||
//
|
||||
// RealNameAuthViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import PhotosUI
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 实名认证页面,展示审核状态并允许提交基础实名资料。
|
||||
final class RealNameAuthViewController: UIViewController {
|
||||
|
||||
private let viewModel = RealNameAuthViewModel()
|
||||
|
||||
private let scrollView = UIScrollView()
|
||||
private let contentStack = UIStackView()
|
||||
|
||||
private let realNameField = UITextField()
|
||||
private let idCardField = UITextField()
|
||||
private let smsCodeField = UITextField()
|
||||
private let frontImageView = UIImageView()
|
||||
private let backImageView = UIImageView()
|
||||
private let statusLabel = UILabel()
|
||||
private let longValidSwitch = UISwitch()
|
||||
private let startDatePicker = UIDatePicker()
|
||||
private let endDatePicker = UIDatePicker()
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = "实名认证"
|
||||
view.backgroundColor = AppDesignUIKit.pageBackground
|
||||
setupForm()
|
||||
viewModel.onChange = { [weak self] in self?.applyViewModel() }
|
||||
Task { await loadInfo() }
|
||||
}
|
||||
|
||||
private func setupForm() {
|
||||
contentStack.axis = .vertical
|
||||
contentStack.spacing = 16
|
||||
view.addSubview(scrollView)
|
||||
scrollView.addSubview(contentStack)
|
||||
scrollView.snp.makeConstraints { make in make.edges.equalToSuperview() }
|
||||
contentStack.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(16)
|
||||
make.width.equalTo(scrollView).offset(-32)
|
||||
}
|
||||
|
||||
[realNameField, idCardField, smsCodeField].forEach {
|
||||
$0.borderStyle = .roundedRect
|
||||
$0.font = .systemFont(ofSize: 16)
|
||||
}
|
||||
realNameField.placeholder = "请输入姓名"
|
||||
idCardField.placeholder = "请输入身份证号码"
|
||||
smsCodeField.placeholder = "请输入短信验证码"
|
||||
smsCodeField.keyboardType = .numberPad
|
||||
|
||||
[frontImageView, backImageView].forEach {
|
||||
$0.backgroundColor = UIColor(hex: 0xF8FAFC)
|
||||
$0.contentMode = .scaleAspectFit
|
||||
$0.layer.cornerRadius = 8
|
||||
$0.clipsToBounds = true
|
||||
$0.isUserInteractionEnabled = true
|
||||
$0.snp.makeConstraints { make in make.height.equalTo(160) }
|
||||
}
|
||||
frontImageView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(pickFront)))
|
||||
backImageView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(pickBack)))
|
||||
|
||||
startDatePicker.datePickerMode = .date
|
||||
endDatePicker.datePickerMode = .date
|
||||
if #available(iOS 17.0, *) {
|
||||
startDatePicker.preferredDatePickerStyle = .compact
|
||||
endDatePicker.preferredDatePickerStyle = .compact
|
||||
}
|
||||
|
||||
let sendCodeButton = UIButton(type: .system)
|
||||
sendCodeButton.setTitle("获取验证码", for: .normal)
|
||||
sendCodeButton.addTarget(self, action: #selector(sendCodeTapped), for: .touchUpInside)
|
||||
|
||||
let submitButton = makePrimaryButton(title: "下一步")
|
||||
submitButton.addTarget(self, action: #selector(submitTapped), for: .touchUpInside)
|
||||
|
||||
statusLabel.numberOfLines = 0
|
||||
statusLabel.font = .systemFont(ofSize: 13)
|
||||
statusLabel.textColor = AppDesignUIKit.textSecondary
|
||||
|
||||
contentStack.addArrangedSubview(makeSectionTitle("审核状态"))
|
||||
contentStack.addArrangedSubview(statusLabel)
|
||||
contentStack.addArrangedSubview(makeSectionTitle("身份证国徽面"))
|
||||
contentStack.addArrangedSubview(backImageView)
|
||||
contentStack.addArrangedSubview(makeSectionTitle("身份证人像面"))
|
||||
contentStack.addArrangedSubview(frontImageView)
|
||||
contentStack.addArrangedSubview(labeledField("姓名", realNameField))
|
||||
contentStack.addArrangedSubview(labeledField("身份证号码", idCardField))
|
||||
|
||||
let longValidRow = UIStackView(arrangedSubviews: [UILabel(text: "长期有效"), longValidSwitch])
|
||||
longValidRow.axis = .horizontal
|
||||
longValidSwitch.addTarget(self, action: #selector(longValidChanged), for: .valueChanged)
|
||||
contentStack.addArrangedSubview(longValidRow)
|
||||
contentStack.addArrangedSubview(startDatePicker)
|
||||
contentStack.addArrangedSubview(endDatePicker)
|
||||
|
||||
let smsRow = UIStackView(arrangedSubviews: [smsCodeField, sendCodeButton])
|
||||
smsRow.axis = .horizontal
|
||||
smsRow.spacing = 8
|
||||
smsCodeField.snp.makeConstraints { make in make.height.equalTo(44) }
|
||||
contentStack.addArrangedSubview(smsRow)
|
||||
contentStack.addArrangedSubview(submitButton)
|
||||
}
|
||||
|
||||
private func makeSectionTitle(_ text: String) -> UILabel {
|
||||
let label = UILabel()
|
||||
label.text = text
|
||||
label.font = .systemFont(ofSize: 17, weight: .semibold)
|
||||
return label
|
||||
}
|
||||
|
||||
private func labeledField(_ title: String, _ field: UITextField) -> UIStackView {
|
||||
let label = UILabel()
|
||||
label.text = title
|
||||
label.font = .systemFont(ofSize: 15, weight: .medium)
|
||||
let stack = UIStackView(arrangedSubviews: [label, field])
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 8
|
||||
field.snp.makeConstraints { make in make.height.equalTo(44) }
|
||||
return stack
|
||||
}
|
||||
|
||||
private func applyViewModel() {
|
||||
realNameField.text = viewModel.realName
|
||||
idCardField.text = viewModel.idCardNo
|
||||
smsCodeField.text = viewModel.smsCode
|
||||
longValidSwitch.isOn = viewModel.isLongValid
|
||||
startDatePicker.date = viewModel.startDate
|
||||
endDatePicker.date = viewModel.endDate
|
||||
endDatePicker.isEnabled = !viewModel.isLongValid
|
||||
|
||||
if let data = viewModel.pendingFrontImageData, let image = UIImage(data: data) {
|
||||
frontImageView.image = image
|
||||
} else if !viewModel.frontUrl.isEmpty {
|
||||
frontImageView.loadRemoteImage(urlString: viewModel.frontUrl, contentMode: .scaleAspectFit)
|
||||
}
|
||||
|
||||
if let data = viewModel.pendingBackImageData, let image = UIImage(data: data) {
|
||||
backImageView.image = image
|
||||
} else if !viewModel.backUrl.isEmpty {
|
||||
backImageView.image = nil
|
||||
backImageView.loadRemoteImage(urlString: viewModel.backUrl, contentMode: .scaleAspectFit)
|
||||
}
|
||||
|
||||
if let info = viewModel.info {
|
||||
statusLabel.text = """
|
||||
审核状态:\(viewModel.auditStatusText(info.auditStatus))
|
||||
审核人:\(info.auditor?.name ?? "--")
|
||||
审核时间:\(info.auditAt ?? "--")
|
||||
审核备注:\(info.rejectReason ?? "--")
|
||||
"""
|
||||
} else {
|
||||
statusLabel.text = "尚未提交实名认证"
|
||||
}
|
||||
|
||||
if let message = viewModel.statusMessage {
|
||||
showToast(message)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadInfo() async {
|
||||
do {
|
||||
try await appServices.globalLoading.withLoading(message: "加载中...") {
|
||||
try await self.viewModel.load(api: self.appServices.profileAPI)
|
||||
}
|
||||
} catch {
|
||||
showToast(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func longValidChanged() {
|
||||
viewModel.isLongValid = longValidSwitch.isOn
|
||||
}
|
||||
|
||||
@objc private func sendCodeTapped() {
|
||||
Task {
|
||||
do {
|
||||
try await viewModel.sendCode(api: appServices.profileAPI)
|
||||
} catch {
|
||||
showToast(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func submitTapped() {
|
||||
viewModel.realName = realNameField.text ?? ""
|
||||
viewModel.idCardNo = idCardField.text ?? ""
|
||||
viewModel.smsCode = smsCodeField.text ?? ""
|
||||
viewModel.startDate = startDatePicker.date
|
||||
viewModel.endDate = endDatePicker.date
|
||||
|
||||
guard let scenicId = appServices.accountContext.currentScenic?.id else {
|
||||
showToast("请先选择景区")
|
||||
return
|
||||
}
|
||||
Task {
|
||||
do {
|
||||
try await appServices.globalLoading.withLoading(message: "提交中...") {
|
||||
try await self.viewModel.submit(
|
||||
api: self.appServices.profileAPI,
|
||||
uploader: self.appServices.ossUploadService,
|
||||
scenicId: scenicId
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
showToast(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var pendingSide: RealNameImageSide = .front
|
||||
|
||||
@objc private func pickFront() {
|
||||
pendingSide = .front
|
||||
presentImagePicker()
|
||||
}
|
||||
|
||||
@objc private func pickBack() {
|
||||
pendingSide = .back
|
||||
presentImagePicker()
|
||||
}
|
||||
|
||||
private func presentImagePicker() {
|
||||
var config = PHPickerConfiguration()
|
||||
config.filter = .images
|
||||
config.selectionLimit = 1
|
||||
let picker = PHPickerViewController(configuration: config)
|
||||
picker.delegate = self
|
||||
present(picker, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
extension RealNameAuthViewController: PHPickerViewControllerDelegate {
|
||||
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
|
||||
picker.dismiss(animated: true)
|
||||
guard let provider = results.first?.itemProvider, provider.canLoadObject(ofClass: UIImage.self) else { return }
|
||||
provider.loadObject(ofClass: UIImage.self) { [weak self] object, _ in
|
||||
guard let self, let image = object as? UIImage, let data = image.jpegData(compressionQuality: 0.9) else { return }
|
||||
DispatchQueue.main.async {
|
||||
do {
|
||||
try self.viewModel.prepareIdentityImage(data: data, side: self.pendingSide)
|
||||
} catch {
|
||||
self.showToast(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension UILabel {
|
||||
convenience init(text: String) {
|
||||
self.init()
|
||||
self.text = text
|
||||
self.font = .systemFont(ofSize: 16, weight: .semibold)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,135 @@
|
||||
//
|
||||
// SettingsViewControllers.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
import WebKit
|
||||
|
||||
/// 设置中心页面。
|
||||
final class SettingsViewController: UIViewController {
|
||||
|
||||
private var copiedDownloadLink = false
|
||||
|
||||
private lazy var tableView: UITableView = {
|
||||
let table = UITableView(frame: .zero, style: .insetGrouped)
|
||||
table.dataSource = self
|
||||
table.delegate = self
|
||||
return table
|
||||
}()
|
||||
|
||||
private let rows: [(title: String, action: SettingsRowAction)] = [
|
||||
("关于我们", .agreement(.about)),
|
||||
("系统版本", .version),
|
||||
("App下载", .download),
|
||||
("用户协议", .agreement(.userAgreement)),
|
||||
("隐私政策", .agreement(.privacyPolicy))
|
||||
]
|
||||
|
||||
private enum SettingsRowAction {
|
||||
case agreement(AgreementPage)
|
||||
case version
|
||||
case download
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = "设置"
|
||||
view.addSubview(tableView)
|
||||
tableView.snp.makeConstraints { make in make.edges.equalToSuperview() }
|
||||
}
|
||||
|
||||
private var downloadLink: String {
|
||||
APIEnvironment.current.baseURL.appending(path: "/h5/app/download").absoluteString
|
||||
}
|
||||
|
||||
private func copyDownloadLink() {
|
||||
UIPasteboard.general.string = downloadLink
|
||||
copiedDownloadLink = true
|
||||
showToast("下载链接已复制")
|
||||
tableView.reloadRows(at: [IndexPath(row: 2, section: 0)], with: .none)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in
|
||||
self?.copiedDownloadLink = false
|
||||
self?.tableView.reloadRows(at: [IndexPath(row: 2, section: 0)], with: .none)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension SettingsViewController: UITableViewDataSource, UITableViewDelegate {
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { rows.count }
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
|
||||
let row = rows[indexPath.row]
|
||||
cell.textLabel?.text = row.title
|
||||
cell.textLabel?.textColor = UIColor(hex: 0x4B5563)
|
||||
switch row.action {
|
||||
case .version:
|
||||
cell.detailTextLabel?.text = SettingsDisplayPolicy.versionText()
|
||||
cell.selectionStyle = .none
|
||||
case .download:
|
||||
cell.detailTextLabel?.text = copiedDownloadLink ? "已复制" : "复制链接"
|
||||
cell.detailTextLabel?.textColor = AppDesignUIKit.primary
|
||||
case .agreement:
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
}
|
||||
return cell
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
switch rows[indexPath.row].action {
|
||||
case .agreement(let page):
|
||||
HomeMenuRouting.pushProfile(.agreement(page), from: self)
|
||||
case .download:
|
||||
copyDownloadLink()
|
||||
case .version:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? {
|
||||
"Copyright © 2025 All Rights Reserved\n苏ICP备2025157647号"
|
||||
}
|
||||
}
|
||||
|
||||
/// 协议 H5 页面。
|
||||
final class AgreementViewController: UIViewController {
|
||||
|
||||
private let page: AgreementPage
|
||||
private let webView = WKWebView(frame: .zero)
|
||||
private let loadingIndicator = UIActivityIndicatorView(style: .large)
|
||||
|
||||
init(page: AgreementPage) {
|
||||
self.page = page
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = page.title
|
||||
view.backgroundColor = AppDesignUIKit.pageBackground
|
||||
webView.navigationDelegate = self
|
||||
view.addSubview(webView)
|
||||
view.addSubview(loadingIndicator)
|
||||
webView.snp.makeConstraints { make in make.edges.equalToSuperview() }
|
||||
loadingIndicator.snp.makeConstraints { make in make.center.equalToSuperview() }
|
||||
loadingIndicator.startAnimating()
|
||||
webView.load(URLRequest(url: page.url))
|
||||
}
|
||||
}
|
||||
|
||||
extension AgreementViewController: WKNavigationDelegate {
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
loadingIndicator.stopAnimating()
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
||||
loadingIndicator.stopAnimating()
|
||||
showToast(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,77 @@
|
||||
//
|
||||
// AccountSwitchViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
/// 账号切换 ViewModel,管理可切换账号列表、选择状态和提交状态。
|
||||
final class AccountSwitchViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
private(set) var accounts: [AccountSwitchAccount] = [] { didSet { onChange?() } }
|
||||
var selectedAccountId: String? { didSet { onChange?() } }
|
||||
private(set) var loading = false { didSet { onChange?() } }
|
||||
private(set) var switching = false { didSet { onChange?() } }
|
||||
private var didLoad = false
|
||||
|
||||
/// 当前选中的账号。
|
||||
var selectedAccount: AccountSwitchAccount? {
|
||||
accounts.first { $0.id == selectedAccountId }
|
||||
}
|
||||
|
||||
/// 拉取可切换账号列表,默认只加载一次。
|
||||
func load(api: ProfileAPI, force: Bool = false, currentAccountId: String? = nil) async throws {
|
||||
guard force || !didLoad else { return }
|
||||
didLoad = true
|
||||
loading = true
|
||||
defer { loading = false }
|
||||
|
||||
let response = try await api.switchableAccounts()
|
||||
accounts = response.accounts.filter { $0.businessUserId > 0 }
|
||||
selectedAccountId = accounts.first(where: { $0.isCurrent || isCurrent($0, currentAccountId: currentAccountId) })?.id
|
||||
?? accounts.first?.id
|
||||
}
|
||||
|
||||
/// 选择指定账号。
|
||||
func select(_ account: AccountSwitchAccount) {
|
||||
selectedAccountId = account.id
|
||||
}
|
||||
|
||||
/// 提交账号切换,调用 set-user 换取正式 token。
|
||||
func switchAccount(_ account: AccountSwitchAccount, api: AuthAPI) async throws -> V9AuthResponse {
|
||||
guard !switching else {
|
||||
throw AccountSwitchError.submitting
|
||||
}
|
||||
guard account.businessUserId > 0 else {
|
||||
throw AccountSwitchError.invalidAccount
|
||||
}
|
||||
|
||||
switching = true
|
||||
defer { switching = false }
|
||||
return try await api.setUser(account.toSetUserRequest())
|
||||
}
|
||||
|
||||
/// 判断账号是否与当前账号标识一致。
|
||||
func isCurrent(_ account: AccountSwitchAccount, currentAccountId: String?) -> Bool {
|
||||
guard let currentAccountId else { return false }
|
||||
return account.id == currentAccountId || String(account.businessUserId) == currentAccountId
|
||||
}
|
||||
}
|
||||
|
||||
/// 账号切换错误实体,表示重复提交或账号数据异常。
|
||||
enum AccountSwitchError: LocalizedError, Equatable {
|
||||
case submitting
|
||||
case invalidAccount
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .submitting:
|
||||
"账号正在切换,请稍候"
|
||||
case .invalidAccount:
|
||||
"账号信息异常,请重新选择账号"
|
||||
}
|
||||
}
|
||||
}
|
||||
200
suixinkan_ios/Features/Profile/ViewModels/ProfileViewModel.swift
Normal file
200
suixinkan_ios/Features/Profile/ViewModels/ProfileViewModel.swift
Normal file
@ -0,0 +1,200 @@
|
||||
//
|
||||
// ProfileViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/20.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
/// 个人信息页 ViewModel,负责资料加载、编辑状态和提交更新。
|
||||
final class ProfileViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
var userInfo: UserInfoResponse? { didSet { onChange?() } }
|
||||
var realNameInfo: RealNameInfo? { didSet { onChange?() } }
|
||||
var isLoading = false { didSet { onChange?() } }
|
||||
var isSaving = false { didSet { onChange?() } }
|
||||
var isEditingProfile = false { didSet { onChange?() } }
|
||||
var editingNickname = "" { didSet { onChange?() } }
|
||||
private(set) var pendingAvatarData: Data? { didSet { onChange?() } }
|
||||
private(set) var pendingAvatarFileName: String? { didSet { onChange?() } }
|
||||
private(set) var avatarUploadProgress: Int? { didSet { onChange?() } }
|
||||
|
||||
var displayNickname: String {
|
||||
nonEmpty(userInfo?.nickname) ?? "未设置昵称"
|
||||
}
|
||||
|
||||
var displayRealName: String {
|
||||
nonEmpty(userInfo?.realName) ?? "--"
|
||||
}
|
||||
|
||||
var displayPhone: String {
|
||||
nonEmpty(userInfo?.phone) ?? "--"
|
||||
}
|
||||
|
||||
var displayAvatarURL: String {
|
||||
nonEmpty(userInfo?.avatar) ?? ""
|
||||
}
|
||||
|
||||
var accountStatusText: String {
|
||||
nonEmpty(userInfo?.statusName) ?? "--"
|
||||
}
|
||||
|
||||
var realNameStatusText: String {
|
||||
guard let realNameInfo else { return "点击去实名认证" }
|
||||
switch realNameInfo.auditStatus {
|
||||
case 2:
|
||||
return "已实名认证"
|
||||
case 3:
|
||||
return "审核不通过"
|
||||
default:
|
||||
return "审核中"
|
||||
}
|
||||
}
|
||||
|
||||
/// 重新加载用户资料和实名认证状态。
|
||||
func reload(api: ProfileAPI) async throws {
|
||||
guard !isLoading else { return }
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
|
||||
async let user = api.userInfo()
|
||||
async let realName = api.realNameInfo()
|
||||
let result = try await (user, realName)
|
||||
|
||||
userInfo = result.0
|
||||
realNameInfo = result.1.realNameInfo
|
||||
}
|
||||
|
||||
/// 进入昵称编辑状态,并把当前昵称填入编辑框。
|
||||
func beginEditing() {
|
||||
editingNickname = displayNickname == "未设置昵称" ? "" : displayNickname
|
||||
isEditingProfile = true
|
||||
}
|
||||
|
||||
/// 退出昵称编辑状态并清空编辑输入。
|
||||
func cancelEditing() {
|
||||
editingNickname = ""
|
||||
isEditingProfile = false
|
||||
clearPendingAvatar()
|
||||
}
|
||||
|
||||
/// 准备待上传头像,并进入资料编辑状态。
|
||||
func prepareAvatarImage(data: Data, timestamp: TimeInterval = Date().timeIntervalSince1970) throws {
|
||||
let processed = try AvatarImageProcessor.process(data: data, timestamp: timestamp)
|
||||
pendingAvatarData = processed.data
|
||||
pendingAvatarFileName = processed.fileName
|
||||
if !isEditingProfile {
|
||||
beginEditing()
|
||||
}
|
||||
}
|
||||
|
||||
/// 保存昵称和头像修改,并同步更新本地 userInfo。
|
||||
func saveProfile(api: ProfileAPI, uploader: any OSSUploadServing, scenicId: Int) async throws {
|
||||
let nextNickname = editingNickname.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !nextNickname.isEmpty else {
|
||||
throw ProfileValidationError.emptyNickname
|
||||
}
|
||||
|
||||
let nicknameChanged = nextNickname != displayNickname
|
||||
let avatarChanged = pendingAvatarData != nil
|
||||
guard nicknameChanged || avatarChanged else {
|
||||
cancelEditing()
|
||||
return
|
||||
}
|
||||
|
||||
guard !isSaving else { return }
|
||||
isSaving = true
|
||||
avatarUploadProgress = avatarChanged ? 1 : nil
|
||||
defer {
|
||||
isSaving = false
|
||||
avatarUploadProgress = nil
|
||||
}
|
||||
|
||||
let uploadedAvatarURL = try await uploadPendingAvatarIfNeeded(uploader: uploader, scenicId: scenicId)
|
||||
if let uploadedAvatarURL {
|
||||
try await api.updateUserAvatarURL(uploadedAvatarURL)
|
||||
}
|
||||
if nicknameChanged {
|
||||
try await api.updateUserInfo(nickname: nextNickname)
|
||||
}
|
||||
var nextUser = userInfo ?? UserInfoResponse()
|
||||
nextUser = UserInfoResponse(
|
||||
avatar: uploadedAvatarURL ?? nextUser.avatar,
|
||||
realName: nextUser.realName,
|
||||
phone: nextUser.phone,
|
||||
nickname: nextNickname,
|
||||
roleName: nextUser.roleName,
|
||||
status: nextUser.status,
|
||||
statusName: nextUser.statusName
|
||||
)
|
||||
userInfo = nextUser
|
||||
clearPendingAvatar()
|
||||
cancelEditing()
|
||||
}
|
||||
|
||||
/// 修改用户密码,并校验最小长度。
|
||||
func updatePassword(_ password: String, api: ProfileAPI) async throws {
|
||||
let nextPassword = password.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard nextPassword.count >= 6 else {
|
||||
throw ProfileValidationError.shortPassword
|
||||
}
|
||||
|
||||
guard !isSaving else { return }
|
||||
isSaving = true
|
||||
defer { isSaving = false }
|
||||
|
||||
try await api.updateUserInfo(password: nextPassword)
|
||||
}
|
||||
|
||||
/// 将最新 userInfo 合成为全局 AccountProfile,保留已有 userId 等兜底字段。
|
||||
func accountProfileFallback(_ fallback: AccountProfile?) -> AccountProfile? {
|
||||
guard let userInfo else { return fallback }
|
||||
return AccountProfile(
|
||||
userId: fallback?.userId ?? "",
|
||||
displayName: nonEmpty(userInfo.nickname) ?? nonEmpty(userInfo.realName) ?? fallback?.displayName ?? "未设置昵称",
|
||||
phone: nonEmpty(userInfo.phone) ?? fallback?.phone,
|
||||
avatarURL: nonEmpty(userInfo.avatar) ?? fallback?.avatarURL
|
||||
)
|
||||
}
|
||||
|
||||
/// 去除空白字符后返回非空字符串。
|
||||
private func nonEmpty(_ value: String?) -> String? {
|
||||
let text = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return text.isEmpty ? nil : text
|
||||
}
|
||||
|
||||
/// 上传待保存头像;没有待上传图片时返回 nil。
|
||||
private func uploadPendingAvatarIfNeeded(uploader: any OSSUploadServing, scenicId: Int) async throws -> String? {
|
||||
guard let pendingAvatarData else { return nil }
|
||||
let fileName = pendingAvatarFileName ?? "avatar_\(Int(Date().timeIntervalSince1970)).jpg"
|
||||
return try await uploader.uploadUserAvatar(data: pendingAvatarData, fileName: fileName, scenicId: scenicId) { [weak self] progress in
|
||||
Task { @MainActor in
|
||||
self?.avatarUploadProgress = progress
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 清空本地待上传头像数据。
|
||||
private func clearPendingAvatar() {
|
||||
pendingAvatarData = nil
|
||||
pendingAvatarFileName = nil
|
||||
avatarUploadProgress = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// 个人信息页校验错误实体,表示昵称和密码输入不符合要求。
|
||||
enum ProfileValidationError: LocalizedError {
|
||||
case emptyNickname
|
||||
case shortPassword
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .emptyNickname:
|
||||
"请输入昵称"
|
||||
case .shortPassword:
|
||||
"密码至少 6 位"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,230 @@
|
||||
//
|
||||
// RealNameAuthViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
/// 实名认证 ViewModel,管理认证资料表单、审核状态、短信验证码和提交状态。
|
||||
final class RealNameAuthViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
private(set) var info: RealNameInfo? { didSet { onChange?() } }
|
||||
var realName = "" { didSet { onChange?() } }
|
||||
var idCardNo = "" { didSet { onChange?() } }
|
||||
var smsCode = "" { didSet { onChange?() } }
|
||||
var startDate = Date() { didSet { onChange?() } }
|
||||
var endDate = Calendar.current.date(byAdding: .year, value: 10, to: Date()) ?? Date() { didSet { onChange?() } }
|
||||
var isLongValid = false { didSet { onChange?() } }
|
||||
var frontUrl = "" { didSet { onChange?() } }
|
||||
var backUrl = "" { didSet { onChange?() } }
|
||||
private(set) var pendingFrontImageData: Data? { didSet { onChange?() } }
|
||||
private(set) var pendingBackImageData: Data? { didSet { onChange?() } }
|
||||
private(set) var frontUploadProgress: Int? { didSet { onChange?() } }
|
||||
private(set) var backUploadProgress: Int? { didSet { onChange?() } }
|
||||
private var pendingFrontFileName: String?
|
||||
private var pendingBackFileName: String?
|
||||
private(set) var loading = false { didSet { onChange?() } }
|
||||
private(set) var sendingCode = false { didSet { onChange?() } }
|
||||
private(set) var submitting = false { didSet { onChange?() } }
|
||||
var statusMessage: String? { didSet { onChange?() } }
|
||||
|
||||
/// 拉取实名认证信息,并回填表单。
|
||||
func load(api: ProfileAPI) async throws {
|
||||
guard !loading else { return }
|
||||
loading = true
|
||||
defer { loading = false }
|
||||
|
||||
let response = try await api.realNameInfo()
|
||||
apply(response.realNameInfo)
|
||||
}
|
||||
|
||||
/// 发送短信验证码。
|
||||
func sendCode(api: ProfileAPI) async throws {
|
||||
guard !sendingCode else { return }
|
||||
sendingCode = true
|
||||
defer { sendingCode = false }
|
||||
|
||||
try await api.realNameSmsVerifyCode()
|
||||
statusMessage = "验证码已发送"
|
||||
}
|
||||
|
||||
/// 准备待上传证件图片,并更新对应面的本地预览。
|
||||
func prepareIdentityImage(data: Data, side: RealNameImageSide, timestamp: TimeInterval = Date().timeIntervalSince1970) throws {
|
||||
let processed = try RealNameImageProcessor.process(data: data, side: side, timestamp: timestamp)
|
||||
switch side {
|
||||
case .front:
|
||||
pendingFrontImageData = processed.data
|
||||
pendingFrontFileName = processed.fileName
|
||||
frontUrl = ""
|
||||
case .back:
|
||||
pendingBackImageData = processed.data
|
||||
pendingBackFileName = processed.fileName
|
||||
backUrl = ""
|
||||
}
|
||||
}
|
||||
|
||||
/// 提交实名认证资料,成功后刷新审核状态。
|
||||
func submit(api: ProfileAPI, uploader: any OSSUploadServing, scenicId: Int) async throws {
|
||||
if info?.verified == true {
|
||||
statusMessage = "已完成实名认证"
|
||||
return
|
||||
}
|
||||
if let validationMessage {
|
||||
throw RealNameValidationError.message(validationMessage)
|
||||
}
|
||||
|
||||
submitting = true
|
||||
defer { submitting = false }
|
||||
try await uploadPendingIdentityImages(uploader: uploader, scenicId: scenicId)
|
||||
try await api.realNameSubmit(makeRequest())
|
||||
statusMessage = "已提交审核"
|
||||
try await load(api: api)
|
||||
}
|
||||
|
||||
/// 根据审核状态生成展示文案。
|
||||
func auditStatusText(_ status: Int) -> String {
|
||||
switch status {
|
||||
case 1: "待审核"
|
||||
case 2: "审核通过"
|
||||
case 3: "审核不通过"
|
||||
default: "未认证"
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前表单校验错误,nil 表示可以提交。
|
||||
var validationMessage: String? {
|
||||
if realName.trimmed.isEmpty { return "请输入真实姓名" }
|
||||
if idCardNo.trimmed.isEmpty { return "请输入身份证号" }
|
||||
if !Self.isValidMainlandIDCardNumber(idCardNo) { return "请输入有效的身份证号" }
|
||||
if info?.verified != true {
|
||||
if smsCode.trimmed.isEmpty { return "请输入短信验证码" }
|
||||
if frontUrl.trimmed.isEmpty && pendingFrontImageData == nil { return "请选择身份证人像面图片" }
|
||||
if backUrl.trimmed.isEmpty && pendingBackImageData == nil { return "请选择身份证国徽面图片" }
|
||||
}
|
||||
if !isLongValid && endDate < startDate { return "证件结束日期不能早于起始日期" }
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 应用接口返回的实名认证信息。
|
||||
private func apply(_ info: RealNameInfo?) {
|
||||
self.info = info
|
||||
guard let info else { return }
|
||||
realName = info.realName
|
||||
idCardNo = info.idCardNo
|
||||
frontUrl = info.frontUrl ?? ""
|
||||
backUrl = info.backUrl ?? ""
|
||||
pendingFrontImageData = nil
|
||||
pendingBackImageData = nil
|
||||
pendingFrontFileName = nil
|
||||
pendingBackFileName = nil
|
||||
isLongValid = info.isLongValid
|
||||
startDate = Self.date(from: info.startDate) ?? startDate
|
||||
endDate = Self.date(from: info.endDate) ?? endDate
|
||||
}
|
||||
|
||||
/// 生成提交请求。
|
||||
private func makeRequest() -> RealNameAuthRequest {
|
||||
RealNameAuthRequest(
|
||||
realName: realName.trimmed,
|
||||
idCardNo: Self.normalizedIDCardNumber(idCardNo),
|
||||
smsVerifyCode: smsCode.trimmed,
|
||||
startDate: Self.dateFormatter.string(from: startDate),
|
||||
endDate: isLongValid ? "" : Self.dateFormatter.string(from: endDate),
|
||||
isLongValid: isLongValid ? 1 : 0,
|
||||
frontUrl: frontUrl.trimmed,
|
||||
backUrl: backUrl.trimmed
|
||||
)
|
||||
}
|
||||
|
||||
/// 规范化身份证号,去除空白并大写末位 X。
|
||||
nonisolated static func normalizedIDCardNumber(_ value: String) -> String {
|
||||
value.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
|
||||
}
|
||||
|
||||
/// 校验大陆身份证号基础格式和校验位。
|
||||
nonisolated static func isValidMainlandIDCardNumber(_ value: String) -> Bool {
|
||||
let number = normalizedIDCardNumber(value)
|
||||
guard number.count == 18 else { return false }
|
||||
let chars = Array(number)
|
||||
guard chars.prefix(17).allSatisfy(\.isNumber) else { return false }
|
||||
guard chars[17].isNumber || chars[17] == "X" else { return false }
|
||||
|
||||
let weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]
|
||||
let checkCodes = Array("10X98765432")
|
||||
let sum = zip(chars.prefix(17), weights).reduce(0) { partial, pair in
|
||||
partial + (pair.0.wholeNumberValue ?? 0) * pair.1
|
||||
}
|
||||
return chars[17] == checkCodes[sum % 11]
|
||||
}
|
||||
|
||||
private static let dateFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.calendar = Calendar(identifier: .gregorian)
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
return formatter
|
||||
}()
|
||||
|
||||
/// 将 yyyy-MM-dd 文本解析为日期。
|
||||
private static func date(from value: String?) -> Date? {
|
||||
guard let value, !value.isEmpty else { return nil }
|
||||
return dateFormatter.date(from: value)
|
||||
}
|
||||
|
||||
/// 上传待提交证件图片,并把返回 URL 写回表单字段。
|
||||
private func uploadPendingIdentityImages(uploader: any OSSUploadServing, scenicId: Int) async throws {
|
||||
if let pendingFrontImageData {
|
||||
frontUploadProgress = 1
|
||||
defer { frontUploadProgress = nil }
|
||||
frontUrl = try await uploader.uploadRealNameImage(
|
||||
data: pendingFrontImageData,
|
||||
fileName: pendingFrontFileName ?? "real_name_front_\(Int(Date().timeIntervalSince1970)).jpg",
|
||||
scenicId: scenicId
|
||||
) { [weak self] progress in
|
||||
Task { @MainActor in
|
||||
self?.frontUploadProgress = progress
|
||||
}
|
||||
}
|
||||
self.pendingFrontImageData = nil
|
||||
pendingFrontFileName = nil
|
||||
}
|
||||
|
||||
if let pendingBackImageData {
|
||||
backUploadProgress = 1
|
||||
defer { backUploadProgress = nil }
|
||||
backUrl = try await uploader.uploadRealNameImage(
|
||||
data: pendingBackImageData,
|
||||
fileName: pendingBackFileName ?? "real_name_back_\(Int(Date().timeIntervalSince1970)).jpg",
|
||||
scenicId: scenicId
|
||||
) { [weak self] progress in
|
||||
Task { @MainActor in
|
||||
self?.backUploadProgress = progress
|
||||
}
|
||||
}
|
||||
self.pendingBackImageData = nil
|
||||
pendingBackFileName = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 实名认证校验错误实体,用于把表单错误统一抛给页面展示。
|
||||
enum RealNameValidationError: LocalizedError, Equatable {
|
||||
case message(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .message(let message):
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
/// 去除首尾空白后的文本。
|
||||
var trimmed: String {
|
||||
trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
176
suixinkan_ios/Features/Projects/API/ProjectAPI.swift
Normal file
176
suixinkan_ios/Features/Projects/API/ProjectAPI.swift
Normal file
@ -0,0 +1,176 @@
|
||||
//
|
||||
// ProjectAPI.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 项目服务协议,抽象摄影师项目和店铺项目接口以便 ViewModel 测试替换。
|
||||
@MainActor
|
||||
protocol ProjectServing {
|
||||
/// 获取摄影师项目列表。
|
||||
func projectList(scenicId: Int, name: String?, page: Int, pageSize: Int) async throws -> ListPayload<PhotographerProjectItem>
|
||||
|
||||
/// 获取摄影师项目详情。
|
||||
func projectDetail(id: Int) async throws -> PhotographerProjectDetailResponse
|
||||
|
||||
/// 创建摄影师项目。
|
||||
func createProject(_ request: ProjectCreateRequest) async throws
|
||||
|
||||
/// 编辑摄影师项目。
|
||||
func editProject(_ request: ProjectCreateRequest) async throws
|
||||
|
||||
/// 删除摄影师项目。
|
||||
func deleteProject(id: Int) async throws
|
||||
|
||||
/// 获取店铺项目可管理景区。
|
||||
func storeManagerScenicList(userId: String) async throws -> ListPayload<StoreManagerScenicItem>
|
||||
|
||||
/// 获取店铺项目列表。
|
||||
func storeManagerProjectList(userId: String?, page: Int, pageSize: Int) async throws -> ListPayload<PhotographerProjectItem>
|
||||
|
||||
/// 获取店铺项目详情。
|
||||
func storeManagerProjectDetail(id: Int) async throws -> PhotographerProjectDetailResponse
|
||||
|
||||
/// 创建店铺多点位项目。
|
||||
func storeManagerCreate(_ request: StoreManagerCreateRequest) async throws
|
||||
|
||||
/// 创建店铺押金项目。
|
||||
func storeManagerOfflineCreate(_ request: StoreManagerOfflineCreateRequest) async throws
|
||||
|
||||
/// 更新店铺多点位项目。
|
||||
func storeManagerUpdate(_ request: StoreManagerUpdateRequest) async throws
|
||||
|
||||
/// 更新店铺押金项目。
|
||||
func storeManagerOfflineUpdate(_ request: StoreManagerOfflineUpdateRequest) async throws
|
||||
|
||||
/// 删除店铺项目。
|
||||
func storeManagerDeleteProject(id: Int) async throws
|
||||
|
||||
/// 获取全部门店,用于店铺押金项目选择门店。
|
||||
func storeAll() async throws -> ListPayload<StoreItem>
|
||||
}
|
||||
|
||||
/// 项目 API,封装项目管理、店铺项目管理相关接口。
|
||||
@MainActor
|
||||
final class ProjectAPI: ProjectServing {
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化项目 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
self.client = client
|
||||
}
|
||||
|
||||
/// 获取摄影师项目列表。
|
||||
func projectList(scenicId: Int, name: String? = nil, page: Int = 1, pageSize: Int = 10) async throws -> ListPayload<PhotographerProjectItem> {
|
||||
var query = [
|
||||
URLQueryItem(name: "scenic_id", value: "\(scenicId)"),
|
||||
URLQueryItem(name: "page", value: "\(max(page, 1))"),
|
||||
URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))")
|
||||
]
|
||||
if let name = name?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty {
|
||||
query.append(URLQueryItem(name: "name", value: name))
|
||||
}
|
||||
return try await client.send(
|
||||
APIRequest(method: .get, path: "/api/yf-handset-app/photog/project/list", queryItems: query)
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取摄影师项目详情。
|
||||
func projectDetail(id: Int) async throws -> PhotographerProjectDetailResponse {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/yf-handset-app/photog/project/info-view",
|
||||
queryItems: [URLQueryItem(name: "id", value: "\(id)")]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 创建摄影师项目。
|
||||
func createProject(_ request: ProjectCreateRequest) async throws {
|
||||
_ = try await client.send(
|
||||
APIRequest(method: .post, path: "/api/yf-handset-app/photog/project/create", body: request)
|
||||
) as EmptyPayload
|
||||
}
|
||||
|
||||
/// 编辑摄影师项目。
|
||||
func editProject(_ request: ProjectCreateRequest) async throws {
|
||||
_ = try await client.send(
|
||||
APIRequest(method: .post, path: "/api/yf-handset-app/photog/project/edit", body: request)
|
||||
) as EmptyPayload
|
||||
}
|
||||
|
||||
/// 删除摄影师项目。
|
||||
func deleteProject(id: Int) async throws {
|
||||
_ = try await client.send(
|
||||
APIRequest(method: .post, path: "/api/yf-handset-app/photog/project/delete", body: ProjectDeleteRequest(id: id))
|
||||
) as EmptyPayload
|
||||
}
|
||||
|
||||
/// 获取店铺项目可管理景区。
|
||||
func storeManagerScenicList(userId: String) async throws -> ListPayload<StoreManagerScenicItem> {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/app/store-manager/scenic-list",
|
||||
queryItems: [URLQueryItem(name: "user_id", value: userId)]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取店铺项目列表。
|
||||
func storeManagerProjectList(userId: String?, page: Int = 1, pageSize: Int = 20) async throws -> ListPayload<PhotographerProjectItem> {
|
||||
var query = [
|
||||
URLQueryItem(name: "page", value: "\(max(page, 1))"),
|
||||
URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))")
|
||||
]
|
||||
if let userId = userId?.trimmingCharacters(in: .whitespacesAndNewlines), !userId.isEmpty {
|
||||
query.append(URLQueryItem(name: "user_id", value: userId))
|
||||
}
|
||||
return try await client.send(
|
||||
APIRequest(method: .get, path: "/api/app/store-manager/list", queryItems: query)
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取店铺项目详情。
|
||||
func storeManagerProjectDetail(id: Int) async throws -> PhotographerProjectDetailResponse {
|
||||
try await client.send(
|
||||
APIRequest(method: .get, path: "/api/app/store-manager/detail", queryItems: [URLQueryItem(name: "id", value: "\(id)")])
|
||||
)
|
||||
}
|
||||
|
||||
/// 创建店铺多点位项目。
|
||||
func storeManagerCreate(_ request: StoreManagerCreateRequest) async throws {
|
||||
_ = try await client.send(APIRequest(method: .post, path: "/api/app/store-manager/create", body: request)) as EmptyPayload
|
||||
}
|
||||
|
||||
/// 创建店铺押金项目。
|
||||
func storeManagerOfflineCreate(_ request: StoreManagerOfflineCreateRequest) async throws {
|
||||
_ = try await client.send(APIRequest(method: .post, path: "/api/app/store-manager/offline-create", body: request)) as EmptyPayload
|
||||
}
|
||||
|
||||
/// 更新店铺多点位项目。
|
||||
func storeManagerUpdate(_ request: StoreManagerUpdateRequest) async throws {
|
||||
_ = try await client.send(APIRequest(method: .post, path: "/api/app/store-manager/update", body: request)) as EmptyPayload
|
||||
}
|
||||
|
||||
/// 更新店铺押金项目。
|
||||
func storeManagerOfflineUpdate(_ request: StoreManagerOfflineUpdateRequest) async throws {
|
||||
_ = try await client.send(APIRequest(method: .post, path: "/api/app/store-manager/offline-update", body: request)) as EmptyPayload
|
||||
}
|
||||
|
||||
/// 删除店铺项目。
|
||||
func storeManagerDeleteProject(id: Int) async throws {
|
||||
_ = try await client.send(
|
||||
APIRequest(method: .post, path: "/api/app/store-manager/delete", body: ProjectDeleteRequest(id: id))
|
||||
) as EmptyPayload
|
||||
}
|
||||
|
||||
/// 获取全部门店。
|
||||
func storeAll() async throws -> ListPayload<StoreItem> {
|
||||
try await client.send(APIRequest(method: .get, path: "/api/app/store/all"))
|
||||
}
|
||||
}
|
||||
440
suixinkan_ios/Features/Projects/Models/ProjectModels.swift
Normal file
440
suixinkan_ios/Features/Projects/Models/ProjectModels.swift
Normal file
@ -0,0 +1,440 @@
|
||||
//
|
||||
// ProjectModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 项目详情实体,表示摄影师项目或店铺项目详情页需要展示的完整业务字段。
|
||||
struct PhotographerProjectDetailResponse: Decodable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let scenicId: Int
|
||||
let cover: String
|
||||
let coverProject: String
|
||||
let coverCarousel: [String]
|
||||
let name: String
|
||||
let type: Int
|
||||
let typeName: String
|
||||
let status: Int
|
||||
let statusName: String
|
||||
let price: String
|
||||
let otPrice: String
|
||||
let priceDeposit: String
|
||||
let description: String
|
||||
let attrLabel: [String]
|
||||
let projectContent: String
|
||||
let sold: Int
|
||||
let unitName: String
|
||||
let materialNum: Int
|
||||
let photoNum: Int
|
||||
let videoNum: Int
|
||||
let createdAt: String
|
||||
let updatedAt: String
|
||||
let creatorName: String
|
||||
let creatorPhone: String
|
||||
let creatorRoleName: String
|
||||
let auditRemark: String
|
||||
let photogList: [ProjectPhotographerBrief]
|
||||
let scenicList: [ProjectScenicSpotBrief]
|
||||
|
||||
/// 字段映射,兼容后端下划线命名。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case scenicId = "scenic_id"
|
||||
case cover
|
||||
case coverProject = "cover_project"
|
||||
case coverCarousel = "cover_carousel"
|
||||
case name
|
||||
case type
|
||||
case typeName = "type_name"
|
||||
case status
|
||||
case statusName = "status_name"
|
||||
case price
|
||||
case otPrice = "ot_price"
|
||||
case priceDeposit = "price_deposit"
|
||||
case description
|
||||
case attrLabel = "attr_label"
|
||||
case projectContent = "project_content"
|
||||
case sold
|
||||
case unitName = "unit_name"
|
||||
case materialNum = "material_num"
|
||||
case photoNum = "photo_num"
|
||||
case videoNum = "video_num"
|
||||
case createdAt = "created_at"
|
||||
case updatedAt = "updated_at"
|
||||
case creatorName = "creator_name"
|
||||
case creatorPhone = "creator_phone"
|
||||
case creatorRoleName = "creator_role_name"
|
||||
case auditRemark = "audit_remark"
|
||||
case photogList = "photog_list"
|
||||
case scenicList = "scenic_list"
|
||||
}
|
||||
|
||||
/// 宽松解码项目详情字段。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decodeProjectLossyInt(forKey: .id) ?? 0
|
||||
scenicId = try container.decodeProjectLossyInt(forKey: .scenicId) ?? 0
|
||||
cover = try container.decodeProjectLossyString(forKey: .cover)
|
||||
coverProject = try container.decodeProjectLossyString(forKey: .coverProject)
|
||||
coverCarousel = try container.decodeProjectLossyStringArray(forKey: .coverCarousel)
|
||||
name = try container.decodeProjectLossyString(forKey: .name)
|
||||
type = try container.decodeProjectLossyInt(forKey: .type) ?? 0
|
||||
typeName = try container.decodeProjectLossyString(forKey: .typeName)
|
||||
status = try container.decodeProjectLossyInt(forKey: .status) ?? 0
|
||||
statusName = try container.decodeProjectLossyString(forKey: .statusName)
|
||||
price = try container.decodeProjectLossyString(forKey: .price)
|
||||
otPrice = try container.decodeProjectLossyString(forKey: .otPrice)
|
||||
priceDeposit = try container.decodeProjectLossyString(forKey: .priceDeposit)
|
||||
description = try container.decodeProjectLossyString(forKey: .description)
|
||||
attrLabel = try container.decodeProjectLossyStringArray(forKey: .attrLabel)
|
||||
projectContent = try container.decodeProjectLossyString(forKey: .projectContent)
|
||||
sold = try container.decodeProjectLossyInt(forKey: .sold) ?? 0
|
||||
unitName = try container.decodeProjectLossyString(forKey: .unitName)
|
||||
materialNum = try container.decodeProjectLossyInt(forKey: .materialNum) ?? 0
|
||||
photoNum = try container.decodeProjectLossyInt(forKey: .photoNum) ?? 0
|
||||
videoNum = try container.decodeProjectLossyInt(forKey: .videoNum) ?? 0
|
||||
createdAt = try container.decodeProjectLossyString(forKey: .createdAt)
|
||||
updatedAt = try container.decodeProjectLossyString(forKey: .updatedAt)
|
||||
creatorName = try container.decodeProjectLossyString(forKey: .creatorName)
|
||||
creatorPhone = try container.decodeProjectLossyString(forKey: .creatorPhone)
|
||||
creatorRoleName = try container.decodeProjectLossyString(forKey: .creatorRoleName)
|
||||
auditRemark = try container.decodeProjectLossyString(forKey: .auditRemark)
|
||||
photogList = (try? container.decode([ProjectPhotographerBrief].self, forKey: .photogList)) ?? []
|
||||
scenicList = (try? container.decode([ProjectScenicSpotBrief].self, forKey: .scenicList)) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
/// 项目摄影师摘要实体,表示项目详情中的摄影师信息。
|
||||
struct ProjectPhotographerBrief: Decodable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let photogUid: Int
|
||||
let nickname: String
|
||||
let avatar: String
|
||||
let orderNum: Int
|
||||
let completedOrderCount: Int
|
||||
|
||||
/// 字段映射,兼容后端下划线命名。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case photogUid = "photog_uid"
|
||||
case nickname
|
||||
case avatar
|
||||
case orderNum = "order_num"
|
||||
case completedOrderCount = "completed_order_count"
|
||||
}
|
||||
|
||||
/// 宽松解码摄影师摘要。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decodeProjectLossyInt(forKey: .id) ?? 0
|
||||
photogUid = try container.decodeProjectLossyInt(forKey: .photogUid) ?? 0
|
||||
nickname = try container.decodeProjectLossyString(forKey: .nickname)
|
||||
avatar = try container.decodeProjectLossyString(forKey: .avatar)
|
||||
orderNum = try container.decodeProjectLossyInt(forKey: .orderNum) ?? 0
|
||||
completedOrderCount = try container.decodeProjectLossyInt(forKey: .completedOrderCount) ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
/// 项目打卡点摘要实体,表示项目关联的景点或打卡点。
|
||||
struct ProjectScenicSpotBrief: Decodable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let name: String
|
||||
|
||||
/// 字段映射。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case name
|
||||
}
|
||||
|
||||
/// 宽松解码打卡点摘要。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decodeProjectLossyInt(forKey: .id) ?? 0
|
||||
name = try container.decodeProjectLossyString(forKey: .name)
|
||||
}
|
||||
}
|
||||
|
||||
/// 摄影师项目创建或编辑请求实体。
|
||||
struct ProjectCreateRequest: Encodable, Equatable {
|
||||
let id: Int?
|
||||
let type: Int
|
||||
let scenicId: Int
|
||||
let name: String
|
||||
let price: String
|
||||
let otPrice: String?
|
||||
let coverProject: String
|
||||
let coverCarousel: [String]?
|
||||
let description: String
|
||||
let attrLabel: [String]?
|
||||
let extra: ProjectCreateExtra
|
||||
let allowNFC: Int = 1
|
||||
|
||||
/// 请求字段映射,兼容后端下划线命名。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case type
|
||||
case scenicId = "scenic_id"
|
||||
case name
|
||||
case price
|
||||
case otPrice = "ot_price"
|
||||
case coverProject = "cover_project"
|
||||
case coverCarousel = "cover_carousel"
|
||||
case description
|
||||
case attrLabel = "attr_label"
|
||||
case extra
|
||||
case allowNFC = "allow_nfc"
|
||||
}
|
||||
}
|
||||
|
||||
/// 摄影师项目扩展配置实体,表示交付数量、押金和关联打卡点/摄影师。
|
||||
struct ProjectCreateExtra: Encodable, Equatable {
|
||||
let priceDeposit: String
|
||||
let materialNum: Int
|
||||
let photoNum: Int
|
||||
let videoNum: Int
|
||||
let scenicSpotId: [Int]
|
||||
let photogUid: [Int]
|
||||
|
||||
/// 请求字段映射,兼容后端下划线命名。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case priceDeposit = "price_deposit"
|
||||
case materialNum = "material_num"
|
||||
case photoNum = "photo_num"
|
||||
case videoNum = "video_num"
|
||||
case scenicSpotId = "scenic_spot_id"
|
||||
case photogUid = "photog_uid"
|
||||
}
|
||||
}
|
||||
|
||||
/// 项目删除请求实体。
|
||||
struct ProjectDeleteRequest: Encodable, Equatable {
|
||||
let id: Int
|
||||
}
|
||||
|
||||
/// 店铺项目可管理景区实体。
|
||||
struct StoreManagerScenicItem: Decodable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let name: String
|
||||
|
||||
/// 字段映射。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case name
|
||||
}
|
||||
|
||||
/// 宽松解码店铺项目景区。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decodeProjectLossyInt(forKey: .id) ?? 0
|
||||
name = try container.decodeProjectLossyString(forKey: .name)
|
||||
}
|
||||
}
|
||||
|
||||
/// 店铺项目套餐实体,表示多点位项目的套餐价格配置。
|
||||
struct StoreManagerPackageItem: Codable, Hashable {
|
||||
let materialNum: Int
|
||||
let price: String
|
||||
|
||||
/// 请求字段映射,兼容后端下划线命名。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case materialNum = "material_num"
|
||||
case price
|
||||
}
|
||||
}
|
||||
|
||||
/// 店铺项目创建请求实体,用于多点位项目。
|
||||
struct StoreManagerCreateRequest: Encodable, Equatable {
|
||||
let name: String
|
||||
let storeId: Int?
|
||||
let type: Int
|
||||
let description: String
|
||||
let coverProject: String?
|
||||
let coverCarousel: [String]?
|
||||
let projectRule: String?
|
||||
let scenicId: [Int]
|
||||
let settleSpotNum: Int
|
||||
let price: Double
|
||||
let priceMaterial: Double
|
||||
let pricePhoto: Double
|
||||
let priceVideo: Double
|
||||
let priceMaterialAll: Double?
|
||||
let packageList: [StoreManagerPackageItem]?
|
||||
let userId: Int
|
||||
let singleSpotMaterialNum: Int
|
||||
let singleSpotPhotoNum: Int
|
||||
let singleSpotVideoNum: Int
|
||||
|
||||
/// 请求字段映射,兼容后端下划线命名。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case name
|
||||
case storeId = "store_id"
|
||||
case type
|
||||
case description
|
||||
case coverProject = "cover_project"
|
||||
case coverCarousel = "cover_carousel"
|
||||
case projectRule = "project_rule"
|
||||
case scenicId = "scenic_id"
|
||||
case settleSpotNum = "settle_spot_num"
|
||||
case price
|
||||
case priceMaterial = "price_material"
|
||||
case pricePhoto = "price_photo"
|
||||
case priceVideo = "price_video"
|
||||
case priceMaterialAll = "price_material_all"
|
||||
case packageList = "package"
|
||||
case userId = "user_id"
|
||||
case singleSpotMaterialNum = "single_spot_material_num"
|
||||
case singleSpotPhotoNum = "single_spot_photo_num"
|
||||
case singleSpotVideoNum = "single_spot_video_num"
|
||||
}
|
||||
}
|
||||
|
||||
/// 店铺项目更新请求实体,用于多点位项目。
|
||||
struct StoreManagerUpdateRequest: Encodable, Equatable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let type: Int
|
||||
let description: String
|
||||
let coverProject: String?
|
||||
let coverCarousel: [String]?
|
||||
let projectRule: String?
|
||||
let scenicId: [Int]
|
||||
let settleSpotNum: Int
|
||||
let price: Double
|
||||
let priceMaterial: Double
|
||||
let pricePhoto: Double
|
||||
let priceVideo: Double
|
||||
let priceMaterialAll: Double?
|
||||
let packageList: [StoreManagerPackageItem]?
|
||||
let userId: Int
|
||||
let singleSpotMaterialNum: Int
|
||||
let singleSpotPhotoNum: Int
|
||||
let singleSpotVideoNum: Int
|
||||
|
||||
/// 请求字段映射,兼容后端下划线命名。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case name
|
||||
case type
|
||||
case description
|
||||
case coverProject = "cover_project"
|
||||
case coverCarousel = "cover_carousel"
|
||||
case projectRule = "project_rule"
|
||||
case scenicId = "scenic_id"
|
||||
case settleSpotNum = "settle_spot_num"
|
||||
case price
|
||||
case priceMaterial = "price_material"
|
||||
case pricePhoto = "price_photo"
|
||||
case priceVideo = "price_video"
|
||||
case priceMaterialAll = "price_material_all"
|
||||
case packageList = "package"
|
||||
case userId = "user_id"
|
||||
case singleSpotMaterialNum = "single_spot_material_num"
|
||||
case singleSpotPhotoNum = "single_spot_photo_num"
|
||||
case singleSpotVideoNum = "single_spot_video_num"
|
||||
}
|
||||
}
|
||||
|
||||
/// 店铺押金项目创建请求实体。
|
||||
struct StoreManagerOfflineCreateRequest: Encodable, Equatable {
|
||||
let name: String
|
||||
let scenicId: Int
|
||||
let storeId: Int
|
||||
let description: String
|
||||
let price: Double
|
||||
let coverProject: String
|
||||
let coverCarousel: [String]
|
||||
|
||||
/// 请求字段映射,兼容后端下划线命名。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case name
|
||||
case scenicId = "scenic_id"
|
||||
case storeId = "store_id"
|
||||
case description
|
||||
case price
|
||||
case coverProject = "cover_project"
|
||||
case coverCarousel = "cover_carousel"
|
||||
}
|
||||
}
|
||||
|
||||
/// 店铺押金项目更新请求实体。
|
||||
struct StoreManagerOfflineUpdateRequest: Encodable, Equatable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let scenicId: Int
|
||||
let description: String
|
||||
let price: Double
|
||||
let coverProject: String?
|
||||
let coverCarousel: [String]?
|
||||
let storeId: Int?
|
||||
|
||||
/// 请求字段映射,兼容后端下划线命名。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case name
|
||||
case scenicId = "scenic_id"
|
||||
case description
|
||||
case price
|
||||
case coverProject = "cover_project"
|
||||
case coverCarousel = "cover_carousel"
|
||||
case storeId = "store_id"
|
||||
}
|
||||
}
|
||||
|
||||
/// 项目本地待上传图片实体,保存 PhotosPicker 读取后的图片数据。
|
||||
struct ProjectLocalImage: Identifiable, Equatable {
|
||||
let id = UUID()
|
||||
let data: Data
|
||||
let fileName: String
|
||||
let previewURL: String?
|
||||
}
|
||||
|
||||
private extension KeyedDecodingContainer {
|
||||
/// 将 String、数字和 Bool 宽松解码为字符串。
|
||||
func decodeProjectLossyString(forKey key: Key) throws -> String {
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||
return String(value)
|
||||
}
|
||||
if let value = try? decodeIfPresent(Double.self, forKey: key) {
|
||||
return String(value)
|
||||
}
|
||||
if let value = try? decodeIfPresent(Bool.self, forKey: key) {
|
||||
return value ? "1" : "0"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
/// 将 String、Double 和 Int 宽松解码为 Int。
|
||||
func decodeProjectLossyInt(forKey key: Key) throws -> Int? {
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try? decodeIfPresent(Double.self, forKey: key) {
|
||||
return Int(value)
|
||||
}
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return Int(text) ?? Double(text).map(Int.init)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 将数组或逗号分隔字符串宽松解码为字符串数组。
|
||||
func decodeProjectLossyStringArray(forKey key: Key) throws -> [String] {
|
||||
if let values = try? decodeIfPresent([String].self, forKey: key) {
|
||||
return values
|
||||
}
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
return value
|
||||
.components(separatedBy: CharacterSet(charactersIn: ",,\n"))
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
}
|
||||
return []
|
||||
}
|
||||
}
|
||||
31
suixinkan_ios/Features/Projects/Projects.md
Normal file
31
suixinkan_ios/Features/Projects/Projects.md
Normal file
@ -0,0 +1,31 @@
|
||||
# Projects 模块业务逻辑
|
||||
|
||||
## 模块职责
|
||||
|
||||
Projects 模块负责首页项目相关入口:
|
||||
- `pm`、`project_edit`:摄影师项目管理。
|
||||
- `pm_manager`:店铺项目管理。
|
||||
|
||||
模块只保存项目列表、详情、编辑表单和上传中的临时状态,不写入 `AppSession`、`AccountContext`、TabBar 或首页全局状态。
|
||||
|
||||
## 摄影师项目
|
||||
|
||||
`ProjectManagementViewModel` 管理摄影师项目列表、搜索、分页、详情入口和删除动作。列表请求依赖当前景区 ID,缺少景区时清空本模块列表并停止请求。
|
||||
|
||||
`ProjectEditorViewModel` 管理新建和编辑表单。项目封面、轮播图先通过 `OSSUploadService.uploadProjectImage` 上传到 `project/yyyyMMdd/scenicId/...` 路径,再把最终 URL 提交给项目创建或编辑接口。
|
||||
|
||||
## 店铺项目
|
||||
|
||||
`StoreProjectManagementViewModel` 管理店铺项目列表和可管理景区。店铺项目按业务账号 ID 查询,缺少用户 ID 时不请求。
|
||||
|
||||
`StoreProjectEditorViewModel` 支持多点位项目和押金项目两种提交路径。多点位项目提交景区、点位、展示图和项目套餐;押金项目提交门店、押金金额、底片数量等字段。
|
||||
|
||||
## 接口边界
|
||||
|
||||
`ProjectAPI` 只封装项目模块需要的接口,包括摄影师项目列表/详情/创建/编辑/删除,以及店铺项目列表/详情/创建/更新/删除和可管理景区列表。
|
||||
|
||||
样片上传需要的轻量项目选择复用共享 `PhotographerProjectItem`,但不反向依赖 Projects 页面状态。
|
||||
|
||||
## 缓存边界
|
||||
|
||||
项目图片临时数据、OSS STS、编辑表单和分页状态都不落盘。网络图片展示继续交给 `RemoteImage` / Kingfisher。
|
||||
@ -0,0 +1,285 @@
|
||||
//
|
||||
// ProjectViewControllers.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/26.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
/// 摄影师项目管理页。
|
||||
final class ProjectManagementViewController: ModuleTableViewController {
|
||||
private let viewModel = ProjectManagementViewModel()
|
||||
|
||||
override func viewDidLoad() {
|
||||
title = "项目管理"
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
title: "新建",
|
||||
style: .plain,
|
||||
target: self,
|
||||
action: #selector(createProject)
|
||||
)
|
||||
super.viewDidLoad()
|
||||
wireViewModel(viewModel) { }
|
||||
}
|
||||
|
||||
override func tableRowCount() -> Int { viewModel.items.count }
|
||||
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let item = viewModel.items[indexPath.row]
|
||||
cell.configure(title: item.name, subtitle: item.statusName, detail: "¥\(item.price)")
|
||||
}
|
||||
|
||||
override func didSelectTableRow(at indexPath: IndexPath) {
|
||||
let item = viewModel.items[indexPath.row]
|
||||
navigationController?.pushViewController(
|
||||
ProjectDetailViewController(projectId: item.id, storeMode: false),
|
||||
animated: true
|
||||
)
|
||||
}
|
||||
|
||||
override func reloadContent() async {
|
||||
await viewModel.reload(api: services.projectAPI, scenicId: services.currentScenicId)
|
||||
}
|
||||
|
||||
@objc private func createProject() {
|
||||
navigationController?.pushViewController(
|
||||
ProjectEditorViewController(projectId: nil),
|
||||
animated: true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension ProjectManagementViewModel: ViewModelBindable {}
|
||||
|
||||
/// 店铺项目管理页。
|
||||
final class StoreProjectManagementViewController: ModuleTableViewController {
|
||||
private let viewModel = StoreProjectManagementViewModel()
|
||||
|
||||
override func viewDidLoad() {
|
||||
title = "店铺项目"
|
||||
super.viewDidLoad()
|
||||
wireViewModel(viewModel) { }
|
||||
}
|
||||
|
||||
override func tableRowCount() -> Int { viewModel.filteredItems.count }
|
||||
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let item = viewModel.filteredItems[indexPath.row]
|
||||
cell.configure(title: item.name, subtitle: item.typeName, detail: "¥\(item.price)")
|
||||
}
|
||||
|
||||
override func didSelectTableRow(at indexPath: IndexPath) {
|
||||
let item = viewModel.filteredItems[indexPath.row]
|
||||
navigationController?.pushViewController(
|
||||
ProjectDetailViewController(projectId: item.id, storeMode: true),
|
||||
animated: true
|
||||
)
|
||||
}
|
||||
|
||||
override func reloadContent() async {
|
||||
await viewModel.reload(api: services.projectAPI, userId: services.userId)
|
||||
}
|
||||
}
|
||||
|
||||
extension StoreProjectManagementViewModel: ViewModelBindable {}
|
||||
|
||||
/// 项目详情页。
|
||||
final class ProjectDetailViewController: ModuleTableViewController {
|
||||
private let projectId: Int
|
||||
private let storeMode: Bool
|
||||
private let photographerViewModel = ProjectManagementViewModel()
|
||||
private let storeViewModel = StoreProjectManagementViewModel()
|
||||
|
||||
init(projectId: Int, storeMode: Bool) {
|
||||
self.projectId = projectId
|
||||
self.storeMode = storeMode
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
title = "项目详情"
|
||||
super.viewDidLoad()
|
||||
if storeMode {
|
||||
storeViewModel.onChange = { [weak self] in self?.reloadTable() }
|
||||
} else {
|
||||
photographerViewModel.onChange = { [weak self] in self?.reloadTable() }
|
||||
}
|
||||
}
|
||||
|
||||
private var detail: PhotographerProjectDetailResponse? {
|
||||
storeMode ? storeViewModel.selectedDetail : photographerViewModel.selectedDetail
|
||||
}
|
||||
|
||||
override func tableRowCount() -> Int {
|
||||
detail == nil ? 0 : 4
|
||||
}
|
||||
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
guard let detail else { return }
|
||||
switch indexPath.row {
|
||||
case 0: cell.configure(title: "项目名称", subtitle: detail.name)
|
||||
case 1: cell.configure(title: "简介", subtitle: detail.description)
|
||||
case 2: cell.configure(title: "价格", subtitle: detail.price)
|
||||
default: cell.configure(title: "打卡点", subtitle: "\(detail.scenicList.count) 个")
|
||||
}
|
||||
}
|
||||
|
||||
override func reloadContent() async {
|
||||
if storeMode {
|
||||
await storeViewModel.loadDetail(id: projectId, api: services.projectAPI)
|
||||
} else {
|
||||
await photographerViewModel.loadDetail(id: projectId, api: services.projectAPI)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 摄影师项目编辑页。
|
||||
final class ProjectEditorViewController: ModuleTableViewController {
|
||||
private let viewModel = ProjectEditorViewModel(mode: .create)
|
||||
private let nameField = UITextField()
|
||||
private let descriptionField = UITextField()
|
||||
private let priceField = UITextField()
|
||||
private let projectId: Int?
|
||||
|
||||
init(projectId: Int?) {
|
||||
self.projectId = projectId
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
title = projectId == nil ? "新建项目" : "编辑项目"
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
title: "保存",
|
||||
style: .done,
|
||||
target: self,
|
||||
action: #selector(save)
|
||||
)
|
||||
super.viewDidLoad()
|
||||
setupHeader()
|
||||
wireViewModel(viewModel) { [weak self] in self?.fillFormIfNeeded() }
|
||||
}
|
||||
|
||||
private func setupHeader() {
|
||||
nameField.placeholder = "项目名称"
|
||||
nameField.borderStyle = .roundedRect
|
||||
descriptionField.placeholder = "项目简介"
|
||||
descriptionField.borderStyle = .roundedRect
|
||||
priceField.placeholder = "价格"
|
||||
priceField.borderStyle = .roundedRect
|
||||
priceField.keyboardType = .decimalPad
|
||||
let stack = UIStackView(arrangedSubviews: [nameField, descriptionField, priceField])
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 8
|
||||
stack.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 132)
|
||||
stack.layoutMargins = UIEdgeInsets(top: 12, left: 16, bottom: 12, right: 16)
|
||||
stack.isLayoutMarginsRelativeArrangement = true
|
||||
tableView.tableHeaderView = stack
|
||||
}
|
||||
|
||||
override func tableRowCount() -> Int { 0 }
|
||||
|
||||
override func reloadContent() async {
|
||||
guard let projectId else { return }
|
||||
if let detail = try? await services.projectAPI.projectDetail(id: projectId) {
|
||||
viewModel.apply(detail)
|
||||
fillFormIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
private func fillFormIfNeeded() {
|
||||
if nameField.text?.isEmpty != false { nameField.text = viewModel.name }
|
||||
if descriptionField.text?.isEmpty != false { descriptionField.text = viewModel.descriptionText }
|
||||
if priceField.text?.isEmpty != false { priceField.text = viewModel.price }
|
||||
}
|
||||
|
||||
@objc private func save() {
|
||||
viewModel.name = nameField.text ?? ""
|
||||
viewModel.descriptionText = descriptionField.text ?? ""
|
||||
viewModel.price = priceField.text ?? ""
|
||||
Task {
|
||||
let userId = Int(services.userId ?? "")
|
||||
let success = await viewModel.submit(
|
||||
scenicId: services.currentScenicId,
|
||||
userId: userId,
|
||||
api: services.projectAPI,
|
||||
uploadService: services.ossUploadService
|
||||
)
|
||||
if success {
|
||||
navigationController?.popViewController(animated: true)
|
||||
} else if let message = viewModel.errorMessage {
|
||||
services.toastCenter.show(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ProjectEditorViewModel: ViewModelBindable {}
|
||||
|
||||
/// 店铺项目编辑页。
|
||||
final class StoreProjectEditorViewController: ModuleTableViewController {
|
||||
private let viewModel = StoreProjectEditorViewModel(mode: .create)
|
||||
private let nameField = UITextField()
|
||||
|
||||
init(projectId: Int?) {
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
title = "店铺项目"
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
title: "保存",
|
||||
style: .done,
|
||||
target: self,
|
||||
action: #selector(save)
|
||||
)
|
||||
super.viewDidLoad()
|
||||
nameField.placeholder = "项目名称"
|
||||
nameField.borderStyle = .roundedRect
|
||||
nameField.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 52)
|
||||
tableView.tableHeaderView = nameField
|
||||
wireViewModel(viewModel) { }
|
||||
}
|
||||
|
||||
override func tableRowCount() -> Int { viewModel.scenicList.count }
|
||||
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let scenic = viewModel.scenicList[indexPath.row]
|
||||
cell.configure(title: scenic.name, subtitle: viewModel.selectedScenicIds.contains(scenic.id) ? "已选择" : nil)
|
||||
}
|
||||
|
||||
override func reloadContent() async {
|
||||
guard let userId = Int(services.userId ?? "") else { return }
|
||||
await viewModel.loadScenicList(api: services.projectAPI, userId: userId)
|
||||
}
|
||||
|
||||
@objc private func save() {
|
||||
viewModel.name = nameField.text ?? ""
|
||||
Task {
|
||||
guard let userId = Int(services.userId ?? "") else { return }
|
||||
let success = await viewModel.submit(
|
||||
userId: userId,
|
||||
api: services.projectAPI,
|
||||
uploadService: services.ossUploadService
|
||||
)
|
||||
if success { navigationController?.popViewController(animated: true) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension StoreProjectEditorViewModel: ViewModelBindable {}
|
||||
@ -0,0 +1,624 @@
|
||||
//
|
||||
// ProjectViewModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 项目表单模式,区分新建和编辑。
|
||||
enum ProjectEditorMode: Equatable {
|
||||
case create
|
||||
case edit(PhotographerProjectDetailResponse)
|
||||
}
|
||||
|
||||
/// 店铺项目表单模式,区分新建和编辑。
|
||||
enum StoreProjectEditorMode: Equatable {
|
||||
case create
|
||||
case edit(PhotographerProjectDetailResponse)
|
||||
}
|
||||
|
||||
/// 项目表单错误实体,表示校验、上传或提交失败。
|
||||
enum ProjectEditorError: LocalizedError, Equatable {
|
||||
case missingScenic
|
||||
case missingUser
|
||||
case missingName
|
||||
case missingDescription
|
||||
case missingPrice
|
||||
case missingSpot
|
||||
case missingImage
|
||||
case missingStore
|
||||
|
||||
/// 错误文案。
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .missingScenic: return "当前缺少景区信息"
|
||||
case .missingUser: return "当前缺少用户信息"
|
||||
case .missingName: return "请输入项目名称"
|
||||
case .missingDescription: return "请输入项目简介"
|
||||
case .missingPrice: return "请填写有效价格"
|
||||
case .missingSpot: return "请至少选择一个打卡点"
|
||||
case .missingImage: return "请至少上传项目封面"
|
||||
case .missingStore: return "请选择所属门店"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 摄影师项目管理 ViewModel,负责项目列表、分页、详情和删除。
|
||||
@MainActor
|
||||
final class ProjectManagementViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
private(set) var items: [PhotographerProjectItem] = [] { didSet { onChange?() } }
|
||||
private(set) var loading = false { didSet { onChange?() } }
|
||||
private(set) var loadingMore = false { didSet { onChange?() } }
|
||||
private(set) var hasMore = false { didSet { onChange?() } }
|
||||
private(set) var selectedDetail: PhotographerProjectDetailResponse? { didSet { onChange?() } }
|
||||
var searchText = "" { didSet { onChange?() } }
|
||||
var errorMessage: String? { didSet { onChange?() } }
|
||||
|
||||
private var page = 1
|
||||
private var total = 0
|
||||
private let pageSize = 10
|
||||
|
||||
/// 重新加载摄影师项目列表。
|
||||
func reload(api: any ProjectServing, scenicId: Int?) async {
|
||||
guard let scenicId else {
|
||||
resetList()
|
||||
return
|
||||
}
|
||||
loading = true
|
||||
errorMessage = nil
|
||||
defer { loading = false }
|
||||
do {
|
||||
page = 1
|
||||
let response = try await api.projectList(
|
||||
scenicId: scenicId,
|
||||
name: normalizedSearch,
|
||||
page: page,
|
||||
pageSize: pageSize
|
||||
)
|
||||
total = response.total
|
||||
items = sorted(response.list)
|
||||
hasMore = items.count < total
|
||||
page = 2
|
||||
} catch {
|
||||
resetList()
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载下一页摄影师项目。
|
||||
func loadMore(api: any ProjectServing, scenicId: Int?) async {
|
||||
guard let scenicId, hasMore, !loadingMore else { return }
|
||||
loadingMore = true
|
||||
defer { loadingMore = false }
|
||||
do {
|
||||
let response = try await api.projectList(scenicId: scenicId, name: normalizedSearch, page: page, pageSize: pageSize)
|
||||
total = response.total
|
||||
items = sorted(deduplicated(items + response.list))
|
||||
hasMore = items.count < total
|
||||
page += 1
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载项目详情。
|
||||
func loadDetail(id: Int, api: any ProjectServing) async {
|
||||
do {
|
||||
selectedDetail = try await api.projectDetail(id: id)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除摄影师项目并从本地列表移除。
|
||||
func delete(id: Int, api: any ProjectServing) async {
|
||||
do {
|
||||
try await api.deleteProject(id: id)
|
||||
items.removeAll { $0.id == id }
|
||||
total = max(total - 1, 0)
|
||||
hasMore = items.count < total
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private var normalizedSearch: String? {
|
||||
let value = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return value.isEmpty ? nil : value
|
||||
}
|
||||
|
||||
private func resetList() {
|
||||
items = []
|
||||
loading = false
|
||||
loadingMore = false
|
||||
hasMore = false
|
||||
page = 1
|
||||
total = 0
|
||||
}
|
||||
|
||||
private func sorted(_ values: [PhotographerProjectItem]) -> [PhotographerProjectItem] {
|
||||
values.sorted { lhs, rhs in
|
||||
if lhs.status != rhs.status { return lhs.status < rhs.status }
|
||||
return lhs.id > rhs.id
|
||||
}
|
||||
}
|
||||
|
||||
private func deduplicated(_ values: [PhotographerProjectItem]) -> [PhotographerProjectItem] {
|
||||
var result: [Int: PhotographerProjectItem] = [:]
|
||||
values.forEach { result[$0.id] = $0 }
|
||||
return Array(result.values)
|
||||
}
|
||||
}
|
||||
|
||||
/// 项目编辑 ViewModel,负责摄影师项目表单、图片上传和提交。
|
||||
@MainActor
|
||||
final class ProjectEditorViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
var name = "" { didSet { onChange?() } }
|
||||
var descriptionText = "" { didSet { onChange?() } }
|
||||
var price = "" { didSet { onChange?() } }
|
||||
var otPrice = "" { didSet { onChange?() } }
|
||||
var deposit = "" { didSet { onChange?() } }
|
||||
var attrLabelText = "" { didSet { onChange?() } }
|
||||
var materialNum = "1" { didSet { onChange?() } }
|
||||
var photoNum = "1" { didSet { onChange?() } }
|
||||
var videoNum = "1" { didSet { onChange?() } }
|
||||
var selectedSpotIds: Set<Int> = [] { didSet { onChange?() } }
|
||||
var coverImage: ProjectLocalImage? { didSet { onChange?() } }
|
||||
var carouselImages: [ProjectLocalImage] = [] { didSet { onChange?() } }
|
||||
var existingCoverURL = "" { didSet { onChange?() } }
|
||||
var existingCarouselURLs: [String] = [] { didSet { onChange?() } }
|
||||
private(set) var submitting = false { didSet { onChange?() } }
|
||||
private(set) var uploadProgress = 0 { didSet { onChange?() } }
|
||||
var errorMessage: String? { didSet { onChange?() } }
|
||||
|
||||
private var mode: ProjectEditorMode
|
||||
|
||||
/// 初始化摄影师项目表单,并在编辑模式下回填详情。
|
||||
init(mode: ProjectEditorMode) {
|
||||
self.mode = mode
|
||||
if case let .edit(detail) = mode {
|
||||
apply(detail)
|
||||
}
|
||||
}
|
||||
|
||||
/// 回填编辑详情,保留同一个 ObservableObject 实例。
|
||||
func apply(_ detail: PhotographerProjectDetailResponse) {
|
||||
mode = .edit(detail)
|
||||
name = detail.name
|
||||
descriptionText = detail.description
|
||||
price = detail.price
|
||||
otPrice = detail.otPrice
|
||||
deposit = detail.priceDeposit
|
||||
attrLabelText = detail.attrLabel.joined(separator: ",")
|
||||
materialNum = "\(max(detail.materialNum, 0))"
|
||||
photoNum = "\(max(detail.photoNum, 0))"
|
||||
videoNum = "\(max(detail.videoNum, 0))"
|
||||
selectedSpotIds = Set(detail.scenicList.map(\.id))
|
||||
existingCoverURL = detail.coverProject
|
||||
existingCarouselURLs = detail.coverCarousel
|
||||
coverImage = nil
|
||||
carouselImages = []
|
||||
}
|
||||
|
||||
/// 提交摄影师项目,必要时先上传封面和轮播图。
|
||||
func submit(
|
||||
scenicId: Int?,
|
||||
userId: Int?,
|
||||
api: any ProjectServing,
|
||||
uploadService: any OSSUploadServing
|
||||
) async -> Bool {
|
||||
guard !submitting else { return false }
|
||||
guard let scenicId else { return fail(.missingScenic) }
|
||||
guard let userId, userId > 0 else { return fail(.missingUser) }
|
||||
let normalizedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let normalizedDescription = descriptionText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedName.isEmpty else { return fail(.missingName) }
|
||||
guard !normalizedDescription.isEmpty else { return fail(.missingDescription) }
|
||||
guard Double(price) ?? 0 > 0 else { return fail(.missingPrice) }
|
||||
guard !selectedSpotIds.isEmpty else { return fail(.missingSpot) }
|
||||
|
||||
submitting = true
|
||||
errorMessage = nil
|
||||
uploadProgress = 0
|
||||
defer { submitting = false }
|
||||
|
||||
do {
|
||||
let coverURL = try await resolveCoverURL(scenicId: scenicId, uploadService: uploadService)
|
||||
guard !coverURL.isEmpty else { throw ProjectEditorError.missingImage }
|
||||
let carouselURLs = try await resolveCarouselURLs(scenicId: scenicId, uploadService: uploadService)
|
||||
let request = ProjectCreateRequest(
|
||||
id: editID,
|
||||
type: 11,
|
||||
scenicId: scenicId,
|
||||
name: normalizedName,
|
||||
price: normalizedMoney(price) ?? "0.00",
|
||||
otPrice: normalizedMoney(otPrice),
|
||||
coverProject: coverURL,
|
||||
coverCarousel: carouselURLs.isEmpty ? nil : carouselURLs,
|
||||
description: normalizedDescription,
|
||||
attrLabel: normalizedLabels,
|
||||
extra: ProjectCreateExtra(
|
||||
priceDeposit: normalizedMoney(deposit) ?? "0.00",
|
||||
materialNum: max(Int(materialNum) ?? 0, 0),
|
||||
photoNum: max(Int(photoNum) ?? 0, 0),
|
||||
videoNum: max(Int(videoNum) ?? 0, 0),
|
||||
scenicSpotId: selectedSpotIds.sorted(),
|
||||
photogUid: [userId]
|
||||
)
|
||||
)
|
||||
if editID == nil {
|
||||
try await api.createProject(request)
|
||||
} else {
|
||||
try await api.editProject(request)
|
||||
}
|
||||
uploadProgress = 100
|
||||
return true
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private var editID: Int? {
|
||||
if case let .edit(detail) = mode {
|
||||
return detail.id
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private var normalizedLabels: [String]? {
|
||||
let labels = attrLabelText
|
||||
.components(separatedBy: CharacterSet(charactersIn: ",,"))
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
return labels.isEmpty ? nil : labels
|
||||
}
|
||||
|
||||
private func resolveCoverURL(scenicId: Int, uploadService: any OSSUploadServing) async throws -> String {
|
||||
if let coverImage {
|
||||
return try await uploadService.uploadProjectImage(data: coverImage.data, fileName: coverImage.fileName, scenicId: scenicId) { [weak self] progress in
|
||||
self?.uploadProgress = progress
|
||||
}
|
||||
}
|
||||
return existingCoverURL
|
||||
}
|
||||
|
||||
private func resolveCarouselURLs(scenicId: Int, uploadService: any OSSUploadServing) async throws -> [String] {
|
||||
var urls = existingCarouselURLs
|
||||
for image in carouselImages {
|
||||
let url = try await uploadService.uploadProjectImage(data: image.data, fileName: image.fileName, scenicId: scenicId) { [weak self] progress in
|
||||
self?.uploadProgress = progress
|
||||
}
|
||||
urls.append(url)
|
||||
}
|
||||
return urls
|
||||
}
|
||||
|
||||
private func normalizedMoney(_ value: String) -> String? {
|
||||
let amount = Double(value.trimmingCharacters(in: .whitespacesAndNewlines)) ?? 0
|
||||
return amount > 0 ? String(format: "%.2f", amount) : nil
|
||||
}
|
||||
|
||||
private func fail(_ error: ProjectEditorError) -> Bool {
|
||||
errorMessage = error.localizedDescription
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// 店铺项目管理 ViewModel,负责店铺项目列表、筛选、详情和删除。
|
||||
@MainActor
|
||||
final class StoreProjectManagementViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
private(set) var items: [PhotographerProjectItem] = [] { didSet { onChange?() } }
|
||||
private(set) var loading = false { didSet { onChange?() } }
|
||||
private(set) var selectedDetail: PhotographerProjectDetailResponse? { didSet { onChange?() } }
|
||||
var searchText = "" { didSet { onChange?() } }
|
||||
var selectedType = 0 { didSet { onChange?() } }
|
||||
var errorMessage: String? { didSet { onChange?() } }
|
||||
|
||||
/// 根据本地筛选条件返回展示项目。
|
||||
var filteredItems: [PhotographerProjectItem] {
|
||||
let keyword = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return items.filter { item in
|
||||
let typeMatched = selectedType == 0 || item.type == selectedType
|
||||
let keywordMatched = keyword.isEmpty || item.name.localizedCaseInsensitiveContains(keyword)
|
||||
return typeMatched && keywordMatched
|
||||
}
|
||||
}
|
||||
|
||||
/// 重新加载店铺项目列表。
|
||||
func reload(api: any ProjectServing, userId: String?) async {
|
||||
let normalizedUserId = userId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !normalizedUserId.isEmpty else {
|
||||
items = []
|
||||
return
|
||||
}
|
||||
loading = true
|
||||
errorMessage = nil
|
||||
defer { loading = false }
|
||||
do {
|
||||
let response = try await api.storeManagerProjectList(userId: normalizedUserId, page: 1, pageSize: 100)
|
||||
items = response.list
|
||||
} catch {
|
||||
items = []
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载店铺项目详情。
|
||||
func loadDetail(id: Int, api: any ProjectServing) async {
|
||||
do {
|
||||
selectedDetail = try await api.storeManagerProjectDetail(id: id)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除店铺项目。
|
||||
func delete(id: Int, api: any ProjectServing) async {
|
||||
do {
|
||||
try await api.storeManagerDeleteProject(id: id)
|
||||
items.removeAll { $0.id == id }
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 店铺项目编辑 ViewModel,负责多点位和押金项目表单提交。
|
||||
@MainActor
|
||||
final class StoreProjectEditorViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
var projectType = 19 { didSet { onChange?() } }
|
||||
var scenicList: [StoreManagerScenicItem] = [] { didSet { onChange?() } }
|
||||
var storeList: [StoreItem] = [] { didSet { onChange?() } }
|
||||
var selectedScenicIds: Set<Int> = [] { didSet { onChange?() } }
|
||||
var selectedStoreId: Int? { didSet { onChange?() } }
|
||||
var name = "" { didSet { onChange?() } }
|
||||
var descriptionText = "" { didSet { onChange?() } }
|
||||
var coverImage: ProjectLocalImage? { didSet { onChange?() } }
|
||||
var carouselImages: [ProjectLocalImage] = [] { didSet { onChange?() } }
|
||||
var existingCoverURL = "" { didSet { onChange?() } }
|
||||
var existingCarouselURLs: [String] = [] { didSet { onChange?() } }
|
||||
var settleSpotNum = "1" { didSet { onChange?() } }
|
||||
var projectPrice = "" { didSet { onChange?() } }
|
||||
var priceMaterial = "" { didSet { onChange?() } }
|
||||
var pricePhoto = "" { didSet { onChange?() } }
|
||||
var priceVideo = "" { didSet { onChange?() } }
|
||||
var priceDeposit = "" { didSet { onChange?() } }
|
||||
var materialNum = "1" { didSet { onChange?() } }
|
||||
var photoNum = "1" { didSet { onChange?() } }
|
||||
var videoNum = "1" { didSet { onChange?() } }
|
||||
private(set) var submitting = false { didSet { onChange?() } }
|
||||
var errorMessage: String? { didSet { onChange?() } }
|
||||
|
||||
private var mode: StoreProjectEditorMode
|
||||
|
||||
/// 初始化店铺项目表单,并在编辑模式下回填详情。
|
||||
init(mode: StoreProjectEditorMode) {
|
||||
self.mode = mode
|
||||
if case let .edit(detail) = mode {
|
||||
apply(detail)
|
||||
}
|
||||
}
|
||||
|
||||
/// 回填编辑详情,保留同一个 ObservableObject 实例。
|
||||
func apply(_ detail: PhotographerProjectDetailResponse) {
|
||||
mode = .edit(detail)
|
||||
projectType = detail.type == 4 ? 4 : 19
|
||||
name = detail.name
|
||||
descriptionText = detail.description
|
||||
existingCoverURL = detail.coverProject
|
||||
existingCarouselURLs = detail.coverCarousel
|
||||
selectedScenicIds = Set(detail.scenicList.map(\.id))
|
||||
projectPrice = detail.price
|
||||
priceDeposit = detail.priceDeposit
|
||||
materialNum = "\(max(detail.materialNum, 0))"
|
||||
photoNum = "\(max(detail.photoNum, 0))"
|
||||
videoNum = "\(max(detail.videoNum, 0))"
|
||||
coverImage = nil
|
||||
carouselImages = []
|
||||
}
|
||||
|
||||
/// 加载店铺项目可管理景区。
|
||||
func loadScenicList(api: any ProjectServing, userId: Int) async {
|
||||
guard scenicList.isEmpty, userId > 0 else { return }
|
||||
do {
|
||||
scenicList = try await api.storeManagerScenicList(userId: "\(userId)").list
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载当前景区下门店列表。
|
||||
func loadStoreList(api: any ProjectServing) async {
|
||||
guard projectType == 4, let scenicId = selectedScenicIds.first else { return }
|
||||
do {
|
||||
storeList = try await api.storeAll().list.filter { $0.scenicId == scenicId }
|
||||
if selectedStoreId == nil {
|
||||
selectedStoreId = storeList.first?.id
|
||||
}
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 切换店铺项目景区选择。
|
||||
func toggleScenic(_ id: Int) {
|
||||
if projectType == 4 {
|
||||
selectedScenicIds = [id]
|
||||
} else if selectedScenicIds.contains(id) {
|
||||
selectedScenicIds.remove(id)
|
||||
} else {
|
||||
selectedScenicIds.insert(id)
|
||||
}
|
||||
}
|
||||
|
||||
/// 提交店铺项目。
|
||||
func submit(userId: Int, api: any ProjectServing, uploadService: any OSSUploadServing) async -> Bool {
|
||||
guard !submitting else { return false }
|
||||
guard userId > 0 else { return fail(.missingUser) }
|
||||
let normalizedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let normalizedDescription = descriptionText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedName.isEmpty else { return fail(.missingName) }
|
||||
guard !normalizedDescription.isEmpty else { return fail(.missingDescription) }
|
||||
guard !selectedScenicIds.isEmpty else { return fail(.missingScenic) }
|
||||
guard Double(projectType == 4 ? priceDeposit : projectPrice) ?? 0 > 0 else { return fail(.missingPrice) }
|
||||
if projectType == 4, selectedStoreId == nil { return fail(.missingStore) }
|
||||
|
||||
submitting = true
|
||||
errorMessage = nil
|
||||
defer { submitting = false }
|
||||
|
||||
do {
|
||||
let scenicIdForUpload = selectedScenicIds.first ?? 0
|
||||
let coverURL = try await resolveCoverURL(scenicId: scenicIdForUpload, uploadService: uploadService)
|
||||
guard !coverURL.isEmpty else { throw ProjectEditorError.missingImage }
|
||||
let carouselURLs = try await resolveCarouselURLs(scenicId: scenicIdForUpload, uploadService: uploadService)
|
||||
if projectType == 4 {
|
||||
try await submitOfflineProject(
|
||||
api: api,
|
||||
name: normalizedName,
|
||||
description: normalizedDescription,
|
||||
coverURL: coverURL,
|
||||
carouselURLs: carouselURLs
|
||||
)
|
||||
} else {
|
||||
try await submitMultiPointProject(
|
||||
userId: userId,
|
||||
api: api,
|
||||
name: normalizedName,
|
||||
description: normalizedDescription,
|
||||
coverURL: coverURL,
|
||||
carouselURLs: carouselURLs
|
||||
)
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private var editID: Int? {
|
||||
if case let .edit(detail) = mode {
|
||||
return detail.id
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func resolveCoverURL(scenicId: Int, uploadService: any OSSUploadServing) async throws -> String {
|
||||
if let coverImage {
|
||||
return try await uploadService.uploadProjectImage(data: coverImage.data, fileName: coverImage.fileName, scenicId: scenicId) { _ in }
|
||||
}
|
||||
return existingCoverURL
|
||||
}
|
||||
|
||||
private func resolveCarouselURLs(scenicId: Int, uploadService: any OSSUploadServing) async throws -> [String] {
|
||||
var urls = existingCarouselURLs
|
||||
for image in carouselImages {
|
||||
let url = try await uploadService.uploadProjectImage(data: image.data, fileName: image.fileName, scenicId: scenicId) { _ in }
|
||||
urls.append(url)
|
||||
}
|
||||
return urls
|
||||
}
|
||||
|
||||
private func submitMultiPointProject(
|
||||
userId: Int,
|
||||
api: any ProjectServing,
|
||||
name: String,
|
||||
description: String,
|
||||
coverURL: String,
|
||||
carouselURLs: [String]
|
||||
) async throws {
|
||||
if let editID {
|
||||
try await api.storeManagerUpdate(
|
||||
StoreManagerUpdateRequest(
|
||||
id: editID,
|
||||
name: name,
|
||||
type: 19,
|
||||
description: description,
|
||||
coverProject: coverURL,
|
||||
coverCarousel: carouselURLs,
|
||||
projectRule: nil,
|
||||
scenicId: selectedScenicIds.sorted(),
|
||||
settleSpotNum: max(Int(settleSpotNum) ?? 1, 1),
|
||||
price: Double(projectPrice) ?? 0,
|
||||
priceMaterial: Double(priceMaterial) ?? 0,
|
||||
pricePhoto: Double(pricePhoto) ?? 0,
|
||||
priceVideo: Double(priceVideo) ?? 0,
|
||||
priceMaterialAll: nil,
|
||||
packageList: nil,
|
||||
userId: userId,
|
||||
singleSpotMaterialNum: max(Int(materialNum) ?? 0, 0),
|
||||
singleSpotPhotoNum: max(Int(photoNum) ?? 0, 0),
|
||||
singleSpotVideoNum: max(Int(videoNum) ?? 0, 0)
|
||||
)
|
||||
)
|
||||
} else {
|
||||
try await api.storeManagerCreate(
|
||||
StoreManagerCreateRequest(
|
||||
name: name,
|
||||
storeId: nil,
|
||||
type: 19,
|
||||
description: description,
|
||||
coverProject: coverURL,
|
||||
coverCarousel: carouselURLs,
|
||||
projectRule: nil,
|
||||
scenicId: selectedScenicIds.sorted(),
|
||||
settleSpotNum: max(Int(settleSpotNum) ?? 1, 1),
|
||||
price: Double(projectPrice) ?? 0,
|
||||
priceMaterial: Double(priceMaterial) ?? 0,
|
||||
pricePhoto: Double(pricePhoto) ?? 0,
|
||||
priceVideo: Double(priceVideo) ?? 0,
|
||||
priceMaterialAll: nil,
|
||||
packageList: nil,
|
||||
userId: userId,
|
||||
singleSpotMaterialNum: max(Int(materialNum) ?? 0, 0),
|
||||
singleSpotPhotoNum: max(Int(photoNum) ?? 0, 0),
|
||||
singleSpotVideoNum: max(Int(videoNum) ?? 0, 0)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func submitOfflineProject(api: any ProjectServing, name: String, description: String, coverURL: String, carouselURLs: [String]) async throws {
|
||||
let scenicId = selectedScenicIds.first ?? 0
|
||||
let storeId = selectedStoreId ?? 0
|
||||
if let editID {
|
||||
try await api.storeManagerOfflineUpdate(
|
||||
StoreManagerOfflineUpdateRequest(
|
||||
id: editID,
|
||||
name: name,
|
||||
scenicId: scenicId,
|
||||
description: description,
|
||||
price: Double(priceDeposit) ?? 0,
|
||||
coverProject: coverURL,
|
||||
coverCarousel: carouselURLs,
|
||||
storeId: storeId
|
||||
)
|
||||
)
|
||||
} else {
|
||||
try await api.storeManagerOfflineCreate(
|
||||
StoreManagerOfflineCreateRequest(
|
||||
name: name,
|
||||
scenicId: scenicId,
|
||||
storeId: storeId,
|
||||
description: description,
|
||||
price: Double(priceDeposit) ?? 0,
|
||||
coverProject: coverURL,
|
||||
coverCarousel: carouselURLs
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func fail(_ error: ProjectEditorError) -> Bool {
|
||||
errorMessage = error.localizedDescription
|
||||
return false
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
199
suixinkan_ios/Features/QueueManagement/API/ScenicQueueAPI.swift
Normal file
199
suixinkan_ios/Features/QueueManagement/API/ScenicQueueAPI.swift
Normal file
@ -0,0 +1,199 @@
|
||||
//
|
||||
// ScenicQueueAPI.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 排队管理服务协议,定义列表、动作、设置、二维码和实时 token 能力。
|
||||
@MainActor
|
||||
protocol ScenicQueueServing: AnyObject {
|
||||
func scenicQueueStats(scenicId: Int, scenicSpotId: Int) async throws -> ScenicQueueStatsData
|
||||
func scenicQueueHome(scenicId: Int, scenicSpotId: Int, type: Int, page: Int, pageSize: Int) async throws -> ScenicQueueHomeData
|
||||
func socketToken() async throws -> SocketTokenResponse
|
||||
func scenicQueueCall(id: Int64) async throws -> ScenicQueueCallData
|
||||
func scenicQueuePass(id: Int64) async throws -> ScenicQueuePassData
|
||||
func scenicQueueFinish(id: Int64) async throws -> ScenicQueueFinishData
|
||||
func scenicQueueRequeueInsertBefore(recordId: Int64, operatorId: Int) async throws
|
||||
func scenicQueueUserMark(_ request: ScenicQueueUserMarkRequest) async throws
|
||||
func scenicQueueSetting(scenicId: Int, scenicSpotId: Int?) async throws -> ScenicQueueSettingData
|
||||
func scenicQueueSaveSetting(_ request: ScenicQueueSaveSettingRequest) async throws
|
||||
func scenicQueueShootQueueQRCode(scenicId: Int, scenicSpotId: Int) async throws -> ScenicQueueShootQueueQRCodeData
|
||||
func scenicQueueSettingChangeLog(scenicId: Int, scenicSpotId: Int?, page: Int, pageSize: Int) async throws -> ScenicQueueSettingChangeLogData
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 排队管理 API,封装 `/api/app/scenic-queue` 下的排队接口。
|
||||
final class ScenicQueueAPI {
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化排队管理 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
self.client = client
|
||||
}
|
||||
|
||||
/// 获取当前打卡点排队统计。
|
||||
func scenicQueueStats(scenicId: Int, scenicSpotId: Int) async throws -> ScenicQueueStatsData {
|
||||
do {
|
||||
return try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/app/scenic-queue/queue-stats",
|
||||
queryItems: [
|
||||
URLQueryItem(name: "scenic_id", value: String(scenicId)),
|
||||
URLQueryItem(name: "scenic_spot_id", value: String(scenicSpotId))
|
||||
]
|
||||
)
|
||||
)
|
||||
} catch APIError.emptyData {
|
||||
return ScenicQueueStatsData()
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取当前打卡点排队列表。
|
||||
func scenicQueueHome(
|
||||
scenicId: Int,
|
||||
scenicSpotId: Int,
|
||||
type: Int,
|
||||
page: Int = 1,
|
||||
pageSize: Int = 20
|
||||
) async throws -> ScenicQueueHomeData {
|
||||
do {
|
||||
return try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/app/scenic-queue/home",
|
||||
queryItems: [
|
||||
URLQueryItem(name: "scenic_id", value: String(scenicId)),
|
||||
URLQueryItem(name: "scenic_spot_id", value: String(scenicSpotId)),
|
||||
URLQueryItem(name: "type", value: String(type)),
|
||||
URLQueryItem(name: "page", value: String(max(page, 1))),
|
||||
URLQueryItem(name: "page_size", value: String(max(pageSize, 1)))
|
||||
]
|
||||
)
|
||||
)
|
||||
} catch APIError.emptyData {
|
||||
return ScenicQueueHomeData()
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取 WebSocket 订阅 token。
|
||||
func socketToken() async throws -> SocketTokenResponse {
|
||||
try await client.send(APIRequest(method: .get, path: "/api/app/socket-token"))
|
||||
}
|
||||
|
||||
/// 叫号。
|
||||
func scenicQueueCall(id: Int64) async throws -> ScenicQueueCallData {
|
||||
do {
|
||||
return try await client.send(
|
||||
APIRequest(method: .post, path: "/api/app/scenic-queue/call", body: ScenicQueueActionRequest(id: id))
|
||||
)
|
||||
} catch APIError.emptyData {
|
||||
return ScenicQueueCallData(id: id)
|
||||
}
|
||||
}
|
||||
|
||||
/// 标记过号。
|
||||
func scenicQueuePass(id: Int64) async throws -> ScenicQueuePassData {
|
||||
do {
|
||||
return try await client.send(
|
||||
APIRequest(method: .post, path: "/api/app/scenic-queue/pass", body: ScenicQueueActionRequest(id: id))
|
||||
)
|
||||
} catch APIError.emptyData {
|
||||
return ScenicQueuePassData(id: id)
|
||||
}
|
||||
}
|
||||
|
||||
/// 标记完成。
|
||||
func scenicQueueFinish(id: Int64) async throws -> ScenicQueueFinishData {
|
||||
do {
|
||||
return try await client.send(
|
||||
APIRequest(method: .post, path: "/api/app/scenic-queue/finish", body: ScenicQueueActionRequest(id: id))
|
||||
)
|
||||
} catch APIError.emptyData {
|
||||
return ScenicQueueFinishData(id: id)
|
||||
}
|
||||
}
|
||||
|
||||
/// 将过号记录重新插回当前排队队列。
|
||||
func scenicQueueRequeueInsertBefore(recordId: Int64, operatorId: Int) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/app/scenic-queue/requeue-insert-before",
|
||||
body: ScenicQueueRequeueInsertBeforeRequest(recordId: recordId, operatorId: operatorId)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 标记用户身份/限制排队。
|
||||
func scenicQueueUserMark(_ request: ScenicQueueUserMarkRequest) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(method: .post, path: "/api/app/scenic-queue/user-mark", body: request)
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取排队设置。
|
||||
func scenicQueueSetting(scenicId: Int, scenicSpotId: Int?) async throws -> ScenicQueueSettingData {
|
||||
var query = [URLQueryItem(name: "scenic_id", value: String(scenicId))]
|
||||
if let scenicSpotId {
|
||||
query.append(URLQueryItem(name: "scenic_spot_id", value: String(scenicSpotId)))
|
||||
}
|
||||
do {
|
||||
return try await client.send(
|
||||
APIRequest(method: .get, path: "/api/app/scenic-queue/setting", queryItems: query)
|
||||
)
|
||||
} catch APIError.emptyData {
|
||||
return ScenicQueueSettingData()
|
||||
}
|
||||
}
|
||||
|
||||
/// 保存排队设置。
|
||||
func scenicQueueSaveSetting(_ request: ScenicQueueSaveSettingRequest) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(method: .post, path: "/api/app/scenic-queue/save-setting", body: request)
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取取号二维码。
|
||||
func scenicQueueShootQueueQRCode(scenicId: Int, scenicSpotId: Int) async throws -> ScenicQueueShootQueueQRCodeData {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/app/scenic-queue/shoot-queue-qrcode",
|
||||
queryItems: [
|
||||
URLQueryItem(name: "scenic_id", value: String(scenicId)),
|
||||
URLQueryItem(name: "scenic_spot_id", value: String(scenicSpotId))
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取排队设置变更日志。
|
||||
func scenicQueueSettingChangeLog(
|
||||
scenicId: Int,
|
||||
scenicSpotId: Int?,
|
||||
page: Int = 1,
|
||||
pageSize: Int = 20
|
||||
) async throws -> ScenicQueueSettingChangeLogData {
|
||||
var query = [
|
||||
URLQueryItem(name: "scenic_id", value: String(scenicId)),
|
||||
URLQueryItem(name: "page", value: String(max(page, 1))),
|
||||
URLQueryItem(name: "page_size", value: String(max(pageSize, 1)))
|
||||
]
|
||||
if let scenicSpotId {
|
||||
query.append(URLQueryItem(name: "scenic_spot_id", value: String(scenicSpotId)))
|
||||
}
|
||||
do {
|
||||
return try await client.send(
|
||||
APIRequest(method: .get, path: "/api/app/scenic-queue/setting-change-log", queryItems: query)
|
||||
)
|
||||
} catch APIError.emptyData {
|
||||
return ScenicQueueSettingChangeLogData()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ScenicQueueAPI: ScenicQueueServing {}
|
||||
@ -0,0 +1,491 @@
|
||||
//
|
||||
// ScenicQueueModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 排队统计响应。
|
||||
struct ScenicQueueStatsData: Decodable, Equatable {
|
||||
let queueCount: Int
|
||||
let avgWaitMin: Double
|
||||
let time: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case queueCount = "queue_count"
|
||||
case avgWaitMin = "avg_wait_min"
|
||||
case time
|
||||
}
|
||||
|
||||
/// 创建排队统计,主要用于空响应兜底。
|
||||
init(queueCount: Int = 0, avgWaitMin: Double = 0, time: String = "") {
|
||||
self.queueCount = queueCount
|
||||
self.avgWaitMin = avgWaitMin
|
||||
self.time = time
|
||||
}
|
||||
|
||||
/// 宽松解码统计字段。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
queueCount = try container.decodeLossyInt(forKey: .queueCount) ?? 0
|
||||
avgWaitMin = try container.decodeLossyDouble(forKey: .avgWaitMin) ?? 0
|
||||
time = try container.decodeLossyString(forKey: .time)
|
||||
}
|
||||
}
|
||||
|
||||
/// 排队首页响应,兼容 list 为数组或分页对象两种形态。
|
||||
struct ScenicQueueHomeData: Decodable, Equatable {
|
||||
let stats: ScenicQueueHomeStats?
|
||||
let list: ScenicQueueHomeListBlock?
|
||||
let time: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case stats
|
||||
case list
|
||||
case time
|
||||
}
|
||||
|
||||
/// 创建排队首页数据,主要用于空响应兜底。
|
||||
init(stats: ScenicQueueHomeStats? = nil, list: ScenicQueueHomeListBlock? = nil, time: String? = nil) {
|
||||
self.stats = stats
|
||||
self.list = list
|
||||
self.time = time
|
||||
}
|
||||
|
||||
/// 宽松解码排队首页数据。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
stats = try container.decodeIfPresent(ScenicQueueHomeStats.self, forKey: .stats)
|
||||
if let block = try? container.decodeIfPresent(ScenicQueueHomeListBlock.self, forKey: .list) {
|
||||
list = block
|
||||
} else if let tickets = try? container.decodeIfPresent([ScenicQueueTicket].self, forKey: .list) {
|
||||
list = ScenicQueueHomeListBlock(list: tickets)
|
||||
} else {
|
||||
list = nil
|
||||
}
|
||||
time = try container.decodeIfPresent(String.self, forKey: .time)
|
||||
}
|
||||
}
|
||||
|
||||
/// 排队首页统计块。
|
||||
struct ScenicQueueHomeStats: Decodable, Equatable {
|
||||
let type: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case type
|
||||
}
|
||||
|
||||
/// 创建统计块。
|
||||
init(type: Int = 0) {
|
||||
self.type = type
|
||||
}
|
||||
|
||||
/// 宽松解码统计块。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
type = try container.decodeLossyInt(forKey: .type) ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
/// 排队列表分页块。
|
||||
struct ScenicQueueHomeListBlock: Decodable, Equatable {
|
||||
let list: [ScenicQueueTicket]
|
||||
let total: Int
|
||||
let page: Int
|
||||
let pageSize: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case list
|
||||
case total
|
||||
case page
|
||||
case pageSize = "page_size"
|
||||
}
|
||||
|
||||
/// 创建分页块。
|
||||
init(list: [ScenicQueueTicket] = [], total: Int? = nil, page: Int = 1, pageSize: Int = 20) {
|
||||
self.list = list
|
||||
self.total = total ?? list.count
|
||||
self.page = page
|
||||
self.pageSize = pageSize
|
||||
}
|
||||
|
||||
/// 宽松解码分页块。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
list = try container.decodeIfPresent([ScenicQueueTicket].self, forKey: .list) ?? []
|
||||
total = try container.decodeLossyInt(forKey: .total) ?? list.count
|
||||
page = try container.decodeLossyInt(forKey: .page) ?? 1
|
||||
pageSize = try container.decodeLossyInt(forKey: .pageSize) ?? max(list.count, 20)
|
||||
}
|
||||
}
|
||||
|
||||
/// 单个排队取号记录。
|
||||
struct ScenicQueueTicket: Decodable, Equatable, Identifiable {
|
||||
let id: Int64
|
||||
let queueCode: String
|
||||
let mobile: String
|
||||
let status: Int
|
||||
let statusText: String
|
||||
let waitMin: Int
|
||||
let aheadCount: Int
|
||||
let isCalled: Int
|
||||
let createdAt: String
|
||||
let calledAt: String
|
||||
let expiredAt: String
|
||||
let finishedAt: String
|
||||
let queueBanLabel: String
|
||||
let identityTag: String
|
||||
let queueTime: String
|
||||
let queueCountToday: Int
|
||||
let uid: Int64
|
||||
let markAsPhotog: Int
|
||||
let markAsFreelancePhotog: Int
|
||||
let isMissRequeue: Int
|
||||
let missRequeueText: String
|
||||
|
||||
var phoneMasked: String {
|
||||
let digits = mobile.filter(\.isNumber)
|
||||
if mobile.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return "--" }
|
||||
guard digits.count >= 7 else { return mobile }
|
||||
return "\(digits.prefix(3))****\(digits.suffix(4))"
|
||||
}
|
||||
|
||||
var dialPhoneDigits: String {
|
||||
let digits = mobile.filter(\.isNumber)
|
||||
return digits.isEmpty ? mobile.filter { !$0.isWhitespace } : digits
|
||||
}
|
||||
|
||||
var queueTimeDisplay: String {
|
||||
Self.formatQueueTime(queueTime.nonEmptyOrDefault(createdAt))
|
||||
}
|
||||
|
||||
var skippedTimeDisplay: String {
|
||||
Self.formatQueueTime(expiredAt.nonEmptyOrDefault(calledAt.nonEmptyOrDefault(createdAt)))
|
||||
}
|
||||
|
||||
var shouldShowIdentityTag: Bool {
|
||||
let text = identityTag.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return !text.isEmpty && text != "普通用户"
|
||||
}
|
||||
|
||||
var isMissRequeueRecord: Bool {
|
||||
isMissRequeue == 1
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case queueCode = "queue_code"
|
||||
case mobile
|
||||
case status
|
||||
case statusText = "status_text"
|
||||
case waitMin = "wait_min"
|
||||
case aheadCount = "ahead_count"
|
||||
case isCalled = "is_called"
|
||||
case createdAt = "created_at"
|
||||
case calledAt = "called_at"
|
||||
case expiredAt = "expired_at"
|
||||
case finishedAt = "finished_at"
|
||||
case queueBanLabel = "queue_ban_label"
|
||||
case identityTag = "identity_tag"
|
||||
case queueTime = "queue_time"
|
||||
case queueCountToday = "queue_count_today"
|
||||
case uid
|
||||
case markAsPhotog = "mark_as_photog"
|
||||
case markAsFreelancePhotog = "mark_as_freelance_photog"
|
||||
case isMissRequeue = "is_miss_requeue"
|
||||
case missRequeueText = "is_miss_requeue_text"
|
||||
}
|
||||
|
||||
enum AlternateCodingKeys: String, CodingKey {
|
||||
case queueNo = "queue_no"
|
||||
case queueNumber = "queue_number"
|
||||
case code
|
||||
case phone
|
||||
case statusName = "status_name"
|
||||
}
|
||||
|
||||
/// 创建取号记录,主要用于本地状态替换和测试。
|
||||
init(
|
||||
id: Int64,
|
||||
queueCode: String,
|
||||
mobile: String,
|
||||
status: Int,
|
||||
statusText: String,
|
||||
waitMin: Int,
|
||||
aheadCount: Int,
|
||||
isCalled: Int,
|
||||
createdAt: String,
|
||||
calledAt: String,
|
||||
expiredAt: String,
|
||||
finishedAt: String,
|
||||
queueBanLabel: String = "",
|
||||
identityTag: String = "",
|
||||
queueTime: String = "",
|
||||
queueCountToday: Int = 0,
|
||||
uid: Int64 = 0,
|
||||
markAsPhotog: Int = 0,
|
||||
markAsFreelancePhotog: Int = 0,
|
||||
isMissRequeue: Int = 0,
|
||||
missRequeueText: String = ""
|
||||
) {
|
||||
self.id = id
|
||||
self.queueCode = queueCode
|
||||
self.mobile = mobile
|
||||
self.status = status
|
||||
self.statusText = statusText
|
||||
self.waitMin = waitMin
|
||||
self.aheadCount = aheadCount
|
||||
self.isCalled = isCalled
|
||||
self.createdAt = createdAt
|
||||
self.calledAt = calledAt
|
||||
self.expiredAt = expiredAt
|
||||
self.finishedAt = finishedAt
|
||||
self.queueBanLabel = queueBanLabel
|
||||
self.identityTag = identityTag
|
||||
self.queueTime = queueTime
|
||||
self.queueCountToday = queueCountToday
|
||||
self.uid = uid
|
||||
self.markAsPhotog = markAsPhotog
|
||||
self.markAsFreelancePhotog = markAsFreelancePhotog
|
||||
self.isMissRequeue = isMissRequeue
|
||||
self.missRequeueText = missRequeueText
|
||||
}
|
||||
|
||||
/// 宽松解码取号记录,兼容 Android 历史字段别名。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let alternate = try decoder.container(keyedBy: AlternateCodingKeys.self)
|
||||
id = Int64(try container.decodeLossyInt(forKey: .id) ?? 0)
|
||||
queueCode = try container.decodeLossyString(forKey: .queueCode)
|
||||
.nonEmptyOrDefault(
|
||||
try alternate.decodeLossyString(forKey: .queueNo)
|
||||
.nonEmptyOrDefault(
|
||||
try alternate.decodeLossyString(forKey: .queueNumber)
|
||||
.nonEmptyOrDefault(try alternate.decodeLossyString(forKey: .code))
|
||||
)
|
||||
)
|
||||
mobile = try container.decodeLossyString(forKey: .mobile)
|
||||
.nonEmptyOrDefault(try alternate.decodeLossyString(forKey: .phone))
|
||||
status = try container.decodeLossyInt(forKey: .status) ?? 0
|
||||
statusText = try container.decodeLossyString(forKey: .statusText)
|
||||
.nonEmptyOrDefault(try alternate.decodeLossyString(forKey: .statusName))
|
||||
waitMin = try container.decodeLossyInt(forKey: .waitMin) ?? 0
|
||||
aheadCount = try container.decodeLossyInt(forKey: .aheadCount) ?? 0
|
||||
isCalled = try container.decodeLossyInt(forKey: .isCalled) ?? 0
|
||||
createdAt = try container.decodeLossyString(forKey: .createdAt)
|
||||
calledAt = try container.decodeLossyString(forKey: .calledAt)
|
||||
expiredAt = try container.decodeLossyString(forKey: .expiredAt)
|
||||
finishedAt = try container.decodeLossyString(forKey: .finishedAt)
|
||||
queueBanLabel = try container.decodeLossyString(forKey: .queueBanLabel).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
identityTag = try container.decodeLossyString(forKey: .identityTag).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
queueTime = try container.decodeLossyString(forKey: .queueTime)
|
||||
queueCountToday = max(try container.decodeLossyInt(forKey: .queueCountToday) ?? 0, 0)
|
||||
uid = Int64(try container.decodeLossyInt(forKey: .uid) ?? 0)
|
||||
markAsPhotog = try container.decodeLossyInt(forKey: .markAsPhotog) ?? 0
|
||||
markAsFreelancePhotog = try container.decodeLossyInt(forKey: .markAsFreelancePhotog) ?? 0
|
||||
isMissRequeue = try container.decodeLossyInt(forKey: .isMissRequeue) ?? 0
|
||||
missRequeueText = try container.decodeLossyString(forKey: .missRequeueText).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private static func formatQueueTime(_ raw: String) -> String {
|
||||
let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else { return "--" }
|
||||
if text.range(of: #"^\d{2}-\d{2}\s+\d{2}:\d{2}$"#, options: .regularExpression) != nil {
|
||||
return text
|
||||
}
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
for format in ["yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd HH:mm"] {
|
||||
formatter.dateFormat = format
|
||||
if let date = formatter.date(from: text) {
|
||||
formatter.dateFormat = "MM-dd HH:mm"
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
}
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
/// 排队动作请求体。
|
||||
struct ScenicQueueActionRequest: Encodable {
|
||||
let id: Int64
|
||||
}
|
||||
|
||||
/// socket token 响应。
|
||||
struct SocketTokenResponse: Decodable, Equatable {
|
||||
let socketToken: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case socketToken = "socket_token"
|
||||
}
|
||||
|
||||
/// 创建 socket token 响应。
|
||||
init(socketToken: String = "") {
|
||||
self.socketToken = socketToken
|
||||
}
|
||||
|
||||
/// 宽松解码 socket token。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
socketToken = try container.decodeLossyString(forKey: .socketToken)
|
||||
}
|
||||
}
|
||||
|
||||
/// 排队动作响应。
|
||||
struct ScenicQueueActionData: Decodable, Equatable {
|
||||
let id: Int64
|
||||
let status: Int
|
||||
let statusText: String
|
||||
let calledAt: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case status
|
||||
case statusText = "status_text"
|
||||
case calledAt = "called_at"
|
||||
}
|
||||
|
||||
/// 创建动作响应。
|
||||
init(id: Int64 = 0, status: Int = 0, statusText: String = "", calledAt: String? = nil) {
|
||||
self.id = id
|
||||
self.status = status
|
||||
self.statusText = statusText
|
||||
self.calledAt = calledAt
|
||||
}
|
||||
|
||||
/// 宽松解码动作响应。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = Int64(try container.decodeLossyInt(forKey: .id) ?? 0)
|
||||
status = try container.decodeLossyInt(forKey: .status) ?? 0
|
||||
statusText = try container.decodeLossyString(forKey: .statusText)
|
||||
calledAt = try container.decodeIfPresent(String.self, forKey: .calledAt)
|
||||
}
|
||||
}
|
||||
|
||||
typealias ScenicQueueCallData = ScenicQueueActionData
|
||||
typealias ScenicQueuePassData = ScenicQueueActionData
|
||||
typealias ScenicQueueFinishData = ScenicQueueActionData
|
||||
typealias QueueItem = ScenicQueueTicket
|
||||
|
||||
/// 重新排队请求体。
|
||||
struct ScenicQueueRequeueInsertBeforeRequest: Encodable {
|
||||
let recordId: Int64
|
||||
let operatorId: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case recordId = "record_id"
|
||||
case operatorId = "operator_id"
|
||||
}
|
||||
}
|
||||
|
||||
/// 用户标记请求体。
|
||||
struct ScenicQueueUserMarkRequest: Encodable, Equatable {
|
||||
let uid: Int64
|
||||
let scenicId: Int64
|
||||
let markAsFreelancePhotog: Int
|
||||
let queueBanDays: Int?
|
||||
let operatorId: Int?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case uid
|
||||
case scenicId = "scenic_id"
|
||||
case markAsFreelancePhotog = "mark_as_freelance_photog"
|
||||
case queueBanDays = "queue_ban_days"
|
||||
case operatorId = "operator_id"
|
||||
}
|
||||
}
|
||||
|
||||
/// 排队列表类型。
|
||||
enum QueueListType: Int, CaseIterable, Identifiable {
|
||||
case queueing = 1
|
||||
case passed = 2
|
||||
|
||||
var id: Int { rawValue }
|
||||
|
||||
/// 分段标题。
|
||||
var title: String {
|
||||
switch self {
|
||||
case .queueing:
|
||||
return "当前排队"
|
||||
case .passed:
|
||||
return "过号列表"
|
||||
}
|
||||
}
|
||||
|
||||
/// 空态文案。
|
||||
var emptyText: String {
|
||||
switch self {
|
||||
case .queueing:
|
||||
return "暂无排队"
|
||||
case .passed:
|
||||
return "暂无过号"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 远端叫号提示。
|
||||
struct ScenicQueueRemoteCallAnnouncement: Identifiable, Equatable {
|
||||
let id: Int64
|
||||
let queueCode: String
|
||||
}
|
||||
|
||||
private extension KeyedDecodingContainer {
|
||||
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 ""
|
||||
}
|
||||
|
||||
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),
|
||||
case let text = value.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!text.isEmpty {
|
||||
return Int(text) ?? Int(Double(text) ?? 0)
|
||||
}
|
||||
if let value = try? decodeIfPresent(Bool.self, forKey: key) {
|
||||
return value ? 1 : 0
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
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),
|
||||
case let text = value.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!text.isEmpty {
|
||||
return Double(text)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
func nonEmptyOrDefault(_ fallback: String) -> String {
|
||||
let text = trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return text.isEmpty ? fallback : text
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user