feat: add WeChat sharing and invite flow

This commit is contained in:
2026-07-07 16:53:39 +08:00
parent f1937e23d4
commit a80c181bd7
160 changed files with 7557 additions and 849 deletions

View File

@ -218,9 +218,7 @@ struct V9ScenicUser: Decodable, Equatable {
accountType: accountType.nonEmpty ?? Self.accountTypeValue,
businessUserId: businessUserId,
title: scenicName.nonEmpty ?? nickname.nonEmpty ?? realName.nonEmpty ?? "景区账号",
subtitle: [realName.nonEmpty, nickname.nonEmpty]
.compactMap(\.self)
.joined(separator: " · "),
subtitle: username,
phone: phone,
avatar: "",
scenicName: scenicName,
@ -237,7 +235,8 @@ struct V9ScenicUser: Decodable, Equatable {
case userId = "user_id"
case scenicUserId = "scenic_user_id"
case ssUserId = "ss_user_id"
case username
case username = "user_name"
case legacyUsername = "username"
case realName = "real_name"
case nickname
case phone
@ -255,7 +254,9 @@ struct V9ScenicUser: Decodable, Equatable {
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)
let preferredUsername = try container.decodeLossyString(forKey: .username)
let legacyUsername = try container.decodeLossyString(forKey: .legacyUsername)
username = preferredUsername.nonEmpty ?? legacyUsername
realName = try container.decodeLossyString(forKey: .realName)
nickname = try container.decodeLossyString(forKey: .nickname)
phone = try container.decodeLossyString(forKey: .phone)
@ -301,9 +302,7 @@ struct V9StoreUser: Decodable, Equatable {
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: " · "),
subtitle: userName.nonEmpty ?? username,
phone: phone,
avatar: avatar,
scenicName: scenicName,

View File

@ -28,6 +28,8 @@ enum HomeMenuCatalog {
HomeMenuItem(uri: "pm_manager", title: "项目管理", iconName: "folder"),
HomeMenuItem(uri: "location_report", title: "位置上报", iconName: "location"),
HomeMenuItem(uri: "registration_invitation", title: "注册邀请", iconName: "person.badge.plus"),
HomeMenuItem(uri: "photographer_invite", title: "邀请摄影师", iconName: "person.badge.plus"),
HomeMenuItem(uri: "invite_record", title: "邀请记录", iconName: "calendar.badge.clock"),
HomeMenuItem(uri: "store", title: "店铺管理", iconName: "building.2"),
HomeMenuItem(uri: "fly", title: "飞行管理", iconName: "airplane"),
HomeMenuItem(uri: "pilot_cert", title: "飞手认证", iconName: "person.text.rectangle"),

View File

@ -50,10 +50,16 @@ enum HomeRouteHandler {
viewController.navigationController?.pushViewController(CooperationOrderListViewController(), animated: true)
case "location_report":
viewController.navigationController?.pushViewController(LocationReportViewController(), animated: true)
case "registration_invitation", "photographer_invite":
viewController.navigationController?.pushViewController(PhotographerInviteViewController(), animated: true)
case "invite_record":
viewController.navigationController?.pushViewController(InviteRecordViewController(), animated: true)
case "/scenic-queue":
viewController.navigationController?.pushViewController(ScenicQueueViewController(), animated: true)
case "task_management", "task_management_editor":
viewController.navigationController?.pushViewController(TaskAddViewController(), animated: true)
case "wallet":
showDeveloping(from: viewController)
default:
showDeveloping(from: viewController)
}

View File

@ -0,0 +1,46 @@
//
// InviteAPI.swift
// suixinkan
//
import Foundation
@MainActor
///
protocol InviteServing {
///
func inviteInfo() async throws -> InviteInfoResponse
///
func inviteUserList(page: Int, pageSize: Int) async throws -> [InviteUserItem]
}
@MainActor
/// API
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: String(max(page, 1))),
URLQueryItem(name: "page_size", value: String(max(pageSize, 1))),
]
)
)
}
}

View File

@ -0,0 +1,172 @@
//
// InviteModels.swift
// suixinkan
//
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(enableInvite: Bool, inviteCode: String, inviteUrl: String, description: [String]) {
self.enableInvite = enableInvite
self.inviteCode = inviteCode
self.inviteUrl = inviteUrl
self.description = 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, 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(id: Int, realName: String, phone: String, avatar: String, createdAt: String, inviteLevel: Int) {
self.id = id
self.realName = realName
self.phone = phone
self.avatar = avatar
self.createdAt = createdAt
self.inviteLevel = inviteLevel
}
///
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) ?? 1
}
}
///
enum InviteRecordTab: Int, CaseIterable, Equatable {
case invite = 0
case reward = 1
///
var title: String {
switch self {
case .invite:
return "邀请记录"
case .reward:
return "奖励记录"
}
}
}
///
struct InviteDisplayRow: Hashable {
let id: String
let title: String
let phone: String
let avatar: String
let time: String
let inviteLevel: Int
///
var levelText: String {
inviteLevel == 1 ? "一级" : "二级"
}
///
var isPrimaryLevel: Bool {
inviteLevel == 1
}
///
var avatarPlaceholder: String {
if let first = title.first {
return String(first)
}
if let first = phone.first {
return String(first)
}
return "?"
}
}
private extension KeyedDecodingContainer {
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 ""
}
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
}
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,218 @@
//
// InviteViewModels.swift
// suixinkan
//
import Foundation
import UIKit
/// ViewModel
final class PhotographerInviteViewModel {
private(set) var isLoading = false
private(set) var inviteCode = ""
private(set) var inviteUrl = ""
private(set) var rules: [String] = []
private(set) var qrImage: UIImage?
private(set) var errorMessage: String?
var onStateChange: (() -> Void)?
///
func reload(api: any InviteServing) async {
isLoading = true
errorMessage = nil
notifyStateChange()
do {
let response = try await api.inviteInfo()
inviteCode = response.inviteCode
inviteUrl = response.inviteUrl
rules = response.description
qrImage = QRCodeGenerator.generateStyledQRCode(
content: response.inviteUrl,
pixelSize: 560,
backgroundColor: .white,
cornerRadius: 0
)
} catch {
clear()
errorMessage = error.localizedDescription
}
isLoading = false
notifyStateChange()
}
///
func inviteCodeForCopy() -> String {
inviteCode
}
///
func inviteUrlForCopy() -> String {
inviteUrl
}
private func clear() {
inviteCode = ""
inviteUrl = ""
rules = []
qrImage = nil
}
private func notifyStateChange() {
onStateChange?()
}
}
/// ViewModel
final class InviteRecordViewModel {
private(set) var selectedTab: InviteRecordTab = .invite
private(set) var totalRewardText = "0.00"
private(set) var withdrawableText = "0.00"
private(set) var inviteRows: [InviteDisplayRow] = []
private(set) var rewardRows: [WalletTransactionItem] = []
private(set) var isInviteRefreshing = false
private(set) var isRewardRefreshing = false
private(set) var canLoadMoreInvite = false
private(set) var canLoadMoreReward = false
private(set) var errorMessage: String?
var onStateChange: (() -> Void)?
private var invitePage = 1
private let invitePageSize = 20
private var rewardPage = 1
private let rewardPageSize = 10
private var isLoadingInvite = false
private var isLoadingReward = false
///
func loadInitial(inviteAPI: any InviteServing, walletAPI: any WalletServing) async {
await refreshInvite(inviteAPI: inviteAPI, walletAPI: walletAPI)
}
///
func selectTab(_ tab: InviteRecordTab, inviteAPI: any InviteServing, walletAPI: any WalletServing) async {
guard tab != selectedTab else { return }
selectedTab = tab
notifyStateChange()
if tab == .reward, rewardRows.isEmpty {
await refreshReward(walletAPI: walletAPI)
}
}
///
func refreshInvite(inviteAPI: any InviteServing, walletAPI: any WalletServing) async {
await loadSummary(walletAPI: walletAPI)
await loadInviteRows(inviteAPI: inviteAPI, refresh: true)
}
///
func loadMoreInvite(inviteAPI: any InviteServing) async {
guard canLoadMoreInvite, !isInviteRefreshing else { return }
await loadInviteRows(inviteAPI: inviteAPI, refresh: false)
}
///
func refreshReward(walletAPI: any WalletServing) async {
await loadSummary(walletAPI: walletAPI)
await loadRewardRows(walletAPI: walletAPI, refresh: true)
}
///
func loadMoreReward(walletAPI: any WalletServing) async {
guard canLoadMoreReward, !isRewardRefreshing else { return }
await loadRewardRows(walletAPI: walletAPI, refresh: false)
}
private func loadSummary(walletAPI: any WalletServing) async {
do {
let summary = try await walletAPI.walletSummary(type: 2)
totalRewardText = summary.amountTotal.isEmpty ? "0.00" : summary.amountTotal
withdrawableText = summary.amountWithdrawable.isEmpty ? "0.00" : summary.amountWithdrawable
} catch {
errorMessage = error.localizedDescription
}
notifyStateChange()
}
private func loadInviteRows(inviteAPI: any InviteServing, refresh: Bool) async {
if isLoadingInvite && !refresh { return }
if refresh { invitePage = 1 }
isLoadingInvite = true
isInviteRefreshing = refresh
errorMessage = nil
notifyStateChange()
do {
let users = try await inviteAPI.inviteUserList(page: invitePage, pageSize: invitePageSize)
let mapped = users.map { user in
InviteDisplayRow(
id: "invite_\(user.id)",
title: user.realName.isEmpty ? "**\(user.phone.suffix(4))" : user.realName,
phone: user.phone,
avatar: user.avatar,
time: user.createdAt,
inviteLevel: user.inviteLevel
)
}
inviteRows = refresh ? mapped : inviteRows + mapped
invitePage += 1
canLoadMoreInvite = users.count >= invitePageSize
} catch {
if refresh {
inviteRows = []
canLoadMoreInvite = false
}
errorMessage = error.localizedDescription
}
isLoadingInvite = false
isInviteRefreshing = false
notifyStateChange()
}
private func loadRewardRows(walletAPI: any WalletServing, refresh: Bool) async {
if isLoadingReward && !refresh { return }
if refresh { rewardPage = 1 }
isLoadingReward = true
isRewardRefreshing = refresh
errorMessage = nil
notifyStateChange()
do {
let response = try await walletAPI.walletTransactionList(
startDate: "2025-01-01",
endDate: Self.dayFormatter.string(from: Date()),
page: rewardPage,
pageSize: rewardPageSize,
type: 2
)
rewardRows = refresh ? response.list : rewardRows + response.list
rewardPage += 1
canLoadMoreReward = response.list.count >= rewardPageSize && rewardRows.count < response.total
} catch {
if refresh {
rewardRows = []
canLoadMoreReward = false
}
errorMessage = error.localizedDescription
}
isLoadingReward = false
isRewardRefreshing = false
notifyStateChange()
}
private func notifyStateChange() {
onStateChange?()
}
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,67 @@
//
// WalletAPI.swift
// suixinkan
//
import Foundation
@MainActor
///
protocol WalletServing {
/// `type = 2`
func walletSummary(type: Int) async throws -> WalletSummaryResponse
///
func walletTransactionList(
startDate: String,
endDate: String,
page: Int,
pageSize: Int,
type: Int
) async throws -> WalletTransactionListResponse
}
@MainActor
/// API
final class WalletAPI: WalletServing {
private let client: APIClient
/// API
init(client: APIClient) {
self.client = client
}
///
func walletSummary(type: Int = 0) async throws -> WalletSummaryResponse {
try await client.send(
APIRequest(
method: .get,
path: "/api/yf-handset-app/photog/wallet/summary",
queryItems: [URLQueryItem(name: "type", value: String(type))]
)
)
}
///
func walletTransactionList(
startDate: String,
endDate: String,
page: Int = 1,
pageSize: Int = 10,
type: Int = 2
) async throws -> WalletTransactionListResponse {
try await client.send(
APIRequest(
method: .get,
path: "/api/yf-handset-app/photog/wallet/transaction-list",
queryItems: [
URLQueryItem(name: "start_date", value: startDate),
URLQueryItem(name: "end_date", value: endDate),
URLQueryItem(name: "page", value: String(max(page, 1))),
URLQueryItem(name: "page_size", value: String(max(pageSize, 1))),
URLQueryItem(name: "type", value: String(type)),
]
)
)
}
}

View File

@ -0,0 +1,127 @@
//
// WalletModels.swift
// suixinkan
//
import Foundation
///
struct WalletSummaryResponse: Decodable, Equatable {
let amountTotal: String
let amountCurrentBalance: String
let amountWithdrawable: String
enum CodingKeys: String, CodingKey {
case amountTotal = "amount_total"
case amountCurrentBalance = "amount_current_balance"
case amountWithdrawable = "amount_withdrawable"
}
///
init(amountTotal: String = "", amountCurrentBalance: String = "", amountWithdrawable: String = "") {
self.amountTotal = amountTotal
self.amountCurrentBalance = amountCurrentBalance
self.amountWithdrawable = amountWithdrawable
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
amountTotal = try container.decodeWalletLossyString(forKey: .amountTotal)
amountCurrentBalance = try container.decodeWalletLossyString(forKey: .amountCurrentBalance)
amountWithdrawable = try container.decodeWalletLossyString(forKey: .amountWithdrawable)
}
}
///
struct WalletTransactionListResponse: Decodable, Equatable {
let total: Int
let list: [WalletTransactionItem]
///
init(total: Int = 0, list: [WalletTransactionItem] = []) {
self.total = total
self.list = list
}
}
///
struct WalletTransactionItem: Decodable, Hashable {
let id: Int
let amount: String
let transactionType: Int
let transactionTypeLabel: String
let createdAt: String
let orderNumberSuffix: String
let withdrawLabel: String
enum CodingKeys: String, CodingKey {
case id
case amount
case transactionType = "transaction_type"
case transactionTypeLabel = "transaction_type_label"
case createdAt = "created_at"
case orderNumberSuffix = "order_number_suffix"
case withdrawLabel = "withdraw_label"
}
///
init(
id: Int,
amount: String,
transactionType: Int = 0,
transactionTypeLabel: String = "",
createdAt: String,
orderNumberSuffix: String = "",
withdrawLabel: String = ""
) {
self.id = id
self.amount = amount
self.transactionType = transactionType
self.transactionTypeLabel = transactionTypeLabel
self.createdAt = createdAt
self.orderNumberSuffix = orderNumberSuffix
self.withdrawLabel = withdrawLabel
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeWalletLossyInt(forKey: .id) ?? 0
amount = try container.decodeWalletLossyString(forKey: .amount)
transactionType = try container.decodeWalletLossyInt(forKey: .transactionType) ?? 0
transactionTypeLabel = try container.decodeWalletLossyString(forKey: .transactionTypeLabel)
createdAt = try container.decodeWalletLossyString(forKey: .createdAt)
orderNumberSuffix = try container.decodeWalletLossyString(forKey: .orderNumberSuffix)
withdrawLabel = try container.decodeWalletLossyString(forKey: .withdrawLabel)
}
}
private extension KeyedDecodingContainer {
func decodeWalletLossyString(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)
}
return ""
}
func decodeWalletLossyInt(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
}
}