迁移合作订单完整能力,并统一 AppRoleCode 角色权限与首页菜单展示。

新增 CooperationOrder 模块(双 Tab 列表、获客员绑定、主 Tab 扫码)、订单来源/带客单绑定及配套 API 测试;同步引入 roleCode 权限匹配与相关单元测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-29 13:33:00 +08:00
parent d310d26293
commit 1970572edd
66 changed files with 4247 additions and 201 deletions

View File

@ -64,15 +64,11 @@
};
A00000022FE9000000000002 /* suixinkanTests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = suixinkanTests;
sourceTree = "<group>";
};
B00000022FEF000000000002 /* suixinkanUITests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = suixinkanUITests;
sourceTree = "<group>";
};
@ -327,10 +323,14 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources.sh\"\n";

View File

@ -123,6 +123,10 @@ private struct WalletAPIEnvironmentKey: EnvironmentKey {
static var defaultValue: WalletAPI { WalletAPI(client: EnvironmentServiceDefaults.apiClient()) }
}
private struct CooperationOrderAPIEnvironmentKey: EnvironmentKey {
static var defaultValue: CooperationOrderAPI { CooperationOrderAPI(client: EnvironmentServiceDefaults.apiClient()) }
}
extension EnvironmentValues {
var apiClient: APIClient {
get { self[APIClientEnvironmentKey.self] }
@ -258,4 +262,9 @@ extension EnvironmentValues {
get { self[WalletAPIEnvironmentKey.self] }
set { self[WalletAPIEnvironmentKey.self] = newValue }
}
var cooperationOrderAPI: CooperationOrderAPI {
get { self[CooperationOrderAPIEnvironmentKey.self] }
set { self[CooperationOrderAPIEnvironmentKey.self] = newValue }
}
}

View File

@ -44,6 +44,7 @@ struct RootView: View {
@State private var assetsAPI: AssetsAPI
@State private var punchPointAPI: PunchPointAPI
@State private var locationReportAPI: LocationReportAPI
@State private var cooperationOrderAPI: CooperationOrderAPI
@State private var authSessionCoordinator: AuthSessionCoordinator
@State private var sessionBootstrapper: SessionBootstrapper
@ -79,6 +80,7 @@ struct RootView: View {
_assetsAPI = State(initialValue: AssetsAPI(client: apiClient))
_punchPointAPI = State(initialValue: PunchPointAPI(client: apiClient))
_locationReportAPI = State(initialValue: LocationReportAPI(client: apiClient))
_cooperationOrderAPI = State(initialValue: CooperationOrderAPI(client: apiClient))
_authSessionCoordinator = State(
initialValue: AuthSessionCoordinator(
tokenStore: tokenStore,
@ -132,6 +134,7 @@ struct RootView: View {
.environment(\.assetsAPI, assetsAPI)
.environment(\.punchPointAPI, punchPointAPI)
.environment(\.locationReportAPI, locationReportAPI)
.environment(\.cooperationOrderAPI, cooperationOrderAPI)
.environment(\.authSessionCoordinator, authSessionCoordinator)
.task {
apiClient.bindAuthTokenProvider { appSession.token }

View File

@ -23,7 +23,8 @@ struct AccountContextLoader {
permissionContext: PermissionContext,
profileAPI: UserProfileServing,
accountContextAPI: AccountContextServing,
cachedCurrentRoleId: Int? = nil,
cachedCurrentRoleCode: String? = nil,
cachedLegacyRoleId: Int? = nil,
cachedCurrentScenicId: Int? = nil,
cachedCurrentStoreId: Int? = nil
) async throws {
@ -31,7 +32,11 @@ struct AccountContextLoader {
async let roleData = accountContextAPI.rolePermissions()
let (userInfo, rolePermissions) = try await (userData, roleData)
permissionContext.replaceRolePermissions(rolePermissions, currentRoleId: cachedCurrentRoleId)
permissionContext.replaceRolePermissions(
rolePermissions,
currentRoleCode: cachedCurrentRoleCode,
cachedLegacyRoleId: cachedLegacyRoleId
)
let scenicResponse = await loadScenicList(accountContextAPI: accountContextAPI, rolePermissions: rolePermissions)
let storesResponse = await loadStores(accountContextAPI: accountContextAPI)

View File

@ -73,6 +73,7 @@ final class AuthSessionCoordinator {
permissionContext: permissionContext,
profileAPI: profileAPI,
accountContextAPI: accountContextAPI,
cachedCurrentRoleCode: response.currentAccountRoleCode,
cachedCurrentScenicId: accountContext.currentScenic?.id,
cachedCurrentStoreId: accountContext.currentStore?.id
)
@ -114,7 +115,7 @@ final class AuthSessionCoordinator {
profile: profile,
accountType: snapshotStore.load()?.accountType,
businessUserId: snapshotStore.load()?.businessUserId,
currentRoleId: snapshotStore.load()?.currentRoleId,
currentRoleCode: snapshotStore.load()?.currentRoleCode,
accountContext: accountContext
)
}
@ -144,7 +145,7 @@ final class AuthSessionCoordinator {
accountType: String?,
businessUserId: Int?,
permissionContext: PermissionContext? = nil,
currentRoleId: Int? = nil,
currentRoleCode: String? = nil,
accountContext: AccountContext
) {
snapshotStore.save(
@ -152,7 +153,7 @@ final class AuthSessionCoordinator {
profile: profile,
accountType: accountType,
businessUserId: businessUserId,
currentRoleId: permissionContext?.currentRole?.id ?? currentRoleId,
currentRoleCode: permissionContext?.currentAppRole?.rawValue ?? currentRoleCode,
scenicScopes: accountContext.scenicScopes,
storeScopes: accountContext.storeScopes,
currentScenicId: accountContext.currentScenic?.id,

View File

@ -15,20 +15,94 @@ final class PermissionContext: ObservableObject {
@Published private(set) var permissionURIs: Set<String> = []
@Published var currentRole: RoleInfo?
/// ID
func replaceRolePermissions(_ rolePermissions: [RolePermissionResponse], currentRoleId: Int? = nil) {
/// App role_code legacy id
var currentAppRole: AppRoleCode? {
if let code = AppRoleCode.fromCode(currentRole?.roleCode) { return code }
if let roleId = currentRole?.id, let code = AppRoleCode.fromLegacyRoleId(roleId) { return code }
return AppRoleCode.fromRoleName(currentRole?.name)
}
/// role_code
func replaceRolePermissions(
_ rolePermissions: [RolePermissionResponse],
currentRoleCode: String? = nil,
cachedLegacyRoleId: Int? = nil,
roleName: String? = nil
) {
self.rolePermissions = rolePermissions
currentRole = currentRoleId.flatMap { id in
rolePermissions.first { $0.role.id == id }?.role
} ?? currentRole.flatMap { role in
rolePermissions.first { $0.role.id == role.id }?.role
} ?? rolePermissions.first?.role
let resolvedCode = RolePermissionMatching.resolveRoleCode(
roleCode: currentRoleCode,
roleId: cachedLegacyRoleId,
roleName: roleName,
permissions: rolePermissions
)?.rawValue ?? currentRoleCode
if let matched = RolePermissionMatching.match(
in: rolePermissions,
roleCode: resolvedCode,
roleId: cachedLegacyRoleId,
roleName: roleName ?? currentRole?.name
) {
currentRole = matched.role
} else if let currentRole,
let matched = rolePermissions.first(where: { $0.role.id == currentRole.id }) {
self.currentRole = matched.role
} else {
currentRole = rolePermissions.first?.role
}
permissionURIs = Set(Self.flattenPermissions(currentRole?.permission ?? []))
}
///
func selectRole(code: AppRoleCode, accountContext: AccountContext) {
guard let rolePermission = rolePermissions.first(where: {
AppRoleCode.fromCode($0.role.roleCode) == code
}) else { return }
applyRoleSelection(rolePermission, accountContext: accountContext)
}
/// legacy id使 API role id
func selectRole(id roleId: Int, accountContext: AccountContext) {
guard let rolePermission = rolePermissions.first(where: { $0.role.id == roleId }) else { return }
applyRoleSelection(rolePermission, accountContext: accountContext)
}
/// URI
func canAccess(_ uri: String) -> Bool {
permissionURIs.contains(uri.trimmingCharacters(in: .whitespacesAndNewlines))
}
/// URI API
func topLevelPermissionURIs(for roleCode: String?) -> [String] {
guard let roleCode,
let rolePermission = matchedRolePermission(roleCode: roleCode) else {
return []
}
return rolePermission.role.permission.compactMap { item in
let uri = item.uri.trimmingCharacters(in: .whitespacesAndNewlines)
return uri.isEmpty ? nil : uri
}
}
///
func currentRoleScenicScopes() -> [BusinessScope] {
guard let currentRole else { return [] }
guard let rolePermission = matchedRolePermission(roleCode: currentRole.roleCode)
?? rolePermissions.first(where: { $0.role.id == currentRole.id }) else {
return []
}
return Self.uniqueScenicScopes(from: rolePermission.scenic)
}
/// 退
func reset() {
rolePermissions = []
permissionURIs = []
currentRole = nil
}
///
private func applyRoleSelection(_ rolePermission: RolePermissionResponse, accountContext: AccountContext) {
currentRole = rolePermission.role
permissionURIs = Set(Self.flattenPermissions(rolePermission.role.permission))
@ -41,25 +115,14 @@ final class PermissionContext: ObservableObject {
)
}
/// URI
func canAccess(_ uri: String) -> Bool {
permissionURIs.contains(uri.trimmingCharacters(in: .whitespacesAndNewlines))
}
///
func currentRoleScenicScopes() -> [BusinessScope] {
guard let currentRole else { return [] }
guard let rolePermission = rolePermissions.first(where: { $0.role.id == currentRole.id }) else {
return []
}
return Self.uniqueScenicScopes(from: rolePermission.scenic)
}
/// 退
func reset() {
rolePermissions = []
permissionURIs = []
currentRole = nil
/// role_code
private func matchedRolePermission(roleCode: String) -> RolePermissionResponse? {
RolePermissionMatching.match(
in: rolePermissions,
roleCode: roleCode,
roleId: nil,
roleName: nil
)
}
/// URI

View File

@ -58,7 +58,8 @@ final class SessionBootstrapper {
permissionContext: permissionContext,
profileAPI: profileAPI,
accountContextAPI: accountContextAPI,
cachedCurrentRoleId: cachedSnapshot?.currentRoleId,
cachedCurrentRoleCode: cachedSnapshot?.currentRoleCode,
cachedLegacyRoleId: cachedSnapshot?.currentRoleId,
cachedCurrentScenicId: cachedSnapshot?.currentScenicId,
cachedCurrentStoreId: cachedSnapshot?.currentStoreId
)
@ -100,7 +101,7 @@ final class SessionBootstrapper {
profile: accountContext.profile,
accountType: existing?.accountType,
businessUserId: existing?.businessUserId,
currentRoleId: permissionContext.currentRole?.id ?? existing?.currentRoleId,
currentRoleCode: permissionContext.currentAppRole?.rawValue ?? existing?.currentRoleCode,
scenicScopes: accountContext.scenicScopes,
storeScopes: accountContext.storeScopes,
currentScenicId: accountContext.currentScenic?.id,

View File

@ -0,0 +1,100 @@
//
// AppRoleCode.swift
// suixinkan
//
// Created by Codex on 2026/6/29.
//
import Foundation
/// App `app_role_code` / `role_code`
enum AppRoleCode: String, CaseIterable, Hashable {
case driver = "driver"
case floristBuilder = "florist_builder"
case clerk = "clerk"
case liveStream = "live_stream"
case frontDesk = "front_desk"
case makeupArtist = "makeup_artist"
case photographerAssistant = "photographer_assistant"
case assistantManager = "assistant_manager"
case scenicOperation = "scenic_operation"
case scenicAdmin = "scenic_admin"
case scenicCS = "scenic_cs"
case editor = "editor"
case storeAdmin = "store_admin"
case photographer = "photographer"
///
var displayName: String {
switch self {
case .driver: "司机"
case .floristBuilder: "搭建花艺师"
case .clerk: "店员"
case .liveStream: "直播"
case .frontDesk: "前台"
case .makeupArtist: "化妆师"
case .photographerAssistant: "摄影师助理"
case .assistantManager: "副店长"
case .scenicOperation: "景区运营"
case .scenicAdmin: "景区管理员"
case .scenicCS: "飞手"
case .editor: "剪辑师"
case .storeAdmin: "店铺管理员"
case .photographer: "摄影师"
}
}
var isStoreAdmin: Bool { self == .storeAdmin }
var isScenicAdmin: Bool { self == .scenicAdmin }
var isPhotographer: Bool { self == .photographer }
/// role_code
static func fromCode(_ code: String?) -> AppRoleCode? {
guard let trimmed = code?.trimmingCharacters(in: .whitespacesAndNewlines),
!trimmed.isEmpty else { return nil }
return AppRoleCode(rawValue: trimmed)
}
/// numeric role id
static func fromLegacyRoleId(_ roleId: Int) -> AppRoleCode? {
switch roleId {
case 41: .photographer
case 46: .storeAdmin
case 47: .editor
case 52: .scenicCS
case 53: .scenicAdmin
case 54: .scenicOperation
default: nil
}
}
/// role_code
static func fromRoleName(_ roleName: String?) -> AppRoleCode? {
let name = roleName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !name.isEmpty else { return nil }
if name.contains("摄影师助理") { return .photographerAssistant }
if name.contains("副店长") { return .assistantManager }
if name.contains("搭建花艺") || name.contains("花艺师") { return .floristBuilder }
if name.contains("店铺管理") || name.contains("店长") { return .storeAdmin }
if name.contains("景区管理") { return .scenicAdmin }
if name.contains("景区运营") { return .scenicOperation }
if name.contains("景区客服") || name.contains("飞手") { return .scenicCS }
if name.contains("剪辑") { return .editor }
if name.contains("化妆师") { return .makeupArtist }
if name.contains("摄影师") { return .photographer }
if name.contains("司机") { return .driver }
if name.contains("店员") { return .clerk }
if name.contains("直播") { return .liveStream }
if name.contains("前台") { return .frontDesk }
return AppRoleCode.allCases.first { name.contains($0.displayName) }
}
/// 线/// 46/47/52/53/54
static let homeMinimalTopRoles: Set<AppRoleCode> = [
.storeAdmin,
.editor,
.scenicCS,
.scenicAdmin,
.scenicOperation,
]
}

View File

@ -0,0 +1,76 @@
//
// RolePermissionMatching.swift
// suixinkan
//
// Created by Codex on 2026/6/29.
//
import Foundation
/// role_code legacy id
enum RolePermissionMatching {
/// App session
static func resolveRoleCode(
roleCode: String?,
roleId: Int?,
roleName: String?,
permissions: [RolePermissionResponse] = []
) -> AppRoleCode? {
if let code = AppRoleCode.fromCode(roleCode) { return code }
if let legacyId = roleId, legacyId > 0, let code = AppRoleCode.fromLegacyRoleId(legacyId) {
return code
}
if let code = AppRoleCode.fromRoleName(roleName) { return code }
return inferFromPermissionList(roleId: roleId, roleName: roleName, permissions: permissions)
}
/// role_code
static func match(
in permissions: [RolePermissionResponse],
roleCode: String?,
roleId: Int?,
roleName: String?
) -> RolePermissionResponse? {
if let code = AppRoleCode.fromCode(roleCode) {
if let matched = permissions.first(where: { AppRoleCode.fromCode($0.role.roleCode) == code }) {
return matched
}
}
if let roleId, roleId > 0,
let matched = permissions.first(where: { $0.role.id == roleId }) {
return matched
}
let trimmedName = roleName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedName.isEmpty {
if let matched = permissions.first(where: { item in
let name = item.role.name.trimmingCharacters(in: .whitespacesAndNewlines)
return !name.isEmpty && (name.contains(trimmedName) || trimmedName.contains(name))
}) {
return matched
}
}
return nil
}
/// code
private static func inferFromPermissionList(
roleId: Int?,
roleName: String?,
permissions: [RolePermissionResponse]
) -> AppRoleCode? {
guard !permissions.isEmpty else { return nil }
if let roleId, roleId > 0,
let item = permissions.first(where: { $0.role.id == roleId }) {
return AppRoleCode.fromCode(item.role.roleCode) ?? AppRoleCode.fromRoleName(item.role.name)
}
let trimmedName = roleName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedName.isEmpty,
let item = permissions.first(where: { item in
let name = item.role.name.trimmingCharacters(in: .whitespacesAndNewlines)
return !name.isEmpty && (name.contains(trimmedName) || trimmedName.contains(name))
}) {
return AppRoleCode.fromCode(item.role.roleCode) ?? AppRoleCode.fromRoleName(item.role.name)
}
return nil
}
}

View File

@ -13,6 +13,8 @@ struct AccountSnapshot: Codable, Equatable {
var profile: AccountProfile?
var accountType: String?
var businessUserId: Int?
var currentRoleCode: String?
///
var currentRoleId: Int?
var scenicScopes: [BusinessScope]
var storeScopes: [BusinessScope]
@ -24,6 +26,7 @@ struct AccountSnapshot: Codable, Equatable {
profile: AccountProfile? = nil,
accountType: String? = nil,
businessUserId: Int? = nil,
currentRoleCode: String? = nil,
currentRoleId: Int? = nil,
scenicScopes: [BusinessScope] = [],
storeScopes: [BusinessScope] = [],
@ -33,12 +36,25 @@ struct AccountSnapshot: Codable, Equatable {
self.profile = profile
self.accountType = accountType
self.businessUserId = businessUserId
self.currentRoleCode = currentRoleCode
self.currentRoleId = currentRoleId
self.scenicScopes = scenicScopes
self.storeScopes = storeScopes
self.currentScenicId = currentScenicId
self.currentStoreId = currentStoreId
}
/// legacy role id role_code id
mutating func migrateLegacyRoleIfNeeded() {
guard AppRoleCode.fromCode(currentRoleCode) == nil,
let legacyId = currentRoleId,
legacyId > 0,
let migrated = AppRoleCode.fromLegacyRoleId(legacyId) else {
return
}
currentRoleCode = migrated.rawValue
currentRoleId = nil
}
}
/// 使 UserDefaults
@ -67,11 +83,17 @@ final class AccountSnapshotStore {
defaults.set(data, forKey: key)
}
///
/// legacy
func load() -> AccountSnapshot? {
guard let data = defaults.data(forKey: key) else { return nil }
do {
return try decoder.decode(AccountSnapshot.self, from: data)
var snapshot = try decoder.decode(AccountSnapshot.self, from: data)
let beforeCode = snapshot.currentRoleCode
snapshot.migrateLegacyRoleIfNeeded()
if snapshot.currentRoleCode != beforeCode {
save(snapshot)
}
return snapshot
} catch {
clear()
return nil
@ -87,7 +109,7 @@ final class AccountSnapshotStore {
@MainActor
func saveCurrentSelection(
accountContext: AccountContext,
currentRoleId: Int?
currentRoleCode: String?
) {
let existing = load()
save(
@ -95,7 +117,7 @@ final class AccountSnapshotStore {
profile: accountContext.profile ?? existing?.profile,
accountType: existing?.accountType,
businessUserId: existing?.businessUserId,
currentRoleId: currentRoleId ?? existing?.currentRoleId,
currentRoleCode: currentRoleCode ?? existing?.currentRoleCode,
scenicScopes: accountContext.scenicScopes,
storeScopes: accountContext.storeScopes,
currentScenicId: accountContext.currentScenic?.id,

View File

@ -0,0 +1,107 @@
//
// SaleUserQrParser.swift
// suixinkan
//
// Created by Codex on 2026/6/29.
//
import Foundation
/// sale_user_id
enum SaleUserQrParser {
/// method
static let methodSaleUserBind = "sale_user_bind"
/// sale_user_id
/// `{"method":"sale_user_bind","sale_user_id":2}`
/// URL query method JSON
static func parseSaleUserId(_ content: String) -> Int? {
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return nil }
if trimmed.hasPrefix("{") {
if let value = parseFromJSON(trimmed) {
return value
}
}
if let value = Int(trimmed), value > 0 {
return value
}
if trimmed.contains("://") || trimmed.contains("?") {
return parseFromURL(trimmed)
}
return nil
}
///
static func isSaleUserBindQR(_ content: String) -> Bool {
guard let object = jsonObject(from: content) else { return false }
let method = (object["method"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return method == methodSaleUserBind
}
private static func parseFromJSON(_ json: String) -> Int? {
guard let object = jsonObject(from: json) else { return nil }
return parseFromJSONObject(object)
}
private static func jsonObject(from json: String) -> [String: Any]? {
guard let data = json.data(using: .utf8),
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
else { return nil }
return object
}
private static func parseFromJSONObject(_ object: [String: Any]) -> Int? {
let method = (object["method"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if method == methodSaleUserBind {
return readIntValue(object["sale_user_id"])
}
return readSaleUserId(object)
}
private static func readSaleUserId(_ object: [String: Any]) -> Int? {
let keys = ["sale_user_id", "saleUserId", "user_id", "id"]
for key in keys {
if let value = readIntValue(object[key]) {
return value
}
}
return nil
}
private static func readIntValue(_ value: Any?) -> Int? {
switch value {
case let intValue as Int where intValue > 0:
return intValue
case let number as NSNumber where number.intValue > 0:
return number.intValue
case let stringValue as String:
let trimmed = stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
if let intValue = Int(trimmed), intValue > 0 {
return intValue
}
return nil
default:
return nil
}
}
private static func parseFromURL(_ urlString: String) -> Int? {
guard let components = URLComponents(string: urlString) else { return nil }
let keys = ["sale_user_id", "saleUserId", "user_id", "id"]
for key in keys {
if let value = components.queryItems?.first(where: { $0.name == key })?.value,
let intValue = Int(value.trimmingCharacters(in: .whitespacesAndNewlines)),
intValue > 0 {
return intValue
}
}
return nil
}
}

View File

@ -18,7 +18,7 @@ Account 模块负责同步账号进入业务页前必须具备的上下文数据
登录成功或冷启动恢复时,`AccountContextLoader` 会并行请求用户资料和角色权限。权限成功后继续读取景区和门店:
1. 用户资料刷新 `AccountContext.profile`
2. 角色权限刷新 `PermissionContext`,并计算当前角色的权限 URI 集合。
2. 角色权限刷新 `PermissionContext``role_code` 优先匹配当前角色,并计算权限 URI 集合。
3. 景区列表失败时,从角色权限里的景区数据去重兜底。
4. 门店列表失败时不阻断登录,只写入空门店列表。
5. 当前景区确定后,`ScenicSpotContext` 再按景区 ID 拉取景点/打卡点。
@ -31,6 +31,8 @@ Account 模块负责同步账号进入业务页前必须具备的上下文数据
## 缓存规则
`AccountSnapshotStore` 只保存非敏感上下文快照,包括当前角色 ID、当前景区 ID、当前门店 ID 和基础账号展示信息
`AccountSnapshotStore` 只保存非敏感上下文快照,包括当前 `role_code``currentRoleCode`)、当前景区 ID、当前门店 ID 和基础账号展示信息。旧版快照中的 `currentRoleId` 会在读取时一次性迁移为 `role_code`
角色匹配优先级:`role_code` → legacy `role_id` → 角色名 → 权限列表推断。登录时还会从 V9 响应的 `app_role_code` 写入初始角色 hint。
景点/打卡点列表只保存在内存中,不长期落盘。后续业务需要离线缓存时,需要按账号类型、业务账号 ID 和景区 ID 做隔离。

View File

@ -17,6 +17,7 @@ struct RolePermissionResponse: Decodable, Equatable {
struct RoleInfo: Decodable, Equatable, Identifiable {
let id: Int
let name: String
let roleCode: String
let notes: String?
let permission: [PermissionItem]
@ -24,14 +25,16 @@ struct RoleInfo: Decodable, Equatable, Identifiable {
enum CodingKeys: String, CodingKey {
case id
case name
case roleCode = "role_code"
case notes
case permission
}
///
init(id: Int, name: String, notes: String? = nil, permission: [PermissionItem] = []) {
init(id: Int, name: String, roleCode: String = "", notes: String? = nil, permission: [PermissionItem] = []) {
self.id = id
self.name = name
self.roleCode = roleCode
self.notes = notes
self.permission = permission
}
@ -41,6 +44,7 @@ struct RoleInfo: Decodable, Equatable, Identifiable {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeLossyInt(forKey: .id) ?? 0
name = try container.decodeLossyString(forKey: .name)
roleCode = try container.decodeLossyString(forKey: .roleCode)
notes = try container.decodeIfPresent(String.self, forKey: .notes)
permission = try container.decodeIfPresent([PermissionItem].self, forKey: .permission) ?? []
}

View File

@ -157,6 +157,17 @@ struct V9AuthResponse: Decodable, Equatable {
}
}
/// app_role_code
var currentAccountRoleCode: String? {
if let storeUser = storeUsers.first(where: \.isCurrent) ?? storeUsers.first {
return nonEmpty(storeUser.resolvedAppRoleCode)
}
if let scenicUser = scenicUsers.first(where: \.isCurrent) ?? scenicUsers.first {
return nonEmpty(scenicUser.resolvedAppRoleCode)
}
return nil
}
/// v9 JSON
enum CodingKeys: String, CodingKey {
case token
@ -178,6 +189,11 @@ struct V9AuthResponse: Decodable, Equatable {
scenicUsers = (try? container.decodeIfPresent([V9ScenicUser].self, forKey: .scenicUsers)) ?? []
storeUsers = (try? container.decodeIfPresent([V9StoreUser].self, forKey: .storeUsers)) ?? []
}
private func nonEmpty(_ value: String) -> String? {
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
return text.isEmpty ? nil : text
}
}
/// v9
@ -196,6 +212,13 @@ struct V9ScenicUser: Decodable, Equatable {
let scenicId: Int
let scenicName: String
let isCurrent: Bool
let appRoleCode: String
let appRoleName: String
/// app_role_code
var resolvedAppRoleCode: String {
appRoleCode.trimmingCharacters(in: .whitespacesAndNewlines)
}
/// set-user 使 ID
var businessUserId: Int {
@ -240,6 +263,8 @@ struct V9ScenicUser: Decodable, Equatable {
case scenicId = "scenic_id"
case scenicName = "scenic_name"
case isCurrent = "is_current"
case appRoleCode = "app_role_code"
case appRoleName = "app_role_name"
}
/// ID
@ -257,6 +282,8 @@ struct V9ScenicUser: Decodable, Equatable {
scenicId = try container.decodeLossyInt(forKey: .scenicId) ?? 0
scenicName = try container.decodeLossyString(forKey: .scenicName)
isCurrent = try container.decodeLossyBool(forKey: .isCurrent) ?? false
appRoleCode = try container.decodeLossyString(forKey: .appRoleCode)
appRoleName = try container.decodeLossyString(forKey: .appRoleName)
}
}
@ -278,6 +305,13 @@ struct V9StoreUser: Decodable, Equatable {
let storeId: Int
let storeName: String
let isCurrent: Bool
let appRoleCode: String
let appRoleName: String
/// app_role_code
var resolvedAppRoleCode: String {
appRoleCode.trimmingCharacters(in: .whitespacesAndNewlines)
}
/// set-user 使 ID
var businessUserId: Int {
@ -324,6 +358,8 @@ struct V9StoreUser: Decodable, Equatable {
case storeId = "store_id"
case storeName = "store_name"
case isCurrent = "is_current"
case appRoleCode = "app_role_code"
case appRoleName = "app_role_name"
}
/// ID
@ -343,6 +379,8 @@ struct V9StoreUser: Decodable, Equatable {
storeId = try container.decodeLossyInt(forKey: .storeId) ?? 0
storeName = try container.decodeLossyString(forKey: .storeName)
isCurrent = try container.decodeLossyBool(forKey: .isCurrent) ?? false
appRoleCode = try container.decodeLossyString(forKey: .appRoleCode)
appRoleName = try container.decodeLossyString(forKey: .appRoleName)
}
}

View File

@ -0,0 +1,159 @@
//
// CooperationOrderAPI.swift
// suixinkan
//
// Created by Codex on 2026/6/29.
//
import Foundation
@MainActor
/// 便
protocol CooperationOrderServing {
///
func cooperativeSalers(storeId: Int?, page: Int, pageSize: Int) async throws -> CooperativeSalerListPayload
///
func saleUserInviteInfo(saleUserId: Int) async throws -> SaleUserInviteInfo
///
func saleUserAcceptBind(saleUserId: Int) async throws
/// 线
func saleUserReferralLeads(page: Int, pageSize: Int) async throws -> ListPayload<ReferralLead>
///
func saleUserAcquisitionOrders(keyword: String?, page: Int, pageSize: Int) async throws -> ListPayload<AcquisitionOrder>
///
func saleUserReferralOrders(saleUserId: Int, page: Int, pageSize: Int) async throws -> ReferralOrderListPayload
///
func saleUserBindReferralOrder(orderNumber: String, referralOrderNo: String) async throws
/// 退 status=3
func saleUserUpdateReferralOrderStatus(id: Int, status: Int) async throws
}
@MainActor
/// API线
final class CooperationOrderAPI: CooperationOrderServing {
private let client: APIClient
/// API
init(client: APIClient) {
self.client = client
}
///
func cooperativeSalers(storeId: Int? = nil, page: Int = 1, pageSize: Int = 10) async throws -> CooperativeSalerListPayload {
var query = [
URLQueryItem(name: "page", value: "\(max(page, 1))"),
URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))")
]
if let storeId, storeId > 0 {
query.append(URLQueryItem(name: "store_id", value: "\(storeId)"))
}
return try await client.send(
APIRequest(
method: .get,
path: "/api/yf-handset-app/photog/cooperative-salers",
queryItems: query
)
)
}
///
func saleUserInviteInfo(saleUserId: Int) async throws -> SaleUserInviteInfo {
try await client.send(
APIRequest(
method: .get,
path: "/api/yf-handset-app/photog/sale-user/invite-info",
queryItems: [URLQueryItem(name: "sale_user_id", value: "\(saleUserId)")]
)
)
}
///
func saleUserAcceptBind(saleUserId: Int) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(
method: .post,
path: "/api/yf-handset-app/photog/sale-user/accept-bind",
queryItems: [URLQueryItem(name: "sale_user_id", value: "\(saleUserId)")]
)
)
}
/// 线
func saleUserReferralLeads(page: Int = 1, pageSize: Int = 10) async throws -> ListPayload<ReferralLead> {
try await client.send(
APIRequest(
method: .get,
path: "/api/yf-handset-app/photog/sale-user/referral-leads",
queryItems: [
URLQueryItem(name: "page", value: "\(max(page, 1))"),
URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))")
]
)
)
}
///
func saleUserAcquisitionOrders(keyword: String? = nil, page: Int = 1, pageSize: Int = 10) async throws -> ListPayload<AcquisitionOrder> {
var query = [
URLQueryItem(name: "page", value: "\(max(page, 1))"),
URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))")
]
if let keyword, !keyword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
query.append(URLQueryItem(name: "keyword", value: keyword))
}
return try await client.send(
APIRequest(
method: .get,
path: "/api/yf-handset-app/photog/sale-user/acquisition-orders",
queryItems: query
)
)
}
///
func saleUserReferralOrders(saleUserId: Int, page: Int = 1, pageSize: Int = 10) async throws -> ReferralOrderListPayload {
try await client.send(
APIRequest(
method: .get,
path: "/api/yf-handset-app/photog/sale-user/referral-orders",
queryItems: [
URLQueryItem(name: "sale_user_id", value: "\(saleUserId)"),
URLQueryItem(name: "page", value: "\(max(page, 1))"),
URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))")
]
)
)
}
///
func saleUserBindReferralOrder(orderNumber: String, referralOrderNo: String) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(
method: .post,
path: "/api/yf-handset-app/photog/sale-user/bind-referral-order",
queryItems: [
URLQueryItem(name: "order_number", value: orderNumber),
URLQueryItem(name: "referral_order_no", value: referralOrderNo)
]
)
)
}
///
func saleUserUpdateReferralOrderStatus(id: Int, status: Int) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(
method: .post,
path: "/api/yf-handset-app/photog/sale-user/referral-orders/\(id)/status",
body: ReferralOrderStatusUpdateRequest(status: status)
)
)
}
}

View File

@ -0,0 +1,57 @@
# 合作订单模块
## 职责
合作订单模块面向摄影师/门店角色,管理合作获客业务:
- **获客员线索**:查看合作获客员上报的潜在客户线索(手机号、备注、图片)
- **获客订单**:查看通过合作渠道产生的订单,支持关键词搜索
- **合作获客员**:查看已绑定获客员列表,扫码绑定新获客员
权限 URI`cooperation_order`(与 Android `CooperationOrderFeature.URI` 一致)。
## 核心流程
### 首页入口
1. 用户点击首页「合作订单」菜单(`cooperation_order`
2. `CooperationOrderAccessView` 校验 `PermissionContext.canAccess("cooperation_order")`
3. 无权限时 Toast 提示并返回;有权限进入 `CooperationOrderListView`
### 双 Tab 列表
| Tab | API | 说明 |
| --- | --- | --- |
| 获客员线索 | `GET .../sale-user/referral-leads` | 分页 10 条,下拉刷新/加载更多 |
| 获客订单 | `GET .../sale-user/acquisition-orders` | 支持 `keyword` 搜索 |
### 获客员绑定
1. 列表页右上角「合作获客员」→ `CooperationAcquirerView`
2. 扫码或主 Tab 扫码识别 `{"method":"sale_user_bind","sale_user_id":N}`
3. `BindAcquirerView` 加载邀请信息 → 确认 → `POST .../sale-user/accept-bind`
## 与订单模块关系
订单管理(`orderType` 11 摄影师跟拍 / 14 线下扫码)在订单卡片展示「订单来源」:
- Sheet 内选择获客员 → 带客单 → `POST .../bind-referral-order`
- 全页流程见 `ReferralOrderSelectView`
- 订单退款成功后,若已绑定带客单,调用 `POST .../referral-orders/{id}/status`status=3
## 主要文件
| 文件 | 说明 |
| --- | --- |
| `API/CooperationOrderAPI.swift` | 8 个 sale-user / cooperative 接口 |
| `Models/CooperationOrderModels.swift` | 实体与解码 |
| `Services/CooperationOrderFeature.swift` | 权限 URI 常量 |
| `ViewModels/*` | 列表、获客员、绑定 ViewModel |
| `Views/*` | SwiftUI 页面与卡片组件 |
## 依赖
- `@Environment(\.cooperationOrderAPI)` 注入网络层
- `@EnvironmentObject PermissionContext` 权限校验
- `@EnvironmentObject ToastCenter` 用户提示
- Kingfisher 加载线索图片

View File

@ -0,0 +1,517 @@
//
// CooperationOrderModels.swift
// suixinkan
//
// Created by Codex on 2026/6/29.
//
import Foundation
extension String {
/// fallback
func cooperationNonEmptyOrDefault(_ fallback: String) -> String {
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? fallback : trimmed
}
}
///
struct ReferralOrderStatusUpdateRequest: Encodable {
let status: Int
}
///
struct CooperativeSalerListPayload: Decodable {
let total: Int
let list: [CooperativeSaler]
let page: Int
let pageSize: Int
enum CodingKeys: String, CodingKey {
case total
case list
case page
case pageSize = "page_size"
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
total = try container.decodeLossyInt(forKey: .total) ?? 0
list = try container.decodeIfPresent([CooperativeSaler].self, forKey: .list) ?? []
page = try container.decodeLossyInt(forKey: .page) ?? 0
pageSize = try container.decodeLossyInt(forKey: .pageSize) ?? 0
}
}
///
struct CooperativeSaler: Decodable, Identifiable {
let saleUserId: Int
let bindingId: Int
let name: String
let salerName: String
let phone: String
let phoneMasked: String
let salerPhone: String
let shareCode: String
let storeId: Int
let bindTime: String
let boundAt: String
var id: Int { saleUserId }
var displayId: Int { saleUserId }
var displayName: String {
name.cooperationNonEmptyOrDefault(salerName)
}
var displayPhone: String {
salerPhone.cooperationNonEmptyOrDefault(phone)
}
var displayBindTime: String {
bindTime.cooperationNonEmptyOrDefault(boundAt)
}
enum CodingKeys: String, CodingKey {
case saleUserId = "sale_user_id"
case bindingId = "binding_id"
case name
case salerName = "saler_name"
case phone
case phoneMasked = "phone_masked"
case salerPhone = "saler_phone"
case shareCode = "share_code"
case storeId = "store_id"
case bindTime = "bind_time"
case boundAt = "bound_at"
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
saleUserId = try container.decodeLossyInt(forKey: .saleUserId) ?? 0
bindingId = try container.decodeLossyInt(forKey: .bindingId) ?? 0
name = try container.decodeLossyString(forKey: .name)
salerName = try container.decodeLossyString(forKey: .salerName)
phone = try container.decodeLossyString(forKey: .phone)
phoneMasked = try container.decodeLossyString(forKey: .phoneMasked)
salerPhone = try container.decodeLossyString(forKey: .salerPhone)
shareCode = try container.decodeLossyString(forKey: .shareCode)
storeId = try container.decodeLossyInt(forKey: .storeId) ?? 0
bindTime = try container.decodeLossyString(forKey: .bindTime)
boundAt = try container.decodeLossyString(forKey: .boundAt)
}
}
///
struct SaleUserInviteInfo: Decodable {
let saleUserId: Int
let isBound: Bool
let photographerName: String
let photographerPhone: String
let salerName: String
let salerPhone: String
let shareCode: String
let subtitle: String
let title: String
enum CodingKeys: String, CodingKey {
case saleUserId = "sale_user_id"
case isBound = "is_bound"
case photographerName = "photographer_name"
case photographerPhone = "photographer_phone"
case salerName = "saler_name"
case salerPhone = "saler_phone"
case shareCode = "share_code"
case subtitle
case title
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
saleUserId = try container.decodeLossyInt(forKey: .saleUserId) ?? 0
isBound = try container.decodeLossyBool(forKey: .isBound) ?? false
photographerName = try container.decodeLossyString(forKey: .photographerName)
photographerPhone = try container.decodeLossyString(forKey: .photographerPhone)
salerName = try container.decodeLossyString(forKey: .salerName)
salerPhone = try container.decodeLossyString(forKey: .salerPhone)
shareCode = try container.decodeLossyString(forKey: .shareCode)
subtitle = try container.decodeLossyString(forKey: .subtitle)
title = try container.decodeLossyString(forKey: .title)
}
init(
saleUserId: Int,
isBound: Bool = false,
photographerName: String = "",
photographerPhone: String = "",
salerName: String = "",
salerPhone: String = "",
shareCode: String = "",
subtitle: String = "",
title: String = ""
) {
self.saleUserId = saleUserId
self.isBound = isBound
self.photographerName = photographerName
self.photographerPhone = photographerPhone
self.salerName = salerName
self.salerPhone = salerPhone
self.shareCode = shareCode
self.subtitle = subtitle
self.title = title
}
}
/// 线
struct ReferralLead: Decodable, Identifiable {
let id: Int
let leadId: Int
let userPhone: String
let userMobileMasked: String
let phone: String
let remark: String
let createdAt: String
let imageUrls: [String]
let images: [String]
var displayId: String {
let value = leadId > 0 ? leadId : id
return "\(value)"
}
var displayPhone: String {
userMobileMasked.cooperationNonEmptyOrDefault(userPhone.cooperationNonEmptyOrDefault(phone))
}
var displayImages: [String] {
imageUrls.isEmpty ? images : imageUrls
}
enum CodingKeys: String, CodingKey {
case id
case leadId = "lead_id"
case userPhone = "user_phone"
case userMobileMasked = "user_mobile_masked"
case phone
case remark
case createdAt = "created_at"
case imageUrls = "image_urls"
case images
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeLossyInt(forKey: .id) ?? 0
leadId = try container.decodeLossyInt(forKey: .leadId) ?? 0
userPhone = try container.decodeLossyString(forKey: .userPhone)
userMobileMasked = try container.decodeLossyString(forKey: .userMobileMasked)
phone = try container.decodeLossyString(forKey: .phone)
remark = try container.decodeLossyString(forKey: .remark)
createdAt = try container.decodeLossyString(forKey: .createdAt)
imageUrls = try container.decodeIfPresent([String].self, forKey: .imageUrls) ?? []
images = try container.decodeIfPresent([String].self, forKey: .images) ?? []
}
}
///
struct AcquisitionOrder: Decodable, Identifiable {
let id: Int
let orderNumber: String
let projectName: String
let orderType: Int
let orderTypeLabel: String
let orderAmount: String
let amount: String
let actualPayAmount: String
let createdAt: String
let customerPhone: String
let userMobileMasked: String
let phone: String
let orderStatusName: String
let status: String
let saleUserName: String
let partnerName: String
let partnerRole: String
let remark: String
let imageUrls: [String]
var displayAmount: String {
orderAmount.cooperationNonEmptyOrDefault(amount.cooperationNonEmptyOrDefault(actualPayAmount))
}
var displayCustomerPhone: String {
userMobileMasked.cooperationNonEmptyOrDefault(customerPhone.cooperationNonEmptyOrDefault(phone))
}
var displayStatus: String {
orderStatusName.cooperationNonEmptyOrDefault(status)
}
var displayPartner: String {
if !saleUserName.isEmpty { return saleUserName }
if !partnerName.isEmpty { return partnerName }
return ""
}
enum CodingKeys: String, CodingKey {
case id
case orderNumber = "order_number"
case projectName = "project_name"
case orderType = "order_type"
case orderTypeLabel = "order_type_label"
case orderAmount = "order_amount"
case amount
case actualPayAmount = "actual_pay_amount"
case createdAt = "created_at"
case customerPhone = "customer_phone"
case userMobileMasked = "user_mobile_masked"
case phone
case orderStatusName = "order_status_name"
case status
case saleUserName = "sale_user_name"
case partnerName = "partner_name"
case partnerRole = "partner_role"
case remark
case imageUrls = "image_urls"
}
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)
projectName = try container.decodeLossyString(forKey: .projectName)
orderType = try container.decodeLossyInt(forKey: .orderType) ?? 0
orderTypeLabel = try container.decodeLossyString(forKey: .orderTypeLabel)
orderAmount = try container.decodeLossyString(forKey: .orderAmount)
amount = try container.decodeLossyString(forKey: .amount)
actualPayAmount = try container.decodeLossyString(forKey: .actualPayAmount)
createdAt = try container.decodeLossyString(forKey: .createdAt)
customerPhone = try container.decodeLossyString(forKey: .customerPhone)
userMobileMasked = try container.decodeLossyString(forKey: .userMobileMasked)
phone = try container.decodeLossyString(forKey: .phone)
orderStatusName = try container.decodeLossyString(forKey: .orderStatusName)
status = try container.decodeLossyString(forKey: .status)
saleUserName = try container.decodeLossyString(forKey: .saleUserName)
partnerName = try container.decodeLossyString(forKey: .partnerName)
partnerRole = try container.decodeLossyString(forKey: .partnerRole)
remark = try container.decodeLossyString(forKey: .remark)
imageUrls = try container.decodeIfPresent([String].self, forKey: .imageUrls) ?? []
}
}
///
struct ReferralOrderSaler: Decodable {
let saleUserId: Int
let salerName: String
let salerPhone: String
enum CodingKeys: String, CodingKey {
case saleUserId = "sale_user_id"
case salerName = "saler_name"
case salerPhone = "saler_phone"
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
saleUserId = try container.decodeLossyInt(forKey: .saleUserId) ?? 0
salerName = try container.decodeLossyString(forKey: .salerName)
salerPhone = try container.decodeLossyString(forKey: .salerPhone)
}
}
///
struct ReferralOrderListPayload: Decodable {
let total: Int
let list: [ReferralOrder]
let page: Int
let pageSize: Int
let saler: ReferralOrderSaler?
enum CodingKeys: String, CodingKey {
case total
case list
case page
case pageSize = "page_size"
case saler
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
total = try container.decodeLossyInt(forKey: .total) ?? 0
list = try container.decodeIfPresent([ReferralOrder].self, forKey: .list) ?? []
page = try container.decodeLossyInt(forKey: .page) ?? 0
pageSize = try container.decodeLossyInt(forKey: .pageSize) ?? 0
saler = try container.decodeIfPresent(ReferralOrderSaler.self, forKey: .saler)
}
}
///
struct ReferralOrder: Decodable, Identifiable {
let id: Int
let referralOrderNo: String
let orderNo: String
let userMobile: String
let remark: String
let createdAt: String
let status: Int
let statusLabel: String
let images: [String]
var displayReferralOrderNo: String {
referralOrderNo.cooperationNonEmptyOrDefault(orderNo)
}
enum CodingKeys: String, CodingKey {
case id
case referralOrderNo = "referral_order_no"
case orderNo = "order_no"
case userMobile = "user_mobile"
case remark
case createdAt = "created_at"
case status
case statusLabel = "status_label"
case images
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeLossyInt(forKey: .id) ?? 0
referralOrderNo = try container.decodeLossyString(forKey: .referralOrderNo)
orderNo = try container.decodeLossyString(forKey: .orderNo)
userMobile = try container.decodeLossyString(forKey: .userMobile)
remark = try container.decodeLossyString(forKey: .remark)
createdAt = try container.decodeLossyString(forKey: .createdAt)
status = try container.decodeLossyInt(forKey: .status) ?? 0
statusLabel = try container.decodeLossyString(forKey: .statusLabel)
images = try container.decodeIfPresent([String].self, forKey: .images) ?? []
}
}
/// listv2 referral_order
struct BoundReferralOrder: Decodable, Equatable, Hashable {
let id: Int
let orderNo: String
let saleUserId: Int
let salerName: String
let salerPhone: String
let displayText: String
let userMobile: String
let remark: String
let bindOrderNumber: String
let sourceType: Int
let sourceTypeLabel: String
let status: Int
let statusLabel: String
let createdAt: String
enum CodingKeys: String, CodingKey {
case id
case orderNo = "order_no"
case saleUserId = "sale_user_id"
case salerName = "saler_name"
case salerPhone = "saler_phone"
case displayText = "display_text"
case userMobile = "user_mobile"
case remark
case bindOrderNumber = "bind_order_number"
case sourceType = "source_type"
case sourceTypeLabel = "source_type_label"
case status
case statusLabel = "status_label"
case createdAt = "created_at"
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeLossyInt(forKey: .id) ?? 0
orderNo = try container.decodeLossyString(forKey: .orderNo)
saleUserId = try container.decodeLossyInt(forKey: .saleUserId) ?? 0
salerName = try container.decodeLossyString(forKey: .salerName)
salerPhone = try container.decodeLossyString(forKey: .salerPhone)
displayText = try container.decodeLossyString(forKey: .displayText)
userMobile = try container.decodeLossyString(forKey: .userMobile)
remark = try container.decodeLossyString(forKey: .remark)
bindOrderNumber = try container.decodeLossyString(forKey: .bindOrderNumber)
sourceType = try container.decodeLossyInt(forKey: .sourceType) ?? 0
sourceTypeLabel = try container.decodeLossyString(forKey: .sourceTypeLabel)
status = try container.decodeLossyInt(forKey: .status) ?? 0
statusLabel = try container.decodeLossyString(forKey: .statusLabel)
createdAt = try container.decodeLossyString(forKey: .createdAt)
}
}
/// Sheet
struct BindAcquirerSheetItem: Identifiable {
let saleUserId: Int
var id: Int { saleUserId }
}
///
enum CooperationOrderRoute: Hashable {
case acquirer
case bind(saleUserId: Int)
}
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
}
}

View File

@ -0,0 +1,18 @@
//
// CooperationOrderFeature.swift
// suixinkan
//
// Created by Codex on 2026/6/29.
//
import Foundation
/// URI 访
enum CooperationOrderFeature {
static let uri = "cooperation_order"
///
static func canAccess(permissionContext: PermissionContext) -> Bool {
permissionContext.canAccess(uri)
}
}

View File

@ -0,0 +1,70 @@
//
// BindAcquirerViewModel.swift
// suixinkan
//
// Created by Codex on 2026/6/29.
//
import Foundation
import Combine
/// ViewModel
@MainActor
final class BindAcquirerViewModel: ObservableObject {
@Published var inviteInfo: SaleUserInviteInfo?
@Published var isLoading = false
@Published var isAlreadyBound = false
@Published var errorMessage: String?
private var saleUserId = 0
///
func load(api: CooperationOrderServing, saleUserId: Int) async {
guard saleUserId > 0 else {
errorMessage = "获客员信息无效"
return
}
if self.saleUserId == saleUserId && (inviteInfo != nil || isAlreadyBound) { return }
self.saleUserId = saleUserId
isAlreadyBound = false
isLoading = true
errorMessage = nil
defer { isLoading = false }
do {
var info = try await api.saleUserInviteInfo(saleUserId: saleUserId)
if info.saleUserId <= 0 {
info = SaleUserInviteInfo(
saleUserId: saleUserId,
isBound: info.isBound,
photographerName: info.photographerName,
photographerPhone: info.photographerPhone,
salerName: info.salerName,
salerPhone: info.salerPhone,
shareCode: info.shareCode,
subtitle: info.subtitle,
title: info.title
)
}
if info.isBound {
isAlreadyBound = true
}
inviteInfo = info
} catch {
handleBindError(error.localizedDescription.isEmpty ? "获取邀请信息失败" : error.localizedDescription)
}
}
///
func acceptBind(api: CooperationOrderServing) async throws {
guard saleUserId > 0, !isAlreadyBound else { return }
try await api.saleUserAcceptBind(saleUserId: saleUserId)
}
///
func handleBindError(_ message: String) {
errorMessage = message
if message.contains("已绑定") {
isAlreadyBound = true
}
}
}

View File

@ -0,0 +1,30 @@
//
// CooperationAcquirerViewModel.swift
// suixinkan
//
// Created by Codex on 2026/6/29.
//
import Foundation
import Combine
/// ViewModel
@MainActor
final class CooperationAcquirerViewModel: ObservableObject {
@Published var acquirers: [CooperativeSaler] = []
@Published private(set) var hasLoadedOnce = false
///
func reload(api: CooperationOrderServing, storeId: Int?) async {
do {
let storeParam = storeId.flatMap { $0 > 0 ? $0 : nil }
let data = try await api.cooperativeSalers(storeId: storeParam, page: 1, pageSize: 20)
acquirers = data.list
} catch {
if !hasLoadedOnce {
acquirers = []
}
}
hasLoadedOnce = true
}
}

View File

@ -0,0 +1,130 @@
//
// CooperationOrderListViewModel.swift
// suixinkan
//
// Created by Codex on 2026/6/29.
//
import Foundation
import Combine
/// ViewModel线 Tab
@MainActor
final class CooperationOrderListViewModel: ObservableObject {
@Published var selectedTab = 0
@Published var searchText = ""
@Published var leads: [ReferralLead] = []
@Published var orders: [AcquisitionOrder] = []
@Published var isLoadingMore = false
@Published var canLoadMore = false
@Published private(set) var hasLoadedLeadsOnce = false
@Published private(set) var hasLoadedOrdersOnce = false
private var leadsPage = 1
private var ordersPage = 1
private var leadsTotal = 0
private var ordersTotal = 0
private var isReloading = false
private let pageSize = 10
/// Tab
func onTabSelected(_ index: Int) {
guard selectedTab != index else { return }
selectedTab = index
}
/// Tab
func refreshCurrentTab(api: CooperationOrderServing) async {
if selectedTab == 0 {
await refreshLeads(api: api)
} else {
await refreshOrders(api: api)
}
}
/// 线
func refreshLeads(api: CooperationOrderServing) async {
guard !isReloading else { return }
isReloading = true
defer { isReloading = false }
leadsPage = 1
isLoadingMore = false
await loadLeads(api: api)
}
///
func refreshOrders(api: CooperationOrderServing) async {
guard !isReloading else { return }
isReloading = true
defer { isReloading = false }
ordersPage = 1
isLoadingMore = false
await loadOrders(api: api)
}
/// Tab
func loadMore(api: CooperationOrderServing) async {
guard !isReloading, !isLoadingMore, canLoadMore else { return }
isLoadingMore = true
if selectedTab == 0 {
leadsPage += 1
await loadLeads(api: api)
} else {
ordersPage += 1
await loadOrders(api: api)
}
}
///
func searchOrders(api: CooperationOrderServing) async {
guard selectedTab == 1 else { return }
await refreshOrders(api: api)
}
private func loadLeads(api: CooperationOrderServing) async {
do {
let data = try await api.saleUserReferralLeads(page: leadsPage, pageSize: pageSize)
leadsTotal = data.total
if leadsPage == 1 {
leads = data.list
} else {
leads.append(contentsOf: data.list)
}
canLoadMore = leads.count < leadsTotal
} catch {
if leadsPage == 1, !hasLoadedLeadsOnce {
leads = []
}
}
if leadsPage == 1 {
hasLoadedLeadsOnce = true
}
isLoadingMore = false
}
private func loadOrders(api: CooperationOrderServing) async {
do {
let keyword = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
let data = try await api.saleUserAcquisitionOrders(
keyword: keyword.isEmpty ? nil : keyword,
page: ordersPage,
pageSize: pageSize
)
ordersTotal = data.total
if ordersPage == 1 {
orders = data.list
} else {
orders.append(contentsOf: data.list)
}
canLoadMore = orders.count < ordersTotal
} catch {
if ordersPage == 1, !hasLoadedOrdersOnce {
orders = []
}
}
if ordersPage == 1 {
hasLoadedOrdersOnce = true
}
isLoadingMore = false
}
}

View File

@ -0,0 +1,141 @@
//
// BindAcquirerView.swift
// suixinkan
//
// Created by Codex on 2026/6/29.
//
import SwiftUI
///
struct BindAcquirerView: View {
@EnvironmentObject private var toastCenter: ToastCenter
@Environment(\.cooperationOrderAPI) private var cooperationOrderAPI
@Environment(\.dismiss) private var dismiss
@StateObject private var viewModel = BindAcquirerViewModel()
let saleUserId: Int
let onSuccess: () -> Void
@State private var binding = false
var body: some View {
VStack(spacing: 12) {
if viewModel.isLoading && viewModel.inviteInfo == nil && !viewModel.isAlreadyBound {
Spacer()
ProgressView()
Spacer()
} else if let info = viewModel.inviteInfo {
VStack(alignment: .leading, spacing: 12) {
VStack(alignment: .leading, spacing: 8) {
Text(info.title.cooperationNonEmptyOrDefault("绑定获客员"))
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.white)
if !info.subtitle.isEmpty {
Text(info.subtitle)
.font(.system(size: 13))
.foregroundStyle(.white.opacity(0.9))
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(16)
.background(AppDesign.primary, in: RoundedRectangle(cornerRadius: 10))
infoCard(title: "获客员", value: info.salerName)
infoCard(title: "手机号", value: info.salerPhone)
}
.padding(16)
Spacer()
Button {
Task { await acceptBind() }
} label: {
Text(binding ? "提交中..." : (viewModel.isAlreadyBound ? "已绑定获客员" : "接受邀请"))
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.frame(height: 48)
.background(
viewModel.isAlreadyBound ? AppDesign.textSecondary : AppDesign.primary,
in: RoundedRectangle(cornerRadius: 8)
)
}
.buttonStyle(.plain)
.disabled(binding || viewModel.isAlreadyBound)
.padding(.horizontal, 16)
.padding(.bottom, 12)
} else if viewModel.isAlreadyBound {
Spacer()
Text("已绑定获客员")
.font(.system(size: 14))
.foregroundStyle(AppDesign.textSecondary)
Spacer()
Button {} label: {
Text("已绑定获客员")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.frame(height: 48)
.background(AppDesign.textSecondary, in: RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.plain)
.disabled(true)
.padding(.horizontal, 16)
.padding(.bottom, 12)
} else if let error = viewModel.errorMessage {
Spacer()
Text(error)
.font(.system(size: 14))
.foregroundStyle(AppDesign.textSecondary)
.multilineTextAlignment(.center)
.padding()
Spacer()
}
}
.navigationTitle(viewModel.inviteInfo?.title.cooperationNonEmptyOrDefault("接受邀请") ?? "接受邀请")
.navigationBarTitleDisplayMode(.inline)
.background(Color(hex: 0xF5F7FA).ignoresSafeArea())
.task {
await viewModel.load(api: cooperationOrderAPI, saleUserId: saleUserId)
if viewModel.errorMessage != nil && viewModel.inviteInfo == nil && !viewModel.isAlreadyBound {
if let message = viewModel.errorMessage {
toastCenter.show(message)
}
dismiss()
}
}
}
private func infoCard(title: String, value: String) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(title)
.font(.system(size: 13))
.foregroundStyle(AppDesign.textSecondary)
Text(value.isEmpty ? "--" : value)
.font(.system(size: 15, weight: .medium))
.foregroundStyle(AppDesign.textPrimary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(14)
.background(.white, in: RoundedRectangle(cornerRadius: 10))
}
private func acceptBind() async {
binding = true
defer { binding = false }
do {
try await viewModel.acceptBind(api: cooperationOrderAPI)
toastCenter.show("绑定成功")
onSuccess()
dismiss()
} catch {
viewModel.handleBindError(error.localizedDescription.isEmpty ? "绑定失败" : error.localizedDescription)
if let message = viewModel.errorMessage {
toastCenter.show(message)
}
if !viewModel.isAlreadyBound {
dismiss()
}
}
}
}

View File

@ -0,0 +1,89 @@
//
// CooperationAcquirerView.swift
// suixinkan
//
// Created by Codex on 2026/6/29.
//
import SwiftUI
///
struct CooperationAcquirerView: View {
@EnvironmentObject private var accountContext: AccountContext
@EnvironmentObject private var toastCenter: ToastCenter
@Environment(\.cooperationOrderAPI) private var cooperationOrderAPI
@Environment(\.globalLoading) private var globalLoading
@Environment(\.openURL) private var openURL
@StateObject private var viewModel = CooperationAcquirerViewModel()
@State private var showScanner = false
let onBind: (Int) -> Void
var body: some View {
ScrollView {
LazyVStack(spacing: 12) {
if viewModel.hasLoadedOnce && viewModel.acquirers.isEmpty {
Text("暂无合作获客员")
.font(.system(size: 14))
.foregroundStyle(CooperationOrderColors.text999)
.frame(maxWidth: .infinity)
.padding(.vertical, 48)
} else {
ForEach(viewModel.acquirers, id: \.displayId) { acquirer in
CooperativeSalerCard(acquirer: acquirer) { phone in
dialPhone(phone)
}
}
}
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
}
.navigationTitle("合作获客员")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("扫码绑定") {
showScanner = true
}
.font(.system(size: 14))
.foregroundStyle(CooperationOrderColors.primary)
}
}
.background(CooperationOrderColors.pageBg.ignoresSafeArea())
.refreshable {
await reload(showLoading: false)
}
.fullScreenCover(isPresented: $showScanner) {
OrderScannerPage(
onClose: { showScanner = false },
onSuccess: { raw in
showScanner = false
if let saleUserId = SaleUserQrParser.parseSaleUserId(raw) {
onBind(saleUserId)
} else {
toastCenter.show("请扫描正确的获客员二维码")
}
},
onFailure: { error in
showScanner = false
toastCenter.show(error.localizedDescription)
}
)
}
.task {
await reload(showLoading: true)
}
}
private func reload(showLoading: Bool) async {
await globalLoading.withOptionalLoading(showLoading) {
await viewModel.reload(api: cooperationOrderAPI, storeId: accountContext.currentStore?.id)
}
}
private func dialPhone(_ phone: String) {
let trimmed = phone.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, let url = URL(string: "tel:\(trimmed)") else { return }
openURL(url)
}
}

View File

@ -0,0 +1,29 @@
//
// CooperationOrderAccessView.swift
// suixinkan
//
// Created by Codex on 2026/6/29.
//
import SwiftUI
///
struct CooperationOrderAccessView: View {
@EnvironmentObject private var permissionContext: PermissionContext
@EnvironmentObject private var toastCenter: ToastCenter
@Environment(\.dismiss) private var dismiss
var body: some View {
Group {
if CooperationOrderFeature.canAccess(permissionContext: permissionContext) {
CooperationOrderListView()
} else {
Color.clear
.onAppear {
toastCenter.show("暂无合作订单权限")
dismiss()
}
}
}
}
}

View File

@ -0,0 +1,379 @@
//
// CooperationOrderComponents.swift
// suixinkan
//
// Created by Codex on 2026/6/29.
//
import SwiftUI
import Kingfisher
enum CooperationOrderColors {
static let primary = Color(hex: 0x0073FF)
static let text333 = Color(hex: 0x333333)
static let text666 = Color(hex: 0x666666)
static let text999 = Color(hex: 0x999999)
static let searchPlaceholder = Color(hex: 0xB6BECA)
static let searchBg = Color(hex: 0xF4F4F4)
static let imageEmptyBg = Color(hex: 0xF7F8FA)
static let imagePlaceholderBg = Color(hex: 0xF4F4F4)
static let pageBg = Color(hex: 0xF5F7FA)
}
struct CooperationCard<Content: View>: View {
@ViewBuilder let content: () -> Content
var body: some View {
content()
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.white, in: RoundedRectangle(cornerRadius: 10))
.shadow(color: Color.black.opacity(0.06), radius: 1, x: 0, y: 1)
}
}
struct CooperationListInfoRow: View {
let label: String
let value: String
var body: some View {
HStack(alignment: .center, spacing: 0) {
Text(label)
.font(.system(size: 13))
.foregroundStyle(CooperationOrderColors.text999)
Text(value)
.font(.system(size: 13))
.foregroundStyle(CooperationOrderColors.text333)
.lineLimit(1)
.truncationMode(.tail)
.padding(.leading, 14)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
struct CooperationTypeTag: View {
let text: String
var body: some View {
Text(text)
.font(.system(size: 12))
.foregroundStyle(CooperationOrderColors.primary)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(CooperationOrderColors.primary.opacity(0.09), in: RoundedRectangle(cornerRadius: 4))
}
}
struct CooperationOrderTabs: View {
let selectedTab: Int
let onTabSelected: (Int) -> Void
private let tabs = ["获客员线索", "获客订单"]
var body: some View {
VStack(spacing: 0) {
HStack(spacing: 0) {
ForEach(Array(tabs.enumerated()), id: \.offset) { index, title in
Button {
onTabSelected(index)
} label: {
Text(title)
.font(.system(size: 15, weight: selectedTab == index ? .bold : .regular))
.foregroundStyle(selectedTab == index ? CooperationOrderColors.primary : CooperationOrderColors.text666)
.frame(maxWidth: .infinity)
.frame(height: 48)
}
.buttonStyle(.plain)
}
}
GeometryReader { geo in
let tabWidth = geo.size.width / CGFloat(tabs.count)
RoundedRectangle(cornerRadius: 1.5)
.fill(CooperationOrderColors.primary)
.frame(width: max(tabWidth - 68, 0), height: 3)
.offset(x: tabWidth * CGFloat(selectedTab) + 34, y: 0)
}
.frame(height: 3)
}
.background(.white)
}
}
struct CooperationOrderSearchBar: View {
@Binding var searchText: String
let onSearch: () -> Void
var body: some View {
HStack(spacing: 0) {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.font(.system(size: 18))
.foregroundStyle(CooperationOrderColors.text999)
TextField(
"",
text: $searchText,
prompt: Text("名称搜索").foregroundColor(CooperationOrderColors.searchPlaceholder)
)
.font(.system(size: 14))
.foregroundStyle(CooperationOrderColors.text333)
.textInputAutocapitalization(.never)
.autocorrectionDisabled(true)
.submitLabel(.search)
.onSubmit(onSearch)
}
.padding(.horizontal, 12)
.frame(height: 38)
.background(CooperationOrderColors.searchBg, in: RoundedRectangle(cornerRadius: 6))
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
.background(.white)
}
}
struct CooperationImagePreviewItem: Identifiable {
let urls: [String]
let index: Int
var id: String { "\(index)-\(urls.joined())" }
}
struct ReferralLeadCard: View {
let lead: ReferralLead
@State private var previewIndex: Int?
@State private var previewImages: [String] = []
var body: some View {
CooperationCard {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .center, spacing: 0) {
Text("用户手机号")
.font(.system(size: 13))
.foregroundStyle(CooperationOrderColors.text999)
Text(lead.displayPhone.isEmpty ? "" : lead.displayPhone)
.font(.system(size: 15, weight: .medium))
.foregroundStyle(CooperationOrderColors.text333)
.padding(.leading, 14)
Spacer(minLength: 8)
Text(lead.createdAt)
.font(.system(size: 11))
.foregroundStyle(CooperationOrderColors.text999)
}
Text("备注")
.font(.system(size: 13))
.foregroundStyle(CooperationOrderColors.text999)
Text(lead.remark.isEmpty ? "暂无备注" : lead.remark)
.font(.system(size: 14))
.foregroundStyle(CooperationOrderColors.text333)
.lineSpacing(7)
Text("图片")
.font(.system(size: 13))
.foregroundStyle(CooperationOrderColors.text999)
ReferralLeadImageGrid(images: lead.displayImages) { images, index in
previewImages = images
previewIndex = index
}
}
}
.sheet(item: previewBinding) { item in
SimpleImagePreviewSheet(
imageUrls: item.urls,
startIndex: item.index,
onDismiss: { previewIndex = nil }
)
}
}
private var previewBinding: Binding<CooperationImagePreviewItem?> {
Binding(
get: {
guard let previewIndex else { return nil }
return CooperationImagePreviewItem(urls: previewImages, index: previewIndex)
},
set: { newValue in
if newValue == nil { previewIndex = nil }
}
)
}
}
struct ReferralLeadImageGrid: View {
let images: [String]
var onImageTap: ([String], Int) -> Void = { _, _ in }
var body: some View {
if images.isEmpty {
ZStack {
RoundedRectangle(cornerRadius: 6)
.fill(CooperationOrderColors.imageEmptyBg)
.frame(maxWidth: .infinity)
.frame(height: 72)
Text("暂无图片")
.font(.system(size: 13))
.foregroundStyle(CooperationOrderColors.text999)
}
} else {
HStack(spacing: 8) {
ForEach(0..<3, id: \.self) { index in
if index < images.count, let url = URL(string: images[index]) {
KFImage(url)
.placeholder { CooperationOrderColors.imagePlaceholderBg }
.resizable()
.scaledToFill()
.frame(maxWidth: .infinity)
.aspectRatio(1, contentMode: .fit)
.clipShape(RoundedRectangle(cornerRadius: 6))
.contentShape(Rectangle())
.onTapGesture {
onImageTap(images, index)
}
} else {
Color.clear
.frame(maxWidth: .infinity)
.aspectRatio(1, contentMode: .fit)
}
}
}
}
}
}
struct AcquisitionOrderCard: View {
let order: AcquisitionOrder
var body: some View {
CooperationCard {
VStack(alignment: .leading, spacing: 10) {
Text(order.projectName.isEmpty ? "" : order.projectName)
.font(.system(size: 16, weight: .bold))
.foregroundStyle(CooperationOrderColors.text333)
.lineLimit(1)
.truncationMode(.tail)
HStack(alignment: .center, spacing: 8) {
if !order.orderTypeLabel.isEmpty {
CooperationTypeTag(text: order.orderTypeLabel)
}
Text("订单号 \(order.orderNumber)")
.font(.system(size: 13))
.foregroundStyle(CooperationOrderColors.text666)
.lineLimit(1)
.truncationMode(.tail)
}
CooperationListInfoRow(
label: "合作人员",
value: order.displayPartner.isEmpty ? "暂未绑定" : order.displayPartner
)
CooperationListInfoRow(
label: "下单时间",
value: order.createdAt.isEmpty ? "" : order.createdAt
)
HStack(alignment: .center, spacing: 0) {
Text("客户 \(order.displayCustomerPhone.isEmpty ? "" : order.displayCustomerPhone)")
.font(.system(size: 12))
.foregroundStyle(CooperationOrderColors.text999)
.frame(maxWidth: .infinity, alignment: .leading)
Text("¥\(order.displayAmount.isEmpty ? "" : order.displayAmount)")
.font(.system(size: 17, weight: .bold))
.foregroundStyle(CooperationOrderColors.primary)
}
}
}
}
}
struct CooperativeSalerCard: View {
let acquirer: CooperativeSaler
let onDial: (String) -> Void
var body: some View {
HStack(alignment: .center, spacing: 0) {
Image(systemName: "person.fill")
.font(.system(size: 20))
.foregroundStyle(CooperationOrderColors.primary)
.padding(10)
.background(CooperationOrderColors.primary.opacity(0.08), in: RoundedRectangle(cornerRadius: 8))
VStack(alignment: .leading, spacing: 8) {
AcquirerInfoRow(
label: "获客员名称",
value: acquirer.displayName.isEmpty ? "" : acquirer.displayName,
emphasize: true
)
AcquirerPhoneInfoRow(phone: acquirer.displayPhone, onDial: onDial)
AcquirerInfoRow(
label: "绑定时间",
value: acquirer.displayBindTime.isEmpty ? "" : acquirer.displayBindTime
)
}
.padding(.leading, 12)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(.horizontal, 14)
.padding(.vertical, 16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.white, in: RoundedRectangle(cornerRadius: 10))
.shadow(color: Color.black.opacity(0.06), radius: 1, x: 0, y: 1)
}
}
struct AcquirerInfoRow: View {
let label: String
let value: String
var emphasize: Bool = false
var body: some View {
HStack(alignment: .center, spacing: 0) {
Text(label)
.font(.system(size: 13))
.foregroundStyle(CooperationOrderColors.text999)
Text(value)
.font(.system(size: emphasize ? 15 : 13, weight: emphasize ? .bold : .regular))
.foregroundStyle(emphasize ? CooperationOrderColors.text333 : CooperationOrderColors.text666)
.padding(.leading, 12)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
struct AcquirerPhoneInfoRow: View {
let phone: String
let onDial: (String) -> Void
var body: some View {
HStack(alignment: .center, spacing: 0) {
Text("手机号")
.font(.system(size: 13))
.foregroundStyle(CooperationOrderColors.text999)
Text(maskAcquirerPhone(phone))
.font(.system(size: 13))
.foregroundStyle(CooperationOrderColors.text666)
.padding(.leading, 12)
Spacer(minLength: 8)
if !phone.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
Button {
onDial(phone)
} label: {
Image(systemName: "phone.fill")
.font(.system(size: 15))
.foregroundStyle(CooperationOrderColors.primary)
.frame(width: 30, height: 30)
.background(Color(hex: 0xEFF6FF), in: Circle())
}
.buttonStyle(.plain)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
func maskAcquirerPhone(_ phone: String) -> String {
let digits = phone.filter(\.isNumber)
guard digits.count >= 7 else { return phone.isEmpty ? "" : phone }
return "\(digits.prefix(3))****\(digits.suffix(4))"
}

View File

@ -0,0 +1,123 @@
//
// CooperationOrderListView.swift
// suixinkan
//
// Created by Codex on 2026/6/29.
//
import SwiftUI
/// 线 Tab
struct CooperationOrderListView: View {
@Environment(\.cooperationOrderAPI) private var cooperationOrderAPI
@Environment(\.globalLoading) private var globalLoading
@StateObject private var viewModel = CooperationOrderListViewModel()
@State private var route: CooperationOrderRoute?
var body: some View {
VStack(spacing: 0) {
CooperationOrderTabs(
selectedTab: viewModel.selectedTab,
onTabSelected: { viewModel.onTabSelected($0) }
)
if viewModel.selectedTab == 1 {
CooperationOrderSearchBar(searchText: $viewModel.searchText) {
Task { await searchOrders(showLoading: true) }
}
}
ScrollView {
LazyVStack(spacing: 12) {
if viewModel.selectedTab == 0 {
if viewModel.hasLoadedLeadsOnce && viewModel.leads.isEmpty {
emptyState("暂无获客员线索")
} else {
ForEach(Array(viewModel.leads.enumerated()), id: \.element.id) { index, lead in
ReferralLeadCard(lead: lead)
.onAppear {
guard index >= viewModel.leads.count - 3 else { return }
Task { await viewModel.loadMore(api: cooperationOrderAPI) }
}
}
}
} else {
if viewModel.hasLoadedOrdersOnce && viewModel.orders.isEmpty {
emptyState("暂无相关合作订单")
} else {
ForEach(Array(viewModel.orders.enumerated()), id: \.element.id) { index, order in
AcquisitionOrderCard(order: order)
.onAppear {
guard index >= viewModel.orders.count - 3 else { return }
Task { await viewModel.loadMore(api: cooperationOrderAPI) }
}
}
}
}
if viewModel.isLoadingMore {
ProgressView()
.tint(CooperationOrderColors.primary)
.frame(width: 24, height: 24)
.padding(16)
}
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
}
.refreshable {
await reloadCurrentTab(showLoading: false)
}
}
.navigationTitle("合作订单")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("合作获客员") {
route = .acquirer
}
.font(.system(size: 14))
.foregroundStyle(CooperationOrderColors.primary)
}
}
.background(CooperationOrderColors.pageBg.ignoresSafeArea())
.appNavigationDestination(item: $route) { destination in
switch destination {
case .acquirer:
CooperationAcquirerView(onBind: { saleUserId in
route = .bind(saleUserId: saleUserId)
})
case let .bind(saleUserId):
BindAcquirerView(saleUserId: saleUserId, onSuccess: {
route = .acquirer
})
}
}
.task {
await reloadCurrentTab(showLoading: true)
}
.onChange(of: viewModel.selectedTab) { _ in
Task { await reloadCurrentTab(showLoading: true) }
}
}
private func reloadCurrentTab(showLoading: Bool) async {
await globalLoading.withOptionalLoading(showLoading) {
await viewModel.refreshCurrentTab(api: cooperationOrderAPI)
}
}
private func searchOrders(showLoading: Bool) async {
await globalLoading.withOptionalLoading(showLoading) {
await viewModel.searchOrders(api: cooperationOrderAPI)
}
}
private func emptyState(_ text: String) -> some View {
Text(text)
.font(.system(size: 14))
.foregroundStyle(CooperationOrderColors.text999)
.frame(maxWidth: .infinity)
.padding(.vertical, 48)
}
}

View File

@ -9,17 +9,21 @@ Home 模块负责登录后的首页工作台,包括当前景区展示、工作
## 菜单生成
`HomeViewModel` 从当前角色的权限树递归提取菜单:
- 当前角色 ID 存在但找不到时清空菜单。
- 当前角色 ID 为空时使用第一个角色。
- 当前 `role_code` 存在但找不到时清空菜单。
- 当前 `role_code` 为空时使用第一个角色。
- URI 按旧工程顺序排序。
- 同义 URI 按 `HomeMenuRouter.menuAliasKey` 去重。
角色 UI 判断使用 `PermissionContext.currentAppRole``AppRoleCode` 枚举),例如店长 `isStoreAdmin`、首页隐藏工作状态 `homeMinimalTopRoles`
## 常用应用
`HomeCommonMenuStore` 使用 UserDefaults 保存常用应用 URI
- 首次无配置时写入默认常用应用。
`HomeCommonMenuStore` 使用 UserDefaults 按角色保存常用应用 URIkey`home.common.menu.uris.role.{roleCode}`
- 首次无配置时,按 Android 规则从当前角色顶层权限 URI 生成默认值:权限多于 4 个取前 4 个,否则全部使用。
- 读取时按当前角色权限过滤不可用 URI。
- 添加和移除时按同义 URI 去重。
- 不同角色的常用应用互不影响。
- 升级时会从旧版 `role.{roleId}` key 一次性迁移到 `role.{roleCode}`
## 路由规则
@ -33,6 +37,6 @@ Home 模块负责登录后的首页工作台,包括当前景区展示、工作
## 后续迁移
目前 `payment_collection``payment_qr``payment_code` 已由 `Features/Payment` 的真实收款页面接管,`wallet` 已由 `Features/Wallet` 的真实钱包页面接管,`scenicselection``permission_apply``permission_apply_status``scenicapplication` 已由 `Features/ScenicPermission` 接管,`task_management``task_management_editor``task_create` 已由 `Features/Tasks` 接管,`cloud_management``cloud_storage_transit``asset_management``material_upload``album_list``album_trailer``sample_management``sample_upload` 已由 `Features/Assets` 接管,`checkin_points` 已由 `Features/PunchPoint` 接管,`location_report``location_report_history` 已由 `Features/LocationReport` 接管,`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` 接管。
目前 `payment_collection``payment_qr``payment_code` 已由 `Features/Payment` 的真实收款页面接管,`wallet` 已由 `Features/Wallet` 的真实钱包页面接管,`cooperation_order` 已由 `Features/CooperationOrder` 接管,`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

@ -56,6 +56,7 @@ enum HomeRoute: Hashable {
case locationReportHistory
case depositOrders
case withdrawalAudit
case cooperationOrder
case scenicSettlement
case scenicSettlementReview
case messageCenter

View File

@ -60,6 +60,7 @@ enum HomeMenuRouter {
"deposit_order": "押金订单详情",
"deposit_order_shooting_info": "押金拍摄信息",
"withdrawal_audit": "提现审核",
"cooperation_order": "合作订单",
"location_report_history": "定位上报历史",
"photographer_invite": "邀请摄影师",
"invite_record": "邀请记录",
@ -131,6 +132,8 @@ enum HomeMenuRouter {
return .destination(.depositOrders)
case "withdrawal_audit":
return .destination(.withdrawalAudit)
case "cooperation_order":
return .destination(.cooperationOrder)
case "scenic_settlement":
return .destination(.scenicSettlement)
case "scenic_settlement_review":
@ -199,6 +202,7 @@ enum HomeMenuRouter {
"scenic_settlement",
"scenic_settlement_review",
"verification_order",
"cooperation_order",
"photographer_orders",
"/scenic-order-manage",
"photographer_stats",
@ -301,10 +305,15 @@ enum HomeMenuRouter {
/// URI
enum HomePermissionRouteAuditor {
/// URI
static func audit(permissions: [RolePermissionResponse], currentRoleId: Int?) -> HomePermissionRouteAudit {
static func audit(permissions: [RolePermissionResponse], currentRoleCode: String?) -> HomePermissionRouteAudit {
let source: RolePermissionResponse?
if let currentRoleId {
source = permissions.first { $0.role.id == currentRoleId }
if let currentRoleCode, !currentRoleCode.isEmpty {
source = RolePermissionMatching.match(
in: permissions,
roleCode: currentRoleCode,
roleId: nil,
roleName: nil
)
} else {
source = permissions.first
}

View File

@ -7,47 +7,71 @@
import Foundation
/// URI
/// URI
struct HomeCommonMenuStore {
static let defaultStorageKey = "home.common.menu.uris"
static let defaultBaselineKey = "home.common.menu.android.baseline.v2"
static let storageKeyPrefix = "home.common.menu.uris.role."
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
) {
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
self.storageKey = storageKey
self.baselineKey = baselineKey
}
/// URI使
func load(menuItems: [HomeMenuItem]) -> [String] {
/// Android URI 4 4
static func defaultCommonURIs(
orderedTopLevelURIs: [String],
availableURIs: Set<String>
) -> [String] {
let resolved = orderedTopLevelURIs.compactMap { uri -> String? in
let trimmed = uri.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
let canonical = HomeMenuRouter.canonicalURI(for: trimmed, availableURIs: availableURIs)
return availableURIs.contains(canonical) ? canonical : nil
}
let deduplicated = unique(resolved)
if deduplicated.count > 4 {
return Array(deduplicated.prefix(4))
}
return deduplicated
}
/// URI Android
func load(
menuItems: [HomeMenuItem],
roleCode: String?,
legacyRoleId: Int? = nil,
topLevelPermissionURIs: [String]
) -> [String] {
guard let roleCode, !roleCode.isEmpty else { return [] }
migrateLegacyStorageIfNeeded(roleCode: roleCode, legacyRoleId: legacyRoleId)
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 key = storageKey(for: roleCode)
let saved = defaults.stringArray(forKey: key)
if let saved, !saved.isEmpty {
let filtered = Self.unique(saved.compactMap { uri -> String? in
let resolved = HomeMenuRouter.canonicalURI(for: uri, availableURIs: availableURIs)
return availableURIs.contains(resolved) ? resolved : nil
})
if !filtered.isEmpty {
save(filtered, roleCode: roleCode)
return filtered
}
}
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
let defaultURIs = Self.defaultCommonURIs(
orderedTopLevelURIs: topLevelPermissionURIs,
availableURIs: availableURIs
)
save(defaultURIs, roleCode: roleCode)
return defaultURIs
}
/// URI
func add(_ uri: String, current: [String], menuItems: [HomeMenuItem]) -> [String] {
func add(_ uri: String, current: [String], menuItems: [HomeMenuItem], roleCode: String?) -> [String] {
guard let roleCode, !roleCode.isEmpty else { return current }
let availableURIs = Set(menuItems.map(\.uri))
let resolved = HomeMenuRouter.canonicalURI(for: uri, availableURIs: availableURIs)
guard availableURIs.contains(resolved) else { return current }
@ -55,34 +79,49 @@ struct HomeCommonMenuStore {
return current
}
let next = current + [resolved]
save(next)
save(next, roleCode: roleCode)
return next
}
/// URI URI
func remove(_ uri: String, current: [String]) -> [String] {
func remove(_ uri: String, current: [String], roleCode: String?) -> [String] {
guard let roleCode, !roleCode.isEmpty else { return current }
let aliasKey = HomeMenuRouter.menuAliasKey(for: uri)
let next = current.filter { HomeMenuRouter.menuAliasKey(for: $0) != aliasKey }
save(next)
save(next, roleCode: roleCode)
return next
}
/// URI
func save(_ uris: [String]) {
defaults.set(unique(uris), forKey: storageKey)
/// URI
func save(_ uris: [String], roleCode: String) {
defaults.set(Self.unique(uris), forKey: storageKey(for: roleCode))
}
/// 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
})
/// key legacy role id key
func migrateLegacyStorageIfNeeded(roleCode: String, legacyRoleId: Int?) {
let newKey = storageKey(for: roleCode)
guard defaults.stringArray(forKey: newKey) == nil,
let legacyRoleId,
let legacyData = defaults.stringArray(forKey: legacyStorageKey(for: legacyRoleId)),
!legacyData.isEmpty else {
return
}
defaults.set(legacyData, forKey: newKey)
defaults.removeObject(forKey: legacyStorageKey(for: legacyRoleId))
}
/// role_code UserDefaults key
private func storageKey(for roleCode: String) -> String {
Self.storageKeyPrefix + roleCode
}
/// numeric role id key
private func legacyStorageKey(for roleId: Int) -> String {
Self.storageKeyPrefix + String(roleId)
}
/// URI URI
private func unique(_ uris: [String]) -> [String] {
private static func unique(_ uris: [String]) -> [String] {
var seen = Set<String>()
return uris.filter { uri in
seen.insert(HomeMenuRouter.menuAliasKey(for: uri)).inserted

View File

@ -58,10 +58,15 @@ final class HomeViewModel: ObservableObject {
]
/// URI
func buildMenus(from permissions: [RolePermissionResponse], currentRoleId: Int?) {
func buildMenus(from permissions: [RolePermissionResponse], currentRoleCode: String?) {
let source: RolePermissionResponse?
if let currentRoleId {
source = permissions.first { $0.role.id == currentRoleId }
if let currentRoleCode, !currentRoleCode.isEmpty {
source = RolePermissionMatching.match(
in: permissions,
roleCode: currentRoleCode,
roleId: nil,
roleName: nil
)
} else {
source = permissions.first
}

View File

@ -49,7 +49,7 @@ struct HomeMoreFunctionsView: View {
.task {
rebuildMenus()
}
.onChange(of: permissionContext.currentRole?.id) { _ in
.onChange(of: permissionContext.currentRole?.roleCode) { _ in
rebuildMenus()
}
.onChange(of: permissionContext.rolePermissions.count) { _ in
@ -122,10 +122,16 @@ struct HomeMoreFunctionsView: View {
.buttonStyle(.plain)
Button {
let roleCode = permissionContext.currentAppRole?.rawValue
if isCommon {
commonUris = commonMenuStore.remove(item.uri, current: commonUris)
commonUris = commonMenuStore.remove(item.uri, current: commonUris, roleCode: roleCode)
} else {
commonUris = commonMenuStore.add(item.uri, current: commonUris, menuItems: viewModel.menuItems)
commonUris = commonMenuStore.add(
item.uri,
current: commonUris,
menuItems: viewModel.menuItems,
roleCode: roleCode
)
}
} label: {
Image(systemName: isCommon ? "minus.circle.fill" : "plus.circle.fill")
@ -195,10 +201,17 @@ struct HomeMoreFunctionsView: View {
}
private func rebuildMenus() {
let roleCode = permissionContext.currentAppRole?.rawValue
let legacyRoleId = permissionContext.currentRole?.id
viewModel.buildMenus(
from: permissionContext.rolePermissions,
currentRoleId: permissionContext.currentRole?.id
currentRoleCode: roleCode
)
commonUris = commonMenuStore.load(
menuItems: viewModel.menuItems,
roleCode: roleCode,
legacyRoleId: legacyRoleId,
topLevelPermissionURIs: permissionContext.topLevelPermissionURIs(for: roleCode)
)
commonUris = commonMenuStore.load(menuItems: viewModel.menuItems)
}
}

View File

@ -88,6 +88,8 @@ extension HomeRoute {
DepositOrderEntryView()
case .withdrawalAudit:
WithdrawalAuditView()
case .cooperationOrder:
CooperationOrderAccessView()
case .scenicSettlement:
ScenicSettlementView()
case .scenicSettlementReview:

View File

@ -27,23 +27,18 @@ struct HomeView: View {
private let commonMenuStore = HomeCommonMenuStore()
private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
private let minimalTopRoleIds: Set<Int> = [46, 47, 52, 53, 54]
private var contentMaxWidth: CGFloat {
horizontalSizeClass == .regular ? 760 : .infinity
}
private var currentRoleId: Int? {
permissionContext.currentRole?.id
}
private var isStoreManager: Bool {
currentRoleId == 46
permissionContext.currentAppRole?.isStoreAdmin == true
}
private var shouldShowWorkStatus: Bool {
guard let currentRoleId else { return true }
return !minimalTopRoleIds.contains(currentRoleId)
guard let role = permissionContext.currentAppRole else { return true }
return !AppRoleCode.homeMinimalTopRoles.contains(role)
}
private var currentScenicName: String {
@ -66,9 +61,8 @@ struct HomeView: View {
}
private var displayMenuItems: [HomeMenuItem] {
let selected = commonUris.compactMap(menuItemForHomeEntry(uri:))
let fallback = selected.isEmpty ? Array(viewModel.menuItems.prefix(3)) : selected
return fallback + [HomeMenuItem(title: "更多功能", uri: "more_functions", iconSrc: nil)]
let items = commonUris.compactMap(menuItemForHomeEntry(uri:))
return items + [HomeMenuItem(title: "更多功能", uri: "more_functions", iconSrc: nil)]
}
var body: some View {
@ -113,7 +107,7 @@ struct HomeView: View {
guard isOnline, secondsUntilReport > 0 else { return }
secondsUntilReport -= 1
}
.onChange(of: permissionContext.currentRole?.id) { _ in
.onChange(of: permissionContext.currentRole?.roleCode) { _ in
rebuildMenusFromCurrentContext()
}
.onChange(of: permissionContext.rolePermissions.count) { _ in
@ -412,11 +406,18 @@ struct HomeView: View {
}
private func rebuildMenusFromCurrentContext() {
let roleCode = permissionContext.currentAppRole?.rawValue
let legacyRoleId = permissionContext.currentRole?.id
viewModel.buildMenus(
from: permissionContext.rolePermissions,
currentRoleId: permissionContext.currentRole?.id
currentRoleCode: roleCode
)
commonUris = commonMenuStore.load(
menuItems: viewModel.menuItems,
roleCode: roleCode,
legacyRoleId: legacyRoleId,
topLevelPermissionURIs: permissionContext.topLevelPermissionURIs(for: roleCode)
)
commonUris = commonMenuStore.load(menuItems: viewModel.menuItems)
}
}

View File

@ -19,6 +19,7 @@ struct MainTabsView: View {
@StateObject private var badgeViewModel = MainTabBadgeViewModel()
@State private var showScanner = false
@State private var bindAcquirerSaleUserId: Int?
@State private var tabSelection: MainTabSelection = .tab(.home)
@State private var selectedBusinessTab: AppTab = .home
@ -52,7 +53,7 @@ struct MainTabsView: View {
onClose: { showScanner = false },
onSuccess: { rawCode in
showScanner = false
appRouter.routeToOrderVerification(scannedCode: rawCode)
handleGlobalScanResult(rawCode)
},
onFailure: { error in
showScanner = false
@ -60,6 +61,13 @@ struct MainTabsView: View {
}
)
}
.fullScreenCover(item: bindAcquirerBinding) { item in
NavigationStack {
BindAcquirerView(saleUserId: item.saleUserId, onSuccess: {
bindAcquirerSaleUserId = nil
})
}
}
.onAppear {
syncSelection(with: appRouter.selectedTab)
}
@ -91,6 +99,26 @@ struct MainTabsView: View {
)
}
/// Tab
private func handleGlobalScanResult(_ rawCode: String) {
if SaleUserQrParser.isSaleUserBindQR(rawCode) {
guard let saleUserId = SaleUserQrParser.parseSaleUserId(rawCode), saleUserId > 0 else {
toastCenter.show("请扫描正确的获客员二维码")
return
}
bindAcquirerSaleUserId = saleUserId
return
}
appRouter.routeToOrderVerification(scannedCode: rawCode)
}
private var bindAcquirerBinding: Binding<BindAcquirerSheetItem?> {
Binding(
get: { bindAcquirerSaleUserId.map(BindAcquirerSheetItem.init(saleUserId:)) },
set: { bindAcquirerSaleUserId = $0?.saleUserId }
)
}
/// TabBar Tab
private func handleTabSelection(_ selection: MainTabSelection) {
switch selection {

View File

@ -55,12 +55,9 @@ final class OperatingAreaViewModel: ObservableObject {
///
@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")
let appRole = permissionContext.currentAppRole
if isStoreRole {
if appRole?.isStoreAdmin == true {
guard let storeId = accountContext.currentStore?.id, storeId > 0 else {
setBlocked(.missingStore)
return nil
@ -70,7 +67,7 @@ final class OperatingAreaViewModel: ObservableObject {
return mode
}
if isScenicRole {
if appRole?.isScenicAdmin == true {
guard let scenicId = accountContext.currentScenic?.id, scenicId > 0 else {
setBlocked(.missingScenic)
return nil

View File

@ -42,7 +42,7 @@ struct OperatingAreaView: View {
}
private var taskID: String {
"\(permissionContext.currentRole?.id ?? 0)-\(accountContext.currentScenic?.id ?? 0)-\(accountContext.currentStore?.id ?? 0)"
"\(permissionContext.currentAppRole?.rawValue ?? "")-\(accountContext.currentScenic?.id ?? 0)-\(accountContext.currentStore?.id ?? 0)"
}
private var summarySection: some View {

View File

@ -61,6 +61,11 @@ struct OrderEntity: Decodable, Identifiable, Equatable, Hashable {
let photoTravel: OrderPhotoTravel?
let isNeedEdit: Bool
let isRefined: Int
let canBindReferral: Int
let showOrderSource: Int
let referralOrderNo: String
let referralSalerName: String
let referralOrder: BoundReferralOrder?
enum CodingKeys: String, CodingKey {
case photogUid = "photog_uid"
@ -89,6 +94,11 @@ struct OrderEntity: Decodable, Identifiable, Equatable, Hashable {
case photoTravel = "photo_travel"
case isNeedEdit
case isRefined = "is_refined"
case canBindReferral = "can_bind_referral"
case showOrderSource = "show_order_source"
case referralOrderNo = "referral_order_no"
case referralSalerName = "referral_saler_name"
case referralOrder = "referral_order"
}
///
@ -120,6 +130,11 @@ struct OrderEntity: Decodable, Identifiable, Equatable, Hashable {
photoTravel = try container.decodeIfPresent(OrderPhotoTravel.self, forKey: .photoTravel)
isNeedEdit = try container.decodeLossyBool(forKey: .isNeedEdit) ?? true
isRefined = try container.decodeLossyInt(forKey: .isRefined) ?? 0
canBindReferral = try container.decodeLossyInt(forKey: .canBindReferral) ?? 0
showOrderSource = try container.decodeLossyInt(forKey: .showOrderSource) ?? 0
referralOrderNo = try container.decodeLossyString(forKey: .referralOrderNo)
referralSalerName = try container.decodeLossyString(forKey: .referralSalerName)
referralOrder = try container.decodeIfPresent(BoundReferralOrder.self, forKey: .referralOrder)
}
}

View File

@ -0,0 +1,106 @@
import Foundation
struct OrderSourcePerson: Identifiable, Hashable {
let key: String
let name: String
let phone: String
var id: String { key }
}
struct OrderSourceLead: Identifiable, Hashable {
let key: String
let userPhone: String
let remark: String
let images: [String]
var id: String { key }
}
struct OrderSourceSelection: Hashable {
let person: OrderSourcePerson
let lead: OrderSourceLead
}
extension CooperativeSaler {
func toOrderSourcePerson() -> OrderSourcePerson {
OrderSourcePerson(
key: "\(displayId)",
name: displayName,
phone: displayPhone
)
}
}
extension ReferralOrder {
func toOrderSourceLead() -> OrderSourceLead {
let remarkText: String
if !remark.isEmpty {
remarkText = remark
} else if !statusLabel.isEmpty {
remarkText = statusLabel
} else {
remarkText = "带客单 \(displayReferralOrderNo)"
}
return OrderSourceLead(
key: displayReferralOrderNo,
userPhone: userMobile,
remark: remarkText,
images: images
)
}
}
extension BoundReferralOrder {
func toOrderSourceSelection(fallbackSalerName: String = "") -> OrderSourceSelection? {
let referralNo = orderNo.trimmingCharacters(in: .whitespacesAndNewlines)
guard !referralNo.isEmpty else { return nil }
let personName = salerName.isEmpty
? (fallbackSalerName.isEmpty ? "" : fallbackSalerName)
: salerName
let remarkText: String
if !remark.isEmpty {
remarkText = remark
} else if !displayText.isEmpty {
remarkText = displayText
} else {
remarkText = "带客单号 \(referralNo)"
}
return OrderSourceSelection(
person: OrderSourcePerson(
key: saleUserId > 0 ? "\(saleUserId)" : "",
name: personName,
phone: salerPhone
),
lead: OrderSourceLead(
key: referralNo,
userPhone: userMobile,
remark: remarkText,
images: []
)
)
}
}
extension OrderEntity {
func toBoundOrderSourceSelection() -> OrderSourceSelection? {
if let referralOrder,
let selection = referralOrder.toOrderSourceSelection(fallbackSalerName: referralSalerName) {
return selection
}
guard !referralOrderNo.isEmpty else { return nil }
return OrderSourceSelection(
person: OrderSourcePerson(
key: "",
name: referralSalerName.isEmpty ? "" : referralSalerName,
phone: ""
),
lead: OrderSourceLead(
key: referralOrderNo,
userPhone: phone,
remark: "带客单号 \(referralOrderNo)",
images: []
)
)
}
}

View File

@ -2,7 +2,7 @@
## 模块职责
Orders 模块负责登录后的订单 Tab。当前已覆盖订单管理、核销订单和押金订单相关入口提供列表展示、筛选、分页、刷新、订单详情、扫码核销、手动核销、押金核销、押金退款、普通订单退款、历史拍摄、任务上传尾片入口闭环。
Orders 模块负责登录后的订单 Tab。当前已覆盖订单管理、核销订单和押金订单相关入口提供列表展示、筛选、分页、刷新、订单详情、扫码核销、手动核销、押金核销、押金退款、普通订单退款、历史拍摄、任务上传尾片入口,以及 orderType 11/14 的订单来源(带客单绑定)闭环。
当前仍不迁移独立视频播放器增强、尾片审核进度、上传后自动关联订单尾片等后端未体现的能力;这些能力后续按明确接口再补。
@ -24,7 +24,7 @@ Orders 模块负责登录后的订单 Tab。当前已覆盖订单管理、核销
## 数据流程
订单页面从 `AccountContext.currentScenic` 读取当前景区 ID`PermissionContext.currentRole` 读取角色 ID。景区管理员角色 `roleId == 53` 使用景区管理员订单接口,其他角色使用摄影师订单接口。
订单页面从 `AccountContext.currentScenic` 读取当前景区 ID`PermissionContext.currentAppRole` 读取当前角色。景区管理员角色`AppRoleCode.scenicAdmin`使用景区管理员订单接口,其他角色使用摄影师订单接口。
订单管理支持状态筛选、手机号搜索、开始/结束日期筛选。筛选变更后从第一页重新加载,滚动到列表底部时加载下一页。
@ -42,6 +42,8 @@ Orders 模块负责登录后的订单 Tab。当前已覆盖订单管理、核销
视频预告和尾片上传按旧工程现状统一进入 `OrderTrailerView`,该页只做订单尾片流程引导,并打开已迁移的 `AlbumTrailerEntryView` 完成相册预览上传,不新增独立订单尾片接口。
订单来源(带客单绑定)仅对 `orderType == 11`(摄影师跟拍)或 `14`(线下扫码)展示,景区管理员只读不可操作。订单卡片嵌入 `OrderSourcePicker` Sheet选择合作获客员 → 加载其可绑定带客单 → 确认绑定。全页流程使用 `ReferralOrderSelectView`。绑定接口为 `POST .../sale-user/bind-referral-order`;普通订单退款成功后若存在 `referralOrder.id`,异步调用 `POST .../sale-user/referral-orders/{id}/status`status=3。获客员与带客单数据由 `CooperationOrderAPI` 提供。
## 路由边界
首页 `photographer_orders``/scenic-order-manage` 会进入订单管理,`verification_order` 会进入核销订单,`deposit_order_detail``deposit_order``deposit_order_shooting_info` 会进入押金订单页。订单详情、核销订单详情、押金详情、押金拍摄信息、历史拍摄、任务上传和订单尾片已接入 `AppRoute.orders` 真实路由push 后继续隐藏 TabBar。

View File

@ -31,6 +31,16 @@ final class OrdersViewModel: ObservableObject {
@Published private(set) var writeOffPage = 1
@Published private(set) var writeOffHasMore = false
@Published var orderSourcePeople: [OrderSourcePerson] = []
@Published var orderSourceLeads: [String: [OrderSourceLead]] = [:]
@Published var orderSourceSelections: [String: OrderSourceSelection] = [:]
@Published var loadingOrderSourceLeadsFor: String?
private var loadingOrderSourcePeople = false
static let orderTypePhotographerFollow = 11
static let orderTypePhotographerFollowOfflineScan = 14
///
var normalizedPhone: String? {
let text = searchPhone.trimmingCharacters(in: .whitespacesAndNewlines)
@ -38,17 +48,17 @@ final class OrdersViewModel: ObservableObject {
}
///
func reload(api: OrderServing, scenicId: Int?, storeId: Int? = nil, roleId: Int?, showLoading: Bool = true) async throws {
func reload(api: OrderServing, scenicId: Int?, storeId: Int? = nil, appRole: AppRoleCode?, showLoading: Bool = true) async throws {
switch selectedEntry {
case .storeOrders:
try await reloadStoreOrders(api: api, scenicId: scenicId, roleId: roleId, showLoading: showLoading)
try await reloadStoreOrders(api: api, scenicId: scenicId, appRole: appRole, 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 {
func reloadStoreOrders(api: OrderServing, scenicId: Int?, appRole: AppRoleCode?, showLoading: Bool = true) async throws {
guard let scenicId else {
storeOrders = []
storeTotal = 0
@ -69,16 +79,17 @@ final class OrdersViewModel: ObservableObject {
startTime: startTimeQuery,
endTime: endTimeQuery,
isRefined: refinedQuery,
isScenicAdmin: roleId == 53
isScenicAdmin: appRole?.isScenicAdmin == true
)
storeOrders = result.list
storeTotal = result.total
storePage = 1
storeHasMore = storeOrders.count < result.total && !result.list.isEmpty
syncOrderSourceSelections(from: result.list)
}
///
func loadMoreStoreOrders(api: OrderServing, scenicId: Int?, roleId: Int?) async throws {
func loadMoreStoreOrders(api: OrderServing, scenicId: Int?, appRole: AppRoleCode?) async throws {
guard !loadingMore, storeHasMore, let scenicId else { return }
loadingMore = true
defer { loadingMore = false }
@ -93,7 +104,7 @@ final class OrdersViewModel: ObservableObject {
startTime: startTimeQuery,
endTime: endTimeQuery,
isRefined: refinedQuery,
isScenicAdmin: roleId == 53
isScenicAdmin: appRole?.isScenicAdmin == true
)
storeOrders.append(contentsOf: result.list)
storeTotal = result.total
@ -154,6 +165,81 @@ final class OrdersViewModel: ObservableObject {
writeOffHasMore = writeOffOrders.count < refreshed.total && !refreshed.list.isEmpty
}
///
func shouldShowOrderSource(order: OrderEntity, appRole: AppRoleCode?) -> Bool {
guard !Self.isScenicAdminReadOnly(appRole: appRole) else { return false }
return order.orderType == Self.orderTypePhotographerFollow
|| order.orderType == Self.orderTypePhotographerFollowOfflineScan
}
///
static func isScenicAdminReadOnly(appRole: AppRoleCode?) -> Bool {
appRole?.isScenicAdmin == true
}
///
func getOrderSourceSelection(for order: OrderEntity) -> OrderSourceSelection? {
orderSourceSelections[order.orderNumber] ?? order.toBoundOrderSourceSelection()
}
///
func syncOrderSourceSelections(from orders: [OrderEntity]) {
var updated = orderSourceSelections
for order in orders {
if let selection = order.toBoundOrderSourceSelection() {
updated[order.orderNumber] = selection
}
}
orderSourceSelections = updated
}
/// Sheet
func prepareOrderSourceSheet(api: CooperationOrderServing, storeId: Int?) async {
orderSourceLeads = [:]
await loadOrderSourcePeople(api: api, storeId: storeId)
}
///
func loadOrderSourcePeople(api: CooperationOrderServing, storeId: Int?) async {
if loadingOrderSourcePeople { return }
loadingOrderSourcePeople = true
defer { loadingOrderSourcePeople = false }
do {
let storeParam = storeId.flatMap { $0 > 0 ? $0 : nil }
let data = try await api.cooperativeSalers(storeId: storeParam, page: 1, pageSize: 50)
orderSourcePeople = data.list.map { $0.toOrderSourcePerson() }
} catch {
orderSourcePeople = []
}
}
///
func loadOrderSourceLeads(api: CooperationOrderServing, person: OrderSourcePerson) async {
if loadingOrderSourceLeadsFor == person.key { return }
loadingOrderSourceLeadsFor = person.key
defer { loadingOrderSourceLeadsFor = nil }
guard let saleUserId = Int(person.key) else { return }
do {
let data = try await api.saleUserReferralOrders(saleUserId: saleUserId, page: 1, pageSize: 50)
orderSourceLeads[person.key] = data.list.map { $0.toOrderSourceLead() }
} catch {
orderSourceLeads[person.key] = []
}
}
///
func bindOrderSource(
api: CooperationOrderServing,
orderNumber: String,
selection: OrderSourceSelection
) async throws {
try await api.saleUserBindReferralOrder(
orderNumber: orderNumber,
referralOrderNo: selection.lead.key
)
orderSourceSelections[orderNumber] = selection
}
///
func matchedWriteOffOrder(for rawCode: String) -> (orderNumber: String, matched: WriteOffOrderItem?)? {
guard let orderNumber = OrderNumberParser.parse(rawCode) else {

View File

@ -11,6 +11,7 @@ import SwiftUI
struct StoreOrderDetailView: View {
@EnvironmentObject private var accountContext: AccountContext
@Environment(\.ordersAPI) private var ordersAPI
@Environment(\.cooperationOrderAPI) private var cooperationOrderAPI
@EnvironmentObject private var router: RouterPath
@EnvironmentObject private var toastCenter: ToastCenter
@Environment(\.globalLoading) private var globalLoading
@ -190,6 +191,11 @@ struct StoreOrderDetailView: View {
private func submitRefund() async {
let success = await refundViewModel.submit(api: ordersAPI, item: item)
guard success else { return }
if let referralOrderId = item.referralOrder?.id, referralOrderId > 0 {
Task {
try? await cooperationOrderAPI.saleUserUpdateReferralOrderStatus(id: referralOrderId, status: 3)
}
}
showRefundSheet = false
toastCenter.show("退款申请已提交")
await viewModel.load(api: ordersAPI, fallbackStoreId: accountContext.currentStore?.id, forceReload: true)

View File

@ -0,0 +1,365 @@
import SwiftUI
import Kingfisher
enum OrderSourceColors {
static let primary = Color(hex: 0x0073FF)
static let text333 = Color(hex: 0x333333)
static let text563 = Color(hex: 0x4B5563)
static let text666 = Color(hex: 0x666666)
static let text999 = Color(hex: 0x999999)
static let border = Color(hex: 0xE6EAF0)
static let divider = Color(hex: 0xE9EDF3)
static let pageBg = Color(hex: 0xF5F7FA)
static let primarySoft = Color(hex: 0xEFF6FF)
static let imageEmptyBg = Color(hex: 0xF3F5F8)
static let entryBg = Color(hex: 0xF9FAFC)
}
struct AcquirerCallButton: View {
let phone: String
let onCall: (String) -> Void
var body: some View {
Button {
onCall(phone)
} label: {
Image(systemName: "phone.fill")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(OrderSourceColors.primary)
.frame(width: 32, height: 32)
.background(OrderSourceColors.primarySoft, in: Circle())
}
.buttonStyle(.plain)
.accessibilityLabel("拨打\(phone)")
}
}
struct SourcePersonRow: View {
let person: OrderSourcePerson
let selected: Bool
let onCall: (String) -> Void
let onTap: () -> Void
var body: some View {
HStack(alignment: .center, spacing: 8) {
VStack(alignment: .leading, spacing: 0) {
Text(person.name)
.font(.system(size: 16, weight: .medium))
.foregroundStyle(OrderSourceColors.text333)
if !person.phone.isEmpty {
Text(person.phone)
.font(.system(size: 14))
.foregroundStyle(OrderSourceColors.text666)
.padding(.top, 6)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
if !person.phone.isEmpty {
AcquirerCallButton(phone: person.phone, onCall: onCall)
}
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(OrderSourceColors.text666)
.frame(width: 20, height: 20)
}
.padding(.horizontal, 14)
.padding(.vertical, 14)
.background(
selected ? OrderSourceColors.primary.opacity(0.05) : Color.white,
in: RoundedRectangle(cornerRadius: 8)
)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(selected ? OrderSourceColors.primary : OrderSourceColors.border, lineWidth: 1)
)
.contentShape(RoundedRectangle(cornerRadius: 8))
.onTapGesture(perform: onTap)
}
}
struct SourceLeadContent: View {
let lead: OrderSourceLead
var compact = false
var enablePhoneCall = true
let onCall: (String) -> Void
let onImageTap: ([String], Int) -> Void
var body: some View {
VStack(alignment: .leading, spacing: compact ? 6 : 10) {
if !lead.userPhone.isEmpty {
HStack(alignment: .center, spacing: 0) {
Text("客户手机号")
.font(.system(size: 12))
.foregroundStyle(OrderSourceColors.text666)
Text(lead.userPhone)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(enablePhoneCall ? OrderSourceColors.primary : OrderSourceColors.text333)
.padding(.leading, 10)
.onTapGesture {
if enablePhoneCall {
onCall(lead.userPhone)
}
}
}
}
if !lead.remark.isEmpty {
Text(lead.remark)
.font(.system(size: compact ? 12 : 13))
.foregroundStyle(OrderSourceColors.text333)
.lineSpacing(compact ? 4 : 6)
.lineLimit(compact ? 2 : 4)
}
if lead.images.isEmpty {
if !compact {
Text("暂无图片")
.font(.system(size: 12))
.foregroundStyle(OrderSourceColors.text666)
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.background(OrderSourceColors.imageEmptyBg, in: RoundedRectangle(cornerRadius: 6))
}
} else {
HStack(spacing: 8) {
ForEach(Array(lead.images.prefix(3).enumerated()), id: \.offset) { index, imageUrl in
leadThumbnail(imageUrl: imageUrl, index: index)
}
ForEach(0..<max(0, 3 - min(lead.images.count, 3)), id: \.self) { _ in
Color.clear
.aspectRatio(1, contentMode: .fit)
.frame(maxWidth: .infinity)
}
}
}
}
}
private func leadThumbnail(imageUrl: String, index: Int) -> some View {
Group {
if let url = URL(string: imageUrl), !imageUrl.isEmpty {
KFImage(url)
.resizable()
.scaledToFill()
} else {
OrderSourceColors.imageEmptyBg
}
}
.frame(maxWidth: .infinity)
.aspectRatio(1, contentMode: .fit)
.clipShape(RoundedRectangle(cornerRadius: 6))
.contentShape(Rectangle())
.onTapGesture {
onImageTap(lead.images, index)
}
}
}
struct SourceLeadCard: View {
let lead: OrderSourceLead
let selected: Bool
let onCall: (String) -> Void
let onImageTap: ([String], Int) -> Void
let onSelect: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .center) {
Text("带客单")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(OrderSourceColors.primary)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(OrderSourceColors.primary.opacity(0.08), in: RoundedRectangle(cornerRadius: 4))
Spacer()
if selected {
Image(systemName: "checkmark")
.font(.system(size: 14, weight: .bold))
.foregroundStyle(OrderSourceColors.primary)
.frame(width: 20, height: 20)
}
}
SourceLeadContent(
lead: lead,
compact: false,
enablePhoneCall: true,
onCall: onCall,
onImageTap: onImageTap
)
Text(selected ? "当前已选择" : "选择此带客单")
.font(.system(size: 14))
.foregroundStyle(selected ? OrderSourceColors.primary : .white)
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(
selected ? OrderSourceColors.primary.opacity(0.1) : OrderSourceColors.primary,
in: RoundedRectangle(cornerRadius: 6)
)
.contentShape(Rectangle())
.onTapGesture(perform: onSelect)
}
.padding(12)
.background(
selected ? OrderSourceColors.primary.opacity(0.04) : Color.white,
in: RoundedRectangle(cornerRadius: 8)
)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(selected ? OrderSourceColors.primary : OrderSourceColors.border, lineWidth: 1)
)
.contentShape(Rectangle())
.onTapGesture(perform: onSelect)
}
}
struct AcquirerSelectCard: View {
let name: String
let phone: String
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
VStack(alignment: .leading, spacing: 0) {
Text(name)
.font(.system(size: 16, weight: .bold))
.foregroundStyle(OrderSourceColors.text333)
if !phone.isEmpty {
Text(phone)
.font(.system(size: 13))
.foregroundStyle(OrderSourceColors.text666)
.padding(.top, 6)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(14)
.background(.white, in: RoundedRectangle(cornerRadius: 10))
}
.buttonStyle(.plain)
}
}
struct ReferralOrderSelectCard: View {
let order: ReferralOrder
let selected: Bool
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
VStack(alignment: .leading, spacing: 8) {
Text("带客单号 \(order.displayReferralOrderNo)")
.font(.system(size: 15, weight: .bold))
.foregroundStyle(OrderSourceColors.text333)
if !order.statusLabel.isEmpty {
Text(order.statusLabel)
.font(.system(size: 13))
.foregroundStyle(OrderSourceColors.text666)
}
Text("用户 \(order.userMobile.isEmpty ? "" : order.userMobile)")
.font(.system(size: 12))
.foregroundStyle(OrderSourceColors.text999)
if !order.remark.isEmpty {
Text(order.remark)
.font(.system(size: 13))
.foregroundStyle(OrderSourceColors.text666)
}
if !order.createdAt.isEmpty {
Text(order.createdAt)
.font(.system(size: 11))
.foregroundStyle(OrderSourceColors.text999)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(14)
.background(.white, in: RoundedRectangle(cornerRadius: 10))
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(selected ? OrderSourceColors.primary : Color.clear, lineWidth: 1.5)
)
}
.buttonStyle(.plain)
}
}
struct SimpleImagePreviewContent: View {
let imageUrls: [String]
@Binding var currentIndex: Int
var body: some View {
TabView(selection: $currentIndex) {
ForEach(Array(imageUrls.enumerated()), id: \.offset) { index, urlString in
Group {
if let url = URL(string: urlString), !urlString.isEmpty {
KFImage(url)
.resizable()
.scaledToFit()
} else {
Text("无法加载图片")
.foregroundStyle(OrderSourceColors.text666)
}
}
.tag(index)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.black)
}
}
.tabViewStyle(.page(indexDisplayMode: imageUrls.count > 1 ? .automatic : .never))
.background(Color.black.ignoresSafeArea())
}
}
struct SimpleImagePreviewSheet: View {
let imageUrls: [String]
let startIndex: Int
let onDismiss: () -> Void
@State private var currentIndex: Int
init(imageUrls: [String], startIndex: Int, onDismiss: @escaping () -> Void) {
self.imageUrls = imageUrls
self.startIndex = startIndex
self.onDismiss = onDismiss
_currentIndex = State(initialValue: startIndex)
}
var body: some View {
NavigationStack {
SimpleImagePreviewContent(imageUrls: imageUrls, currentIndex: $currentIndex)
.navigationTitle("图片预览")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("关闭", action: onDismiss)
}
}
}
}
}
struct SimpleImagePreviewScreen: View {
let imageUrls: [String]
let startIndex: Int
@State private var currentIndex: Int
init(imageUrls: [String], startIndex: Int) {
self.imageUrls = imageUrls
self.startIndex = startIndex
_currentIndex = State(initialValue: startIndex)
}
var body: some View {
SimpleImagePreviewContent(imageUrls: imageUrls, currentIndex: $currentIndex)
.navigationTitle("图片预览")
.navigationBarTitleDisplayMode(.inline)
}
}

View File

@ -0,0 +1,227 @@
import SwiftUI
struct OrderSourcePicker: View {
let selection: OrderSourceSelection?
let people: [OrderSourcePerson]
let leadsForPerson: (OrderSourcePerson) -> [OrderSourceLead]
let leadsLoading: Bool
let onSheetOpen: () -> Void
let onPersonClick: (OrderSourcePerson) -> Void
let onSelectionChange: (OrderSourceSelection) -> Void
let callPhone: (String) -> Void
@State private var sheetVisible = false
@State private var navigationPath = NavigationPath()
@State private var previewImages: [String] = []
@State private var previewIndex: Int?
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .center) {
VStack(alignment: .leading, spacing: 4) {
Text("订单来源")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(OrderSourceColors.text563)
if let selection {
HStack(spacing: 10) {
Text(selection.person.name)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(OrderSourceColors.text333)
if !selection.person.phone.isEmpty {
Text(selection.person.phone)
.font(.system(size: 13))
.foregroundStyle(OrderSourceColors.text666)
}
}
} else {
Text("选择获客员及其创建的带客单")
.font(.system(size: 13))
.foregroundStyle(OrderSourceColors.text666)
}
}
Spacer(minLength: 0)
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(OrderSourceColors.primary)
.frame(width: 20, height: 20)
}
if let selection {
Divider().overlay(OrderSourceColors.divider)
SourceLeadContent(
lead: selection.lead,
compact: true,
enablePhoneCall: false,
onCall: callPhone,
onImageTap: { images, index in
previewImages = images
previewIndex = index
}
)
}
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(OrderSourceColors.entryBg, in: RoundedRectangle(cornerRadius: 8))
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(OrderSourceColors.border, lineWidth: 1)
)
.contentShape(RoundedRectangle(cornerRadius: 8))
.onTapGesture {
openSheet()
}
.sheet(isPresented: $sheetVisible) {
sheetContainer
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
}
.sheet(item: previewBinding) { item in
SimpleImagePreviewSheet(
imageUrls: item.urls,
startIndex: item.index,
onDismiss: { previewIndex = nil }
)
}
}
private func openSheet() {
onSheetOpen()
navigationPath = NavigationPath()
sheetVisible = true
}
private var previewBinding: Binding<ImagePreviewItem?> {
Binding(
get: {
guard let previewIndex else { return nil }
return ImagePreviewItem(urls: previewImages, index: previewIndex)
},
set: { newValue in
if newValue == nil { previewIndex = nil }
}
)
}
private var sheetContainer: some View {
NavigationStack(path: $navigationPath) {
personSheetContent
.navigationDestination(for: OrderSourcePerson.self) { person in
leadSheetContent(person: person)
}
.navigationDestination(for: OrderSourceImagePreviewRoute.self) { route in
SimpleImagePreviewScreen(
imageUrls: route.urls,
startIndex: route.startIndex
)
}
}
.background(Color.white)
}
private var personSheetContent: some View {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
Text("选择人员后,再选择该人员创建的带客单")
.font(.system(size: 13))
.foregroundStyle(OrderSourceColors.text666)
.padding(.bottom, 14)
if people.isEmpty {
Text("暂无合作获客员")
.font(.system(size: 14))
.foregroundStyle(OrderSourceColors.text666)
.frame(maxWidth: .infinity)
.padding(.vertical, 32)
} else {
LazyVStack(spacing: 10) {
ForEach(people) { person in
SourcePersonRow(
person: person,
selected: selection?.person.key == person.key,
onCall: callPhone,
onTap: {
onPersonClick(person)
navigationPath.append(person)
}
)
}
}
}
}
.padding(.horizontal, 16)
.padding(.top, 8)
.padding(.bottom, 24)
}
.navigationTitle("选择获客员")
.navigationBarTitleDisplayMode(.inline)
}
private func leadSheetContent(person: OrderSourcePerson) -> some View {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
if !person.phone.isEmpty {
HStack(spacing: 6) {
Text(person.phone)
.font(.system(size: 13))
.foregroundStyle(OrderSourceColors.primary)
.onTapGesture { callPhone(person.phone) }
AcquirerCallButton(phone: person.phone, onCall: callPhone)
}
.padding(.bottom, 14)
}
if leadsLoading {
ProgressView()
.tint(OrderSourceColors.primary)
.frame(maxWidth: .infinity)
.padding(.top, 32)
} else {
let leads = leadsForPerson(person)
if leads.isEmpty {
Text("暂无可绑定带客单")
.font(.system(size: 14))
.foregroundStyle(OrderSourceColors.text666)
.frame(maxWidth: .infinity)
.padding(.vertical, 24)
} else {
LazyVStack(spacing: 12) {
ForEach(leads) { lead in
SourceLeadCard(
lead: lead,
selected: selection?.person.key == person.key && selection?.lead.key == lead.key,
onCall: callPhone,
onImageTap: { images, index in
navigationPath.append(
OrderSourceImagePreviewRoute(urls: images, startIndex: index)
)
},
onSelect: {
onSelectionChange(OrderSourceSelection(person: person, lead: lead))
sheetVisible = false
}
)
}
}
}
}
}
.padding(.horizontal, 16)
.padding(.top, 8)
.padding(.bottom, 24)
}
.navigationTitle("\(person.name)创建的带客单")
.navigationBarTitleDisplayMode(.inline)
}
}
private struct ImagePreviewItem: Identifiable {
let urls: [String]
let index: Int
var id: String { "\(index)-\(urls.joined())" }
}
private struct OrderSourceImagePreviewRoute: Hashable {
let urls: [String]
let startIndex: Int
}

View File

@ -14,9 +14,11 @@ struct OrdersView: View {
@EnvironmentObject private var appRouter: AppRouter
@EnvironmentObject private var router: RouterPath
@Environment(\.ordersAPI) private var ordersAPI
@Environment(\.cooperationOrderAPI) private var cooperationOrderAPI
@EnvironmentObject private var toastCenter: ToastCenter
@Environment(\.globalLoading) private var globalLoading
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@Environment(\.openURL) private var openURL
@StateObject private var viewModel = OrdersViewModel()
@State private var manualOrderNumber = ""
@ -27,6 +29,8 @@ struct OrdersView: View {
@State private var pendingVerifyOrderNumber: String?
@State private var matchedScannedOrderNumber: String?
@State private var scanHintMessage: String?
@State private var bindAcquirerSaleUserId: Int?
@State private var referralBindOrderNumber: String?
private var currentScenicId: Int? {
accountContext.currentScenic?.id
@ -36,8 +40,8 @@ struct OrdersView: View {
accountContext.currentStore?.id
}
private var currentRoleId: Int? {
permissionContext.currentRole?.id
private var currentAppRole: AppRoleCode? {
permissionContext.currentAppRole
}
private var contentMaxWidth: CGFloat {
@ -139,6 +143,32 @@ struct OrdersView: View {
onApply: { Task { await reload() } }
)
}
.fullScreenCover(item: bindAcquirerBinding) { item in
NavigationStack {
BindAcquirerView(saleUserId: item.saleUserId, onSuccess: {
bindAcquirerSaleUserId = nil
})
}
}
.appNavigationDestination(item: referralBindBinding) { item in
ReferralOrderSelectView(orderNumber: item.orderNumber) {
Task { await reload(showLoading: false) }
}
}
}
private var bindAcquirerBinding: Binding<BindAcquirerSheetItem?> {
Binding(
get: { bindAcquirerSaleUserId.map(BindAcquirerSheetItem.init(saleUserId:)) },
set: { bindAcquirerSaleUserId = $0?.saleUserId }
)
}
private var referralBindBinding: Binding<OrderNumberSheetItem?> {
Binding(
get: { referralBindOrderNumber.map(OrderNumberSheetItem.init(orderNumber:)) },
set: { referralBindOrderNumber = $0?.orderNumber }
)
}
private var entrySection: some View {
@ -413,6 +443,48 @@ struct OrdersView: View {
orderInfoRow("付款方式", item.payTypeName.isEmpty ? "--" : item.payTypeName)
orderInfoRow("手机号", maskPhoneNumber(item.phone))
orderInfoRow("关联项目", item.projectName.isEmpty ? "--" : item.projectName)
if viewModel.shouldShowOrderSource(order: item, appRole: currentAppRole) {
OrderSourcePicker(
selection: viewModel.getOrderSourceSelection(for: item),
people: viewModel.orderSourcePeople,
leadsForPerson: { person in
viewModel.orderSourceLeads[person.key] ?? []
},
leadsLoading: viewModel.loadingOrderSourceLeadsFor != nil,
onSheetOpen: {
Task {
await viewModel.prepareOrderSourceSheet(
api: cooperationOrderAPI,
storeId: currentStoreId
)
}
},
onPersonClick: { person in
Task {
await viewModel.loadOrderSourceLeads(api: cooperationOrderAPI, person: person)
}
},
onSelectionChange: { selection in
Task {
do {
try await viewModel.bindOrderSource(
api: cooperationOrderAPI,
orderNumber: item.orderNumber,
selection: selection
)
toastCenter.show("绑定成功")
await reload(showLoading: false)
} catch {
toastCenter.show(error.localizedDescription)
}
}
},
callPhone: { phone in
dialPhone(phone)
}
)
}
}
.padding(AppMetrics.Spacing.medium)
.frame(maxWidth: .infinity, alignment: .leading)
@ -522,7 +594,7 @@ struct OrdersView: View {
api: ordersAPI,
scenicId: currentScenicId,
storeId: currentStoreId,
roleId: currentRoleId,
appRole: currentAppRole,
showLoading: showLoading
)
}
@ -544,7 +616,7 @@ struct OrdersView: View {
private func loadMoreStoreOrders() async {
do {
try await viewModel.loadMoreStoreOrders(api: ordersAPI, scenicId: currentScenicId, roleId: currentRoleId)
try await viewModel.loadMoreStoreOrders(api: ordersAPI, scenicId: currentScenicId, appRole: currentAppRole)
} catch {
toastCenter.show(error.localizedDescription)
}
@ -595,15 +667,30 @@ struct OrdersView: View {
}
}
/// TabBar
/// TabBar
private func consumePendingScanCodeIfNeeded() async {
guard let rawCode = appRouter.consumePendingOrderScanCode() else { return }
if SaleUserQrParser.isSaleUserBindQR(rawCode) {
guard let saleUserId = SaleUserQrParser.parseSaleUserId(rawCode), saleUserId > 0 else {
toastCenter.show("请扫描正确的获客员二维码")
return
}
bindAcquirerSaleUserId = saleUserId
return
}
viewModel.selectedEntry = .verificationOrders
appRouter.selectOrders(entry: .verificationOrders)
await reload(showLoading: false)
handleScanResult(rawCode)
}
///
private func dialPhone(_ phone: String) {
let trimmed = phone.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, let url = URL(string: "tel:\(trimmed)") else { return }
openURL(url)
}
///
private func handleScanResult(_ rawCode: String) {
guard let result = viewModel.matchedWriteOffOrder(for: rawCode) else {

View File

@ -0,0 +1,312 @@
//
// ReferralOrderSelectView.swift
// suixinkan
//
// Created by Codex on 2026/6/29.
//
import SwiftUI
import Combine
/// ViewModel
@MainActor
final class ReferralOrderSelectViewModel: ObservableObject {
enum Step {
case selectAcquirer
case selectReferralOrder
}
@Published var step: Step = .selectAcquirer
@Published var acquirers: [CooperativeSaler] = []
@Published var selectedAcquirer: CooperativeSaler?
@Published var referralOrders: [ReferralOrder] = []
@Published var selectedReferralOrder: ReferralOrder?
@Published var isLoadingMore = false
@Published var canLoadMore = false
@Published private(set) var hasLoadedAcquirersOnce = false
@Published private(set) var hasLoadedReferralOrdersOnce = false
private var orderNumber = ""
private var currentPage = 1
private var totalCount = 0
private var isReloadingReferralOrders = false
private let pageSize = 10
///
func initOrder(orderNumber: String, api: CooperationOrderServing, storeId: Int?) async {
if self.orderNumber == orderNumber && !acquirers.isEmpty { return }
self.orderNumber = orderNumber
await loadAcquirers(api: api, storeId: storeId)
}
private func loadAcquirers(api: CooperationOrderServing, storeId: Int?) async {
do {
let storeParam = storeId.flatMap { $0 > 0 ? $0 : nil }
let data = try await api.cooperativeSalers(storeId: storeParam, page: 1, pageSize: 50)
acquirers = data.list
if data.list.count == 1 {
await selectAcquirer(data.list[0], api: api)
} else {
step = .selectAcquirer
}
} catch {
if !hasLoadedAcquirersOnce {
acquirers = []
}
}
hasLoadedAcquirersOnce = true
}
///
func selectAcquirer(_ acquirer: CooperativeSaler, api: CooperationOrderServing) async {
selectedAcquirer = acquirer
selectedReferralOrder = nil
hasLoadedReferralOrdersOnce = false
step = .selectReferralOrder
await refreshReferralOrders(api: api)
}
///
func backToAcquirerStep() {
if acquirers.count <= 1 { return }
step = .selectAcquirer
selectedReferralOrder = nil
referralOrders = []
}
///
func selectReferralOrder(_ order: ReferralOrder) {
selectedReferralOrder = order
}
///
func refreshReferralOrders(api: CooperationOrderServing) async {
guard let acquirer = selectedAcquirer else { return }
guard !isReloadingReferralOrders else { return }
isReloadingReferralOrders = true
defer { isReloadingReferralOrders = false }
currentPage = 1
isLoadingMore = false
await loadReferralOrders(api: api, saleUserId: acquirer.displayId)
}
///
func loadMoreReferralOrders(api: CooperationOrderServing) async {
guard !isReloadingReferralOrders, !isLoadingMore, canLoadMore,
let acquirer = selectedAcquirer else { return }
isLoadingMore = true
currentPage += 1
await loadReferralOrders(api: api, saleUserId: acquirer.displayId)
}
private func loadReferralOrders(api: CooperationOrderServing, saleUserId: Int) async {
do {
let data = try await api.saleUserReferralOrders(
saleUserId: saleUserId,
page: currentPage,
pageSize: pageSize
)
totalCount = data.total
if currentPage == 1 {
referralOrders = data.list
} else {
referralOrders.append(contentsOf: data.list)
}
canLoadMore = referralOrders.count < totalCount
} catch {
if currentPage == 1, !hasLoadedReferralOrdersOnce {
referralOrders = []
}
if currentPage > 1 {
currentPage = max(currentPage - 1, 1)
}
}
if currentPage == 1 {
hasLoadedReferralOrdersOnce = true
}
isLoadingMore = false
}
///
func confirmBind(api: CooperationOrderServing) async throws {
guard let referralOrder = selectedReferralOrder else {
throw NSError(domain: "ReferralOrder", code: 1, userInfo: [NSLocalizedDescriptionKey: "请选择带客单"])
}
guard !orderNumber.isEmpty else {
throw NSError(domain: "ReferralOrder", code: 2, userInfo: [NSLocalizedDescriptionKey: "订单号无效"])
}
try await api.saleUserBindReferralOrder(
orderNumber: orderNumber,
referralOrderNo: referralOrder.displayReferralOrderNo
)
}
}
///
struct ReferralOrderSelectView: View {
@EnvironmentObject private var accountContext: AccountContext
@EnvironmentObject private var toastCenter: ToastCenter
@Environment(\.cooperationOrderAPI) private var cooperationOrderAPI
@Environment(\.globalLoading) private var globalLoading
@Environment(\.dismiss) private var dismiss
@StateObject private var viewModel = ReferralOrderSelectViewModel()
let orderNumber: String
let onBound: () -> Void
@State private var binding = false
var body: some View {
Group {
if viewModel.step == .selectAcquirer {
acquirerList
} else {
referralOrderList
}
}
.navigationTitle(viewModel.step == .selectAcquirer ? "选择获客员" : "选择带客单")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button {
if viewModel.step == .selectReferralOrder && viewModel.acquirers.count > 1 {
viewModel.backToAcquirerStep()
} else {
dismiss()
}
} label: {
Image(systemName: "chevron.backward")
}
}
}
.safeAreaInset(edge: .bottom) {
if viewModel.step == .selectReferralOrder {
Button {
Task { await confirmBind() }
} label: {
Text(binding ? "提交中..." : "确认绑定")
.font(.system(size: 16))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.frame(height: 48)
.background(
viewModel.selectedReferralOrder != nil
? OrderSourceColors.primary
: OrderSourceColors.primary.opacity(0.4),
in: RoundedRectangle(cornerRadius: 8)
)
}
.buttonStyle(.plain)
.disabled(viewModel.selectedReferralOrder == nil || binding)
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(.white)
}
}
.background(OrderSourceColors.pageBg.ignoresSafeArea())
.task {
await globalLoading.withLoading {
await viewModel.initOrder(
orderNumber: orderNumber,
api: cooperationOrderAPI,
storeId: accountContext.currentStore?.id
)
}
if viewModel.acquirers.isEmpty {
toastCenter.show("暂无合作获客员,请先绑定获客员")
dismiss()
}
}
}
private func selectAcquirer(_ acquirer: CooperativeSaler) async {
await globalLoading.withLoading {
await viewModel.selectAcquirer(acquirer, api: cooperationOrderAPI)
}
}
private var acquirerList: some View {
ScrollView {
LazyVStack(spacing: 12) {
ForEach(viewModel.acquirers, id: \.displayId) { acquirer in
AcquirerSelectCard(
name: acquirer.displayName,
phone: acquirer.displayPhone
) {
Task { await selectAcquirer(acquirer) }
}
}
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
}
}
private var referralOrderList: some View {
ScrollView {
LazyVStack(spacing: 12) {
if let acquirer = viewModel.selectedAcquirer, !acquirer.displayName.isEmpty {
Text("获客员:\(acquirer.displayName)")
.font(.system(size: 13))
.foregroundStyle(OrderSourceColors.text666)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.bottom, 4)
}
if viewModel.hasLoadedReferralOrdersOnce && viewModel.referralOrders.isEmpty {
Text("暂无可绑定带客单")
.font(.system(size: 14))
.foregroundStyle(OrderSourceColors.text999)
.frame(maxWidth: .infinity)
.padding(.vertical, 32)
} else {
ForEach(Array(viewModel.referralOrders.enumerated()), id: \.element.id) { index, order in
let selected = viewModel.selectedReferralOrder?.id == order.id
ReferralOrderSelectCard(
order: order,
selected: selected
) {
viewModel.selectReferralOrder(order)
}
.onAppear {
guard index >= viewModel.referralOrders.count - 3 else { return }
guard viewModel.canLoadMore, !viewModel.isLoadingMore else { return }
Task { await viewModel.loadMoreReferralOrders(api: cooperationOrderAPI) }
}
}
if viewModel.isLoadingMore {
ProgressView()
.tint(OrderSourceColors.primary)
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
}
}
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
}
.refreshable {
await globalLoading.withOptionalLoading(false) {
await viewModel.refreshReferralOrders(api: cooperationOrderAPI)
}
}
}
private func confirmBind() async {
binding = true
defer { binding = false }
do {
try await viewModel.confirmBind(api: cooperationOrderAPI)
onBound()
dismiss()
} catch {
toastCenter.show(error.localizedDescription)
}
}
}
/// Sheet
struct OrderNumberSheetItem: Identifiable {
let orderNumber: String
var id: String { orderNumber }
}

View File

@ -86,7 +86,7 @@ final class ScenicSelectionViewModel: ObservableObject {
accountContext.selectScenic(id: scenicId)
snapshotStore.saveCurrentSelection(
accountContext: accountContext,
currentRoleId: permissionContext.currentRole?.id
currentRoleCode: permissionContext.currentAppRole?.rawValue
)
}

View File

@ -19,7 +19,7 @@ Statistics 模块负责登录后的数据 Tab展示当前景区下的订单
切换时间段时同时请求汇总接口和第一页日数据接口。汇总接口使用 `range` 参数,日数据接口使用 `start_time``end_time``page``page_size`
`roleId == 53` 时使用景区管理员接口,其他角色使用摄影师接口。日数据响应使用 `DataListPayload` 解码,兼容后端返回 `data``list` 字段。
`currentAppRole.isScenicAdmin == true` 时使用景区管理员接口,其他角色使用摄影师接口。日数据响应使用 `DataListPayload` 解码,兼容后端返回 `data``list` 字段。
## 分页规则

View File

@ -28,13 +28,13 @@ final class StatisticsViewModel: ObservableObject {
}
///
func selectPeriod(_ period: StatisticsPeriod, api: StatisticsServing, scenicId: Int?, roleId: Int?) async throws {
func selectPeriod(_ period: StatisticsPeriod, api: StatisticsServing, scenicId: Int?, appRole: AppRoleCode?) async throws {
selectedPeriod = period
try await reload(api: api, scenicId: scenicId, roleId: roleId)
try await reload(api: api, scenicId: scenicId, appRole: appRole)
}
///
func reload(api: StatisticsServing, scenicId: Int?, roleId: Int?, showLoading: Bool = true) async throws {
func reload(api: StatisticsServing, scenicId: Int?, appRole: AppRoleCode?, showLoading: Bool = true) async throws {
guard let scenicId else {
reset()
return
@ -43,7 +43,7 @@ final class StatisticsViewModel: ObservableObject {
if showLoading { loading = true }
defer { if showLoading { loading = false } }
let isScenicAdmin = roleId == 53
let isScenicAdmin = appRole?.isScenicAdmin == true
async let summaryResult = api.summary(
scenicId: scenicId,
range: selectedPeriod.apiRange,
@ -66,7 +66,7 @@ final class StatisticsViewModel: ObservableObject {
}
///
func loadMore(api: StatisticsServing, scenicId: Int?, roleId: Int?) async throws {
func loadMore(api: StatisticsServing, scenicId: Int?, appRole: AppRoleCode?) async throws {
guard !loadingMore, hasMore, let scenicId else { return }
loadingMore = true
@ -79,7 +79,7 @@ final class StatisticsViewModel: ObservableObject {
endTime: selectedPeriod.apiEndTime,
page: nextPage,
pageSize: pageSize,
isScenicAdmin: roleId == 53
isScenicAdmin: appRole?.isScenicAdmin == true
)
dailyItems.append(contentsOf: result.data)
totalDailyCount = result.total

View File

@ -22,8 +22,8 @@ struct StatisticsView: View {
accountContext.currentScenic?.id
}
private var currentRoleId: Int? {
permissionContext.currentRole?.id
private var currentAppRole: AppRoleCode? {
permissionContext.currentAppRole
}
private var contentMaxWidth: CGFloat {
@ -62,7 +62,7 @@ struct StatisticsView: View {
.onChange(of: accountContext.currentScenic?.id) { _ in
Task { await reload() }
}
.onChange(of: permissionContext.currentRole?.id) { _ in
.onChange(of: permissionContext.currentRole?.roleCode) { _ in
Task { await reload() }
}
}
@ -289,7 +289,7 @@ struct StatisticsView: View {
private func selectPeriod(_ period: StatisticsPeriod) async {
do {
try await globalLoading.withLoading(message: "加载中...") {
try await viewModel.selectPeriod(period, api: statisticsAPI, scenicId: currentScenicId, roleId: currentRoleId)
try await viewModel.selectPeriod(period, api: statisticsAPI, scenicId: currentScenicId, appRole: currentAppRole)
}
} catch {
toastCenter.show(error.localizedDescription)
@ -299,7 +299,7 @@ struct StatisticsView: View {
private func reload(showLoading: Bool = true) async {
do {
try await globalLoading.withOptionalLoading(showLoading && currentScenicId != nil && viewModel.dailyItems.isEmpty, message: "加载中...") {
try await viewModel.reload(api: statisticsAPI, scenicId: currentScenicId, roleId: currentRoleId, showLoading: showLoading)
try await viewModel.reload(api: statisticsAPI, scenicId: currentScenicId, appRole: currentAppRole, showLoading: showLoading)
}
} catch {
toastCenter.show(error.localizedDescription)
@ -308,7 +308,7 @@ struct StatisticsView: View {
private func loadMore() async {
do {
try await viewModel.loadMore(api: statisticsAPI, scenicId: currentScenicId, roleId: currentRoleId)
try await viewModel.loadMore(api: statisticsAPI, scenicId: currentScenicId, appRole: currentAppRole)
} catch {
toastCenter.show(error.localizedDescription)
}

View File

@ -0,0 +1,93 @@
//
// AppRoleCodeTests.swift
// suixinkanTests
//
// Created by Codex on 2026/6/29.
//
import XCTest
@testable import suixinkan
final class AppRoleCodeTests: XCTestCase {
func testFromCodeParsesKnownRoles() {
XCTAssertEqual(AppRoleCode.fromCode("store_admin"), .storeAdmin)
XCTAssertEqual(AppRoleCode.fromCode("scenic_admin"), .scenicAdmin)
XCTAssertEqual(AppRoleCode.fromCode("photographer"), .photographer)
}
func testFromLegacyRoleIdMapsKnownIds() {
XCTAssertEqual(AppRoleCode.fromLegacyRoleId(46), .storeAdmin)
XCTAssertEqual(AppRoleCode.fromLegacyRoleId(53), .scenicAdmin)
XCTAssertEqual(AppRoleCode.fromLegacyRoleId(41), .photographer)
}
func testFromRoleNameAvoidsAssistantMisclassification() {
XCTAssertEqual(AppRoleCode.fromRoleName("摄影师助理"), .photographerAssistant)
XCTAssertEqual(AppRoleCode.fromRoleName("资深摄影师"), .photographer)
}
func testHomeMinimalTopRolesContainsManagementRoles() {
XCTAssertTrue(AppRoleCode.homeMinimalTopRoles.contains(.storeAdmin))
XCTAssertTrue(AppRoleCode.homeMinimalTopRoles.contains(.scenicAdmin))
XCTAssertFalse(AppRoleCode.homeMinimalTopRoles.contains(.photographer))
}
}
final class RolePermissionMatchingTests: XCTestCase {
func testMatchPrefersRoleCodeOverLegacyId() {
let permissions = [
RolePermissionResponse(
role: RoleInfo(id: 10, name: "摄影师", roleCode: "photographer", notes: nil, permission: []),
scenic: []
),
RolePermissionResponse(
role: RoleInfo(id: 53, name: "景区管理员", roleCode: "scenic_admin", notes: nil, permission: []),
scenic: []
)
]
let matched = RolePermissionMatching.match(
in: permissions,
roleCode: "scenic_admin",
roleId: 10,
roleName: "摄影师"
)
XCTAssertEqual(matched?.role.id, 53)
}
func testResolveRoleCodeMigratesLegacySession() {
let resolved = RolePermissionMatching.resolveRoleCode(
roleCode: nil,
roleId: 46,
roleName: nil,
permissions: []
)
XCTAssertEqual(resolved, .storeAdmin)
}
}
final class AccountSnapshotMigrationTests: XCTestCase {
func testMigrateLegacyRoleIdToRoleCode() {
var snapshot = AccountSnapshot(currentRoleId: 53)
snapshot.migrateLegacyRoleIfNeeded()
XCTAssertEqual(snapshot.currentRoleCode, "scenic_admin")
XCTAssertNil(snapshot.currentRoleId)
}
func testLoadMigratesAndPersistsLegacySnapshot() {
let defaults = UserDefaults(suiteName: "AccountSnapshotMigrationTests")!
defaults.removePersistentDomain(forName: "AccountSnapshotMigrationTests")
let store = AccountSnapshotStore(defaults: defaults, key: "test.snapshot")
store.save(AccountSnapshot(currentRoleId: 46))
let loaded = store.load()
XCTAssertEqual(loaded?.currentRoleCode, "store_admin")
XCTAssertNil(loaded?.currentRoleId)
let persisted = store.load()
XCTAssertEqual(persisted?.currentRoleCode, "store_admin")
XCTAssertNil(persisted?.currentRoleId)
}
}

View File

@ -0,0 +1,196 @@
//
// CooperationOrderAPITests.swift
// suixinkanTests
//
// Created by Codex on 2026/6/29.
//
import XCTest
@testable import suixinkan
@MainActor
/// API
final class CooperationOrderAPITests: XCTestCase {
///
func testCooperativeSalersDecodesFixtureAndUsesExpectedQuery() async throws {
let session = CooperationRecordingURLSession(data: try TestFixture.data(named: "cooperative_salers_success"))
let api = CooperationOrderAPI(client: APIClient(session: session))
let response = try await api.cooperativeSalers(storeId: 66, page: 0, pageSize: 0)
XCTAssertEqual(response.total, 1)
XCTAssertEqual(response.page, 1)
XCTAssertEqual(response.pageSize, 50)
XCTAssertEqual(response.list.first?.displayId, 101)
XCTAssertEqual(response.list.first?.displayName, "管铜彬")
XCTAssertEqual(response.list.first?.displayPhone, "19281871413")
XCTAssertEqual(response.list.first?.bindingId, 5)
XCTAssertEqual(response.list.first?.shareCode, "INV-000101")
let request = try XCTUnwrap(session.requests.last)
XCTAssertEqual(request.httpMethod, "GET")
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/cooperative-salers")
let query = cooperationQueryItems(from: request)
XCTAssertEqual(query["store_id"], "66")
XCTAssertEqual(query["page"], "1")
XCTAssertEqual(query["page_size"], "1")
}
///
func testSaleUserInviteInfoDecodesFixture() async throws {
let session = CooperationRecordingURLSession(data: try TestFixture.data(named: "sale_user_invite_info_success"))
let api = CooperationOrderAPI(client: APIClient(session: session))
let info = try await api.saleUserInviteInfo(saleUserId: 101)
XCTAssertEqual(info.saleUserId, 101)
XCTAssertFalse(info.isBound)
XCTAssertEqual(info.photographerName, "陆久银")
XCTAssertEqual(info.photographerPhone, "18651857230")
XCTAssertEqual(info.salerName, "管铜彬")
XCTAssertEqual(info.salerPhone, "13222319413")
XCTAssertEqual(info.shareCode, "INV-000101")
XCTAssertEqual(info.subtitle, "确认与获客员绑定,可以绑定多个获客员")
XCTAssertEqual(info.title, "绑定获客员")
let request = try XCTUnwrap(session.requests.last)
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/sale-user/invite-info")
XCTAssertEqual(cooperationQueryItems(from: request)["sale_user_id"], "101")
}
/// 线
func testSaleUserReferralLeadsDecodesFixture() async throws {
let session = CooperationRecordingURLSession(data: try TestFixture.data(named: "sale_user_referral_leads_success"))
let api = CooperationOrderAPI(client: APIClient(session: session))
let response = try await api.saleUserReferralLeads(page: 2, pageSize: 20)
XCTAssertEqual(response.total, 1)
XCTAssertEqual(response.list.first?.displayId, "501")
XCTAssertEqual(response.list.first?.displayPhone, "13900000002")
XCTAssertEqual(response.list.first?.remark, "景区门口获客")
XCTAssertEqual(response.list.first?.displayImages.first, "https://example.com/lead.jpg")
let request = try XCTUnwrap(session.requests.last)
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/sale-user/referral-leads")
let query = cooperationQueryItems(from: request)
XCTAssertEqual(query["page"], "2")
XCTAssertEqual(query["page_size"], "20")
}
/// keyword
func testSaleUserAcquisitionOrdersDecodesFixtureWithKeyword() async throws {
let session = CooperationRecordingURLSession(data: try TestFixture.data(named: "sale_user_acquisition_orders_success"))
let api = CooperationOrderAPI(client: APIClient(session: session))
let response = try await api.saleUserAcquisitionOrders(keyword: "138", page: 1, pageSize: 10)
XCTAssertEqual(response.total, 1)
XCTAssertEqual(response.list.first?.orderNumber, "ACQ001")
XCTAssertEqual(response.list.first?.displayAmount, "299.00")
XCTAssertEqual(response.list.first?.displayCustomerPhone, "13700000003")
XCTAssertEqual(response.list.first?.displayPartner, "张三")
let request = try XCTUnwrap(session.requests.last)
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/sale-user/acquisition-orders")
let query = cooperationQueryItems(from: request)
XCTAssertEqual(query["keyword"], "138")
}
///
func testSaleUserReferralOrdersDecodesFixture() async throws {
let session = CooperationRecordingURLSession(data: try TestFixture.data(named: "sale_user_referral_orders_success"))
let api = CooperationOrderAPI(client: APIClient(session: session))
let response = try await api.saleUserReferralOrders(saleUserId: 101, page: 1, pageSize: 10)
XCTAssertEqual(response.total, 1)
XCTAssertEqual(response.page, 1)
XCTAssertEqual(response.pageSize, 50)
XCTAssertEqual(response.saler?.saleUserId, 101)
XCTAssertEqual(response.saler?.salerName, "管铜彬")
XCTAssertEqual(response.list.first?.id, 54)
XCTAssertEqual(response.list.first?.displayReferralOrderNo, "LD202606250009")
XCTAssertEqual(response.list.first?.userMobile, "13322222223")
XCTAssertEqual(response.list.first?.remark, "带客备注")
XCTAssertEqual(response.list.first?.statusLabel, "未完成")
XCTAssertEqual(response.list.first?.images.count, 1)
let request = try XCTUnwrap(session.requests.last)
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/sale-user/referral-orders")
let query = cooperationQueryItems(from: request)
XCTAssertEqual(query["sale_user_id"], "101")
}
///
func testSaleUserAcceptBindAndBindReferralOrderUsePostQuery() async throws {
let emptyData = try TestFixture.data(named: "empty_success")
let acceptSession = CooperationRecordingURLSession(data: emptyData)
let acceptAPI = CooperationOrderAPI(client: APIClient(session: acceptSession))
try await acceptAPI.saleUserAcceptBind(saleUserId: 101)
let acceptRequest = try XCTUnwrap(acceptSession.requests.last)
XCTAssertEqual(acceptRequest.httpMethod, "POST")
XCTAssertEqual(acceptRequest.url?.path, "/api/yf-handset-app/photog/sale-user/accept-bind")
XCTAssertEqual(cooperationQueryItems(from: acceptRequest)["sale_user_id"], "101")
XCTAssertNil(acceptRequest.httpBody)
let bindSession = CooperationRecordingURLSession(data: emptyData)
let bindAPI = CooperationOrderAPI(client: APIClient(session: bindSession))
try await bindAPI.saleUserBindReferralOrder(orderNumber: "ORD001", referralOrderNo: "REF001")
let bindRequest = try XCTUnwrap(bindSession.requests.last)
XCTAssertEqual(bindRequest.httpMethod, "POST")
XCTAssertEqual(bindRequest.url?.path, "/api/yf-handset-app/photog/sale-user/bind-referral-order")
let bindQuery = cooperationQueryItems(from: bindRequest)
XCTAssertEqual(bindQuery["order_number"], "ORD001")
XCTAssertEqual(bindQuery["referral_order_no"], "REF001")
XCTAssertNil(bindRequest.httpBody)
let statusSession = CooperationRecordingURLSession(data: emptyData)
let statusAPI = CooperationOrderAPI(client: APIClient(session: statusSession))
try await statusAPI.saleUserUpdateReferralOrderStatus(id: 15, status: 3)
let statusRequest = try XCTUnwrap(statusSession.requests.last)
XCTAssertEqual(statusRequest.httpMethod, "POST")
XCTAssertEqual(
statusRequest.url?.path,
"/api/yf-handset-app/photog/sale-user/referral-orders/15/status"
)
let statusBody = try XCTUnwrap(statusRequest.httpBody)
let statusJSON = try XCTUnwrap(JSONSerialization.jsonObject(with: statusBody) as? [String: Any])
XCTAssertEqual(statusJSON["status"] as? Int, 3)
}
}
/// URLSession API
private final class CooperationRecordingURLSession: URLSessionProtocol {
let data: Data
var statusCode: Int
private(set) var requests: [URLRequest] = []
init(data: Data, statusCode: Int = 200) {
self.data = data
self.statusCode = statusCode
}
/// HTTP
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
requests.append(request)
return (
data,
HTTPURLResponse(url: request.url!, statusCode: statusCode, httpVersion: nil, headerFields: nil)!
)
}
}
private func cooperationQueryItems(from request: URLRequest) -> [String: String] {
guard let url = request.url,
let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
return [:]
}
return Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).compactMap { item in
item.value.map { (item.name, $0) }
})
}

View File

@ -0,0 +1,24 @@
{
"code": 100000,
"msg": "success",
"data": {
"total": 1,
"page": 1,
"page_size": 50,
"list": [
{
"bind_time": "2026-06-25 17:11:01",
"binding_id": 5,
"bound_at": "2026-06-25 17:11:01",
"name": "管铜彬",
"phone": "19281871413",
"phone_masked": "192****1413",
"sale_user_id": 101,
"saler_name": "管铜彬",
"saler_phone": "19281871413",
"share_code": "INV-000101",
"store_id": 37
}
]
}
}

View File

@ -0,0 +1,20 @@
{
"code": 100000,
"msg": "success",
"data": {
"total": "1",
"list": [
{
"id": 801,
"order_number": "ACQ001",
"project_name": "亲子跟拍",
"order_type": 11,
"actual_pay_amount": "299.00",
"customer_phone": "13700000003",
"order_status_name": "已完成",
"sale_user_name": "张三",
"created_at": "2026-05-22 16:00:00"
}
]
}
}

View File

@ -0,0 +1,15 @@
{
"code": 100000,
"msg": "success",
"data": {
"is_bound": false,
"photographer_name": "陆久银",
"photographer_phone": "18651857230",
"sale_user_id": 101,
"saler_name": "管铜彬",
"saler_phone": "13222319413",
"share_code": "INV-000101",
"subtitle": "确认与获客员绑定,可以绑定多个获客员",
"title": "绑定获客员"
}
}

View File

@ -0,0 +1,16 @@
{
"code": 100000,
"msg": "success",
"data": {
"total": "1",
"list": [
{
"lead_id": 501,
"user_phone": "13900000002",
"remark": "景区门口获客",
"created_at": "2026-05-21 14:00:00",
"image_urls": ["https://example.com/lead.jpg"]
}
]
}
}

View File

@ -0,0 +1,29 @@
{
"code": 100000,
"msg": "success",
"data": {
"total": 1,
"page": 1,
"page_size": 50,
"saler": {
"sale_user_id": 101,
"saler_name": "管铜彬",
"saler_phone": "19281871413"
},
"list": [
{
"id": 54,
"referral_order_no": "LD202606250009",
"order_no": "LD202606250009",
"user_mobile": "13322222223",
"remark": "带客备注",
"created_at": "2026-06-25 17:18:52",
"status": 1,
"status_label": "未完成",
"images": [
"https://vipsky.oss-cn-shanghai.aliyuncs.com/sales/referral-order/create/6/1782379129957_hd2z9x.jpg"
]
}
]
}
}

View File

@ -10,38 +10,117 @@ import XCTest
///
final class HomeCommonMenuStoreTests: XCTestCase {
///
func testLoadCreatesDefaultCommonMenusWhenBaselineIsMissing() {
///
func testLoadCreatesDefaultCommonMenusFromTopLevelPermissions() {
let defaults = makeIsolatedDefaults()
let store = HomeCommonMenuStore(defaults: defaults)
let menus = [
menu("registration_invitation"),
menu("location_report"),
menu("pm_manager")
menu("space_settings"),
menu("wallet"),
menu("cloud_management"),
menu("message_center")
]
let uris = store.load(menuItems: menus)
let uris = store.load(
menuItems: menus,
roleCode: "photographer",
topLevelPermissionURIs: ["space_settings", "wallet", "cloud_management", "message_center"]
)
XCTAssertEqual(uris, ["registration_invitation", "location_report", "pm_manager"])
XCTAssertTrue(defaults.bool(forKey: HomeCommonMenuStore.defaultBaselineKey))
XCTAssertEqual(uris, ["space_settings", "wallet", "cloud_management", "message_center"])
XCTAssertEqual(
defaults.stringArray(forKey: HomeCommonMenuStore.storageKeyPrefix + "photographer"),
["space_settings", "wallet", "cloud_management", "message_center"]
)
}
/// 4 4
func testDefaultCommonURIsTakeFirstFourWhenMoreThanFourPermissions() {
let availableURIs: Set<String> = [
"space_settings",
"wallet",
"cloud_management",
"message_center",
"task_management"
]
let uris = HomeCommonMenuStore.defaultCommonURIs(
orderedTopLevelURIs: [
"space_settings",
"wallet",
"cloud_management",
"message_center",
"task_management"
],
availableURIs: availableURIs
)
XCTAssertEqual(uris, ["space_settings", "wallet", "cloud_management", "message_center"])
}
/// URI
func testLoadCreatesDefaultsWithoutLegacyPreferredURIs() {
let defaults = makeIsolatedDefaults()
let store = HomeCommonMenuStore(defaults: defaults)
let menus = [
menu("space_settings"),
menu("wallet"),
menu("cloud_management")
]
let uris = store.load(
menuItems: menus,
roleCode: "editor",
topLevelPermissionURIs: ["space_settings", "wallet", "cloud_management"]
)
XCTAssertEqual(uris, ["space_settings", "wallet", "cloud_management"])
}
/// URI URI
func testLoadFiltersSavedUrisByCurrentPermissions() {
let defaults = makeIsolatedDefaults()
defaults.set(true, forKey: HomeCommonMenuStore.defaultBaselineKey)
defaults.set(["task_management", "unknown", "pm"], forKey: HomeCommonMenuStore.defaultStorageKey)
defaults.set(["task_management", "unknown", "pm"], forKey: HomeCommonMenuStore.storageKeyPrefix + "photographer")
let store = HomeCommonMenuStore(defaults: defaults)
let menus = [
menu("task_management_editor"),
menu("project_edit")
]
let uris = store.load(menuItems: menus)
let uris = store.load(
menuItems: menus,
roleCode: "photographer",
topLevelPermissionURIs: ["task_management_editor", "project_edit"]
)
XCTAssertEqual(uris, ["task_management_editor", "project_edit"])
}
/// 使 key
func testLoadUsesRoleScopedStorageKeys() {
let defaults = makeIsolatedDefaults()
defaults.set(["wallet"], forKey: HomeCommonMenuStore.storageKeyPrefix + "photographer")
defaults.set(["cloud_management"], forKey: HomeCommonMenuStore.storageKeyPrefix + "editor")
let store = HomeCommonMenuStore(defaults: defaults)
let menus = [
menu("wallet"),
menu("cloud_management")
]
let roleOne = store.load(
menuItems: menus,
roleCode: "photographer",
topLevelPermissionURIs: ["wallet", "cloud_management"]
)
let roleTwo = store.load(
menuItems: menus,
roleCode: "editor",
topLevelPermissionURIs: ["wallet", "cloud_management"]
)
XCTAssertEqual(roleOne, ["wallet"])
XCTAssertEqual(roleTwo, ["cloud_management"])
}
/// URI
func testAddDeduplicatesAliases() {
let defaults = makeIsolatedDefaults()
@ -50,7 +129,12 @@ final class HomeCommonMenuStoreTests: XCTestCase {
menu("task_management_editor")
]
let uris = store.add("task_management", current: ["task_management_editor"], menuItems: menus)
let uris = store.add(
"task_management",
current: ["task_management_editor"],
menuItems: menus,
roleCode: "photographer"
)
XCTAssertEqual(uris, ["task_management_editor"])
}
@ -60,11 +144,45 @@ final class HomeCommonMenuStoreTests: XCTestCase {
let defaults = makeIsolatedDefaults()
let store = HomeCommonMenuStore(defaults: defaults)
let uris = store.remove("pm", current: ["project_edit", "pm_manager", "location_report"])
let uris = store.remove("pm", current: ["project_edit", "pm_manager", "location_report"], roleCode: "photographer")
XCTAssertEqual(uris, ["pm_manager", "location_report"])
}
/// role_code
func testLoadReturnsEmptyWhenRoleCodeIsMissing() {
let defaults = makeIsolatedDefaults()
let store = HomeCommonMenuStore(defaults: defaults)
let uris = store.load(
menuItems: [menu("wallet")],
roleCode: nil,
topLevelPermissionURIs: ["wallet"]
)
XCTAssertTrue(uris.isEmpty)
XCTAssertNil(defaults.stringArray(forKey: HomeCommonMenuStore.storageKeyPrefix + "photographer"))
}
/// legacy role id key
func testLoadMigratesLegacyRoleIdStorageKey() {
let defaults = makeIsolatedDefaults()
defaults.set(["wallet", "cloud_management"], forKey: HomeCommonMenuStore.storageKeyPrefix + "46")
let store = HomeCommonMenuStore(defaults: defaults)
let menus = [menu("wallet"), menu("cloud_management")]
let uris = store.load(
menuItems: menus,
roleCode: "store_admin",
legacyRoleId: 46,
topLevelPermissionURIs: ["wallet", "cloud_management"]
)
XCTAssertEqual(uris, ["wallet", "cloud_management"])
XCTAssertEqual(defaults.stringArray(forKey: HomeCommonMenuStore.storageKeyPrefix + "store_admin"), ["wallet", "cloud_management"])
XCTAssertNil(defaults.stringArray(forKey: HomeCommonMenuStore.storageKeyPrefix + "46"))
}
///
private func menu(_ uri: String) -> HomeMenuItem {
HomeMenuItem(title: HomeMenuRouter.title(for: uri), uri: uri, iconSrc: nil)

View File

@ -57,6 +57,7 @@ final class HomeMenuRouterTests: XCTestCase {
XCTAssertEqual(HomeMenuRouter.resolve(uri: "deposit_order", title: ""), .destination(.depositOrders))
XCTAssertEqual(HomeMenuRouter.resolve(uri: "deposit_order_shooting_info", title: ""), .destination(.depositOrders))
XCTAssertEqual(HomeMenuRouter.resolve(uri: "withdrawal_audit", title: ""), .destination(.withdrawalAudit))
XCTAssertEqual(HomeMenuRouter.resolve(uri: "cooperation_order", title: ""), .destination(.cooperationOrder))
XCTAssertEqual(HomeMenuRouter.resolve(uri: "scenic_settlement", title: ""), .destination(.scenicSettlement))
XCTAssertEqual(HomeMenuRouter.resolve(uri: "scenic_settlement_review", title: ""), .destination(.scenicSettlementReview))
XCTAssertEqual(HomeMenuRouter.resolve(uri: "message_center", title: ""), .destination(.messageCenter))
@ -150,6 +151,7 @@ final class HomeMenuRouterTests: XCTestCase {
func testPermissionRouteAuditSeparatesRoutableUnsupportedAndUnknownEntries() {
let permissions = [
rolePermission(
roleCode: "photographer",
roleId: 1,
permissions: [
permission(id: 1, name: "任务管理", uri: "task_management", children: [
@ -162,7 +164,7 @@ final class HomeMenuRouterTests: XCTestCase {
)
]
let audit = HomePermissionRouteAuditor.audit(permissions: permissions, currentRoleId: 1)
let audit = HomePermissionRouteAuditor.audit(permissions: permissions, currentRoleCode: "photographer")
XCTAssertEqual(audit.routable.map(\.uri), ["task_management", "task_create"])
XCTAssertEqual(audit.unsupported.map(\.uri), ["pilot_controller"])
@ -214,9 +216,9 @@ final class HomeMenuRouterTests: XCTestCase {
#endif
///
private func rolePermission(roleId: Int, permissions: [PermissionItem]) -> RolePermissionResponse {
private func rolePermission(roleCode: String, roleId: Int, permissions: [PermissionItem]) -> RolePermissionResponse {
RolePermissionResponse(
role: RoleInfo(id: roleId, name: "角色\(roleId)", permission: permissions),
role: RoleInfo(id: roleId, name: "角色\(roleId)", roleCode: roleCode, permission: permissions),
scenic: []
)
}

View File

@ -15,15 +15,15 @@ final class HomeViewModelTests: XCTestCase {
func testBuildMenusClearsStaleMenusWhenCurrentRoleIsMissing() {
let viewModel = HomeViewModel()
let permissions = [
rolePermission(roleId: 1, permissions: [
rolePermission(roleCode: "photographer", roleId: 1, permissions: [
permission(id: 1, name: "消息中心", uri: "message_center")
])
]
viewModel.buildMenus(from: permissions, currentRoleId: 1)
viewModel.buildMenus(from: permissions, currentRoleCode: "photographer")
XCTAssertEqual(viewModel.menuItems.map(\.uri), ["message_center"])
viewModel.buildMenus(from: permissions, currentRoleId: 99)
viewModel.buildMenus(from: permissions, currentRoleCode: "unknown_role")
XCTAssertTrue(viewModel.menuItems.isEmpty)
}
@ -32,15 +32,15 @@ final class HomeViewModelTests: XCTestCase {
func testBuildMenusFallsBackToFirstRoleOnlyWhenCurrentRoleIsNil() {
let viewModel = HomeViewModel()
let permissions = [
rolePermission(roleId: 1, permissions: [
rolePermission(roleCode: "photographer", roleId: 1, permissions: [
permission(id: 1, name: "任务管理", uri: "task_management")
]),
rolePermission(roleId: 2, permissions: [
rolePermission(roleCode: "editor", roleId: 2, permissions: [
permission(id: 2, name: "消息中心", uri: "message_center")
])
]
viewModel.buildMenus(from: permissions, currentRoleId: nil)
viewModel.buildMenus(from: permissions, currentRoleCode: nil)
XCTAssertEqual(viewModel.menuItems.map(\.uri), ["task_management"])
}
@ -49,14 +49,14 @@ final class HomeViewModelTests: XCTestCase {
func testBuildMenusFlattensPermissionTree() {
let viewModel = HomeViewModel()
let permissions = [
rolePermission(roleId: 1, permissions: [
rolePermission(roleCode: "photographer", roleId: 1, permissions: [
permission(id: 1, name: "任务管理", uri: "task_management", children: [
permission(id: 2, name: "消息中心", uri: "message_center")
])
])
]
viewModel.buildMenus(from: permissions, currentRoleId: 1)
viewModel.buildMenus(from: permissions, currentRoleCode: "photographer")
XCTAssertEqual(viewModel.menuItems.map(\.uri), ["task_management", "message_center"])
}
@ -65,7 +65,7 @@ final class HomeViewModelTests: XCTestCase {
func testBuildMenusDeduplicatesKnownAndroidAliases() {
let viewModel = HomeViewModel()
let permissions = [
rolePermission(roleId: 1, permissions: [
rolePermission(roleCode: "photographer", roleId: 1, permissions: [
permission(id: 1, name: "项目管理", uri: "pm_manager"),
permission(id: 2, name: "项目管理旧入口", uri: "pm"),
permission(id: 3, name: "收款码", uri: "payment_code"),
@ -75,7 +75,7 @@ final class HomeViewModelTests: XCTestCase {
])
]
viewModel.buildMenus(from: permissions, currentRoleId: 1)
viewModel.buildMenus(from: permissions, currentRoleCode: "photographer")
XCTAssertEqual(viewModel.menuItems.map(\.uri), ["pm", "deposit_order_detail", "pm_manager", "payment_code"])
}
@ -84,22 +84,22 @@ final class HomeViewModelTests: XCTestCase {
func testBuildMenusSortsByPreferredOrder() {
let viewModel = HomeViewModel()
let permissions = [
rolePermission(roleId: 1, permissions: [
rolePermission(roleCode: "photographer", roleId: 1, permissions: [
permission(id: 1, name: "消息中心", uri: "message_center"),
permission(id: 2, name: "空间设置", uri: "space_settings"),
permission(id: 3, name: "钱包", uri: "wallet")
])
]
viewModel.buildMenus(from: permissions, currentRoleId: 1)
viewModel.buildMenus(from: permissions, currentRoleCode: "photographer")
XCTAssertEqual(viewModel.menuItems.map(\.uri), ["space_settings", "wallet", "message_center"])
}
///
private func rolePermission(roleId: Int, permissions: [PermissionItem]) -> RolePermissionResponse {
private func rolePermission(roleCode: String, roleId: Int, permissions: [PermissionItem]) -> RolePermissionResponse {
RolePermissionResponse(
role: RoleInfo(id: roleId, name: "角色\(roleId)", permission: permissions),
role: RoleInfo(id: roleId, name: "角色\(roleId)", roleCode: roleCode, permission: permissions),
scenic: []
)
}

View File

@ -156,8 +156,17 @@ private func accountContext(scenicId: Int? = 88, storeId: Int? = 101) -> Account
@MainActor
private func permissionContext(roleName: String) -> PermissionContext {
let context = PermissionContext()
let role = RoleInfo(id: 1, name: roleName, permission: [PermissionItem(id: 1, name: "运营区域", uri: "operating-area")])
context.replaceRolePermissions([RolePermissionResponse(role: role, scenic: [])], currentRoleId: 1)
let roleCode = AppRoleCode.fromRoleName(roleName)?.rawValue ?? ""
let role = RoleInfo(
id: 1,
name: roleName,
roleCode: roleCode,
permission: [PermissionItem(id: 1, name: "运营区域", uri: "operating-area")]
)
context.replaceRolePermissions(
[RolePermissionResponse(role: role, scenic: [])],
currentRoleCode: roleCode
)
return context
}

View File

@ -17,10 +17,10 @@ final class OrdersViewModelTests: XCTestCase {
api.storeResponses = [try TestFixture.payload(ListPayload<OrderEntity>.self, named: "order_v2_success")]
let viewModel = OrdersViewModel()
try await viewModel.reloadStoreOrders(api: api, scenicId: 88, roleId: nil)
try await viewModel.reloadStoreOrders(api: api, scenicId: 88, appRole: nil)
XCTAssertFalse(viewModel.storeOrders.isEmpty)
try await viewModel.reloadStoreOrders(api: api, scenicId: nil, roleId: nil)
try await viewModel.reloadStoreOrders(api: api, scenicId: nil, appRole: nil)
XCTAssertTrue(viewModel.storeOrders.isEmpty)
XCTAssertEqual(viewModel.storeTotal, 0)
@ -35,8 +35,8 @@ final class OrdersViewModelTests: XCTestCase {
api.storeResponses = [try TestFixture.payload(ListPayload<OrderEntity>.self, named: "order_v2_success")]
let viewModel = OrdersViewModel()
try await viewModel.reloadStoreOrders(api: api, scenicId: 88, roleId: nil)
try await viewModel.loadMoreStoreOrders(api: api, scenicId: 88, roleId: nil)
try await viewModel.reloadStoreOrders(api: api, scenicId: 88, appRole: nil)
try await viewModel.loadMoreStoreOrders(api: api, scenicId: 88, appRole: nil)
XCTAssertEqual(viewModel.storeOrders.count, 2)
XCTAssertEqual(viewModel.storeTotal, 2)
@ -58,13 +58,13 @@ final class OrdersViewModelTests: XCTestCase {
viewModel.filterStartDate = Self.date("2026-05-01")
viewModel.filterEndDate = Self.date("2026-05-31")
try await viewModel.reloadStoreOrders(api: api, scenicId: 88, roleId: nil)
try await viewModel.reloadStoreOrders(api: api, scenicId: 88, appRole: nil)
viewModel.selectedStatus = 30
viewModel.searchPhone = " "
viewModel.filterStartDate = nil
viewModel.filterEndDate = nil
try await viewModel.reloadStoreOrders(api: api, scenicId: 88, roleId: 53)
try await viewModel.reloadStoreOrders(api: api, scenicId: 88, appRole: .scenicAdmin)
XCTAssertEqual(api.orderListCalls.count, 2)
XCTAssertEqual(api.orderListCalls[0].page, 1)

View File

@ -0,0 +1,42 @@
//
// SaleUserQrParserTests.swift
// suixinkanTests
//
// Created by Codex on 2026/6/29.
//
import XCTest
@testable import suixinkan
final class SaleUserQrParserTests: XCTestCase {
///
func testParsePlainNumber() {
XCTAssertEqual(SaleUserQrParser.parseSaleUserId("12345"), 12345)
}
/// JSON
func testParseLegacyJSONWithoutMethod() {
let json = #"{"sale_user_id":88}"#
XCTAssertEqual(SaleUserQrParser.parseSaleUserId(json), 88)
}
/// sale_user_bind
func testParseStandardSaleUserBindQR() {
let json = #"{"method":"sale_user_bind","sale_user_id":2}"#
XCTAssertEqual(SaleUserQrParser.parseSaleUserId(json), 2)
XCTAssertTrue(SaleUserQrParser.isSaleUserBindQR(json))
}
/// URL query sale_user_id
func testParseFromURLQuery() {
let url = "https://example.com/bind?sale_user_id=99"
XCTAssertEqual(SaleUserQrParser.parseSaleUserId(url), 99)
}
/// nil
func testParseInvalidContentReturnsNil() {
XCTAssertNil(SaleUserQrParser.parseSaleUserId("not-a-code"))
XCTAssertNil(SaleUserQrParser.parseSaleUserId(""))
XCTAssertFalse(SaleUserQrParser.isSaleUserBindQR("12345"))
}
}

View File

@ -101,7 +101,9 @@ final class ScenicSelectionViewModelTests: XCTestCase {
currentScenicId: 1,
currentStoreId: 10
)
permissionContext.replaceRolePermissions([RolePermissionResponse(role: RoleInfo(id: 7, name: "运营"), scenic: [])])
permissionContext.replaceRolePermissions([
RolePermissionResponse(role: RoleInfo(id: 7, name: "运营", roleCode: "scenic_operation"), scenic: [])
], currentRoleCode: "scenic_operation")
ScenicSelectionViewModel().select(
scenicId: 2,
@ -114,6 +116,6 @@ final class ScenicSelectionViewModelTests: XCTestCase {
XCTAssertEqual(accountContext.currentStore?.id, 20)
XCTAssertEqual(snapshotStore.load()?.currentScenicId, 2)
XCTAssertEqual(snapshotStore.load()?.currentStoreId, 20)
XCTAssertEqual(snapshotStore.load()?.currentRoleId, 7)
XCTAssertEqual(snapshotStore.load()?.currentRoleCode, "scenic_operation")
}
}

View File

@ -18,7 +18,7 @@ final class StatisticsViewModelTests: XCTestCase {
api.dailyResponses = [try TestFixture.payload(DataListPayload<StatisticsDailyItem>.self, named: "statistics_daily_page1_total3")]
let viewModel = StatisticsViewModel()
try await viewModel.reload(api: api, scenicId: 88, roleId: nil)
try await viewModel.reload(api: api, scenicId: 88, appRole: nil)
XCTAssertEqual(viewModel.summary.orderCount, 18)
XCTAssertEqual(viewModel.dailyItems.map(\.date), ["2026-05-23"])
@ -44,11 +44,11 @@ final class StatisticsViewModelTests: XCTestCase {
]
let viewModel = StatisticsViewModel()
try await viewModel.reload(api: api, scenicId: 88, roleId: nil)
try await viewModel.loadMore(api: api, scenicId: 88, roleId: nil)
try await viewModel.reload(api: api, scenicId: 88, appRole: nil)
try await viewModel.loadMore(api: api, scenicId: 88, appRole: nil)
XCTAssertEqual(viewModel.dailyItems.count, 3)
try await viewModel.selectPeriod(.sevenDays, api: api, scenicId: 88, roleId: nil)
try await viewModel.selectPeriod(.sevenDays, api: api, scenicId: 88, appRole: nil)
XCTAssertEqual(viewModel.selectedPeriod, .sevenDays)
XCTAssertEqual(viewModel.dailyItems.map(\.date), ["2026-05-23"])
@ -70,8 +70,8 @@ final class StatisticsViewModelTests: XCTestCase {
]
let viewModel = StatisticsViewModel()
try await viewModel.reload(api: api, scenicId: 88, roleId: nil)
try await viewModel.loadMore(api: api, scenicId: 88, roleId: nil)
try await viewModel.reload(api: api, scenicId: 88, appRole: nil)
try await viewModel.loadMore(api: api, scenicId: 88, appRole: nil)
XCTAssertEqual(viewModel.dailyItems.map(\.date), ["2026-05-23", "2026-05-22", "2026-05-21"])
XCTAssertFalse(viewModel.hasMore)
@ -86,11 +86,11 @@ final class StatisticsViewModelTests: XCTestCase {
api.dailyResponses = [try TestFixture.payload(DataListPayload<StatisticsDailyItem>.self, named: "statistics_daily_page1_total3")]
let viewModel = StatisticsViewModel()
try await viewModel.reload(api: api, scenicId: 88, roleId: nil)
try await viewModel.reload(api: api, scenicId: 88, appRole: nil)
api.nextDailyError = APIError.httpStatus(500, "server error")
do {
try await viewModel.loadMore(api: api, scenicId: 88, roleId: nil)
try await viewModel.loadMore(api: api, scenicId: 88, appRole: nil)
XCTFail("Expected load more to fail")
} catch APIError.httpStatus(let statusCode, _) {
XCTAssertEqual(statusCode, 500)
@ -110,10 +110,10 @@ final class StatisticsViewModelTests: XCTestCase {
api.dailyResponses = [try TestFixture.payload(DataListPayload<StatisticsDailyItem>.self, named: "statistics_daily_page1_total3")]
let viewModel = StatisticsViewModel()
try await viewModel.reload(api: api, scenicId: 88, roleId: nil)
try await viewModel.reload(api: api, scenicId: 88, appRole: nil)
XCTAssertEqual(viewModel.summary.orderCount, 18)
try await viewModel.reload(api: api, scenicId: nil, roleId: nil)
try await viewModel.reload(api: api, scenicId: nil, appRole: nil)
XCTAssertEqual(api.summaryCalls.count, 1)
XCTAssertEqual(viewModel.summary.orderCount, 0)

View File

@ -1,6 +1,6 @@
# 功能同步 Checklist
更新时间2026-06-24
更新时间2026-06-29
## 状态说明
@ -59,6 +59,7 @@
| 完成 | 历史拍摄 | 多点位历史拍摄项目、拍摄点和媒体展示。 | 仅展示已有媒体,不做上传、删除、下载或编辑。 |
| 完成 | 任务上传 | 多点旅拍订单选择已核销打卡点、云盘素材、本地图片/视频 OSS 上传并提交素材。 | 仅 `orderType == 19` 展示。 |
| 完成 | 视频预告/尾片上传 | 进入订单尾片引导页,并复用相册预览上传。 | 没有独立订单尾片接口,暂不做尾片审核进度。 |
| 完成 | 订单来源/带客单绑定 | orderType 11/14 订单卡片展示「订单来源」Sheet支持选择获客员与带客单绑定全页 `ReferralOrderSelectView`;退款后同步带客单状态。 | 依赖 `CooperationOrderAPI` 与首页合作订单模块。 |
## 数据 Tab
@ -110,6 +111,7 @@
| 完成 | 位置上报 | 当前位置、标记点、在线状态、立即上报、提醒设置、历史记录、筛选和分页已接入。 | 本轮不做后台定位、离线队列或推送提醒。 |
| 完成 | 注册邀请 | 邀请码、邀请链接、二维码、复制链接、规则展示、邀请记录和奖励记录已接入。 | 邀请二维码不落盘,后续如需分享图片再临时生成。 |
| 完成 | 飞手认证 | `pilot_cert` 已接真实页面;支持实名状态、认证详情、验证码、证件图 OSS 上传、首次提交和驳回编辑。 | 不包含 DJI/飞控 SDK 或真实飞行控制。 |
| 完成 | 合作订单 | `cooperation_order` 已接真实页面;双 Tab 列表(获客线索/获客订单)、合作获客员、扫码绑定、主 Tab 扫码识别 sale_user_bind。 | 与订单模块 orderType 11/14 订单来源联动。 |
| 不迁移 | 飞控/DJI | `fly` / `pilot_controller` 已明确为 iOS 不支持。 | 不再迁移。 |
## 旧工程能力尚未系统迁移