Initial commit: suixinkan_ios UIKit rewrite project.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-26 14:33:31 +08:00
commit 9edf993432
297 changed files with 47151 additions and 0 deletions

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

View 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 做隔离。

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

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

View 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 和路由测试。测试不通过时先修复问题,再继续迁移后续功能。

File diff suppressed because it is too large Load Diff

View File

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

View 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
)
}
}
/// ViewModelOSS
@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
}
}

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

View 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`,不落盘。

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

View File

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

View File

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@ -0,0 +1,30 @@
# Invite 模块业务逻辑
## 模块职责
Invite 模块负责首页 `registration_invitation``photographer_invite``invite_record` 入口。
邀请页展示邀请码、邀请链接、二维码和规则;邀请记录页展示邀请用户和奖励记录。模块不保存业务状态到全局对象。
## 邀请页
`PhotographerInviteViewModel` 通过 `InviteAPI.inviteInfo()` 获取邀请信息,并使用 CoreImage 在本地生成二维码图片。二维码图片只用于当前页面展示,不落盘。
复制邀请链接时只由 View 层写入剪贴板ViewModel 不关心系统剪贴板状态。
## 邀请记录
`InviteRecordViewModel` 提供“邀请记录 / 奖励记录”分段:
- 邀请记录来自 `InviteAPI.inviteUserList`
- 奖励记录复用 `WalletAPI.walletEarningDetail`
- 顶部奖励汇总复用 `WalletAPI.walletSummary(type: 2)`
分页状态、筛选分段和展示行只存在 ViewModel 内存中。
## 接口边界
`InviteAPI` 只封装邀请信息和邀请用户列表。钱包收益和提现入口继续由 Wallet 模块负责Invite 模块只调用其公开服务协议,不持有钱包业务状态。
## 缓存边界
邀请码、邀请记录、奖励记录和二维码都不落盘。后续如果需要分享图片,应在分享流程中临时生成。

View File

@ -0,0 +1,156 @@
//
// InviteModels.swift
// suixinkan
//
// Created by Codex on 2026/6/24.
//
import Foundation
///
struct InviteInfoResponse: Decodable, Equatable {
let enableInvite: Bool
let inviteCode: String
let inviteUrl: String
let description: [String]
/// 线
enum CodingKeys: String, CodingKey {
case enableInvite = "enable_invite"
case inviteCode = "invite_code"
case inviteUrl = "invite_url"
case description
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
enableInvite = try container.decodeInviteLossyBool(forKey: .enableInvite) ?? true
inviteCode = try container.decodeInviteLossyString(forKey: .inviteCode)
inviteUrl = try container.decodeInviteLossyString(forKey: .inviteUrl)
description = (try? container.decodeIfPresent([String].self, forKey: .description)) ?? []
}
}
///
struct InviteUserItem: Decodable, Identifiable, Hashable {
let id: Int
let realName: String
let phone: String
let avatar: String
let createdAt: String
let inviteLevel: Int
/// 线
enum CodingKeys: String, CodingKey {
case id
case realName = "real_name"
case phone
case avatar
case createdAt = "created_at"
case inviteLevel = "invite_level"
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeInviteLossyInt(forKey: .id) ?? 0
realName = try container.decodeInviteLossyString(forKey: .realName)
phone = try container.decodeInviteLossyString(forKey: .phone)
avatar = try container.decodeInviteLossyString(forKey: .avatar)
createdAt = try container.decodeInviteLossyString(forKey: .createdAt)
inviteLevel = try container.decodeInviteLossyInt(forKey: .inviteLevel) ?? 0
}
}
///
struct InviteDisplayRow: Identifiable, Equatable {
let id: String
let title: String
let subtitle: String
let amount: String
let time: String
let phone: String
let avatar: String
let inviteLevel: Int
///
var levelText: String {
inviteLevel == 1 ? "一级" : "二级"
}
///
var avatarPlaceholder: String {
if let first = title.first {
return String(first)
}
if let first = phone.first {
return String(first)
}
return "?"
}
}
///
enum InviteRecordTab: CaseIterable, Equatable {
case invite
case reward
///
var title: String {
switch self {
case .invite: return "邀请记录"
case .reward: return "奖励记录"
}
}
}
private extension KeyedDecodingContainer {
/// String Bool
func decodeInviteLossyString(forKey key: Key) throws -> String {
if let value = try? decodeIfPresent(String.self, forKey: key) {
return value
}
if let value = try? decodeIfPresent(Int.self, forKey: key) {
return String(value)
}
if let value = try? decodeIfPresent(Double.self, forKey: key) {
return String(value)
}
if let value = try? decodeIfPresent(Bool.self, forKey: key) {
return value ? "1" : "0"
}
return ""
}
/// StringDouble Int Int
func decodeInviteLossyInt(forKey key: Key) throws -> Int? {
if let value = try? decodeIfPresent(Int.self, forKey: key) {
return value
}
if let value = try? decodeIfPresent(Double.self, forKey: key) {
return Int(value)
}
if let value = try? decodeIfPresent(String.self, forKey: key) {
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
return Int(text) ?? Double(text).map(Int.init)
}
return nil
}
/// String Bool Bool
func decodeInviteLossyBool(forKey key: Key) throws -> Bool? {
if let value = try? decodeIfPresent(Bool.self, forKey: key) {
return value
}
if let value = try? decodeIfPresent(Int.self, forKey: key) {
return value != 0
}
if let value = try? decodeIfPresent(String.self, forKey: key) {
let text = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if ["1", "true", "yes"].contains(text) { return true }
if ["0", "false", "no"].contains(text) { return false }
}
return nil
}
}

View File

@ -0,0 +1,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)
}
}
}

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

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

View 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 提交给创建相册接口。

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

View File

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

View File

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

View File

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

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

View File

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

View 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 和路由测试。测试失败时先修复问题,再继续迁移后续功能。

View File

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

View File

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

View File

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

View 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 只接收绑定、文案和动作回调。
- 真实业务页面接入后,应同步补充该模块文档和单元测试。

View File

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

View File

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

View File

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

View File

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

View 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 能力,逐条调用已读接口;任一失败时保留原状态并透出错误。
- 首屏刷新失败会清空旧数据和分页状态,加载更多失败保留当前列表。

View File

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

View File

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

View File

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

View File

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

View File

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

View 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模拟器或未启用高德时展示坐标摘要兜底。
## 边界
本模块只展示已有区域和围栏,不提供新建、编辑、删除或绘制保存能力。缺少景区/门店、角色不支持、空列表和不可解析围栏都会进入明确空态或阻断提示。

View File

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

View File

@ -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 = []
}
}

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

View 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 ""
}
/// IntDoubleBool
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
}
/// BoolInt
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
}
/// DoubleInt
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
}
}

View 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。
未明确接口的订单后续能力继续进入占位页,避免入口崩溃。

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

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

View File

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

View File

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

View File

@ -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、无人机连接、飞行控制或横屏飞控流程。

View File

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

View File

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

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

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

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

View 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不清空登录态。
- 实名认证失败只影响实名认证页面,不影响登录态和账号上下文。

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

View File

@ -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 ?? "") + " (当前)"
}
}
}

View File

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

View File

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

View File

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

View File

@ -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:
"账号信息异常,请重新选择账号"
}
}
}

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

View File

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

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

View File

@ -0,0 +1,440 @@
//
// ProjectModels.swift
// suixinkan
//
// Created by Codex on 2026/6/24.
//
import Foundation
///
struct PhotographerProjectDetailResponse: Decodable, Identifiable, Hashable {
let id: Int
let scenicId: Int
let cover: String
let coverProject: String
let coverCarousel: [String]
let name: String
let type: Int
let typeName: String
let status: Int
let statusName: String
let price: String
let otPrice: String
let priceDeposit: String
let description: String
let attrLabel: [String]
let projectContent: String
let sold: Int
let unitName: String
let materialNum: Int
let photoNum: Int
let videoNum: Int
let createdAt: String
let updatedAt: String
let creatorName: String
let creatorPhone: String
let creatorRoleName: String
let auditRemark: String
let photogList: [ProjectPhotographerBrief]
let scenicList: [ProjectScenicSpotBrief]
/// 线
enum CodingKeys: String, CodingKey {
case id
case scenicId = "scenic_id"
case cover
case coverProject = "cover_project"
case coverCarousel = "cover_carousel"
case name
case type
case typeName = "type_name"
case status
case statusName = "status_name"
case price
case otPrice = "ot_price"
case priceDeposit = "price_deposit"
case description
case attrLabel = "attr_label"
case projectContent = "project_content"
case sold
case unitName = "unit_name"
case materialNum = "material_num"
case photoNum = "photo_num"
case videoNum = "video_num"
case createdAt = "created_at"
case updatedAt = "updated_at"
case creatorName = "creator_name"
case creatorPhone = "creator_phone"
case creatorRoleName = "creator_role_name"
case auditRemark = "audit_remark"
case photogList = "photog_list"
case scenicList = "scenic_list"
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeProjectLossyInt(forKey: .id) ?? 0
scenicId = try container.decodeProjectLossyInt(forKey: .scenicId) ?? 0
cover = try container.decodeProjectLossyString(forKey: .cover)
coverProject = try container.decodeProjectLossyString(forKey: .coverProject)
coverCarousel = try container.decodeProjectLossyStringArray(forKey: .coverCarousel)
name = try container.decodeProjectLossyString(forKey: .name)
type = try container.decodeProjectLossyInt(forKey: .type) ?? 0
typeName = try container.decodeProjectLossyString(forKey: .typeName)
status = try container.decodeProjectLossyInt(forKey: .status) ?? 0
statusName = try container.decodeProjectLossyString(forKey: .statusName)
price = try container.decodeProjectLossyString(forKey: .price)
otPrice = try container.decodeProjectLossyString(forKey: .otPrice)
priceDeposit = try container.decodeProjectLossyString(forKey: .priceDeposit)
description = try container.decodeProjectLossyString(forKey: .description)
attrLabel = try container.decodeProjectLossyStringArray(forKey: .attrLabel)
projectContent = try container.decodeProjectLossyString(forKey: .projectContent)
sold = try container.decodeProjectLossyInt(forKey: .sold) ?? 0
unitName = try container.decodeProjectLossyString(forKey: .unitName)
materialNum = try container.decodeProjectLossyInt(forKey: .materialNum) ?? 0
photoNum = try container.decodeProjectLossyInt(forKey: .photoNum) ?? 0
videoNum = try container.decodeProjectLossyInt(forKey: .videoNum) ?? 0
createdAt = try container.decodeProjectLossyString(forKey: .createdAt)
updatedAt = try container.decodeProjectLossyString(forKey: .updatedAt)
creatorName = try container.decodeProjectLossyString(forKey: .creatorName)
creatorPhone = try container.decodeProjectLossyString(forKey: .creatorPhone)
creatorRoleName = try container.decodeProjectLossyString(forKey: .creatorRoleName)
auditRemark = try container.decodeProjectLossyString(forKey: .auditRemark)
photogList = (try? container.decode([ProjectPhotographerBrief].self, forKey: .photogList)) ?? []
scenicList = (try? container.decode([ProjectScenicSpotBrief].self, forKey: .scenicList)) ?? []
}
}
///
struct ProjectPhotographerBrief: Decodable, Identifiable, Hashable {
let id: Int
let photogUid: Int
let nickname: String
let avatar: String
let orderNum: Int
let completedOrderCount: Int
/// 线
enum CodingKeys: String, CodingKey {
case id
case photogUid = "photog_uid"
case nickname
case avatar
case orderNum = "order_num"
case completedOrderCount = "completed_order_count"
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeProjectLossyInt(forKey: .id) ?? 0
photogUid = try container.decodeProjectLossyInt(forKey: .photogUid) ?? 0
nickname = try container.decodeProjectLossyString(forKey: .nickname)
avatar = try container.decodeProjectLossyString(forKey: .avatar)
orderNum = try container.decodeProjectLossyInt(forKey: .orderNum) ?? 0
completedOrderCount = try container.decodeProjectLossyInt(forKey: .completedOrderCount) ?? 0
}
}
///
struct ProjectScenicSpotBrief: Decodable, Identifiable, Hashable {
let id: Int
let name: String
///
enum CodingKeys: String, CodingKey {
case id
case name
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeProjectLossyInt(forKey: .id) ?? 0
name = try container.decodeProjectLossyString(forKey: .name)
}
}
///
struct ProjectCreateRequest: Encodable, Equatable {
let id: Int?
let type: Int
let scenicId: Int
let name: String
let price: String
let otPrice: String?
let coverProject: String
let coverCarousel: [String]?
let description: String
let attrLabel: [String]?
let extra: ProjectCreateExtra
let allowNFC: Int = 1
/// 线
enum CodingKeys: String, CodingKey {
case id
case type
case scenicId = "scenic_id"
case name
case price
case otPrice = "ot_price"
case coverProject = "cover_project"
case coverCarousel = "cover_carousel"
case description
case attrLabel = "attr_label"
case extra
case allowNFC = "allow_nfc"
}
}
/// /
struct ProjectCreateExtra: Encodable, Equatable {
let priceDeposit: String
let materialNum: Int
let photoNum: Int
let videoNum: Int
let scenicSpotId: [Int]
let photogUid: [Int]
/// 线
enum CodingKeys: String, CodingKey {
case priceDeposit = "price_deposit"
case materialNum = "material_num"
case photoNum = "photo_num"
case videoNum = "video_num"
case scenicSpotId = "scenic_spot_id"
case photogUid = "photog_uid"
}
}
///
struct ProjectDeleteRequest: Encodable, Equatable {
let id: Int
}
///
struct StoreManagerScenicItem: Decodable, Identifiable, Hashable {
let id: Int
let name: String
///
enum CodingKeys: String, CodingKey {
case id
case name
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeProjectLossyInt(forKey: .id) ?? 0
name = try container.decodeProjectLossyString(forKey: .name)
}
}
///
struct StoreManagerPackageItem: Codable, Hashable {
let materialNum: Int
let price: String
/// 线
enum CodingKeys: String, CodingKey {
case materialNum = "material_num"
case price
}
}
///
struct StoreManagerCreateRequest: Encodable, Equatable {
let name: String
let storeId: Int?
let type: Int
let description: String
let coverProject: String?
let coverCarousel: [String]?
let projectRule: String?
let scenicId: [Int]
let settleSpotNum: Int
let price: Double
let priceMaterial: Double
let pricePhoto: Double
let priceVideo: Double
let priceMaterialAll: Double?
let packageList: [StoreManagerPackageItem]?
let userId: Int
let singleSpotMaterialNum: Int
let singleSpotPhotoNum: Int
let singleSpotVideoNum: Int
/// 线
enum CodingKeys: String, CodingKey {
case name
case storeId = "store_id"
case type
case description
case coverProject = "cover_project"
case coverCarousel = "cover_carousel"
case projectRule = "project_rule"
case scenicId = "scenic_id"
case settleSpotNum = "settle_spot_num"
case price
case priceMaterial = "price_material"
case pricePhoto = "price_photo"
case priceVideo = "price_video"
case priceMaterialAll = "price_material_all"
case packageList = "package"
case userId = "user_id"
case singleSpotMaterialNum = "single_spot_material_num"
case singleSpotPhotoNum = "single_spot_photo_num"
case singleSpotVideoNum = "single_spot_video_num"
}
}
///
struct StoreManagerUpdateRequest: Encodable, Equatable {
let id: Int
let name: String
let type: Int
let description: String
let coverProject: String?
let coverCarousel: [String]?
let projectRule: String?
let scenicId: [Int]
let settleSpotNum: Int
let price: Double
let priceMaterial: Double
let pricePhoto: Double
let priceVideo: Double
let priceMaterialAll: Double?
let packageList: [StoreManagerPackageItem]?
let userId: Int
let singleSpotMaterialNum: Int
let singleSpotPhotoNum: Int
let singleSpotVideoNum: Int
/// 线
enum CodingKeys: String, CodingKey {
case id
case name
case type
case description
case coverProject = "cover_project"
case coverCarousel = "cover_carousel"
case projectRule = "project_rule"
case scenicId = "scenic_id"
case settleSpotNum = "settle_spot_num"
case price
case priceMaterial = "price_material"
case pricePhoto = "price_photo"
case priceVideo = "price_video"
case priceMaterialAll = "price_material_all"
case packageList = "package"
case userId = "user_id"
case singleSpotMaterialNum = "single_spot_material_num"
case singleSpotPhotoNum = "single_spot_photo_num"
case singleSpotVideoNum = "single_spot_video_num"
}
}
///
struct StoreManagerOfflineCreateRequest: Encodable, Equatable {
let name: String
let scenicId: Int
let storeId: Int
let description: String
let price: Double
let coverProject: String
let coverCarousel: [String]
/// 线
enum CodingKeys: String, CodingKey {
case name
case scenicId = "scenic_id"
case storeId = "store_id"
case description
case price
case coverProject = "cover_project"
case coverCarousel = "cover_carousel"
}
}
///
struct StoreManagerOfflineUpdateRequest: Encodable, Equatable {
let id: Int
let name: String
let scenicId: Int
let description: String
let price: Double
let coverProject: String?
let coverCarousel: [String]?
let storeId: Int?
/// 线
enum CodingKeys: String, CodingKey {
case id
case name
case scenicId = "scenic_id"
case description
case price
case coverProject = "cover_project"
case coverCarousel = "cover_carousel"
case storeId = "store_id"
}
}
/// PhotosPicker
struct ProjectLocalImage: Identifiable, Equatable {
let id = UUID()
let data: Data
let fileName: String
let previewURL: String?
}
private extension KeyedDecodingContainer {
/// String Bool
func decodeProjectLossyString(forKey key: Key) throws -> String {
if let value = try? decodeIfPresent(String.self, forKey: key) {
return value
}
if let value = try? decodeIfPresent(Int.self, forKey: key) {
return String(value)
}
if let value = try? decodeIfPresent(Double.self, forKey: key) {
return String(value)
}
if let value = try? decodeIfPresent(Bool.self, forKey: key) {
return value ? "1" : "0"
}
return ""
}
/// StringDouble Int Int
func decodeProjectLossyInt(forKey key: Key) throws -> Int? {
if let value = try? decodeIfPresent(Int.self, forKey: key) {
return value
}
if let value = try? decodeIfPresent(Double.self, forKey: key) {
return Int(value)
}
if let value = try? decodeIfPresent(String.self, forKey: key) {
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
return Int(text) ?? Double(text).map(Int.init)
}
return nil
}
///
func decodeProjectLossyStringArray(forKey key: Key) throws -> [String] {
if let values = try? decodeIfPresent([String].self, forKey: key) {
return values
}
if let value = try? decodeIfPresent(String.self, forKey: key) {
return value
.components(separatedBy: CharacterSet(charactersIn: ",\n"))
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
}
return []
}
}

View File

@ -0,0 +1,31 @@
# Projects 模块业务逻辑
## 模块职责
Projects 模块负责首页项目相关入口:
- `pm``project_edit`:摄影师项目管理。
- `pm_manager`:店铺项目管理。
模块只保存项目列表、详情、编辑表单和上传中的临时状态,不写入 `AppSession``AccountContext`、TabBar 或首页全局状态。
## 摄影师项目
`ProjectManagementViewModel` 管理摄影师项目列表、搜索、分页、详情入口和删除动作。列表请求依赖当前景区 ID缺少景区时清空本模块列表并停止请求。
`ProjectEditorViewModel` 管理新建和编辑表单。项目封面、轮播图先通过 `OSSUploadService.uploadProjectImage` 上传到 `project/yyyyMMdd/scenicId/...` 路径,再把最终 URL 提交给项目创建或编辑接口。
## 店铺项目
`StoreProjectManagementViewModel` 管理店铺项目列表和可管理景区。店铺项目按业务账号 ID 查询,缺少用户 ID 时不请求。
`StoreProjectEditorViewModel` 支持多点位项目和押金项目两种提交路径。多点位项目提交景区、点位、展示图和项目套餐;押金项目提交门店、押金金额、底片数量等字段。
## 接口边界
`ProjectAPI` 只封装项目模块需要的接口,包括摄影师项目列表/详情/创建/编辑/删除,以及店铺项目列表/详情/创建/更新/删除和可管理景区列表。
样片上传需要的轻量项目选择复用共享 `PhotographerProjectItem`,但不反向依赖 Projects 页面状态。
## 缓存边界
项目图片临时数据、OSS STS、编辑表单和分页状态都不落盘。网络图片展示继续交给 `RemoteImage` / Kingfisher。

View File

@ -0,0 +1,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 {}

View File

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

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

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

View 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 和路由测试。测试失败时先修复问题,再继续迁移后续功能。

View File

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

View File

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

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

View File

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