模块化 AppStore 并完善素材管理与个人空间设置。

将会话、权限、位置、收款与排队存储拆分为独立模块,同步更新各 ViewModel 与单元测试,并补充素材管理 UI 与个人空间设置交互。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-13 11:01:08 +08:00
parent ceca780ab3
commit 005349f8e6
128 changed files with 2953 additions and 1123 deletions

View File

@ -59,15 +59,11 @@
}; };
9A01B8E22FFB84950033C402 /* Vendor */ = { 9A01B8E22FFB84950033C402 /* Vendor */ = {
isa = PBXFileSystemSynchronizedRootGroup; isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = Vendor; path = Vendor;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
A1B2C3D42FFB84950033C305 /* suixinkanTests */ = { A1B2C3D42FFB84950033C305 /* suixinkanTests */ = {
isa = PBXFileSystemSynchronizedRootGroup; isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = suixinkanTests; path = suixinkanTests;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@ -336,10 +332,14 @@
inputFileListPaths = ( inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-frameworks-${CONFIGURATION}-input-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-frameworks-${CONFIGURATION}-input-files.xcfilelist",
); );
inputPaths = (
);
name = "[CP] Embed Pods Frameworks"; name = "[CP] Embed Pods Frameworks";
outputFileListPaths = ( outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-frameworks-${CONFIGURATION}-output-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-frameworks-${CONFIGURATION}-output-files.xcfilelist",
); );
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-frameworks.sh\"\n"; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-frameworks.sh\"\n";
@ -353,10 +353,14 @@
inputFileListPaths = ( inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-input-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-input-files.xcfilelist",
); );
inputPaths = (
);
name = "[CP] Copy Pods Resources"; name = "[CP] Copy Pods Resources";
outputFileListPaths = ( outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-output-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-output-files.xcfilelist",
); );
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources.sh\"\n"; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources.sh\"\n";

View File

@ -15,7 +15,7 @@ enum AppRouter {
/// Tab /// Tab
static func makeRootViewController() -> UIViewController { static func makeRootViewController() -> UIViewController {
makeViewController(for: AppStore.shared.isLoggedIn ? .mainTab : .login) makeViewController(for: AppStore.shared.session.isLoggedIn ? .mainTab : .login)
} }
/// ///
@ -25,7 +25,7 @@ enum AppRouter {
/// ///
static func refreshRoot(on window: UIWindow?, animated: Bool = true) { static func refreshRoot(on window: UIWindow?, animated: Bool = true) {
setRoot(AppStore.shared.isLoggedIn ? .mainTab : .login, on: window, animated: animated) setRoot(AppStore.shared.session.isLoggedIn ? .mainTab : .login, on: window, animated: animated)
} }
private static func makeViewController(for root: Root) -> UIViewController { private static func makeViewController(for root: Root) -> UIViewController {

View File

@ -58,7 +58,7 @@ final class NetworkServices {
uploadAPI = UploadAPI(client: client) uploadAPI = UploadAPI(client: client)
ossUploadService = OSSUploadService(configService: uploadAPI) ossUploadService = OSSUploadService(configService: uploadAPI)
client.bindAuthTokenProvider { client.bindAuthTokenProvider {
let token = AppStore.shared.token.trimmingCharacters(in: .whitespacesAndNewlines) let token = AppStore.shared.session.token.trimmingCharacters(in: .whitespacesAndNewlines)
return token.isEmpty ? nil : token return token.isEmpty ? nil : token
} }
} }

View File

@ -15,7 +15,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
configureKeyboardManager() configureKeyboardManager()
UmengBootstrap.configureIfNeeded() UmengBootstrap.configureIfNeeded()
WeChatManager.shared.registerIfNeeded() WeChatManager.shared.registerIfNeeded()
if AppStore.shared.privacyAgreementAccepted, !AppStore.shared.token.isEmpty { if AppStore.shared.session.privacyAgreementAccepted, !AppStore.shared.session.token.isEmpty {
AMapBootstrap.configureIfNeeded() AMapBootstrap.configureIfNeeded()
} }
return true return true

View File

@ -0,0 +1 @@
{"images":[{"filename":"material_ic_add.svg","idiom":"universal"}],"info":{"author":"xcode","version":1}}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2Z" fill="#FFFFFF"/></svg>

After

Width:  |  Height:  |  Size: 152 B

View File

@ -0,0 +1 @@
{"images":[{"filename":"material_ic_arrow_down.svg","idiom":"universal"}],"info":{"author":"xcode","version":1}}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M7.41 8.59 12 13.17 16.59 8.59 18 10l-6 6-6-6 1.41-1.41Z" fill="#4B5563"/></svg>

After

Width:  |  Height:  |  Size: 173 B

View File

@ -0,0 +1 @@
{"images":[{"filename":"material_ic_clear.svg","idiom":"universal"}],"info":{"author":"xcode","version":1}}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41Z" fill="#FFFFFF"/></svg>

After

Width:  |  Height:  |  Size: 226 B

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "material_ic_download_count.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<path d="M13 14H3C1.895 14 1 13.106 1 12.002V9.504C1 9.228 1.224 9.005 1.5 9.005H2.5C2.776 9.005 3 9.228 3 9.504V11.003C3 11.555 3.448 12.002 4 12.002H12C12.552 12.002 13 11.555 13 11.003V9.504C13 9.228 13.224 9.005 13.5 9.005H14.5C14.776 9.005 15 9.228 15 9.504V12.002C15 13.106 14.105 14 13 14ZM3.935 4.199C4.138 3.997 4.466 3.997 4.668 4.199L7 6.529V1.5C7 1.224 7.224 1 7.5 1H8.5C8.776 1 9 1.224 9 1.5V6.529L11.332 4.199C11.535 3.997 11.863 3.997 12.065 4.199L12.798 4.932C13 5.134 13 5.462 12.798 5.664L8.668 9.79C8.65 9.807 8.329 10.004 8.006 10.004C7.679 10.004 7.35 9.808 7.332 9.79L3.202 5.664C3 5.462 3 5.134 3.202 4.932L3.935 4.199Z" fill="#6B7280"/>
</svg>

After

Width:  |  Height:  |  Size: 754 B

View File

@ -0,0 +1 @@
{"images":[{"filename":"material_ic_play.svg","idiom":"universal"}],"info":{"author":"xcode","version":1}}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M8 5v14l11-7L8 5Z" fill="#FFFFFF"/></svg>

After

Width:  |  Height:  |  Size: 134 B

View File

@ -0,0 +1 @@
{"images":[{"filename":"material_ic_search.svg","idiom":"universal"}],"info":{"author":"xcode","version":1}}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M9.5 3A6.5 6.5 0 1 0 13.6 14.55L19.05 20 20.45 18.6 15 13.15A6.5 6.5 0 0 0 9.5 3Zm0 2A4.5 4.5 0 1 1 9.5 14 4.5 4.5 0 0 1 9.5 5Z" fill="#666666"/></svg>

After

Width:  |  Height:  |  Size: 244 B

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "home_menu_material.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path d="M3.703 8.4H20.296C21.319 8.4 22.111 9.346 21.987 10.419L20.945 19.419C20.84 20.321 20.114 21 19.254 21H4.745C3.885 20.999 3.16 20.321 3.055 19.419L2.013 10.419C1.889 9.346 2.68 8.401 3.703 8.4ZM5.577 3H9.41C9.721 3 10.026 3.089 10.292 3.259L12 4.35H18.686C19.356 4.35 19.951 4.804 20.163 5.475L20.331 6.008C20.423 6.299 20.217 6.6 19.927 6.6H4.005C3.735 6.6 3.533 6.338 3.588 6.058L3.909 4.433C4.074 3.598 4.77 3 5.577 3Z" fill="#0073FF"/>
</svg>

After

Width:  |  Height:  |  Size: 542 B

View File

@ -0,0 +1,17 @@
{
"images" : [
{
"filename" : "home_menu_space.svg",
"idiom" : "universal",
"scale" : "1x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="#000" d="M11 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h6v18Zm2 0h6a2 2 0 0 0 2-2v-7h-8v9Zm8-11V5a2 2 0 0 0-2-2h-6v7h8Z"/></svg>

After

Width:  |  Height:  |  Size: 216 B

View File

@ -0,0 +1,17 @@
{
"images" : [
{
"filename" : "profile_avatar_default.svg",
"idiom" : "universal",
"scale" : "1x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 960 960"><path fill="#000" d="M460 670h45V506h165v-46H505V290h-45v170H290v46h170v164Zm20 190a380 380 0 1 1 0-760 380 380 0 0 1 0 760Zm0-45a335 335 0 1 0 0-670 335 335 0 0 0 0 670Z"/></svg>

After

Width:  |  Height:  |  Size: 265 B

View File

@ -0,0 +1,17 @@
{
"images" : [
{
"filename" : "profile_edit.svg",
"idiom" : "universal",
"scale" : "1x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path fill="#000" d="m11 1.82-1.34 1.34 3.54 3.55 1.34-1.34c.33-.35.49-.76.49-1.23 0-.46-.16-.87-.49-1.23l-1.09-1.09a1.7 1.7 0 0 0-1.23-.49c-.45 0-.86.16-1.22.49ZM9.06 3.76 2.72 10.16c-.29.27-.5.61-.63 1.01l-.95 3.28c-.11.38-.05.66.16.66.27.27.55.33.65.16l3.27-.96c.38-.13.72-.33 1.01-.6l6.37-6.4-3.54-3.55Z"/></svg>

After

Width:  |  Height:  |  Size: 400 B

View File

@ -20,7 +20,7 @@ enum UmengBootstrap {
/// App SDK /// App SDK
@discardableResult @discardableResult
static func configureIfNeeded(appStore: AppStore = .shared) -> Bool { static func configureIfNeeded(appStore: AppStore = .shared) -> Bool {
guard appStore.privacyAgreementAccepted else { return false } guard appStore.session.privacyAgreementAccepted else { return false }
lock.lock() lock.lock()
defer { lock.unlock() } defer { lock.unlock() }

View File

@ -146,6 +146,77 @@ final class APIClient {
} }
} }
/// multipart/form-data
func sendMultipart<Response: Decodable>(
path: String,
fieldName: String = "file",
fileName: String,
mimeType: String,
data fileData: Data,
timeoutInterval: TimeInterval = 60,
tokenOverride: String? = nil
) async throws -> Response {
let apiPath = path.hasPrefix("/") ? path : "/" + path
guard let url = URL(
string: environment.baseURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + apiPath
) else {
throw APIError.invalidURL
}
let boundary = "Boundary-\(UUID().uuidString)"
var body = Data()
body.append("--\(boundary)\r\n".data(using: .utf8)!)
body.append(
"Content-Disposition: form-data; name=\"\(fieldName)\"; filename=\"\(fileName)\"\r\n"
.data(using: .utf8)!
)
body.append("Content-Type: \(mimeType)\r\n\r\n".data(using: .utf8)!)
body.append(fileData)
body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
var request = URLRequest(url: url)
request.httpMethod = HTTPMethod.post.rawValue
request.timeoutInterval = timeoutInterval
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue(appVersion, forHTTPHeaderField: "X-APP-VERSION")
request.setValue(osType, forHTTPHeaderField: "X-OS-TYPE")
request.setValue(String(body.count), forHTTPHeaderField: "Content-Length")
let token = tokenOverride ?? authTokenProvider?()
if let token = token?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty {
request.setValue(token, forHTTPHeaderField: "token")
}
request.httpBody = body
logRequest(request)
let responseData: Data
let response: URLResponse
do {
(responseData, response) = try await session.data(for: request)
} catch is CancellationError {
logCancelled(for: request, reason: "CancellationError")
throw CancellationError()
} catch let error as URLError {
if error.code == .cancelled {
logCancelled(for: request, reason: "URLError.cancelled")
throw CancellationError()
}
throw APIError.networkFailed(networkErrorMessage(for: error))
} catch {
throw APIError.networkFailed(error.localizedDescription)
}
logResponse(for: request, response: response, data: responseData)
do {
try validateHTTPResponse(response, data: responseData)
return try decodeEnvelope(Response.self, from: responseData)
} catch let error as APIError {
notifySessionExpiredIfNeeded(for: error)
throw error
}
}
/// URLRequest Headertoken /// URLRequest Headertoken
private func makeURLRequest<Response: Decodable>( private func makeURLRequest<Response: Decodable>(
_ apiRequest: APIRequest<Response>, _ apiRequest: APIRequest<Response>,

View File

@ -316,7 +316,7 @@ enum OSSUploadPolicy {
case "material": case "material":
return "material/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" return "material/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
case "punch_point": case "punch_point":
let userId = AppStore.shared.userId.trimmingCharacters(in: .whitespacesAndNewlines) let userId = AppStore.shared.session.userId.trimmingCharacters(in: .whitespacesAndNewlines)
let ownerId = userId.isEmpty ? String(scenicId) : userId let ownerId = userId.isEmpty ? String(scenicId) : userId
return "punch_point/\(currentDate)/\(ownerId)/\(uploadId)_\(safeName)" return "punch_point/\(currentDate)/\(ownerId)/\(uploadId)_\(safeName)"
case "scenic_apply": case "scenic_apply":
@ -326,7 +326,7 @@ enum OSSUploadPolicy {
case "alive_album": case "alive_album":
return "alive_album/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" return "alive_album/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
case "cloud_driver": case "cloud_driver":
let userId = AppStore.shared.userId.trimmingCharacters(in: .whitespacesAndNewlines) let userId = AppStore.shared.session.userId.trimmingCharacters(in: .whitespacesAndNewlines)
let ownerId = userId.isEmpty ? String(scenicId) : userId let ownerId = userId.isEmpty ? String(scenicId) : userId
let month = String(currentDate.dropFirst(4).prefix(2)) let month = String(currentDate.dropFirst(4).prefix(2))
#if DEBUG #if DEBUG

View File

@ -0,0 +1,57 @@
//
// AppLocationStore.swift
// suixinkan
//
import Foundation
///
final class AppLocationStore {
private enum Key: String, CaseIterable {
case onlineStatus = "key_online_status"
case lastReportTime = "key_last_location_report_time"
case reminderMinutes = "key_location_reminder_minutes"
}
private let defaults: UserDefaults
private let session: AppSessionStore
/// 使
init(defaults: UserDefaults, session: AppSessionStore) {
self.defaults = defaults
self.session = session
}
/// 线
var onlineStatus: Bool {
get { defaults.bool(forKey: scopedKey(.onlineStatus)) }
set { defaults.set(newValue, forKey: scopedKey(.onlineStatus)) }
}
///
var lastReportTime: Int64 {
get { Int64(defaults.integer(forKey: scopedKey(.lastReportTime))) }
set { defaults.set(Int(newValue), forKey: scopedKey(.lastReportTime)) }
}
/// 0
var reminderMinutes: Int {
get { defaults.integer(forKey: scopedKey(.reminderMinutes)) }
set { defaults.set(newValue, forKey: scopedKey(.reminderMinutes)) }
}
///
func clearLastReportTime() {
defaults.removeObject(forKey: scopedKey(.lastReportTime))
}
///
func clear() {
Key.allCases.forEach { defaults.removeObject(forKey: scopedKey($0)) }
}
private func scopedKey(_ key: Key) -> String {
"\(session.accountCachePrefix)_\(key.rawValue)"
}
}

View File

@ -0,0 +1,38 @@
//
// AppPaymentStore.swift
// suixinkan
//
import Foundation
///
final class AppPaymentStore {
private enum Key: String, CaseIterable {
case isOpenReceiveVoice = "key_is_open_receive_voice"
}
private let defaults: UserDefaults
private let session: AppSessionStore
/// 使
init(defaults: UserDefaults, session: AppSessionStore) {
self.defaults = defaults
self.session = session
}
///
var isOpenReceiveVoice: Bool {
get { defaults.bool(forKey: scopedKey(.isOpenReceiveVoice)) }
set { defaults.set(newValue, forKey: scopedKey(.isOpenReceiveVoice)) }
}
///
func clear() {
Key.allCases.forEach { defaults.removeObject(forKey: scopedKey($0)) }
}
private func scopedKey(_ key: Key) -> String {
"\(session.accountCachePrefix)_\(key.rawValue)"
}
}

View File

@ -0,0 +1,91 @@
//
// AppPermissionStore.swift
// suixinkan
//
import Foundation
///
final class AppPermissionStore {
private enum Key: String, CaseIterable {
case legacyRoleId = "key_in_role_id"
case rolePermissionList = "key_role_permission_list"
case permissionItems = "key_in_permission"
case roleScenicList = "key_current_role_scenic_list"
}
private let defaults: UserDefaults
private let session: AppSessionStore
/// 使
init(defaults: UserDefaults, session: AppSessionStore) {
self.defaults = defaults
self.session = session
}
/// role_id
var legacyRoleId: Int {
get { defaults.integer(forKey: scopedKey(.legacyRoleId)) }
set { defaults.set(newValue, forKey: scopedKey(.legacyRoleId)) }
}
/// role-permission
func saveRolePermissionList(_ list: [RolePermissionResponse]) {
save(list, for: .rolePermissionList)
}
/// role-permission
func rolePermissionList() -> [RolePermissionResponse] {
load([RolePermissionResponse].self, for: .rolePermissionList) ?? []
}
///
func savePermissionItems(_ items: [HomePermissionItem]) {
save(items, for: .permissionItems)
}
///
func permissionItems() -> [HomePermissionItem] {
load([HomePermissionItem].self, for: .permissionItems) ?? []
}
///
func saveRoleScenicList(_ scenicList: [ScenicInfo]) {
save(scenicList, for: .roleScenicList)
}
///
func roleScenicList() -> [ScenicInfo] {
load([ScenicInfo].self, for: .roleScenicList) ?? []
}
/// role-permission
func matchRolePermissionItem(in list: [RolePermissionResponse]) -> RolePermissionResponse? {
RolePermissionMatcher.match(
in: list,
roleCode: session.roleCode,
roleName: session.roleName,
legacyRoleId: legacyRoleId
)
}
///
func clear() {
Key.allCases.forEach { defaults.removeObject(forKey: scopedKey($0)) }
}
private func save<Value: Encodable>(_ value: Value, for key: Key) {
guard let data = try? JSONEncoder().encode(value) else { return }
defaults.set(data, forKey: scopedKey(key))
}
private func load<Value: Decodable>(_ type: Value.Type, for key: Key) -> Value? {
guard let data = defaults.data(forKey: scopedKey(key)) else { return nil }
return try? JSONDecoder().decode(type, from: data)
}
private func scopedKey(_ key: Key) -> String {
"\(session.accountCachePrefix)_\(key.rawValue)"
}
}

View File

@ -0,0 +1,97 @@
//
// AppScenicQueueStore.swift
// suixinkan
//
import Foundation
///
final class AppScenicQueueStore {
private enum Key: String, CaseIterable {
case punchSpotId = "_scenic_queue_punch_spot_id"
case punchSpotName = "_scenic_queue_punch_spot_name"
case remark = "_scenic_queue_remark"
case settingsSnapshot = "_scenic_queue_settings_snapshot"
case useOfflineTTS = "_scenic_queue_offline_tts_v1"
case presetVoices = "_scenic_queue_preset_voices_v1"
}
private let defaults: UserDefaults
private let session: AppSessionStore
/// 使
init(defaults: UserDefaults, session: AppSessionStore) {
self.defaults = defaults
self.session = session
}
/// ID
var punchSpotId: String {
get { string(for: .punchSpotId) }
set { defaults.set(newValue, forKey: scopedKey(.punchSpotId)) }
}
///
var punchSpotName: String {
get { string(for: .punchSpotName) }
set { defaults.set(newValue, forKey: scopedKey(.punchSpotName)) }
}
///
var remark: String {
get { string(for: .remark) }
set { defaults.set(newValue, forKey: scopedKey(.remark)) }
}
/// 使线 TTS
var useOfflineTTS: Bool {
get { defaults.bool(forKey: scopedKey(.useOfflineTTS)) }
set { defaults.set(newValue, forKey: scopedKey(.useOfflineTTS)) }
}
///
func hasPunchSpotSaved() -> Bool {
let id = punchSpotId.trimmingCharacters(in: .whitespacesAndNewlines)
return Int64(id).map { $0 > 0 } ?? false
}
///
func saveSettingsSnapshot(_ snapshot: ScenicQueueSettingsSnapshot) {
guard let data = try? JSONEncoder().encode(snapshot) else { return }
defaults.set(data, forKey: scopedKey(.settingsSnapshot))
}
///
func settingsSnapshot() -> ScenicQueueSettingsSnapshot? {
guard let data = defaults.data(forKey: scopedKey(.settingsSnapshot)) else { return nil }
return try? JSONDecoder().decode(ScenicQueueSettingsSnapshot.self, from: data)
}
/// 5
func savePresetVoices(_ voices: [String]) {
let normalized = voices
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
.prefix(5)
defaults.set(Array(normalized), forKey: scopedKey(.presetVoices))
}
///
func presetVoices() -> [String] {
defaults.stringArray(forKey: scopedKey(.presetVoices)) ?? []
}
///
func clear() {
Key.allCases.forEach { defaults.removeObject(forKey: scopedKey($0)) }
}
private func string(for key: Key) -> String {
defaults.string(forKey: scopedKey(key)) ?? ""
}
private func scopedKey(_ key: Key) -> String {
"\(session.accountCachePrefix)_\(key.rawValue)"
}
}

View File

@ -0,0 +1,233 @@
//
// AppSessionStore.swift
// suixinkan
//
import Foundation
///
enum AppAccountType: Equatable, Sendable {
case scenicUser
case storeUser
case unknown(String)
///
init(rawValue: String) {
switch rawValue {
case "scenic_user": self = .scenicUser
case "store_user": self = .storeUser
default: self = .unknown(rawValue)
}
}
///
var rawValue: String {
switch self {
case .scenicUser: "scenic_user"
case .storeUser: "store_user"
case let .unknown(value): value
}
}
}
///
final class AppSessionStore {
private enum Key: String {
case token = "key_in_token"
case lastLoginUsername = "key_last_login_username"
case privacyAgreementAccepted = "key_privacy_agreement_accepted"
case userId = "key_in_user_id"
case userName = "key_in_user_name"
case realName = "key_in_real_name"
case avatar = "key_in_avatar"
case phone = "key_in_phone"
case accountType = "key_in_account_type"
case accountDisplayName = "key_in_account_display_name"
case roleCode = "key_in_role_code"
case roleName = "key_in_role_name"
case currentScenicId = "key_in_current_scenic_id"
case currentScenicName = "key_in_current_scenic_name"
case currentStoreId = "key_in_current_store_id"
}
private let defaults: UserDefaults
/// 使 UserDefaults
init(defaults: UserDefaults) {
self.defaults = defaults
}
/// Token
var token: String {
get { string(for: .token) }
set { defaults.set(newValue, forKey: Key.token.rawValue) }
}
///
var lastLoginUsername: String? {
get { defaults.string(forKey: Key.lastLoginUsername.rawValue) }
set {
if let newValue {
defaults.set(newValue, forKey: Key.lastLoginUsername.rawValue)
} else {
defaults.removeObject(forKey: Key.lastLoginUsername.rawValue)
}
}
}
///
var privacyAgreementAccepted: Bool {
get { defaults.bool(forKey: Key.privacyAgreementAccepted.rawValue) }
set { defaults.set(newValue, forKey: Key.privacyAgreementAccepted.rawValue) }
}
/// ID
var userId: String {
get { string(for: .userId) }
set { defaults.set(newValue, forKey: Key.userId.rawValue) }
}
///
var userName: String {
get { string(for: .userName) }
set { defaults.set(newValue, forKey: Key.userName.rawValue) }
}
///
var realName: String {
get { string(for: .realName) }
set { defaults.set(newValue, forKey: Key.realName.rawValue) }
}
///
var avatar: String {
get { string(for: .avatar) }
set { defaults.set(newValue, forKey: Key.avatar.rawValue) }
}
///
var phone: String {
get { string(for: .phone) }
set { defaults.set(newValue, forKey: Key.phone.rawValue) }
}
///
var accountType: AppAccountType {
get { AppAccountType(rawValue: string(for: .accountType)) }
set { defaults.set(newValue.rawValue, forKey: Key.accountType.rawValue) }
}
///
var accountDisplayName: String {
get { string(for: .accountDisplayName) }
set { defaults.set(newValue, forKey: Key.accountDisplayName.rawValue) }
}
///
var roleCode: String {
get { string(for: .roleCode) }
set { defaults.set(newValue, forKey: Key.roleCode.rawValue) }
}
///
var roleName: String {
get { string(for: .roleName) }
set { defaults.set(newValue, forKey: Key.roleName.rawValue) }
}
/// ID0
var currentScenicId: Int {
get { Int(string(for: .currentScenicId)) ?? 0 }
set { defaults.set(newValue > 0 ? String(newValue) : "", forKey: Key.currentScenicId.rawValue) }
}
///
var currentScenicName: String {
get { string(for: .currentScenicName) }
set { defaults.set(newValue, forKey: Key.currentScenicName.rawValue) }
}
/// ID0
var currentStoreId: Int {
get { defaults.integer(forKey: Key.currentStoreId.rawValue) }
set { defaults.set(newValue, forKey: Key.currentStoreId.rawValue) }
}
/// Android `getAccountCachePrefix`
var accountCachePrefix: String {
let uid = userId.trimmingCharacters(in: .whitespacesAndNewlines)
let type = accountType.rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
if uid.isEmpty { return "guest" }
if type.isEmpty { return uid }
return "\(uid)_\(type)"
}
///
var currentAppRole: AppRoleCode? {
AppRoleCode.fromCode(roleCode) ?? AppRoleCode.fromRoleName(roleName)
}
///
var isPhotographerRole: Bool {
if let role = currentAppRole {
return role.isPhotographer
}
return roleName.trimmingCharacters(in: .whitespacesAndNewlines) == "摄影师"
}
/// Token
var isLoggedIn: Bool {
!token.isEmpty
}
/// Token
func saveToken(_ token: String) {
self.token = token
}
///
func applyAccount(_ account: AccountSwitchAccount) {
userId = String(account.businessUserId)
accountType = AppAccountType(rawValue: account.accountType)
accountDisplayName = account.title
userName = account.subtitle
phone = account.phone
if !account.avatar.isEmpty {
avatar = account.avatar
}
currentScenicName = account.scenicName
currentScenicId = account.scenicId ?? 0
currentStoreId = account.storeId ?? 0
}
///
func applyUserInfo(_ info: UserInfoResponse) {
if !info.nickname.isEmpty { userName = info.nickname }
if !info.realName.isEmpty { realName = info.realName }
if !info.avatar.isEmpty { avatar = info.avatar }
if !info.phone.isEmpty { phone = info.phone }
if !info.roleName.isEmpty, roleName.isEmpty { roleName = info.roleName }
}
///
func clearAuthenticatedSession() {
token = ""
userId = ""
userName = ""
realName = ""
avatar = ""
phone = ""
accountType = .unknown("")
accountDisplayName = ""
roleCode = ""
roleName = ""
currentScenicId = 0
currentScenicName = ""
currentStoreId = 0
}
private func string(for key: Key) -> String {
defaults.string(forKey: key.rawValue) ?? ""
}
}

View File

@ -5,403 +5,43 @@
import Foundation import Foundation
/// Android `AppStoreDataSource` /// 退
final class AppStore { final class AppStore {
///
static let shared = AppStore() static let shared = AppStore()
private enum Key { ///
static let token = "key_in_token" let session: AppSessionStore
static let lastLoginUsername = "key_last_login_username"
static let privacyAgreementAccepted = "key_privacy_agreement_accepted"
static let userId = "key_in_user_id"
static let userName = "key_in_user_name"
static let realName = "key_in_real_name"
static let avatar = "key_in_avatar"
static let phone = "key_in_phone"
static let accountType = "key_in_account_type"
static let accountDisplayName = "key_in_account_display_name"
static let roleCode = "key_in_role_code"
static let roleName = "key_in_role_name"
static let currentScenicId = "key_in_current_scenic_id"
static let currentScenicName = "key_in_current_scenic_name"
static let currentStoreId = "key_in_current_store_id"
static let legacyRoleId = "key_in_role_id"
static let rolePermissionList = "key_role_permission_list"
static let permissionItems = "key_in_permission"
static let roleScenicList = "key_current_role_scenic_list"
static let onlineStatus = "key_online_status"
static let lastLocationReportTime = "key_last_location_report_time"
static let locationReminderMinutes = "key_location_reminder_minutes"
static let isOpenReceiveVoice = "key_is_open_receive_voice"
static let scenicQueuePunchSpotId = "_scenic_queue_punch_spot_id"
static let scenicQueuePunchSpotName = "_scenic_queue_punch_spot_name"
static let scenicQueueRemark = "_scenic_queue_remark"
static let scenicQueueSettingsSnapshot = "_scenic_queue_settings_snapshot"
static let scenicQueueOfflineTTS = "_scenic_queue_offline_tts_v1"
static let scenicQueuePresetVoices = "_scenic_queue_preset_voices_v1"
}
private let defaults: UserDefaults ///
let permissions: AppPermissionStore
///
let location: AppLocationStore
///
let payment: AppPaymentStore
///
let scenicQueue: AppScenicQueueStore
/// 使 UserDefaults
init(defaults: UserDefaults = .standard) { init(defaults: UserDefaults = .standard) {
self.defaults = defaults let session = AppSessionStore(defaults: defaults)
self.session = session
permissions = AppPermissionStore(defaults: defaults, session: session)
location = AppLocationStore(defaults: defaults, session: session)
payment = AppPaymentStore(defaults: defaults, session: session)
scenicQueue = AppScenicQueueStore(defaults: defaults, session: session)
} }
/// Token ///
var token: String {
get { defaults.string(forKey: Key.token) ?? "" }
set { defaults.set(newValue, forKey: Key.token) }
}
///
var lastLoginUsername: String? {
get { defaults.string(forKey: Key.lastLoginUsername) }
set {
if let newValue {
defaults.set(newValue, forKey: Key.lastLoginUsername)
} else {
defaults.removeObject(forKey: Key.lastLoginUsername)
}
}
}
///
var privacyAgreementAccepted: Bool {
get { defaults.bool(forKey: Key.privacyAgreementAccepted) }
set { defaults.set(newValue, forKey: Key.privacyAgreementAccepted) }
}
var userId: String {
get { defaults.string(forKey: Key.userId) ?? "" }
set { defaults.set(newValue, forKey: Key.userId) }
}
var userName: String {
get { defaults.string(forKey: Key.userName) ?? "" }
set { defaults.set(newValue, forKey: Key.userName) }
}
var realName: String {
get { defaults.string(forKey: Key.realName) ?? "" }
set { defaults.set(newValue, forKey: Key.realName) }
}
var avatar: String {
get { defaults.string(forKey: Key.avatar) ?? "" }
set { defaults.set(newValue, forKey: Key.avatar) }
}
var phone: String {
get { defaults.string(forKey: Key.phone) ?? "" }
set { defaults.set(newValue, forKey: Key.phone) }
}
var accountType: String {
get { defaults.string(forKey: Key.accountType) ?? "" }
set { defaults.set(newValue, forKey: Key.accountType) }
}
var accountDisplayName: String {
get { defaults.string(forKey: Key.accountDisplayName) ?? "" }
set { defaults.set(newValue, forKey: Key.accountDisplayName) }
}
var roleCode: String {
get { defaults.string(forKey: Key.roleCode) ?? "" }
set { defaults.set(newValue, forKey: Key.roleCode) }
}
var roleName: String {
get { defaults.string(forKey: Key.roleName) ?? "" }
set { defaults.set(newValue, forKey: Key.roleName) }
}
var currentScenicId: Int {
get {
let value = defaults.string(forKey: Key.currentScenicId) ?? ""
return Int(value) ?? 0
}
set {
defaults.set(newValue > 0 ? String(newValue) : "", forKey: Key.currentScenicId)
}
}
var currentScenicName: String {
get { defaults.string(forKey: Key.currentScenicName) ?? "" }
set { defaults.set(newValue, forKey: Key.currentScenicName) }
}
var currentStoreId: Int {
get { defaults.integer(forKey: Key.currentStoreId) }
set { defaults.set(newValue, forKey: Key.currentStoreId) }
}
/// role_id
var legacyRoleId: Int {
get { defaults.integer(forKey: accountScopedKey(Key.legacyRoleId)) }
set { defaults.set(newValue, forKey: accountScopedKey(Key.legacyRoleId)) }
}
/// 线
var onlineStatus: Bool {
get { defaults.bool(forKey: accountScopedKey(Key.onlineStatus)) }
set { defaults.set(newValue, forKey: accountScopedKey(Key.onlineStatus)) }
}
///
var lastLocationReportTime: Int64 {
get { Int64(defaults.integer(forKey: accountScopedKey(Key.lastLocationReportTime))) }
set { defaults.set(Int(newValue), forKey: accountScopedKey(Key.lastLocationReportTime)) }
}
/// 0
var locationReminderMinutes: Int {
get { defaults.integer(forKey: accountScopedKey(Key.locationReminderMinutes)) }
set { defaults.set(newValue, forKey: accountScopedKey(Key.locationReminderMinutes)) }
}
/// Android `getIsOpenReceiveVoice`
var isOpenReceiveVoice: Bool {
get { defaults.bool(forKey: accountScopedKey(Key.isOpenReceiveVoice)) }
set { defaults.set(newValue, forKey: accountScopedKey(Key.isOpenReceiveVoice)) }
}
/// ID
var scenicQueuePunchSpotId: String {
get { defaults.string(forKey: accountScopedKey(Key.scenicQueuePunchSpotId)) ?? "" }
set { defaults.set(newValue, forKey: accountScopedKey(Key.scenicQueuePunchSpotId)) }
}
///
var scenicQueuePunchSpotName: String {
get { defaults.string(forKey: accountScopedKey(Key.scenicQueuePunchSpotName)) ?? "" }
set { defaults.set(newValue, forKey: accountScopedKey(Key.scenicQueuePunchSpotName)) }
}
///
var scenicQueueRemark: String {
get { defaults.string(forKey: accountScopedKey(Key.scenicQueueRemark)) ?? "" }
set { defaults.set(newValue, forKey: accountScopedKey(Key.scenicQueueRemark)) }
}
/// 使线 TTS
var scenicQueueUseOfflineTTS: Bool {
get { defaults.bool(forKey: accountScopedKey(Key.scenicQueueOfflineTTS)) }
set { defaults.set(newValue, forKey: accountScopedKey(Key.scenicQueueOfflineTTS)) }
}
/// Android `getAccountCachePrefix`
var accountCachePrefix: String {
let uid = userId.trimmingCharacters(in: .whitespacesAndNewlines)
let type = accountType.trimmingCharacters(in: .whitespacesAndNewlines)
if uid.isEmpty { return "guest" }
if type.isEmpty { return uid }
return "\(uid)_\(type)"
}
///
var currentAppRole: AppRoleCode? {
AppRoleCode.fromCode(roleCode) ?? AppRoleCode.fromRoleName(roleName)
}
///
var isPhotographerRole: Bool {
if let role = currentAppRole {
return role.isPhotographer
}
let name = roleName.trimmingCharacters(in: .whitespacesAndNewlines)
return name == "摄影师"
}
/// Token
var isLoggedIn: Bool {
!token.isEmpty
}
/// Token
func saveToken(_ token: String) {
self.token = token
}
///
func applyAccount(_ account: AccountSwitchAccount) {
userId = String(account.businessUserId)
accountType = account.accountType
accountDisplayName = account.title
userName = account.subtitle
phone = account.phone
if !account.avatar.isEmpty {
avatar = account.avatar
}
currentScenicName = account.scenicName
currentScenicId = account.scenicId ?? 0
currentStoreId = account.storeId ?? 0
}
///
func applyUserInfo(_ info: UserInfoResponse) {
if !info.nickname.isEmpty {
userName = info.nickname
}
if !info.realName.isEmpty {
realName = info.realName
}
if !info.avatar.isEmpty {
avatar = info.avatar
}
if !info.phone.isEmpty {
phone = info.phone
}
if !info.roleName.isEmpty, roleName.isEmpty {
roleName = info.roleName
}
}
///
func logout() { func logout() {
clearPermissionSnapshot() permissions.clear()
token = "" location.clear()
userId = "" payment.clear()
userName = "" scenicQueue.clear()
realName = "" session.clearAuthenticatedSession()
avatar = ""
phone = ""
accountType = ""
accountDisplayName = ""
roleCode = ""
roleName = ""
currentScenicId = 0
currentScenicName = ""
currentStoreId = 0
}
/// role-permission
func saveRolePermissionList(_ list: [RolePermissionResponse]) {
guard let data = try? JSONEncoder().encode(list) else { return }
defaults.set(data, forKey: accountScopedKey(Key.rolePermissionList))
}
/// role-permission
func rolePermissionList() -> [RolePermissionResponse] {
guard let data = defaults.data(forKey: accountScopedKey(Key.rolePermissionList)),
let list = try? JSONDecoder().decode([RolePermissionResponse].self, from: data) else {
return []
}
return list
}
///
func savePermissionItems(_ items: [HomePermissionItem]) {
guard let data = try? JSONEncoder().encode(items) else { return }
defaults.set(data, forKey: accountScopedKey(Key.permissionItems))
}
///
func permissionItems() -> [HomePermissionItem] {
guard let data = defaults.data(forKey: accountScopedKey(Key.permissionItems)),
let items = try? JSONDecoder().decode([HomePermissionItem].self, from: data) else {
return []
}
return items
}
///
func saveRoleScenicList(_ scenicList: [ScenicInfo]) {
guard let data = try? JSONEncoder().encode(scenicList) else { return }
defaults.set(data, forKey: accountScopedKey(Key.roleScenicList))
}
///
func roleScenicList() -> [ScenicInfo] {
guard let data = defaults.data(forKey: accountScopedKey(Key.roleScenicList)),
let list = try? JSONDecoder().decode([ScenicInfo].self, from: data) else {
return []
}
return list
}
/// role-permission
func matchRolePermissionItem(in list: [RolePermissionResponse]) -> RolePermissionResponse? {
RolePermissionMatcher.match(
in: list,
roleCode: roleCode,
roleName: roleName,
legacyRoleId: legacyRoleId
)
}
///
func clearLastLocationReportTime() {
defaults.removeObject(forKey: accountScopedKey(Key.lastLocationReportTime))
}
///
func hasScenicQueuePunchSpotSaved() -> Bool {
let id = scenicQueuePunchSpotId.trimmingCharacters(in: .whitespacesAndNewlines)
return Int64(id).map { $0 > 0 } ?? false
}
///
func saveScenicQueueSettingsSnapshot(_ snapshot: ScenicQueueSettingsSnapshot) {
guard let data = try? JSONEncoder().encode(snapshot) else { return }
defaults.set(data, forKey: accountScopedKey(Key.scenicQueueSettingsSnapshot))
}
///
func scenicQueueSettingsSnapshot() -> ScenicQueueSettingsSnapshot? {
guard let data = defaults.data(forKey: accountScopedKey(Key.scenicQueueSettingsSnapshot)) else { return nil }
return try? JSONDecoder().decode(ScenicQueueSettingsSnapshot.self, from: data)
}
/// 5
func saveScenicQueuePresetVoices(_ voices: [String]) {
let normalized = voices
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
.prefix(5)
defaults.set(Array(normalized), forKey: accountScopedKey(Key.scenicQueuePresetVoices))
}
///
func scenicQueuePresetVoices() -> [String] {
defaults.stringArray(forKey: accountScopedKey(Key.scenicQueuePresetVoices)) ?? []
}
///
func clearScenicQueueSnapshot() {
[
Key.scenicQueuePunchSpotId,
Key.scenicQueuePunchSpotName,
Key.scenicQueueRemark,
Key.scenicQueueSettingsSnapshot,
Key.scenicQueueOfflineTTS,
Key.scenicQueuePresetVoices,
].forEach { defaults.removeObject(forKey: accountScopedKey($0)) }
}
///
func clearPermissionSnapshot() {
let prefix = accountScopedKey("")
let keys = [
Key.rolePermissionList,
Key.permissionItems,
Key.roleScenicList,
Key.onlineStatus,
Key.lastLocationReportTime,
Key.locationReminderMinutes,
Key.isOpenReceiveVoice,
Key.scenicQueuePunchSpotId,
Key.scenicQueuePunchSpotName,
Key.scenicQueueRemark,
Key.scenicQueueSettingsSnapshot,
Key.scenicQueueOfflineTTS,
Key.scenicQueuePresetVoices,
]
keys.forEach { defaults.removeObject(forKey: prefix + $0) }
}
private func accountScopedKey(_ key: String) -> String {
"\(accountCachePrefix)_\(key)"
} }
} }

View File

@ -18,9 +18,9 @@ enum AuthSessionHelper {
let token = response.token.trimmingCharacters(in: .whitespacesAndNewlines) let token = response.token.trimmingCharacters(in: .whitespacesAndNewlines)
guard !token.isEmpty else { return } guard !token.isEmpty else { return }
AppStore.shared.saveToken(token) AppStore.shared.session.saveToken(token)
AppStore.shared.lastLoginUsername = username AppStore.shared.session.lastLoginUsername = username
AppStore.shared.privacyAgreementAccepted = privacyAgreementAccepted AppStore.shared.session.privacyAgreementAccepted = privacyAgreementAccepted
applyAccountSession(from: selectedAccount, in: response) applyAccountSession(from: selectedAccount, in: response)
if privacyAgreementAccepted { if privacyAgreementAccepted {
@ -39,14 +39,14 @@ enum AuthSessionHelper {
let token = response.token.trimmingCharacters(in: .whitespacesAndNewlines) let token = response.token.trimmingCharacters(in: .whitespacesAndNewlines)
guard !token.isEmpty else { return } guard !token.isEmpty else { return }
AppStore.shared.saveToken(token) AppStore.shared.session.saveToken(token)
applyAccountSession(from: account, in: response) applyAccountSession(from: account, in: response)
NotificationCenter.default.post(name: NotificationName.accountDidSwitch, object: nil) NotificationCenter.default.post(name: NotificationName.accountDidSwitch, object: nil)
} }
/// v9 AppStore /// v9 AppStore
static func applyAccountSession(from account: AccountSwitchAccount, in response: V9AuthResponse) { static func applyAccountSession(from account: AccountSwitchAccount, in response: V9AuthResponse) {
AppStore.shared.applyAccount(account) AppStore.shared.session.applyAccount(account)
if let scenicUser = response.scenicUsers.first(where: { matches($0, account: account) }) { if let scenicUser = response.scenicUsers.first(where: { matches($0, account: account) }) {
applyScenicUser(scenicUser) applyScenicUser(scenicUser)
@ -67,43 +67,43 @@ enum AuthSessionHelper {
} }
private static func applyScenicUser(_ user: V9ScenicUser) { private static func applyScenicUser(_ user: V9ScenicUser) {
AppStore.shared.userId = String(user.businessUserId) AppStore.shared.session.userId = String(user.businessUserId)
AppStore.shared.accountType = V9ScenicUser.accountTypeValue AppStore.shared.session.accountType = .scenicUser
AppStore.shared.accountDisplayName = firstNonEmpty( AppStore.shared.session.accountDisplayName = firstNonEmpty(
user.scenicName, user.nickname, user.realName, user.username user.scenicName, user.nickname, user.realName, user.username
) )
AppStore.shared.userName = firstNonEmpty(user.nickname, user.username, user.realName) AppStore.shared.session.userName = firstNonEmpty(user.nickname, user.username, user.realName)
AppStore.shared.realName = firstNonEmpty(user.realName, user.nickname, user.username) AppStore.shared.session.realName = firstNonEmpty(user.realName, user.nickname, user.username)
AppStore.shared.phone = user.phone AppStore.shared.session.phone = user.phone
AppStore.shared.currentScenicId = user.scenicId AppStore.shared.session.currentScenicId = user.scenicId
AppStore.shared.currentScenicName = user.scenicName AppStore.shared.session.currentScenicName = user.scenicName
AppStore.shared.currentStoreId = 0 AppStore.shared.session.currentStoreId = 0
if !user.appRoleCode.isEmpty { if !user.appRoleCode.isEmpty {
AppStore.shared.roleCode = user.appRoleCode AppStore.shared.session.roleCode = user.appRoleCode
} }
if !user.appRoleName.isEmpty { if !user.appRoleName.isEmpty {
AppStore.shared.roleName = user.appRoleName AppStore.shared.session.roleName = user.appRoleName
} }
} }
private static func applyStoreUser(_ user: V9StoreUser) { private static func applyStoreUser(_ user: V9StoreUser) {
AppStore.shared.userId = String(user.businessUserId) AppStore.shared.session.userId = String(user.businessUserId)
AppStore.shared.accountType = V9StoreUser.accountTypeValue AppStore.shared.session.accountType = .storeUser
AppStore.shared.accountDisplayName = firstNonEmpty( AppStore.shared.session.accountDisplayName = firstNonEmpty(
user.storeName, user.scenicName, user.realName, user.userName, user.username user.storeName, user.scenicName, user.realName, user.userName, user.username
) )
AppStore.shared.userName = firstNonEmpty(user.userName, user.username, user.realName) AppStore.shared.session.userName = firstNonEmpty(user.userName, user.username, user.realName)
AppStore.shared.realName = firstNonEmpty(user.realName, user.userName, user.username) AppStore.shared.session.realName = firstNonEmpty(user.realName, user.userName, user.username)
AppStore.shared.phone = user.phone AppStore.shared.session.phone = user.phone
AppStore.shared.avatar = user.avatar AppStore.shared.session.avatar = user.avatar
AppStore.shared.currentScenicId = user.scenicId AppStore.shared.session.currentScenicId = user.scenicId
AppStore.shared.currentScenicName = user.scenicName AppStore.shared.session.currentScenicName = user.scenicName
AppStore.shared.currentStoreId = user.storeId AppStore.shared.session.currentStoreId = user.storeId
if !user.appRoleCode.isEmpty { if !user.appRoleCode.isEmpty {
AppStore.shared.roleCode = user.appRoleCode AppStore.shared.session.roleCode = user.appRoleCode
} }
if !user.appRoleName.isEmpty { if !user.appRoleName.isEmpty {
AppStore.shared.roleName = user.appRoleName AppStore.shared.session.roleName = user.appRoleName
} }
} }

View File

@ -14,7 +14,7 @@ final class CloudTransferManager {
api: NetworkServices.shared.cloudDriveAPI, api: NetworkServices.shared.cloudDriveAPI,
downloader: CloudURLSessionDownloader(), downloader: CloudURLSessionDownloader(),
photoSaver: CloudPhotoLibrarySaver(), photoSaver: CloudPhotoLibrarySaver(),
scenicIdProvider: { AppStore.shared.currentScenicId } scenicIdProvider: { AppStore.shared.session.currentScenicId }
) )
private(set) var uploadTasks: [CloudTransferTask] = [] private(set) var uploadTasks: [CloudTransferTask] = []

View File

@ -33,7 +33,7 @@ final class CooperationAcquirerViewModel {
} }
func loadAcquirers(api: OrderAPI, initial: Bool = false) async { func loadAcquirers(api: OrderAPI, initial: Bool = false) async {
let storeId = appStore.currentStoreId let storeId = appStore.session.currentStoreId
let storeIdParam = storeId > 0 ? String(storeId) : nil let storeIdParam = storeId > 0 ? String(storeId) : nil
do { do {
let response = try await api.cooperativeSalers(storeId: storeIdParam, pageSize: "20") let response = try await api.cooperativeSalers(storeId: storeIdParam, pageSize: "20")

View File

@ -24,8 +24,8 @@ final class HomeLocationStateStore {
init(store: AppStore = .shared) { init(store: AppStore = .shared) {
self.store = store self.store = store
reminderMinutes = store.locationReminderMinutes reminderMinutes = store.location.reminderMinutes
isOnline = store.onlineStatus isOnline = store.location.onlineStatus
} }
deinit { deinit {
@ -44,18 +44,18 @@ final class HomeLocationStateStore {
/// 线 /// 线
func restoreStateIfNeeded() { func restoreStateIfNeeded() {
reminderMinutes = store.locationReminderMinutes reminderMinutes = store.location.reminderMinutes
guard store.onlineStatus else { guard store.location.onlineStatus else {
isOnline = false isOnline = false
stopCountdown() stopCountdown()
notifyChange() notifyChange()
return return
} }
let lastTime = store.lastLocationReportTime let lastTime = store.location.lastReportTime
guard lastTime > 0 else { guard lastTime > 0 else {
isOnline = false isOnline = false
store.onlineStatus = false store.location.onlineStatus = false
store.clearLastLocationReportTime() store.location.clearLastReportTime()
stopCountdown() stopCountdown()
notifyChange() notifyChange()
return return
@ -65,7 +65,7 @@ final class HomeLocationStateStore {
let elapsed = now - lastTime let elapsed = now - lastTime
if elapsed >= Self.onlineDurationMillis { if elapsed >= Self.onlineDurationMillis {
updateOnlineStatus(false) updateOnlineStatus(false)
store.clearLastLocationReportTime() store.location.clearLastReportTime()
notifyChange() notifyChange()
return return
} }
@ -81,10 +81,10 @@ final class HomeLocationStateStore {
/// 线 /// 线
func updateOnlineStatus(_ online: Bool) { func updateOnlineStatus(_ online: Bool) {
isOnline = online isOnline = online
store.onlineStatus = online store.location.onlineStatus = online
if !online { if !online {
stopCountdown() stopCountdown()
store.clearLastLocationReportTime() store.location.clearLastReportTime()
} }
notifyChange() notifyChange()
} }
@ -92,16 +92,16 @@ final class HomeLocationStateStore {
/// ///
func updateReminderMinutes(_ minutes: Int) { func updateReminderMinutes(_ minutes: Int) {
reminderMinutes = minutes reminderMinutes = minutes
store.locationReminderMinutes = minutes store.location.reminderMinutes = minutes
notifyChange() notifyChange()
} }
/// ///
func startLocationReport(at timestamp: Int64 = Int64(Date().timeIntervalSince1970 * 1000)) { func startLocationReport(at timestamp: Int64 = Int64(Date().timeIntervalSince1970 * 1000)) {
anchorTimestamp = timestamp anchorTimestamp = timestamp
store.lastLocationReportTime = timestamp store.location.lastReportTime = timestamp
isOnline = true isOnline = true
store.onlineStatus = true store.location.onlineStatus = true
elapsedSeconds = 0 elapsedSeconds = 0
nextReportCountdownSeconds = Self.onlineDurationMillis / 1000 nextReportCountdownSeconds = Self.onlineDurationMillis / 1000
startCountdown(anchorTime: timestamp) startCountdown(anchorTime: timestamp)
@ -156,7 +156,7 @@ final class HomeLocationStateStore {
if remaining <= 0 { if remaining <= 0 {
self.nextReportCountdownSeconds = 0 self.nextReportCountdownSeconds = 0
self.updateOnlineStatus(false) self.updateOnlineStatus(false)
self.store.clearLastLocationReportTime() self.store.location.clearLastReportTime()
return return
} }
self.nextReportCountdownSeconds = max(0, remaining / 1000) self.nextReportCountdownSeconds = max(0, remaining / 1000)

View File

@ -10,10 +10,10 @@ enum HomeMenuCatalog {
/// Android `Constants.menuList` /// Android `Constants.menuList`
static let allItems: [HomeMenuItem] = [ static let allItems: [HomeMenuItem] = [
HomeMenuItem(uri: "space_settings", title: "空间设置", iconName: "person.crop.square.fill"), HomeMenuItem(uri: "space_settings", title: "空间设置", iconName: "home_menu_space"),
HomeMenuItem(uri: "wallet", title: "我的钱包", iconName: "creditcard.fill"), HomeMenuItem(uri: "wallet", title: "我的钱包", iconName: "creditcard.fill"),
HomeMenuItem(uri: "cloud_management", title: "相册云盘", iconName: "icloud.fill"), HomeMenuItem(uri: "cloud_management", title: "相册云盘", iconName: "icloud.fill"),
HomeMenuItem(uri: "asset_management", title: "素材管理", iconName: "photo.on.rectangle.angled"), HomeMenuItem(uri: "asset_management", title: "素材管理", iconName: "home_menu_material"),
HomeMenuItem(uri: "task_management", title: "任务管理", iconName: "checklist"), HomeMenuItem(uri: "task_management", title: "任务管理", iconName: "checklist"),
HomeMenuItem(uri: "task_management_editor", title: "任务管理", iconName: "checklist"), HomeMenuItem(uri: "task_management_editor", title: "任务管理", iconName: "checklist"),
HomeMenuItem(uri: "schedule_management", title: "日程管理", iconName: "calendar"), HomeMenuItem(uri: "schedule_management", title: "日程管理", iconName: "calendar"),

View File

@ -21,7 +21,7 @@ enum HomeRouteHandler {
return return
} }
if uri != "pilot_controller", uri != "wallet", AppStore.shared.currentScenicId <= 0 { if uri != "pilot_controller", uri != "wallet", AppStore.shared.session.currentScenicId <= 0 {
showToast("请先选择景区", from: viewController) showToast("请先选择景区", from: viewController)
return return
} }
@ -87,7 +87,7 @@ enum HomeRouteHandler {
@MainActor @MainActor
private static func handleOperatingArea(from viewController: UIViewController, storeItem: StoreItem?) { private static func handleOperatingArea(from viewController: UIViewController, storeItem: StoreItem?) {
guard let role = AppStore.shared.currentAppRole else { guard let role = AppStore.shared.session.currentAppRole else {
showToast("当前账号暂不支持运营区域", from: viewController) showToast("当前账号暂不支持运营区域", from: viewController)
return return
} }
@ -99,7 +99,7 @@ enum HomeRouteHandler {
} }
showDeveloping(from: viewController) showDeveloping(from: viewController)
case .scenicAdmin: case .scenicAdmin:
guard AppStore.shared.currentScenicId > 0 else { guard AppStore.shared.session.currentScenicId > 0 else {
showToast("请先选择景区", from: viewController) showToast("请先选择景区", from: viewController)
return return
} }
@ -110,7 +110,7 @@ enum HomeRouteHandler {
} }
private static func hasCooperationOrderPermission() -> Bool { private static func hasCooperationOrderPermission() -> Bool {
let permissions = AppStore.shared.permissionItems() let permissions = AppStore.shared.permissions.permissionItems()
return permissions.contains { $0.uri == "cooperation_order" } return permissions.contains { $0.uri == "cooperation_order" }
|| permissions.contains(where: { containsCooperationOrder(in: $0) }) || permissions.contains(where: { containsCooperationOrder(in: $0) })
} }

View File

@ -27,10 +27,10 @@ final class AllFunctionsViewModel {
/// ///
func loadFunctions() { func loadFunctions() {
let permissions = appStore.permissionItems() let permissions = appStore.permissions.permissionItems()
let allMenus = HomeMenuCatalog.visibleMenus(from: permissions) let allMenus = HomeMenuCatalog.visibleMenus(from: permissions)
let accountScope = appStore.accountCachePrefix let accountScope = appStore.session.accountCachePrefix
let roleCode = appStore.roleCode let roleCode = appStore.session.roleCode
let savedURIs = commonMenuStore.savedCommonURIs(accountScope: accountScope, roleCode: roleCode) let savedURIs = commonMenuStore.savedCommonURIs(accountScope: accountScope, roleCode: roleCode)
let defaultURIs = HomeCommonMenuStore.defaultCommonURIs(from: permissions) let defaultURIs = HomeCommonMenuStore.defaultCommonURIs(from: permissions)
let commonURIs = savedURIs.isEmpty ? defaultURIs : savedURIs let commonURIs = savedURIs.isEmpty ? defaultURIs : savedURIs
@ -71,8 +71,8 @@ final class AllFunctionsViewModel {
private func persistCommonMenus() { private func persistCommonMenus() {
commonMenuStore.saveCommonURIs( commonMenuStore.saveCommonURIs(
commonMenus.map(\.uri), commonMenus.map(\.uri),
accountScope: appStore.accountCachePrefix, accountScope: appStore.session.accountCachePrefix,
roleCode: appStore.roleCode roleCode: appStore.session.roleCode
) )
} }

View File

@ -94,7 +94,7 @@ final class HomeViewModel {
/// role-permission /// role-permission
func loadPermissions(api: HomeAPI, force: Bool = false) async { func loadPermissions(api: HomeAPI, force: Bool = false) async {
if !force, !appStore.permissionItems().isEmpty { if !force, !appStore.permissions.permissionItems().isEmpty {
rebuildCommonMenus() rebuildCommonMenus()
await loadStoreListIfNeeded(api: api) await loadStoreListIfNeeded(api: api)
notifyStateChange() notifyStateChange()
@ -104,21 +104,21 @@ final class HomeViewModel {
do { do {
let rolePermissionList = try await api.rolePermissions() let rolePermissionList = try await api.rolePermissions()
if rolePermissionList.isEmpty { if rolePermissionList.isEmpty {
appStore.savePermissionItems([]) appStore.permissions.savePermissionItems([])
showPermissionDialog = true showPermissionDialog = true
rebuildCommonMenus() rebuildCommonMenus()
notifyStateChange() notifyStateChange()
return return
} }
appStore.saveRolePermissionList(rolePermissionList) appStore.permissions.saveRolePermissionList(rolePermissionList)
let matched = appStore.matchRolePermissionItem(in: rolePermissionList) ?? rolePermissionList[0] let matched = appStore.permissions.matchRolePermissionItem(in: rolePermissionList) ?? rolePermissionList[0]
if !matched.role.name.isEmpty { if !matched.role.name.isEmpty {
appStore.roleName = matched.role.name appStore.session.roleName = matched.role.name
} }
appStore.saveRoleScenicList(matched.scenic) appStore.permissions.saveRoleScenicList(matched.scenic)
let permissions = matched.role.permission.map(HomePermissionItem.init) let permissions = matched.role.permission.map(HomePermissionItem.init)
appStore.savePermissionItems(permissions) appStore.permissions.savePermissionItems(permissions)
showPermissionDialog = false showPermissionDialog = false
refreshLocalDisplayState() refreshLocalDisplayState()
rebuildCommonMenus() rebuildCommonMenus()
@ -127,7 +127,7 @@ final class HomeViewModel {
} catch is CancellationError { } catch is CancellationError {
return return
} catch { } catch {
appStore.savePermissionItems([]) appStore.permissions.savePermissionItems([])
showPermissionDialog = true showPermissionDialog = true
rebuildCommonMenus() rebuildCommonMenus()
onShowMessage?(error.localizedDescription) onShowMessage?(error.localizedDescription)
@ -142,7 +142,7 @@ final class HomeViewModel {
notifyStateChange() notifyStateChange()
return return
} }
guard appStore.currentScenicId > 0 else { guard appStore.session.currentScenicId > 0 else {
storeItem = nil storeItem = nil
notifyStateChange() notifyStateChange()
return return
@ -150,13 +150,13 @@ final class HomeViewModel {
do { do {
let response = try await api.storeList() let response = try await api.storeList()
let scenicId = appStore.currentScenicId let scenicId = appStore.session.currentScenicId
let savedStoreId = appStore.currentStoreId let savedStoreId = appStore.session.currentStoreId
let savedMatch = savedStoreId > 0 ? response.list.first { $0.id == savedStoreId } : nil let savedMatch = savedStoreId > 0 ? response.list.first { $0.id == savedStoreId } : nil
let matched = savedMatch ?? response.list.first { $0.scenicId == scenicId } let matched = savedMatch ?? response.list.first { $0.scenicId == scenicId }
storeItem = matched storeItem = matched
if let matched { if let matched {
appStore.currentStoreId = matched.id appStore.session.currentStoreId = matched.id
} }
notifyStateChange() notifyStateChange()
} catch is CancellationError { } catch is CancellationError {
@ -173,7 +173,7 @@ final class HomeViewModel {
showScenicDialog = false showScenicDialog = false
return return
} }
let hasScenic = appStore.currentScenicId > 0 let hasScenic = appStore.session.currentScenicId > 0
showScenicDialog = !hasScenic showScenicDialog = !hasScenic
if showScenicDialog || dismissedLocationTimeoutDialog { if showScenicDialog || dismissedLocationTimeoutDialog {
showLocationTimeoutDialog = false showLocationTimeoutDialog = false
@ -196,7 +196,7 @@ final class HomeViewModel {
notifyStateChange() notifyStateChange()
return return
} }
let staffId = appStore.userId.trimmingCharacters(in: .whitespacesAndNewlines) let staffId = appStore.session.userId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !staffId.isEmpty else { guard !staffId.isEmpty else {
showLocationTimeoutDialog = true showLocationTimeoutDialog = true
notifyStateChange() notifyStateChange()
@ -225,25 +225,25 @@ final class HomeViewModel {
/// Android `initCurrentScenicName` /// Android `initCurrentScenicName`
func refreshLocalDisplayState() { func refreshLocalDisplayState() {
let scenicId = appStore.currentScenicId let scenicId = appStore.session.currentScenicId
let scenicName = appStore.currentScenicName.trimmingCharacters(in: .whitespacesAndNewlines) let scenicName = appStore.session.currentScenicName.trimmingCharacters(in: .whitespacesAndNewlines)
if scenicId > 0, !scenicName.isEmpty { if scenicId > 0, !scenicName.isEmpty {
currentScenicName = scenicName currentScenicName = scenicName
} else { } else {
currentScenicName = "" currentScenicName = ""
appStore.currentScenicId = 0 appStore.session.currentScenicId = 0
appStore.currentScenicName = "" appStore.session.currentScenicName = ""
} }
currentAppRole = appStore.currentAppRole currentAppRole = appStore.session.currentAppRole
isMinimalTopRole = currentAppRole?.usesHomeMinimalTopLayout ?? false isMinimalTopRole = currentAppRole?.usesHomeMinimalTopLayout ?? false
} }
/// ///
func rebuildCommonMenus() { func rebuildCommonMenus() {
let permissions = appStore.permissionItems() let permissions = appStore.permissions.permissionItems()
let allMenus = HomeMenuCatalog.visibleMenus(from: permissions) let allMenus = HomeMenuCatalog.visibleMenus(from: permissions)
let accountScope = appStore.accountCachePrefix let accountScope = appStore.session.accountCachePrefix
let roleCode = appStore.roleCode let roleCode = appStore.session.roleCode
let savedURIs = commonMenuStore.savedCommonURIs(accountScope: accountScope, roleCode: roleCode) let savedURIs = commonMenuStore.savedCommonURIs(accountScope: accountScope, roleCode: roleCode)
if savedURIs.isEmpty, !permissions.isEmpty { if savedURIs.isEmpty, !permissions.isEmpty {

View File

@ -21,7 +21,7 @@ final class PermissionApplyStatusViewModel {
init(applyCode: String, appStore: AppStore = .shared) { init(applyCode: String, appStore: AppStore = .shared) {
self.applyCode = applyCode self.applyCode = applyCode
self.appStore = appStore self.appStore = appStore
rolePermissionList = appStore.rolePermissionList() rolePermissionList = appStore.permissions.rolePermissionList()
} }
func loadPendingData(api: HomeAPI) async { func loadPendingData(api: HomeAPI) async {

View File

@ -45,7 +45,7 @@ final class PermissionApplyViewModel {
} }
func loadRolesFromLocal() { func loadRolesFromLocal() {
let rolePermissionList = appStore.rolePermissionList() let rolePermissionList = appStore.permissions.rolePermissionList()
var seen = Set<Int>() var seen = Set<Int>()
availableRoles = rolePermissionList.compactMap { item in availableRoles = rolePermissionList.compactMap { item in
guard !seen.contains(item.role.id) else { return nil } guard !seen.contains(item.role.id) else { return nil }
@ -158,7 +158,7 @@ final class PermissionApplyViewModel {
/// ///
func existingScenics(for roleId: Int) -> [ScenicInfo] { func existingScenics(for roleId: Int) -> [ScenicInfo] {
appStore.rolePermissionList() appStore.permissions.rolePermissionList()
.filter { $0.role.id == roleId } .filter { $0.role.id == roleId }
.flatMap(\.scenic) .flatMap(\.scenic)
} }

View File

@ -43,7 +43,7 @@ final class ScenicSelectionViewModel {
searchQuery: searchQuery, searchQuery: searchQuery,
currentLat: currentLat, currentLat: currentLat,
currentLng: currentLng, currentLng: currentLng,
selectedScenicId: appStore.currentScenicId selectedScenicId: appStore.session.currentScenicId
) )
notifyStateChange() notifyStateChange()
} }
@ -73,8 +73,8 @@ final class ScenicSelectionViewModel {
} }
func selectSpot(_ spot: ScenicSpotDisplayItem) { func selectSpot(_ spot: ScenicSpotDisplayItem) {
appStore.currentScenicId = spot.id appStore.session.currentScenicId = spot.id
appStore.currentScenicName = spot.name appStore.session.currentScenicName = spot.name
NotificationCenter.default.post( NotificationCenter.default.post(
name: NotificationName.scenicDidChange, name: NotificationName.scenicDidChange,
object: nil, object: nil,
@ -109,11 +109,11 @@ final class ScenicSelectionViewModel {
} }
private func resolvedScenicList() -> [ScenicInfo] { private func resolvedScenicList() -> [ScenicInfo] {
let cached = appStore.roleScenicList() let cached = appStore.permissions.roleScenicList()
if !cached.isEmpty { return cached } if !cached.isEmpty { return cached }
let rolePermissionList = appStore.rolePermissionList() let rolePermissionList = appStore.permissions.rolePermissionList()
guard !rolePermissionList.isEmpty else { return [] } guard !rolePermissionList.isEmpty else { return [] }
return appStore.matchRolePermissionItem(in: rolePermissionList)?.scenic ?? [] return appStore.permissions.matchRolePermissionItem(in: rolePermissionList)?.scenic ?? []
} }
private func notifyStateChange() { private func notifyStateChange() {

View File

@ -34,7 +34,7 @@ final class LiveManageViewModel {
private let pageSize = 10 private let pageSize = 10
/// ViewModel /// ViewModel
init(currentScenicIdProvider: @escaping () -> Int = { AppStore.shared.currentScenicId }) { init(currentScenicIdProvider: @escaping () -> Int = { AppStore.shared.session.currentScenicId }) {
self.currentScenicIdProvider = currentScenicIdProvider self.currentScenicIdProvider = currentScenicIdProvider
} }
@ -282,7 +282,7 @@ final class LiveAlbumListViewModel {
private let pageSize = 10 private let pageSize = 10
/// ViewModel /// ViewModel
init(currentScenicIdProvider: @escaping () -> Int = { AppStore.shared.currentScenicId }) { init(currentScenicIdProvider: @escaping () -> Int = { AppStore.shared.session.currentScenicId }) {
self.currentScenicIdProvider = currentScenicIdProvider self.currentScenicIdProvider = currentScenicIdProvider
} }
@ -419,7 +419,7 @@ final class LiveAlbumAddViewModel {
private let currentScenicIdProvider: () -> Int private let currentScenicIdProvider: () -> Int
/// ViewModel /// ViewModel
init(currentScenicIdProvider: @escaping () -> Int = { AppStore.shared.currentScenicId }) { init(currentScenicIdProvider: @escaping () -> Int = { AppStore.shared.session.currentScenicId }) {
self.currentScenicIdProvider = currentScenicIdProvider self.currentScenicIdProvider = currentScenicIdProvider
} }

View File

@ -14,7 +14,7 @@ enum AMapBootstrap {
/// App SDK /// App SDK
@discardableResult @discardableResult
static func configureIfNeeded(appStore: AppStore = .shared) -> Bool { static func configureIfNeeded(appStore: AppStore = .shared) -> Bool {
guard appStore.privacyAgreementAccepted else { return false } guard appStore.session.privacyAgreementAccepted else { return false }
lock.lock() lock.lock()
defer { lock.unlock() } defer { lock.unlock() }

View File

@ -44,7 +44,7 @@ final class LocationReportService {
let remaining = Int((Self.operationIntervalMillis - (now - lastOnlineStatusSwitchTime)) / 1000) let remaining = Int((Self.operationIntervalMillis - (now - lastOnlineStatusSwitchTime)) / 1000)
throw LocationReportServiceError.tooFrequent(remainingSeconds: remaining) throw LocationReportServiceError.tooFrequent(remainingSeconds: remaining)
} }
guard appStore.currentScenicId > 0 else { guard appStore.session.currentScenicId > 0 else {
throw LocationReportServiceError.missingScenic throw LocationReportServiceError.missingScenic
} }
@ -112,10 +112,10 @@ final class LocationReportService {
} }
} }
guard appStore.currentScenicId > 0 else { guard appStore.session.currentScenicId > 0 else {
throw LocationReportServiceError.missingScenic throw LocationReportServiceError.missingScenic
} }
let staffId = appStore.userId.trimmingCharacters(in: .whitespacesAndNewlines) let staffId = appStore.session.userId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !staffId.isEmpty else { guard !staffId.isEmpty else {
throw LocationReportServiceError.missingStaffId throw LocationReportServiceError.missingStaffId
} }
@ -153,7 +153,7 @@ final class LocationReportService {
longitude: snapshot.longitude, longitude: snapshot.longitude,
address: snapshot.address, address: snapshot.address,
type: type, type: type,
scenicId: String(appStore.currentScenicId) scenicId: String(appStore.session.currentScenicId)
) )
lastLocationReportTime = currentTimestampMillis() lastLocationReportTime = currentTimestampMillis()

View File

@ -34,7 +34,7 @@ final class LocationReportHistoryViewModel {
/// ///
func loadReportList(api: HomeAPI, refresh: Bool = false) async { func loadReportList(api: HomeAPI, refresh: Bool = false) async {
guard !isLoading || refresh else { return } guard !isLoading || refresh else { return }
let staffId = appStore.userId.trimmingCharacters(in: .whitespacesAndNewlines) let staffId = appStore.session.userId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !staffId.isEmpty else { guard !staffId.isEmpty else {
onShowMessage?("获取用户ID失败") onShowMessage?("获取用户ID失败")
return return

View File

@ -62,13 +62,6 @@ enum MaterialMediaType: Int, CaseIterable, Sendable, Equatable {
} }
} }
/// 1 2
var cloudFileType: Int {
switch self {
case .image: 2
case .video: 1
}
}
} }
/// Android `MaterialItemEntity` /// Android `MaterialItemEntity`

View File

@ -141,7 +141,12 @@ final class MaterialListViewModel {
) )
totalCount = response.total totalCount = response.total
orderInfo = response.order ?? MaterialOrderInfo() orderInfo = response.order ?? MaterialOrderInfo()
items = reset ? response.list : items + response.list if reset {
items = response.list
} else {
let existingIDs = Set(items.map(\.id))
items.append(contentsOf: response.list.filter { !existingIDs.contains($0.id) })
}
canLoadMore = items.count < totalCount canLoadMore = items.count < totalCount
} catch is CancellationError { } catch is CancellationError {
return return
@ -244,6 +249,7 @@ final class MaterialFormViewModel {
private(set) var coverImage: MaterialUploadedMediaItem? private(set) var coverImage: MaterialUploadedMediaItem?
private(set) var tagList: [MaterialTagItem] = [] private(set) var tagList: [MaterialTagItem] = []
private(set) var tagInput = "" private(set) var tagInput = ""
private(set) var pendingDeleteTag: MaterialTagItem?
private(set) var uploadDialogState: MaterialUploadDialogState? private(set) var uploadDialogState: MaterialUploadDialogState?
private(set) var isSubmitting = false private(set) var isSubmitting = false
private(set) var isLoadingDetail = false private(set) var isLoadingDetail = false
@ -259,7 +265,7 @@ final class MaterialFormViewModel {
/// ViewModel /// ViewModel
init( init(
mode: Mode = .create, mode: Mode = .create,
currentScenicIdProvider: @escaping () -> Int = { AppStore.shared.currentScenicId } currentScenicIdProvider: @escaping () -> Int = { AppStore.shared.session.currentScenicId }
) { ) {
self.mode = mode self.mode = mode
self.currentScenicIdProvider = currentScenicIdProvider self.currentScenicIdProvider = currentScenicIdProvider
@ -295,7 +301,7 @@ final class MaterialFormViewModel {
/// ///
func updateTagInput(_ text: String) { func updateTagInput(_ text: String) {
tagInput = text tagInput = String(text.prefix(15))
notifyStateChange() notifyStateChange()
} }
@ -327,9 +333,23 @@ final class MaterialFormViewModel {
} }
} }
/// ///
func deleteTag(_ tag: MaterialTagItem) { func requestDeleteTag(_ tag: MaterialTagItem) {
pendingDeleteTag = tag
notifyStateChange()
}
///
func cancelDeleteTag() {
pendingDeleteTag = nil
notifyStateChange()
}
///
func confirmDeleteTag() {
guard let tag = pendingDeleteTag else { return }
tagList.removeAll { $0.id == tag.id } tagList.removeAll { $0.id == tag.id }
pendingDeleteTag = nil
notifyStateChange() notifyStateChange()
} }
@ -354,48 +374,6 @@ final class MaterialFormViewModel {
notifyStateChange() notifyStateChange()
} }
/// 使 URL
func addCloudMediaFiles(_ files: [CloudFile]) {
guard !files.isEmpty else { return }
let remaining = mediaType.maxCount - uploadedMediaList.count
guard remaining > 0 else {
onShowMessage?(mediaType == .video ? "视频只能上传1个" : "图片最多上传9张")
return
}
var seenCloudIDs = importedCloudFileIDs
var accepted: [MaterialUploadedMediaItem] = []
for file in files where accepted.count < remaining {
let fileURL = file.fileUrl.trimmingCharacters(in: .whitespacesAndNewlines)
guard file.type == mediaType.cloudFileType, !fileURL.isEmpty, !seenCloudIDs.contains(file.id) else {
continue
}
seenCloudIDs.insert(file.id)
accepted.append(
MaterialUploadedMediaItem(
id: "cloud_\(file.id)",
data: Data(),
thumbnailData: nil,
previewURL: file.coverUrl.isEmpty ? fileURL : file.coverUrl,
fileName: file.name.isEmpty ? "cloud_file_\(file.id)" : file.name,
size: file.fileSize,
width: 0,
height: 0,
ossUrl: fileURL,
isUploading: false,
uploadProgress: 100
)
)
}
guard !accepted.isEmpty else {
onShowMessage?(mediaType == .video ? "请选择视频素材" : "请选择图片素材")
return
}
uploadedMediaList.append(contentsOf: accepted)
notifyStateChange()
}
/// ///
func setCoverImage(_ item: MaterialUploadedMediaItem, uploader: any MaterialOSSUploading) async { func setCoverImage(_ item: MaterialUploadedMediaItem, uploader: any MaterialOSSUploading) async {
coverImage = item coverImage = item
@ -418,16 +396,6 @@ final class MaterialFormViewModel {
notifyStateChange() notifyStateChange()
} }
/// ID
var importedCloudFileIDs: Set<Int> {
Set(
uploadedMediaList.compactMap { item in
guard item.id.hasPrefix("cloud_") else { return nil }
return Int(item.id.dropFirst("cloud_".count))
}
)
}
/// ///
func submit(api: any MaterialManagementServing) async { func submit(api: any MaterialManagementServing) async {
guard !materialName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { guard !materialName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {

View File

@ -40,7 +40,7 @@ final class MainTabScanHandler {
case "check_in": case "check_in":
await performCheckIn(scanResult: scanResult) await performCheckIn(scanResult: scanResult)
case "order_verify": case "order_verify":
if AppStore.shared.currentAppRole == .storeAdmin { if AppStore.shared.session.currentAppRole == .storeAdmin {
await performStoreVerify(parsed: parsed) await performStoreVerify(parsed: parsed)
} else { } else {
await performPhotographerVerify(jsonBody: parsed.rawJSON) await performPhotographerVerify(jsonBody: parsed.rawJSON)
@ -92,7 +92,7 @@ final class MainTabScanHandler {
onShowMessage?("请扫描正确的二维码") onShowMessage?("请扫描正确的二维码")
return return
} }
json["scenic_id"] = String(AppStore.shared.currentScenicId) json["scenic_id"] = String(AppStore.shared.session.currentScenicId)
guard let body = serializeJSON(json) else { return } guard let body = serializeJSON(json) else { return }
do { do {
let response = try await api.orderCheckIn(jsonBody: body) let response = try await api.orderCheckIn(jsonBody: body)
@ -116,7 +116,7 @@ final class MainTabScanHandler {
onShowMessage?("请扫描正确的核销码") onShowMessage?("请扫描正确的核销码")
return return
} }
let storeId = AppStore.shared.currentStoreId let storeId = AppStore.shared.session.currentStoreId
guard storeId > 0 else { guard storeId > 0 else {
onShowMessage?("店铺信息异常,请先选择店铺") onShowMessage?("店铺信息异常,请先选择店铺")
return return

View File

@ -102,7 +102,7 @@ final class DepositOrderListViewModel {
onShowMessage?("核销码内容不完整") onShowMessage?("核销码内容不完整")
return return
} }
let storeId = AppStore.shared.currentStoreId let storeId = AppStore.shared.session.currentStoreId
guard storeId > 0 else { guard storeId > 0 else {
onShowMessage?("店铺信息异常,请先选择店铺") onShowMessage?("店铺信息异常,请先选择店铺")
return return
@ -125,7 +125,7 @@ final class DepositOrderListViewModel {
refundReason: String, refundReason: String,
api: OrderAPI api: OrderAPI
) async { ) async {
let storeId = AppStore.shared.currentStoreId let storeId = AppStore.shared.session.currentStoreId
guard storeId > 0 else { guard storeId > 0 else {
onShowMessage?("店铺信息异常") onShowMessage?("店铺信息异常")
return return
@ -166,7 +166,7 @@ final class DepositOrderListViewModel {
let phone = searchText.trimmingCharacters(in: .whitespacesAndNewlines) let phone = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
let isPhoneSearch = !phone.isEmpty && Self.isValidPhone(phone) let isPhoneSearch = !phone.isEmpty && Self.isValidPhone(phone)
let storeId = AppStore.shared.currentStoreId let storeId = AppStore.shared.session.currentStoreId
if storeId <= 0 { if storeId <= 0 {
finishLoading() finishLoading()
orderList = [] orderList = []

View File

@ -122,7 +122,7 @@ final class OrderListViewModel {
}() }()
var isScenicAdminReadOnly: Bool { var isScenicAdminReadOnly: Bool {
AppStore.shared.currentAppRole == .scenicAdmin AppStore.shared.session.currentAppRole == .scenicAdmin
} }
var listAPIPath: String { var listAPIPath: String {
@ -359,7 +359,7 @@ final class OrderListViewModel {
isLoadingOrderSourcePeople = false isLoadingOrderSourcePeople = false
notifyStateChange() notifyStateChange()
} }
let storeId = AppStore.shared.currentStoreId let storeId = AppStore.shared.session.currentStoreId
let storeIdParam = storeId > 0 ? String(storeId) : nil let storeIdParam = storeId > 0 ? String(storeId) : nil
do { do {
let response = try await api.cooperativeSalers(storeId: storeIdParam) let response = try await api.cooperativeSalers(storeId: storeIdParam)
@ -402,7 +402,7 @@ final class OrderListViewModel {
} }
private func loadOrderList(api: OrderAPI, append: Bool) async { private func loadOrderList(api: OrderAPI, append: Bool) async {
let scenicId = AppStore.shared.currentScenicId let scenicId = AppStore.shared.session.currentScenicId
guard scenicId > 0 else { guard scenicId > 0 else {
finishLoading() finishLoading()
onShowMessage?("当前景区信息缺失") onShowMessage?("当前景区信息缺失")
@ -479,7 +479,7 @@ final class OrderListViewModel {
var json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { var json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return nil return nil
} }
json["scenic_id"] = String(AppStore.shared.currentScenicId) json["scenic_id"] = String(AppStore.shared.session.currentScenicId)
guard let updated = try? JSONSerialization.data(withJSONObject: json), guard let updated = try? JSONSerialization.data(withJSONObject: json),
let string = String(data: updated, encoding: .utf8) else { let string = String(data: updated, encoding: .utf8) else {
return nil return nil

View File

@ -40,9 +40,9 @@ final class PaymentCollectionDetailsViewModel {
} }
func refreshLocalState() { func refreshLocalState() {
scenicName = appStore.currentScenicName scenicName = appStore.session.currentScenicName
staffId = appStore.userId staffId = appStore.session.userId
isVoiceBroadcastOpen = appStore.isOpenReceiveVoice isVoiceBroadcastOpen = appStore.payment.isOpenReceiveVoice
notifyStateChange() notifyStateChange()
} }
@ -56,7 +56,7 @@ final class PaymentCollectionDetailsViewModel {
notifyStateChange() notifyStateChange()
} }
let scenicId = appStore.currentScenicId let scenicId = appStore.session.currentScenicId
guard scenicId > 0 else { guard scenicId > 0 else {
clearPayCode() clearPayCode()
return return
@ -134,7 +134,7 @@ final class PaymentCollectionDetailsViewModel {
func toggleReceiveVoice() { func toggleReceiveVoice() {
isVoiceBroadcastOpen.toggle() isVoiceBroadcastOpen.toggle()
appStore.isOpenReceiveVoice = isVoiceBroadcastOpen appStore.payment.isOpenReceiveVoice = isVoiceBroadcastOpen
notifyStateChange() notifyStateChange()
} }

View File

@ -28,7 +28,7 @@ final class PaymentCollectionRecordViewModel {
notifyStateChange() notifyStateChange()
} }
let scenicId = appStore.currentScenicId let scenicId = appStore.session.currentScenicId
guard scenicId > 0 else { guard scenicId > 0 else {
groups = [] groups = []
return return

View File

@ -65,6 +65,17 @@ final class ProfileAPI {
) )
} }
/// Android multipart
func uploadAvatar(data: Data, fileName: String) async throws {
let _: EmptyPayload = try await client.sendMultipart(
path: "/api/yf-handset-app/photog/userinfo-update-avatar",
fileName: fileName,
mimeType: "image/jpeg",
data: data,
timeoutInterval: 60
)
}
/// ///
func switchableAccounts() async throws -> V9AuthResponse { func switchableAccounts() async throws -> V9AuthResponse {
try await client.send( try await client.send(

View File

@ -72,9 +72,9 @@ final class ProfileEditViewModel {
try await api.updateUserInfo(nickname: nextNickname) try await api.updateUserInfo(nickname: nextNickname)
} }
AppStore.shared.userName = nextNickname AppStore.shared.session.userName = nextNickname
if let uploadedAvatarURL { if let uploadedAvatarURL {
AppStore.shared.avatar = uploadedAvatarURL AppStore.shared.session.avatar = uploadedAvatarURL
} }
clearPendingAvatar() clearPendingAvatar()

View File

@ -22,6 +22,9 @@ protocol ProfileSpaceSettingsAPI {
/// ///
func deleteSchedule(id: Int) async throws func deleteSchedule(id: Int) async throws
///
func uploadAvatar(data: Data, fileName: String) async throws
} }
extension ProfileAPI: ProfileSpaceSettingsAPI {} extension ProfileAPI: ProfileSpaceSettingsAPI {}
@ -85,6 +88,7 @@ final class ProfileSpaceSettingsViewModel {
private(set) var scheduleMap: [String: [PhotographerSchedule]] = [:] private(set) var scheduleMap: [String: [PhotographerSchedule]] = [:]
private(set) var selectedDate: Date private(set) var selectedDate: Date
private(set) var isEditingProfile = false private(set) var isEditingProfile = false
private(set) var isCertificationExpanded = false
private(set) var isLoading = false private(set) var isLoading = false
private(set) var hasChanges = false private(set) var hasChanges = false
@ -109,7 +113,7 @@ final class ProfileSpaceSettingsViewModel {
/// ID /// ID
var scenicId: Int { var scenicId: Int {
appStore.currentScenicId appStore.session.currentScenicId
} }
/// ///
@ -156,7 +160,7 @@ final class ProfileSpaceSettingsViewModel {
do { do {
try await api.updateUserInfo(nickname: newNickname, password: nil, avatar: nil) try await api.updateUserInfo(nickname: newNickname, password: nil, avatar: nil)
nickname = newNickname nickname = newNickname
appStore.userName = newNickname appStore.session.userName = newNickname
isEditingProfile = false isEditingProfile = false
onShowMessage?("修改成功") onShowMessage?("修改成功")
notifyStateChange() notifyStateChange()
@ -175,10 +179,35 @@ final class ProfileSpaceSettingsViewModel {
/// URL /// URL
func updateAvatarURL(_ url: String) { func updateAvatarURL(_ url: String) {
avatarURL = url avatarURL = url
appStore.avatar = url appStore.session.avatar = url
notifyStateChange() notifyStateChange()
} }
///
func toggleCertificationExpanded() {
isCertificationExpanded.toggle()
notifyStateChange()
}
///
func uploadAvatar(
data: Data,
fileName: String,
api: any ProfileSpaceSettingsAPI
) async {
setLoading(true)
defer { setLoading(false) }
do {
try await api.uploadAvatar(data: data, fileName: fileName)
onShowMessage?("上传成功")
await load(api: api)
} catch is CancellationError {
return
} catch {
onShowMessage?(error.localizedDescription)
}
}
/// ///
func updateEditingNickname(_ value: String) { func updateEditingNickname(_ value: String) {
editingNickname = value editingNickname = value
@ -225,41 +254,41 @@ final class ProfileSpaceSettingsViewModel {
/// ///
func updateBusinessStartTime(_ value: String) { func updateBusinessStartTime(_ value: String) {
businessStartTime = value
if let end = businessEndTime, !Self.isStart(value, before: end) { if let end = businessEndTime, !Self.isStart(value, before: end) {
onShowMessage?("开始时间不能大于结束时间") onShowMessage?("开始时间不能大于结束时间")
businessStartTime = nil return
} }
businessStartTime = value
updateHasChanges() updateHasChanges()
} }
/// ///
func updateBusinessEndTime(_ value: String) { func updateBusinessEndTime(_ value: String) {
businessEndTime = value
if let start = businessStartTime, !Self.isStart(start, before: value) { if let start = businessStartTime, !Self.isStart(start, before: value) {
onShowMessage?("结束时间不能小于开始时间") onShowMessage?("结束时间不能小于开始时间")
businessEndTime = nil return
} }
businessEndTime = value
updateHasChanges() updateHasChanges()
} }
/// ///
func updateHolidayStartTime(_ value: String) { func updateHolidayStartTime(_ value: String) {
holidayStartTime = value
if let end = holidayEndTime, !Self.isStart(value, before: end) { if let end = holidayEndTime, !Self.isStart(value, before: end) {
onShowMessage?("开始时间不能大于结束时间") onShowMessage?("开始时间不能大于结束时间")
holidayStartTime = nil return
} }
holidayStartTime = value
updateHasChanges() updateHasChanges()
} }
/// ///
func updateHolidayEndTime(_ value: String) { func updateHolidayEndTime(_ value: String) {
holidayEndTime = value
if let start = holidayStartTime, !Self.isStart(start, before: value) { if let start = holidayStartTime, !Self.isStart(start, before: value) {
onShowMessage?("结束时间不能小于开始时间") onShowMessage?("结束时间不能小于开始时间")
holidayEndTime = nil return
} }
holidayEndTime = value
updateHasChanges() updateHasChanges()
} }
@ -417,7 +446,7 @@ final class ProfileSpaceSettingsViewModel {
editingNickname = response.nickname editingNickname = response.nickname
if !response.avatar.isEmpty { if !response.avatar.isEmpty {
avatarURL = response.avatar avatarURL = response.avatar
appStore.avatar = response.avatar appStore.session.avatar = response.avatar
} }
scenicCertification = response.scenicCertification scenicCertification = response.scenicCertification
introduction = response.description introduction = response.description
@ -430,8 +459,8 @@ final class ProfileSpaceSettingsViewModel {
holidayEndTime = Self.shortTimeString(response.holidayEndTime) holidayEndTime = Self.shortTimeString(response.holidayEndTime)
acceptOrderStatus = response.acceptOrderStatus acceptOrderStatus = response.acceptOrderStatus
scheduleMap = response.schedule scheduleMap = response.schedule
appStore.userName = response.nickname appStore.session.userName = response.nickname
appStore.realName = response.realName appStore.session.realName = response.realName
originalSnapshot = currentSnapshot() originalSnapshot = currentSnapshot()
hasChanges = false hasChanges = false
notifyStateChange() notifyStateChange()

View File

@ -25,42 +25,42 @@ final class ProfileViewModel {
} }
var displayPhone: String { var displayPhone: String {
nonEmpty(userInfo?.phone) ?? AppStore.shared.phone.nonEmpty ?? "--" nonEmpty(userInfo?.phone) ?? AppStore.shared.session.phone.nonEmpty ?? "--"
} }
var displayAvatarURL: String { var displayAvatarURL: String {
nonEmpty(userInfo?.avatar) ?? AppStore.shared.avatar.nonEmpty ?? "" nonEmpty(userInfo?.avatar) ?? AppStore.shared.session.avatar.nonEmpty ?? ""
} }
var displayUID: String { var displayUID: String {
let uid = AppStore.shared.userId.trimmingCharacters(in: .whitespacesAndNewlines) let uid = AppStore.shared.session.userId.trimmingCharacters(in: .whitespacesAndNewlines)
return uid.isEmpty ? "--" : uid return uid.isEmpty ? "--" : uid
} }
var accountDisplayName: String { var accountDisplayName: String {
let name = AppStore.shared.accountDisplayName.trimmingCharacters(in: .whitespacesAndNewlines) let name = AppStore.shared.session.accountDisplayName.trimmingCharacters(in: .whitespacesAndNewlines)
return name.isEmpty ? "--" : name return name.isEmpty ? "--" : name
} }
/// ///
var accountTypeLabel: String { var accountTypeLabel: String {
let type = AppStore.shared.accountType.trimmingCharacters(in: .whitespacesAndNewlines) switch AppStore.shared.session.accountType {
if type == V9StoreUser.accountTypeValue { case .storeUser:
return "门店账号" "门店账号"
case .scenicUser:
"景区账号"
case .unknown:
"--"
} }
if type == V9ScenicUser.accountTypeValue {
return "景区账号"
}
return "--"
} }
/// ///
var isStoreAccount: Bool { var isStoreAccount: Bool {
AppStore.shared.accountType == V9StoreUser.accountTypeValue AppStore.shared.session.accountType == .storeUser
} }
var currentScenicName: String { var currentScenicName: String {
let name = AppStore.shared.currentScenicName.trimmingCharacters(in: .whitespacesAndNewlines) let name = AppStore.shared.session.currentScenicName.trimmingCharacters(in: .whitespacesAndNewlines)
return name.isEmpty ? "--" : name return name.isEmpty ? "--" : name
} }
@ -69,7 +69,7 @@ final class ProfileViewModel {
} }
var showPhotographerFields: Bool { var showPhotographerFields: Bool {
if AppStore.shared.isPhotographerRole { if AppStore.shared.session.isPhotographerRole {
return true return true
} }
if let roleName = nonEmpty(userInfo?.roleName) { if let roleName = nonEmpty(userInfo?.roleName) {
@ -108,9 +108,9 @@ final class ProfileViewModel {
let info = try await api.userInfo() let info = try await api.userInfo()
userInfo = info userInfo = info
AppStore.shared.applyUserInfo(info) AppStore.shared.session.applyUserInfo(info)
if !info.roleName.isEmpty { if !info.roleName.isEmpty {
AppStore.shared.roleName = info.roleName AppStore.shared.session.roleName = info.roleName
} }
if showPhotographerFields { if showPhotographerFields {
@ -128,8 +128,8 @@ final class ProfileViewModel {
/// `AppStore` /// `AppStore`
func applyLocalProfileUpdate(from store: AppStore = .shared) { func applyLocalProfileUpdate(from store: AppStore = .shared) {
let previous = userInfo ?? UserInfoResponse() let previous = userInfo ?? UserInfoResponse()
let nextNickname = store.userName.trimmingCharacters(in: .whitespacesAndNewlines) let nextNickname = store.session.userName.trimmingCharacters(in: .whitespacesAndNewlines)
let nextAvatar = store.avatar.trimmingCharacters(in: .whitespacesAndNewlines) let nextAvatar = store.session.avatar.trimmingCharacters(in: .whitespacesAndNewlines)
userInfo = UserInfoResponse( userInfo = UserInfoResponse(
avatar: nextAvatar.isEmpty ? previous.avatar : nextAvatar, avatar: nextAvatar.isEmpty ? previous.avatar : nextAvatar,
realName: previous.realName, realName: previous.realName,

View File

@ -36,7 +36,7 @@ final class PunchPointListViewModel {
private let currentScenicIdProvider: () -> Int private let currentScenicIdProvider: () -> Int
/// ViewModel /// ViewModel
init(currentScenicIdProvider: @escaping () -> Int = { AppStore.shared.currentScenicId }) { init(currentScenicIdProvider: @escaping () -> Int = { AppStore.shared.session.currentScenicId }) {
self.currentScenicIdProvider = currentScenicIdProvider self.currentScenicIdProvider = currentScenicIdProvider
} }
@ -223,7 +223,7 @@ final class PunchPointFormViewModel {
/// ViewModel /// ViewModel
init( init(
mode: Mode = .create, mode: Mode = .create,
currentScenicIdProvider: @escaping () -> Int = { AppStore.shared.currentScenicId }, currentScenicIdProvider: @escaping () -> Int = { AppStore.shared.session.currentScenicId },
locationProvider: any LocationProviding = LocationProvider.shared locationProvider: any LocationProviding = LocationProvider.shared
) { ) {
self.mode = mode self.mode = mode

View File

@ -222,8 +222,8 @@ final class UploadSampleViewModel {
/// ViewModel /// ViewModel
init( init(
scenicListProvider: @escaping () -> [ScenicInfo] = { AppStore.shared.roleScenicList() }, scenicListProvider: @escaping () -> [ScenicInfo] = { AppStore.shared.permissions.roleScenicList() },
currentScenicIdProvider: @escaping () -> Int = { AppStore.shared.currentScenicId } currentScenicIdProvider: @escaping () -> Int = { AppStore.shared.session.currentScenicId }
) { ) {
self.scenicListProvider = scenicListProvider self.scenicListProvider = scenicListProvider
self.currentScenicIdProvider = currentScenicIdProvider self.currentScenicIdProvider = currentScenicIdProvider

View File

@ -84,7 +84,7 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth
func prepareQueueTTSEngine(api: ScenicQueueAPIProtocol?) async { func prepareQueueTTSEngine(api: ScenicQueueAPIProtocol?) async {
guard let api else { return } guard let api else { return }
guard !appStore.scenicQueueUseOfflineTTS || supportsOfflineTTS else { guard !appStore.scenicQueue.useOfflineTTS || supportsOfflineTTS else {
nativeReady = false nativeReady = false
return return
} }

View File

@ -39,7 +39,7 @@ final class ScenicQueueSettingChangeLogViewModel {
} }
private func load(api: ScenicQueueAPIProtocol, page: Int, append: Bool) async { private func load(api: ScenicQueueAPIProtocol, page: Int, append: Bool) async {
guard appStore.currentScenicId > 0 else { guard appStore.session.currentScenicId > 0 else {
onShowMessage?("请先选择景区") onShowMessage?("请先选择景区")
return return
} }
@ -53,10 +53,10 @@ final class ScenicQueueSettingChangeLogViewModel {
if append { isLoadingMore = false } else { isRefreshing = false } if append { isLoadingMore = false } else { isRefreshing = false }
notifyStateChange() notifyStateChange()
} }
let spotId = Int64(appStore.scenicQueuePunchSpotId) let spotId = Int64(appStore.scenicQueue.punchSpotId)
do { do {
let data = try await api.settingChangeLog( let data = try await api.settingChangeLog(
scenicId: Int64(appStore.currentScenicId), scenicId: Int64(appStore.session.currentScenicId),
scenicSpotId: spotId, scenicSpotId: spotId,
page: page, page: page,
pageSize: Constants.pageSize pageSize: Constants.pageSize

View File

@ -61,9 +61,9 @@ final class ScenicQueueSettingsViewModel {
init(appStore: AppStore = .shared, ttsManager: ScenicQueueTTSManaging = AliyunNLSTTSManager.shared) { init(appStore: AppStore = .shared, ttsManager: ScenicQueueTTSManaging = AliyunNLSTTSManager.shared) {
self.appStore = appStore self.appStore = appStore
self.ttsManager = ttsManager self.ttsManager = ttsManager
let snapshot = appStore.scenicQueueSettingsSnapshot() ?? ScenicQueueSettingsSnapshot() let snapshot = appStore.scenicQueue.settingsSnapshot() ?? ScenicQueueSettingsSnapshot()
selectedPunchSpotId = appStore.scenicQueuePunchSpotId.nonEmptyTrimmed selectedPunchSpotId = appStore.scenicQueue.punchSpotId.nonEmptyTrimmed
selectedPunchSpotLabel = appStore.scenicQueuePunchSpotName selectedPunchSpotLabel = appStore.scenicQueue.punchSpotName
shootMinute = snapshot.shootMinute.clamped(to: Limits.shootMinute) shootMinute = snapshot.shootMinute.clamped(to: Limits.shootMinute)
shootSecond = snapshot.shootSecond.clamped(to: Limits.shootSecond) shootSecond = snapshot.shootSecond.clamped(to: Limits.shootSecond)
firstAheadCount = snapshot.firstAheadCount.clamped(to: Limits.ahead) firstAheadCount = snapshot.firstAheadCount.clamped(to: Limits.ahead)
@ -84,8 +84,8 @@ final class ScenicQueueSettingsViewModel {
businessOpen = snapshot.businessOpen businessOpen = snapshot.businessOpen
businessStartTime = Self.normalizedTime(snapshot.businessStartTime, fallback: "10:00") businessStartTime = Self.normalizedTime(snapshot.businessStartTime, fallback: "10:00")
businessEndTime = Self.normalizedTime(snapshot.businessEndTime, fallback: "20:00") businessEndTime = Self.normalizedTime(snapshot.businessEndTime, fallback: "20:00")
customTTSText = appStore.scenicQueueRemark customTTSText = appStore.scenicQueue.remark
presetVoices = appStore.scenicQueuePresetVoices() presetVoices = appStore.scenicQueue.presetVoices()
} }
var selectedPunchSpotDisplayText: String { var selectedPunchSpotDisplayText: String {
@ -105,7 +105,7 @@ final class ScenicQueueSettingsViewModel {
/// 使线 /// 使线
var useOfflineTTS: Bool { var useOfflineTTS: Bool {
appStore.scenicQueueUseOfflineTTS appStore.scenicQueue.useOfflineTTS
} }
func openSettingChangeLog() { func openSettingChangeLog() {
@ -114,12 +114,12 @@ final class ScenicQueueSettingsViewModel {
/// ///
func loadPunchSpotOptions(api: ScenicQueueAPIProtocol) async { func loadPunchSpotOptions(api: ScenicQueueAPIProtocol) async {
guard appStore.currentScenicId > 0 else { guard appStore.session.currentScenicId > 0 else {
onShowMessage?("请先选择景区") onShowMessage?("请先选择景区")
return return
} }
do { do {
let response = try await api.scenicSpotListAll(scenicId: String(appStore.currentScenicId)) let response = try await api.scenicSpotListAll(scenicId: String(appStore.session.currentScenicId))
punchSpotOptions = response.list.map { punchSpotOptions = response.list.map {
ScenicQueuePunchSpotOption(id: String($0.id), label: $0.name) ScenicQueuePunchSpotOption(id: String($0.id), label: $0.name)
} }
@ -178,17 +178,17 @@ final class ScenicQueueSettingsViewModel {
/// 线 /// 线
func setUseOfflineTTS(_ enabled: Bool) { func setUseOfflineTTS(_ enabled: Bool) {
guard enabled else { guard enabled else {
appStore.scenicQueueUseOfflineTTS = false appStore.scenicQueue.useOfflineTTS = false
notifyStateChange() notifyStateChange()
return return
} }
guard ttsManager.supportsOfflineTTS else { guard ttsManager.supportsOfflineTTS else {
appStore.scenicQueueUseOfflineTTS = false appStore.scenicQueue.useOfflineTTS = false
onShowMessage?("离线语音资源未安装") onShowMessage?("离线语音资源未安装")
notifyStateChange() notifyStateChange()
return return
} }
appStore.scenicQueueUseOfflineTTS = true appStore.scenicQueue.useOfflineTTS = true
notifyStateChange() notifyStateChange()
} }
@ -234,7 +234,7 @@ final class ScenicQueueSettingsViewModel {
/// ///
func saveCurrentTextAsPresetVoiceLocal() { func saveCurrentTextAsPresetVoiceLocal() {
guard appStore.currentScenicId > 0, selectedPunchSpotId?.nonEmptyTrimmed != nil else { guard appStore.session.currentScenicId > 0, selectedPunchSpotId?.nonEmptyTrimmed != nil else {
onShowMessage?("请先选择打卡点") onShowMessage?("请先选择打卡点")
return return
} }
@ -252,7 +252,7 @@ final class ScenicQueueSettingsViewModel {
return return
} }
presetVoices.append(text) presetVoices.append(text)
appStore.saveScenicQueuePresetVoices(presetVoices) appStore.scenicQueue.savePresetVoices(presetVoices)
onShowMessage?("已保存到本地") onShowMessage?("已保存到本地")
notifyStateChange() notifyStateChange()
} }
@ -260,7 +260,7 @@ final class ScenicQueueSettingsViewModel {
func deletePresetVoice(at index: Int) { func deletePresetVoice(at index: Int) {
guard presetVoices.indices.contains(index) else { return } guard presetVoices.indices.contains(index) else { return }
let removed = presetVoices.remove(at: index) let removed = presetVoices.remove(at: index)
appStore.saveScenicQueuePresetVoices(presetVoices) appStore.scenicQueue.savePresetVoices(presetVoices)
if ttsLoopAnchorText == removed { if ttsLoopAnchorText == removed {
ttsManager.stopCustomTextLoop() ttsManager.stopCustomTextLoop()
ttsLoopAnchorText = nil ttsLoopAnchorText = nil
@ -332,11 +332,11 @@ final class ScenicQueueSettingsViewModel {
) )
do { do {
try await api.saveSetting(request) try await api.saveSetting(request)
appStore.scenicQueuePunchSpotId = String(ids.scenicSpotId) appStore.scenicQueue.punchSpotId = String(ids.scenicSpotId)
appStore.scenicQueuePunchSpotName = selectedPunchSpotLabel appStore.scenicQueue.punchSpotName = selectedPunchSpotLabel
appStore.scenicQueueRemark = remark appStore.scenicQueue.remark = remark
appStore.saveScenicQueuePresetVoices(presetVoices) appStore.scenicQueue.savePresetVoices(presetVoices)
appStore.saveScenicQueueSettingsSnapshot(currentSnapshot()) appStore.scenicQueue.saveSettingsSnapshot(currentSnapshot())
_ = try? await api.stats(scenicId: ids.scenicId, scenicSpotId: ids.scenicSpotId) _ = try? await api.stats(scenicId: ids.scenicId, scenicSpotId: ids.scenicSpotId)
onShowMessage?("保存成功") onShowMessage?("保存成功")
onNavigateBack?() onNavigateBack?()
@ -346,7 +346,7 @@ final class ScenicQueueSettingsViewModel {
} }
private func fetchQueueSettingAfterPunchSpotSelected(_ spotId: String, api: ScenicQueueAPIProtocol) async { private func fetchQueueSettingAfterPunchSpotSelected(_ spotId: String, api: ScenicQueueAPIProtocol) async {
guard let scenicId = Int64("\(appStore.currentScenicId)"), guard let scenicId = Int64("\(appStore.session.currentScenicId)"),
let scenicSpotId = Int64(spotId), let scenicSpotId = Int64(spotId),
scenicId > 0, scenicId > 0,
scenicSpotId > 0 else { return } scenicSpotId > 0 else { return }
@ -480,7 +480,7 @@ final class ScenicQueueSettingsViewModel {
} }
private func currentScenicAndSpotIdsForSelected() -> (scenicId: Int64, scenicSpotId: Int64)? { private func currentScenicAndSpotIdsForSelected() -> (scenicId: Int64, scenicSpotId: Int64)? {
guard let scenicId = Int64("\(appStore.currentScenicId)"), scenicId > 0, guard let scenicId = Int64("\(appStore.session.currentScenicId)"), scenicId > 0,
let spotIdText = selectedPunchSpotId?.trimmingCharacters(in: .whitespacesAndNewlines), let spotIdText = selectedPunchSpotId?.trimmingCharacters(in: .whitespacesAndNewlines),
let scenicSpotId = Int64(spotIdText), scenicSpotId > 0 else { return nil } let scenicSpotId = Int64(spotIdText), scenicSpotId > 0 else { return nil }
return (scenicId, scenicSpotId) return (scenicId, scenicSpotId)

View File

@ -97,8 +97,8 @@ final class ScenicQueueViewModel {
/// WebSocket /// WebSocket
func startQueueStatsPolling(api: ScenicQueueAPIProtocol) { func startQueueStatsPolling(api: ScenicQueueAPIProtocol) {
guard queueGatePassed, guard queueGatePassed,
let scenicId = Int64("\(appStore.currentScenicId)"), let scenicId = Int64("\(appStore.session.currentScenicId)"),
let scenicSpotId = Int64(appStore.scenicQueuePunchSpotId), let scenicSpotId = Int64(appStore.scenicQueue.punchSpotId),
scenicId > 0, scenicId > 0,
scenicSpotId > 0 else { return } scenicSpotId > 0 else { return }
Task { Task {
@ -186,16 +186,16 @@ final class ScenicQueueViewModel {
/// ///
func confirmUserMark(api: ScenicQueueAPIProtocol, markAsFreelancePhotog: Bool, queueBanDays: Int) async { func confirmUserMark(api: ScenicQueueAPIProtocol, markAsFreelancePhotog: Bool, queueBanDays: Int) async {
guard let pendingMark else { return } guard let pendingMark else { return }
guard appStore.currentScenicId > 0 else { guard appStore.session.currentScenicId > 0 else {
onShowMessage?("请先选择景区") onShowMessage?("请先选择景区")
return return
} }
let request = ScenicQueueUserMarkRequest( let request = ScenicQueueUserMarkRequest(
uid: pendingMark.uid, uid: pendingMark.uid,
scenicId: Int64(appStore.currentScenicId), scenicId: Int64(appStore.session.currentScenicId),
markAsFreelancePhotog: markAsFreelancePhotog ? 1 : 0, markAsFreelancePhotog: markAsFreelancePhotog ? 1 : 0,
queueBanDays: markAsFreelancePhotog ? min(max(queueBanDays, 0), 999) : nil, queueBanDays: markAsFreelancePhotog ? min(max(queueBanDays, 0), 999) : nil,
operatorId: Int(appStore.userId) operatorId: Int(appStore.session.userId)
) )
do { do {
try await api.userMark(request) try await api.userMark(request)
@ -270,7 +270,7 @@ final class ScenicQueueViewModel {
func confirmRequeueTicket(api: ScenicQueueAPIProtocol) async { func confirmRequeueTicket(api: ScenicQueueAPIProtocol) async {
guard let pending = pendingRequeue else { return } guard let pending = pendingRequeue else { return }
dismissRequeueDialog() dismissRequeueDialog()
guard let operatorId = Int(appStore.userId), operatorId > 0 else { guard let operatorId = Int(appStore.session.userId), operatorId > 0 else {
onShowMessage?("请先登录") onShowMessage?("请先登录")
return return
} }
@ -395,18 +395,18 @@ final class ScenicQueueViewModel {
} }
private func refreshSelectedPunchSpotLine() { private func refreshSelectedPunchSpotLine() {
selectedPunchSpotLine = appStore.hasScenicQueuePunchSpotSaved() selectedPunchSpotLine = appStore.scenicQueue.hasPunchSpotSaved()
? appStore.scenicQueuePunchSpotName.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "--" ? appStore.scenicQueue.punchSpotName.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "--"
: "--" : "--"
refreshShootingCallConfig() refreshShootingCallConfig()
} }
private func refreshQueueGateLocally() { private func refreshQueueGateLocally() {
queueGatePassed = appStore.hasScenicQueuePunchSpotSaved() queueGatePassed = appStore.scenicQueue.hasPunchSpotSaved()
} }
private func refreshShootingCallConfig() { private func refreshShootingCallConfig() {
let snapshot = appStore.scenicQueueSettingsSnapshot() ?? ScenicQueueSettingsSnapshot() let snapshot = appStore.scenicQueue.settingsSnapshot() ?? ScenicQueueSettingsSnapshot()
showStartShootingButton = snapshot.showStartShootingButton showStartShootingButton = snapshot.showStartShootingButton
quickCallButtonEnabled = snapshot.quickCallButtonEnabled quickCallButtonEnabled = snapshot.quickCallButtonEnabled
prepareCallButtonEnabled = snapshot.prepareCallButtonEnabled prepareCallButtonEnabled = snapshot.prepareCallButtonEnabled
@ -419,18 +419,18 @@ final class ScenicQueueViewModel {
guard data.exists, let setting = data.setting, guard data.exists, let setting = data.setting,
setting.scenicId == nil || setting.scenicId == ids.scenicId, setting.scenicId == nil || setting.scenicId == ids.scenicId,
setting.scenicSpotId == nil || setting.scenicSpotId == ids.scenicSpotId else { return } setting.scenicSpotId == nil || setting.scenicSpotId == ids.scenicSpotId else { return }
let local = appStore.scenicQueueSettingsSnapshot() ?? ScenicQueueSettingsSnapshot() let local = appStore.scenicQueue.settingsSnapshot() ?? ScenicQueueSettingsSnapshot()
appStore.saveScenicQueueSettingsSnapshot(setting.toSnapshot(fallback: local)) appStore.scenicQueue.saveSettingsSnapshot(setting.toSnapshot(fallback: local))
appStore.scenicQueueRemark = setting.remark ?? "" appStore.scenicQueue.remark = setting.remark ?? ""
if let voices = setting.voiceBroadcasts { if let voices = setting.voiceBroadcasts {
appStore.saveScenicQueuePresetVoices( appStore.scenicQueue.savePresetVoices(
voices voices
.sorted { ($0.sortOrder ?? Int.max) < ($1.sortOrder ?? Int.max) } .sorted { ($0.sortOrder ?? Int.max) < ($1.sortOrder ?? Int.max) }
.compactMap { $0.content?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty } .compactMap { $0.content?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty }
) )
} }
if let name = setting.scenicSpotName?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty { if let name = setting.scenicSpotName?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty {
appStore.scenicQueuePunchSpotName = name appStore.scenicQueue.punchSpotName = name
selectedPunchSpotLine = name selectedPunchSpotLine = name
} }
refreshShootingCallConfig() refreshShootingCallConfig()
@ -501,7 +501,7 @@ final class ScenicQueueViewModel {
private func handleSocketMessage(_ message: ScenicQueueSocketMessage) { private func handleSocketMessage(_ message: ScenicQueueSocketMessage) {
guard let params = message.data?.params, guard let params = message.data?.params,
let changedSpotId = params.scenicSpotId, let changedSpotId = params.scenicSpotId,
let currentSpotId = Int64(appStore.scenicQueuePunchSpotId), let currentSpotId = Int64(appStore.scenicQueue.punchSpotId),
changedSpotId == currentSpotId else { return } changedSpotId == currentSpotId else { return }
switch message.data?.action { switch message.data?.action {
case ScenicQueueSocketAction.queueUpdated.rawValue: case ScenicQueueSocketAction.queueUpdated.rawValue:
@ -516,7 +516,7 @@ final class ScenicQueueViewModel {
private func handleRemoteTicketCalled(_ params: ScenicQueueSocketParams) { private func handleRemoteTicketCalled(_ params: ScenicQueueSocketParams) {
guard let recordId = params.recordId, recordId > 0 else { return } guard let recordId = params.recordId, recordId > 0 else { return }
if let myUid = Int64(appStore.userId), let operatorUid = params.operatorUid, myUid == operatorUid { if let myUid = Int64(appStore.session.userId), let operatorUid = params.operatorUid, myUid == operatorUid {
return return
} }
let eventId = params.eventId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let eventId = params.eventId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
@ -614,7 +614,7 @@ final class ScenicQueueViewModel {
} }
private func resolveShootingTiming() -> (totalSeconds: Int, intervalSeconds: Int, readableThresholdSeconds: Int) { private func resolveShootingTiming() -> (totalSeconds: Int, intervalSeconds: Int, readableThresholdSeconds: Int) {
let snapshot = appStore.scenicQueueSettingsSnapshot() ?? ScenicQueueSettingsSnapshot() let snapshot = appStore.scenicQueue.settingsSnapshot() ?? ScenicQueueSettingsSnapshot()
let total = max(0, snapshot.shootMinute * 60 + snapshot.shootSecond) let total = max(0, snapshot.shootMinute * 60 + snapshot.shootSecond)
let interval = (40...60).contains(snapshot.broadcastIntervalSec) ? snapshot.broadcastIntervalSec : 50 let interval = (40...60).contains(snapshot.broadcastIntervalSec) ? snapshot.broadcastIntervalSec : 50
let readable = (10...30).contains(snapshot.countdownThresholdSec) ? snapshot.countdownThresholdSec : 15 let readable = (10...30).contains(snapshot.countdownThresholdSec) ? snapshot.countdownThresholdSec : 15
@ -627,7 +627,7 @@ final class ScenicQueueViewModel {
} }
private func speakFollowingTickets(after queueNo: String) async { private func speakFollowingTickets(after queueNo: String) async {
let count = (appStore.scenicQueueSettingsSnapshot() ?? ScenicQueueSettingsSnapshot()).autoCallAheadCount.clamped(to: 0...5) let count = (appStore.scenicQueue.settingsSnapshot() ?? ScenicQueueSettingsSnapshot()).autoCallAheadCount.clamped(to: 0...5)
guard count > 0, let index = currentQueue.firstIndex(where: { $0.queueNo == queueNo }) else { return } guard count > 0, let index = currentQueue.firstIndex(where: { $0.queueNo == queueNo }) else { return }
let nextNos = currentQueue.dropFirst(index + 1).prefix(count).map(\.queueNo).filter { !$0.isEmpty } let nextNos = currentQueue.dropFirst(index + 1).prefix(count).map(\.queueNo).filter { !$0.isEmpty }
guard !nextNos.isEmpty else { return } guard !nextNos.isEmpty else { return }
@ -639,9 +639,9 @@ final class ScenicQueueViewModel {
} }
private func currentScenicAndSpotIds() -> (scenicId: Int64, scenicSpotId: Int64)? { private func currentScenicAndSpotIds() -> (scenicId: Int64, scenicSpotId: Int64)? {
let scenicId = Int64(appStore.currentScenicId) let scenicId = Int64(appStore.session.currentScenicId)
guard scenicId > 0, guard scenicId > 0,
let scenicSpotId = Int64(appStore.scenicQueuePunchSpotId), let scenicSpotId = Int64(appStore.scenicQueue.punchSpotId),
scenicSpotId > 0 else { return nil } scenicSpotId > 0 else { return nil }
return (scenicId, scenicSpotId) return (scenicId, scenicSpotId)
} }

View File

@ -213,9 +213,9 @@ final class StatisticsViewModel {
let storeId = try resolveStoreId() let storeId = try resolveStoreId()
return try await api.storeSummary(storeId: storeId, range: range) return try await api.storeSummary(storeId: storeId, range: range)
case .scenicAdmin: case .scenicAdmin:
return try await api.scenicAdminSummary(scenicId: AppStore.shared.currentScenicId, range: range) return try await api.scenicAdminSummary(scenicId: AppStore.shared.session.currentScenicId, range: range)
default: default:
return try await api.photographerSummary(scenicId: AppStore.shared.currentScenicId, range: range) return try await api.photographerSummary(scenicId: AppStore.shared.session.currentScenicId, range: range)
} }
} }
@ -231,14 +231,14 @@ final class StatisticsViewModel {
) )
case .scenicAdmin: case .scenicAdmin:
return try await api.scenicAdminDaily( return try await api.scenicAdminDaily(
scenicId: AppStore.shared.currentScenicId, scenicId: AppStore.shared.session.currentScenicId,
startTime: startTime, startTime: startTime,
endTime: endTime, endTime: endTime,
page: currentPage page: currentPage
) )
default: default:
return try await api.photographerDaily( return try await api.photographerDaily(
scenicId: AppStore.shared.currentScenicId, scenicId: AppStore.shared.session.currentScenicId,
startTime: startTime, startTime: startTime,
endTime: endTime, endTime: endTime,
page: currentPage page: currentPage
@ -247,11 +247,11 @@ final class StatisticsViewModel {
} }
private var currentRole: AppRoleCode { private var currentRole: AppRoleCode {
AppStore.shared.currentAppRole ?? .photographer AppStore.shared.session.currentAppRole ?? .photographer
} }
private func resolveStoreId() throws -> Int { private func resolveStoreId() throws -> Int {
let storeId = AppStore.shared.currentStoreId let storeId = AppStore.shared.session.currentStoreId
guard storeId > 0 else { guard storeId > 0 else {
throw StatisticsFlowError.missingStore throw StatisticsFlowError.missingStore
} }

View File

@ -262,7 +262,7 @@ final class TaskAddViewModel {
) )
} }
return AddTaskRequest( return AddTaskRequest(
scenicId: appStore.currentScenicId, scenicId: appStore.session.currentScenicId,
name: name ?? taskName, name: name ?? taskName,
orderNumber: selectedOrderNumber, orderNumber: selectedOrderNumber,
remark: taskDetails, remark: taskDetails,

View File

@ -32,7 +32,7 @@ final class TaskOrderSelectViewModel {
notifyStateChange() notifyStateChange()
} }
let scenicId = appStore.currentScenicId let scenicId = appStore.session.currentScenicId
guard scenicId > 0 else { guard scenicId > 0 else {
orders = [] orders = []
onShowMessage?("请先选择景区") onShowMessage?("请先选择景区")

View File

@ -151,10 +151,10 @@ struct TravelAlbumOTGStorageContext: Equatable, Sendable {
/// 使 /// 使
static var current: TravelAlbumOTGStorageContext { static var current: TravelAlbumOTGStorageContext {
TravelAlbumOTGStorageContext( TravelAlbumOTGStorageContext(
accountCachePrefix: AppStore.shared.accountCachePrefix, accountCachePrefix: AppStore.shared.session.accountCachePrefix,
userId: AppStore.shared.userId, userId: AppStore.shared.session.userId,
scenicId: AppStore.shared.currentScenicId, scenicId: AppStore.shared.session.currentScenicId,
storeId: AppStore.shared.currentStoreId storeId: AppStore.shared.session.currentStoreId
) )
} }
} }

View File

@ -201,7 +201,7 @@ final class TravelAlbumCameraImportViewModel {
status: .pending, status: .pending,
progress: 0, progress: 0,
albumId: albumId, albumId: albumId,
userId: appStore.userId userId: appStore.session.userId
) )
storage.upsert(record, albumId: albumId) storage.upsert(record, albumId: albumId)
existingPhotoIds.insert(object.id) existingPhotoIds.insert(object.id)

View File

@ -44,7 +44,7 @@ final class TravelAlbumEntryViewModel {
private let dateProvider: () -> Date private let dateProvider: () -> Date
init( init(
currentScenicIdProvider: @escaping () -> Int = { AppStore.shared.currentScenicId }, currentScenicIdProvider: @escaping () -> Int = { AppStore.shared.session.currentScenicId },
dateProvider: @escaping () -> Date = Date.init dateProvider: @escaping () -> Date = Date.init
) { ) {
self.currentScenicIdProvider = currentScenicIdProvider self.currentScenicIdProvider = currentScenicIdProvider

View File

@ -383,7 +383,7 @@ final class WiredCameraTransferViewModel {
fileSizeBytes: Int64(item.data.count), fileSizeBytes: Int64(item.data.count),
status: .pending, status: .pending,
albumId: albumId, albumId: albumId,
userId: appStore.userId userId: appStore.session.userId
) )
storage.upsert(record, albumId: albumId) storage.upsert(record, albumId: albumId)
importedPhotoIds.append(record.id) importedPhotoIds.append(record.id)
@ -553,7 +553,7 @@ final class WiredCameraTransferViewModel {
updateRecord(id: id, status: .uploading, progress: max(record.progress, 1), error: nil) updateRecord(id: id, status: .uploading, progress: max(record.progress, 1), error: nil)
let material = try await uploader.upload( let material = try await uploader.upload(
record: record, record: record,
scenicId: appStore.currentScenicId scenicId: appStore.session.currentScenicId
) { [weak self] progress in ) { [weak self] progress in
self?.updateRecord(id: id, status: .uploading, progress: progress, error: nil) self?.updateRecord(id: id, status: .uploading, progress: progress, error: nil)
} }
@ -596,7 +596,7 @@ final class WiredCameraTransferViewModel {
progress: progress, progress: progress,
errorMessage: error, errorMessage: error,
albumId: albumId, albumId: albumId,
userId: appStore.userId, userId: appStore.session.userId,
remoteUrl: remoteUrl ?? item.remoteUrl remoteUrl: remoteUrl ?? item.remoteUrl
) )
} }

View File

@ -99,7 +99,7 @@ final class WalletViewModel {
/// ViewModel /// ViewModel
init( init(
staffIdProvider: @escaping () -> Int? = { staffIdProvider: @escaping () -> Int? = {
Int(AppStore.shared.userId.trimmingCharacters(in: .whitespacesAndNewlines)) Int(AppStore.shared.session.userId.trimmingCharacters(in: .whitespacesAndNewlines))
}, },
calendar: Calendar = Calendar(identifier: .gregorian) calendar: Calendar = Calendar(identifier: .gregorian)
) { ) {
@ -480,7 +480,7 @@ final class PointsRedemptionViewModel {
/// ViewModel /// ViewModel
init(staffIdProvider: @escaping () -> Int? = { init(staffIdProvider: @escaping () -> Int? = {
Int(AppStore.shared.userId.trimmingCharacters(in: .whitespacesAndNewlines)) Int(AppStore.shared.session.userId.trimmingCharacters(in: .whitespacesAndNewlines))
}) { }) {
self.staffIdProvider = staffIdProvider self.staffIdProvider = staffIdProvider
} }

View File

@ -1057,7 +1057,7 @@ final class WildReportRiskMapViewModel {
init() { init() {
self.markers = [] self.markers = []
self.region = Self.region(for: []) self.region = Self.region(for: [])
let scenicName = AppStore.shared.currentScenicName.trimmingCharacters(in: .whitespacesAndNewlines) let scenicName = AppStore.shared.session.currentScenicName.trimmingCharacters(in: .whitespacesAndNewlines)
self.scenicAreaName = scenicName.isEmpty ? "当前景区" : scenicName self.scenicAreaName = scenicName.isEmpty ? "当前景区" : scenicName
self.selectedMarkerID = nil self.selectedMarkerID = nil
} }
@ -1203,7 +1203,7 @@ final class WildReportRiskMapViewModel {
scenicName: String?, scenicName: String?,
selectCurrentLocation: Bool selectCurrentLocation: Bool
) async { ) async {
let resolvedScenicId = scenicId ?? AppStore.shared.currentScenicId let resolvedScenicId = scenicId ?? AppStore.shared.session.currentScenicId
guard resolvedScenicId >= 1 else { guard resolvedScenicId >= 1 else {
errorMessage = "请先选择景区后再查看风险地图" errorMessage = "请先选择景区后再查看风险地图"
notifyStateChange() notifyStateChange()
@ -1244,7 +1244,7 @@ final class WildReportRiskMapViewModel {
)) ))
scenicAreaName = response.scenicName.nonEmpty scenicAreaName = response.scenicName.nonEmpty
?? scenicName?.nonEmpty ?? scenicName?.nonEmpty
?? AppStore.shared.currentScenicName.nonEmpty ?? AppStore.shared.session.currentScenicName.nonEmpty
?? scenicAreaName ?? scenicAreaName
greenMarkerTip = response.greenMarkerTip greenMarkerTip = response.greenMarkerTip
legendItems = response.legend legendItems = response.legend
@ -1376,7 +1376,7 @@ final class WildReportRiskMapViewModel {
for marker: WildReportMapMarker, for marker: WildReportMapMarker,
scenicId: Int? scenicId: Int?
) -> WildReportMarkerDetailRequest? { ) -> WildReportMarkerDetailRequest? {
let resolvedScenicId = scenicId ?? AppStore.shared.currentScenicId let resolvedScenicId = scenicId ?? AppStore.shared.session.currentScenicId
guard resolvedScenicId >= 1 else { return nil } guard resolvedScenicId >= 1 else { return nil }
let parts = marker.id.split(separator: "-", maxSplits: 1).map(String.init) let parts = marker.id.split(separator: "-", maxSplits: 1).map(String.init)
guard parts.count == 2, let id = Int(parts[1]), id >= 1 else { return nil } guard parts.count == 2, let id = Int(parts[1]), id >= 1 else { return nil }

View File

@ -150,7 +150,7 @@ final class BindAcquirerViewController: BaseViewController {
override func viewWillAppear(_ animated: Bool) { override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated) super.viewWillAppear(animated)
guard CooperationOrderFeature.hasPermission(in: AppStore.shared.permissionItems()) else { guard CooperationOrderFeature.hasPermission(in: AppStore.shared.permissions.permissionItems()) else {
showToast("暂无合作订单权限") showToast("暂无合作订单权限")
navigationController?.popViewController(animated: false) navigationController?.popViewController(animated: false)
return return

View File

@ -76,7 +76,7 @@ final class CooperationAcquirerViewController: BaseViewController, UITableViewDa
override func viewWillAppear(_ animated: Bool) { override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated) super.viewWillAppear(animated)
guard CooperationOrderFeature.hasPermission(in: AppStore.shared.permissionItems()) else { guard CooperationOrderFeature.hasPermission(in: AppStore.shared.permissions.permissionItems()) else {
showToast("暂无合作订单权限") showToast("暂无合作订单权限")
navigationController?.popViewController(animated: false) navigationController?.popViewController(animated: false)
return return

View File

@ -118,7 +118,7 @@ final class CooperationOrderListViewController: BaseViewController, UITableViewD
override func viewWillAppear(_ animated: Bool) { override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated) super.viewWillAppear(animated)
guard CooperationOrderFeature.hasPermission(in: AppStore.shared.permissionItems()) else { guard CooperationOrderFeature.hasPermission(in: AppStore.shared.permissions.permissionItems()) else {
showToast("暂无合作订单权限") showToast("暂无合作订单权限")
navigationController?.popViewController(animated: false) navigationController?.popViewController(animated: false)
return return

View File

@ -201,7 +201,7 @@ final class HomeViewController: BaseViewController {
for: indexPath for: indexPath
) as! HomeQuickActionsCell ) as! HomeQuickActionsCell
cell.cardView.onCollectPayment = { [weak self] in cell.cardView.onCollectPayment = { [weak self] in
guard AppStore.shared.currentScenicId > 0 else { guard AppStore.shared.session.currentScenicId > 0 else {
self?.showToast("请先选择景区") self?.showToast("请先选择景区")
return return
} }

View File

@ -42,7 +42,7 @@ final class AllFunctionMenuCell: UICollectionViewCell {
/// ///
func apply(menu: HomeMenuItem, actionStyle: ActionStyle) { func apply(menu: HomeMenuItem, actionStyle: ActionStyle) {
iconView.image = UIImage(systemName: menu.iconName) iconView.image = UIImage(named: menu.iconName) ?? UIImage(systemName: menu.iconName)
titleLabel.text = menu.title titleLabel.text = menu.title
accessibilityLabel = menu.title accessibilityLabel = menu.title

View File

@ -485,7 +485,7 @@ private final class AddLiveSheetViewController: UIViewController, PHPickerViewCo
loadingIndicator.startAnimating() loadingIndicator.startAnimating()
defer { loadingIndicator.stopAnimating() } defer { loadingIndicator.stopAnimating() }
do { do {
let scenicId = AppStore.shared.currentScenicId let scenicId = AppStore.shared.session.currentScenicId
let url = try await uploader.uploadLiveCover( let url = try await uploader.uploadLiveCover(
data: media.data, data: media.data,
fileName: media.fileName, fileName: media.fileName,

View File

@ -37,10 +37,10 @@ final class LoginViewModel {
/// ///
func applyStoredPreferences() { func applyStoredPreferences() {
if account.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, if account.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
let lastUsername = AppStore.shared.lastLoginUsername { let lastUsername = AppStore.shared.session.lastLoginUsername {
account = lastUsername account = lastUsername
} }
isPrivacyChecked = AppStore.shared.privacyAgreementAccepted isPrivacyChecked = AppStore.shared.session.privacyAgreementAccepted
notifyStateChange() notifyStateChange()
} }

View File

@ -146,17 +146,15 @@ final class MaterialDetailViewController: BaseViewController {
} }
@objc private func deleteTapped() { @objc private func deleteTapped() {
let alert = UIAlertController(title: "确认删除", message: "确定删除该素材吗?", preferredStyle: .alert) let dialog = MaterialDeleteDialogViewController(itemName: viewModel.detail?.name ?? "") { [weak self] in
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
alert.addAction(UIAlertAction(title: "删除", style: .destructive) { [weak self] _ in
guard let self else { return } guard let self else { return }
Task { Task {
self.showLoading() self.showLoading()
await self.viewModel.delete(api: self.api) await self.viewModel.delete(api: self.api)
self.hideLoading() self.hideLoading()
} }
}) }
present(alert, animated: true) present(dialog, animated: true)
} }
@objc private func editTapped() { @objc private func editTapped() {
@ -170,6 +168,28 @@ final class MaterialDetailViewController: BaseViewController {
} }
} }
///
struct MaterialCarouselIndexMapper {
///
static func initialRenderedPage(mediaCount: Int) -> Int {
mediaCount > 1 ? 1 : 0
}
///
static func normalizedRenderedPage(_ page: Int, mediaCount: Int) -> Int {
guard mediaCount > 1 else { return 0 }
if page <= 0 { return mediaCount }
if page >= mediaCount + 1 { return 1 }
return page
}
/// 使
static func logicalPage(renderedPage: Int, mediaCount: Int) -> Int {
guard mediaCount > 1 else { return 0 }
return normalizedRenderedPage(renderedPage, mediaCount: mediaCount) - 1
}
}
/// ///
/// ///
private final class MaterialMediaCarouselView: UIView, UIScrollViewDelegate { private final class MaterialMediaCarouselView: UIView, UIScrollViewDelegate {
@ -177,8 +197,11 @@ private final class MaterialMediaCarouselView: UIView, UIScrollViewDelegate {
private let pageControl = UIPageControl() private let pageControl = UIPageControl()
private let placeholderLabel = UILabel() private let placeholderLabel = UILabel()
private var media: [MaterialDetailMediaItem] = [] private var media: [MaterialDetailMediaItem] = []
private var renderedMedia: [MaterialDetailMediaItem] = []
private var players: [Int: AVPlayer] = [:] private var players: [Int: AVPlayer] = [:]
private var playerViews: [Int: MaterialPlayerLayerView] = [:]
private var timer: Timer? private var timer: Timer?
private var renderedPage = 0
override init(frame: CGRect) { override init(frame: CGRect) {
super.init(frame: frame) super.init(frame: frame)
@ -224,7 +247,9 @@ private final class MaterialMediaCarouselView: UIView, UIScrollViewDelegate {
NotificationCenter.default.removeObserver(self) NotificationCenter.default.removeObserver(self)
players.values.forEach { $0.pause() } players.values.forEach { $0.pause() }
players.removeAll() players.removeAll()
playerViews.removeAll()
self.media = media self.media = media
renderedMedia = media.count > 1 ? [media.last!] + media + [media.first!] : media
scrollView.subviews.forEach { $0.removeFromSuperview() } scrollView.subviews.forEach { $0.removeFromSuperview() }
pageControl.numberOfPages = media.count pageControl.numberOfPages = media.count
pageControl.currentPage = 0 pageControl.currentPage = 0
@ -233,7 +258,8 @@ private final class MaterialMediaCarouselView: UIView, UIScrollViewDelegate {
scrollView.isHidden = media.isEmpty scrollView.isHidden = media.isEmpty
guard !media.isEmpty else { return } guard !media.isEmpty else { return }
for (index, item) in media.enumerated() { renderedPage = MaterialCarouselIndexMapper.initialRenderedPage(mediaCount: media.count)
for (index, item) in renderedMedia.enumerated() {
let page = UIView() let page = UIView()
page.backgroundColor = UIColor(hex: 0xF5F5F5) page.backgroundColor = UIColor(hex: 0xF5F5F5)
scrollView.addSubview(page) scrollView.addSubview(page)
@ -245,6 +271,7 @@ private final class MaterialMediaCarouselView: UIView, UIScrollViewDelegate {
} }
setNeedsLayout() setNeedsLayout()
layoutIfNeeded() layoutIfNeeded()
scrollView.contentOffset = CGPoint(x: CGFloat(renderedPage) * bounds.width, y: 0)
playCurrentPageIfNeeded() playCurrentPageIfNeeded()
} }
@ -283,7 +310,8 @@ private final class MaterialMediaCarouselView: UIView, UIScrollViewDelegate {
let player = AVPlayer(url: url) let player = AVPlayer(url: url)
players[index] = player players[index] = player
let playerView = MaterialPlayerLayerView() let playerView = MaterialPlayerLayerView()
playerView.playerLayer.player = player playerView.configure(player: player)
playerViews[index] = playerView
page.addSubview(playerView) page.addSubview(playerView)
playerView.snp.makeConstraints { make in playerView.snp.makeConstraints { make in
make.edges.equalToSuperview() make.edges.equalToSuperview()
@ -302,29 +330,40 @@ private final class MaterialMediaCarouselView: UIView, UIScrollViewDelegate {
for (index, page) in scrollView.subviews.enumerated() { for (index, page) in scrollView.subviews.enumerated() {
page.frame = CGRect(x: CGFloat(index) * size.width, y: 0, width: size.width, height: size.height) page.frame = CGRect(x: CGFloat(index) * size.width, y: 0, width: size.width, height: size.height)
} }
scrollView.contentSize = CGSize(width: size.width * CGFloat(media.count), height: size.height) scrollView.contentSize = CGSize(width: size.width * CGFloat(renderedMedia.count), height: size.height)
scrollView.contentOffset = CGPoint(x: CGFloat(pageControl.currentPage) * size.width, y: 0) scrollView.contentOffset = CGPoint(x: CGFloat(renderedPage) * size.width, y: 0)
} }
private func updateCurrentPage() { private func updateCurrentPage() {
guard bounds.width > 0 else { return } guard bounds.width > 0 else { return }
let page = Int(round(scrollView.contentOffset.x / bounds.width)) let rawPage = Int(round(scrollView.contentOffset.x / bounds.width))
pageControl.currentPage = max(0, min(page, media.count - 1)) let page = MaterialCarouselIndexMapper.normalizedRenderedPage(rawPage, mediaCount: media.count)
if media.count > 1 {
if page != rawPage {
scrollView.setContentOffset(CGPoint(x: CGFloat(page) * bounds.width, y: 0), animated: false)
}
pageControl.currentPage = MaterialCarouselIndexMapper.logicalPage(renderedPage: page, mediaCount: media.count)
} else {
pageControl.currentPage = 0
}
renderedPage = page
playCurrentPageIfNeeded() playCurrentPageIfNeeded()
} }
private func playCurrentPageIfNeeded() { private func playCurrentPageIfNeeded() {
stopTimer() stopTimer()
players.forEach { index, player in players.forEach { index, player in
if index == pageControl.currentPage { if index == renderedPage {
player.seek(to: .zero) player.seek(to: .zero)
player.play() player.play()
playerViews[index]?.setPlaying(true)
} else { } else {
player.pause() player.pause()
playerViews[index]?.setPlaying(false)
} }
} }
guard !media.isEmpty else { return } guard renderedMedia.indices.contains(renderedPage) else { return }
if media[pageControl.currentPage].type == 1 { if renderedMedia[renderedPage].type == 1 {
startImageTimer() startImageTimer()
} }
} }
@ -342,13 +381,13 @@ private final class MaterialMediaCarouselView: UIView, UIScrollViewDelegate {
} }
@objc private func videoDidEnd(_ notification: Notification) { @objc private func videoDidEnd(_ notification: Notification) {
guard players[renderedPage]?.currentItem === notification.object as? AVPlayerItem else { return }
advancePage() advancePage()
} }
private func advancePage() { private func advancePage() {
guard media.count > 1, bounds.width > 0 else { return } guard media.count > 1, bounds.width > 0 else { return }
let nextPage = (pageControl.currentPage + 1) % media.count let nextPage = renderedPage + 1
pageControl.currentPage = nextPage
scrollView.setContentOffset(CGPoint(x: CGFloat(nextPage) * bounds.width, y: 0), animated: true) scrollView.setContentOffset(CGPoint(x: CGFloat(nextPage) * bounds.width, y: 0), animated: true)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
self?.playCurrentPageIfNeeded() self?.playCurrentPageIfNeeded()
@ -361,6 +400,64 @@ private final class MaterialMediaCarouselView: UIView, UIScrollViewDelegate {
private final class MaterialPlayerLayerView: UIView { private final class MaterialPlayerLayerView: UIView {
override class var layerClass: AnyClass { AVPlayerLayer.self } override class var layerClass: AnyClass { AVPlayerLayer.self }
var playerLayer: AVPlayerLayer { layer as! AVPlayerLayer } var playerLayer: AVPlayerLayer { layer as! AVPlayerLayer }
private let controlButton = UIButton(type: .system)
private weak var player: AVPlayer?
private var hideWorkItem: DispatchWorkItem?
override init(frame: CGRect) {
super.init(frame: frame)
controlButton.tintColor = .white
controlButton.backgroundColor = UIColor.black.withAlphaComponent(0.5)
controlButton.layer.cornerRadius = 24
controlButton.addTarget(self, action: #selector(controlTapped), for: .touchUpInside)
addSubview(controlButton)
addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(showControls)))
controlButton.snp.makeConstraints { make in make.center.equalToSuperview(); make.size.equalTo(48) }
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
func configure(player: AVPlayer) {
self.player = player
playerLayer.player = player
playerLayer.videoGravity = .resizeAspect
setPlaying(false)
}
func setPlaying(_ playing: Bool) {
controlButton.setImage(UIImage(systemName: playing ? "pause.fill" : "play.fill"), for: .normal)
if playing {
scheduleHide()
} else {
hideWorkItem?.cancel()
controlButton.isHidden = false
}
}
@objc private func controlTapped() {
guard let player else { return }
if player.timeControlStatus == .playing {
player.pause()
setPlaying(false)
} else {
player.play()
setPlaying(true)
}
}
@objc private func showControls() {
controlButton.isHidden = false
if player?.timeControlStatus == .playing { scheduleHide() }
}
private func scheduleHide() {
hideWorkItem?.cancel()
controlButton.isHidden = false
let work = DispatchWorkItem { [weak self] in self?.controlButton.isHidden = true }
hideWorkItem = work
DispatchQueue.main.asyncAfter(deadline: .now() + 3, execute: work)
}
} }
/// ///
@ -370,7 +467,7 @@ private final class MaterialDetailInfoSection: UIView {
private let listingBadge = UILabel() private let listingBadge = UILabel()
private let tagStack = UIStackView() private let tagStack = UIStackView()
private let statsStack = UIStackView() private let statsStack = UIStackView()
private let downloadStat = MaterialDetailStatView(systemIconName: "arrow.down.circle") private let downloadStat = MaterialDetailStatView(iconName: "material_ic_download_count")
private let likeStat = MaterialDetailStatView(iconName: "sample_ic_like_count") private let likeStat = MaterialDetailStatView(iconName: "sample_ic_like_count")
private let collectStat = MaterialDetailStatView(iconName: "sample_ic_collect_count") private let collectStat = MaterialDetailStatView(iconName: "sample_ic_collect_count")

View File

@ -41,6 +41,7 @@ final class MaterialFormViewController: BaseViewController {
private let cancelButton = UIButton(type: .system) private let cancelButton = UIButton(type: .system)
private let confirmButton = UIButton(type: .system) private let confirmButton = UIButton(type: .system)
private let progressOverlay = MaterialUploadProgressOverlay() private let progressOverlay = MaterialUploadProgressOverlay()
private let dropdownOverlay = MaterialAnchoredDropdownView()
private var pickTarget: PickTarget = .material private var pickTarget: PickTarget = .material
var onSubmitSuccess: (() -> Void)? var onSubmitSuccess: (() -> Void)?
@ -106,7 +107,9 @@ final class MaterialFormViewController: BaseViewController {
bottomBar.addSubview(cancelButton) bottomBar.addSubview(cancelButton)
bottomBar.addSubview(confirmButton) bottomBar.addSubview(confirmButton)
view.addSubview(progressOverlay) view.addSubview(progressOverlay)
view.addSubview(dropdownOverlay)
progressOverlay.isHidden = true progressOverlay.isHidden = true
dropdownOverlay.isHidden = true
} }
override func setupConstraints() { override func setupConstraints() {
@ -147,6 +150,9 @@ final class MaterialFormViewController: BaseViewController {
progressOverlay.snp.makeConstraints { make in progressOverlay.snp.makeConstraints { make in
make.edges.equalToSuperview() make.edges.equalToSuperview()
} }
dropdownOverlay.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
} }
override func bindActions() { override func bindActions() {
@ -167,6 +173,7 @@ final class MaterialFormViewController: BaseViewController {
mediaTypeButton.addTarget(self, action: #selector(mediaTypeTapped), for: .touchUpInside) mediaTypeButton.addTarget(self, action: #selector(mediaTypeTapped), for: .touchUpInside)
mediaGridView.onAdd = { [weak self] in self?.presentPickerSource(target: .material) } mediaGridView.onAdd = { [weak self] in self?.presentPickerSource(target: .material) }
mediaGridView.onDelete = { [weak self] index in self?.viewModel.deleteMaterial(at: index) } mediaGridView.onDelete = { [weak self] index in self?.viewModel.deleteMaterial(at: index) }
mediaGridView.onPreview = { [weak self] index in self?.presentMediaPreview(startingAt: index) }
coverView.onAdd = { [weak self] in self?.presentPickerSource(target: .cover) } coverView.onAdd = { [weak self] in self?.presentPickerSource(target: .cover) }
coverView.onDelete = { [weak self] in self?.viewModel.deleteCoverImage() } coverView.onDelete = { [weak self] in self?.viewModel.deleteCoverImage() }
tagView.onTextChanged = { [weak self] text in self?.viewModel.updateTagInput(text) } tagView.onTextChanged = { [weak self] text in self?.viewModel.updateTagInput(text) }
@ -174,7 +181,7 @@ final class MaterialFormViewController: BaseViewController {
guard let self else { return } guard let self else { return }
Task { await self.viewModel.addTag(api: self.api) } Task { await self.viewModel.addTag(api: self.api) }
} }
tagView.onDelete = { [weak self] tag in self?.viewModel.deleteTag(tag) } tagView.onDelete = { [weak self] tag in self?.viewModel.requestDeleteTag(tag) }
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside) cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside) confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
applyViewModel() applyViewModel()
@ -207,6 +214,7 @@ final class MaterialFormViewController: BaseViewController {
} else { } else {
progressOverlay.isHidden = true progressOverlay.isHidden = true
} }
presentTagDeleteDialogIfNeeded()
} }
private func configureBottomButton(_ button: UIButton, title: String, backgroundColor: UIColor, titleColor: UIColor) { private func configureBottomButton(_ button: UIButton, title: String, backgroundColor: UIColor, titleColor: UIColor) {
@ -222,25 +230,30 @@ final class MaterialFormViewController: BaseViewController {
} }
@objc private func spotTapped() { @objc private func spotTapped() {
let sheet = UIAlertController(title: "关联打卡点", message: nil, preferredStyle: .actionSheet) let spots = viewModel.scenicSpotList
viewModel.scenicSpotList.forEach { spot in dropdownOverlay.show(
sheet.addAction(UIAlertAction(title: spot.name, style: .default) { [weak self] _ in in: view,
self?.viewModel.selectScenicSpot(spot) anchor: spotButton,
}) options: spots.map(\.name),
selectedIndex: spots.firstIndex { $0.id == viewModel.selectedScenicSpot?.id },
maximumHeight: 128
) { [weak self] index in
guard spots.indices.contains(index) else { return }
self?.viewModel.selectScenicSpot(spots[index])
} }
sheet.addAction(UIAlertAction(title: "取消", style: .cancel))
present(sheet, animated: true)
} }
@objc private func mediaTypeTapped() { @objc private func mediaTypeTapped() {
let sheet = UIAlertController(title: "素材类型", message: nil, preferredStyle: .actionSheet) let types = MaterialMediaType.allCases
MaterialMediaType.allCases.forEach { type in dropdownOverlay.show(
sheet.addAction(UIAlertAction(title: type.title, style: .default) { [weak self] _ in in: view,
self?.viewModel.selectMediaType(type) anchor: mediaTypeButton,
}) options: types.map(\.title),
selectedIndex: types.firstIndex(of: viewModel.mediaType)
) { [weak self] index in
guard types.indices.contains(index) else { return }
self?.viewModel.selectMediaType(types[index])
} }
sheet.addAction(UIAlertAction(title: "取消", style: .cancel))
present(sheet, animated: true)
} }
@objc private func cancelTapped() { @objc private func cancelTapped() {
@ -267,24 +280,29 @@ final class MaterialFormViewController: BaseViewController {
sheet.addAction(UIAlertAction(title: "从相册选择", style: .default) { [weak self] _ in sheet.addAction(UIAlertAction(title: "从相册选择", style: .default) { [weak self] _ in
self?.presentPhotoPicker() self?.presentPhotoPicker()
}) })
if target == .material {
sheet.addAction(UIAlertAction(title: "从云盘导入", style: .default) { [weak self] _ in
self?.openCloudPicker()
})
}
sheet.addAction(UIAlertAction(title: "取消", style: .cancel)) sheet.addAction(UIAlertAction(title: "取消", style: .cancel))
present(sheet, animated: true) present(sheet, animated: true)
} }
private func openCloudPicker() { private func presentMediaPreview(startingAt sourceIndex: Int) {
let controller = CloudStoragePickForTaskViewController( let completed = viewModel.uploadedMediaList.enumerated().filter { !$0.element.isUploading && !$0.element.ossUrl.isEmpty }
importedFileIDs: viewModel.importedCloudFileIDs, guard let startIndex = completed.firstIndex(where: { $0.offset == sourceIndex }) else { return }
allowedFileType: viewModel.mediaType.cloudFileType let items = completed.map {
) MaterialPreviewItem(url: $0.element.ossUrl, mediaType: viewModel.mediaType)
controller.onConfirmed = { [weak self] files in
self?.viewModel.addCloudMediaFiles(files)
} }
navigationController?.pushViewController(controller, animated: true) present(MaterialMediaPreviewViewController(items: items, startIndex: startIndex), animated: true)
}
private func presentTagDeleteDialogIfNeeded() {
guard let tag = viewModel.pendingDeleteTag, presentedViewController == nil else { return }
let dialog = MaterialDeleteDialogViewController(
title: "删除标签",
itemName: tag.name,
itemSuffix: "标签吗?",
onCancel: { [weak self] in self?.viewModel.cancelDeleteTag() },
onConfirm: { [weak self] in self?.viewModel.confirmDeleteTag() }
)
present(dialog, animated: true)
} }
private func presentPhotoPicker() { private func presentPhotoPicker() {
@ -462,7 +480,7 @@ private final class MaterialFormFieldView: UIView {
/// ///
private final class MaterialSelectButton: UIControl { private final class MaterialSelectButton: UIControl {
private let titleLabel = UILabel() private let titleLabel = UILabel()
private let chevronView = UIImageView(image: UIImage(systemName: "chevron.down")) private let chevronView = UIImageView(image: UIImage(named: "material_ic_arrow_down"))
override init(frame: CGRect) { override init(frame: CGRect) {
super.init(frame: frame) super.init(frame: frame)
@ -497,12 +515,92 @@ private final class MaterialSelectButton: UIControl {
} }
} }
/// Android
private final class MaterialAnchoredDropdownView: UIControl {
private let cardView = UIView()
private let scrollView = UIScrollView()
private let stackView = UIStackView()
private var onSelect: ((Int) -> Void)?
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .clear
cardView.backgroundColor = .white
cardView.layer.cornerRadius = 4
cardView.layer.shadowColor = UIColor.black.cgColor
cardView.layer.shadowOpacity = 0.14
cardView.layer.shadowRadius = 8
cardView.layer.shadowOffset = CGSize(width: 0, height: 4)
stackView.axis = .vertical
addSubview(cardView)
cardView.addSubview(scrollView)
scrollView.addSubview(stackView)
addTarget(self, action: #selector(dismissMenu), for: .touchUpInside)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
///
func show(
in container: UIView,
anchor: UIView,
options: [String],
selectedIndex: Int?,
maximumHeight: CGFloat = 128,
onSelect: @escaping (Int) -> Void
) {
guard !options.isEmpty else { return }
self.onSelect = onSelect
stackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
for (index, option) in options.enumerated() {
let button = UIButton(type: .system)
button.tag = index
button.contentHorizontalAlignment = .leading
button.contentEdgeInsets = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12)
button.setTitle(option, for: .normal)
button.setTitleColor(index == selectedIndex ? AppColor.primary : .black, for: .normal)
button.titleLabel?.font = .systemFont(ofSize: 14, weight: index == selectedIndex ? .medium : .regular)
button.backgroundColor = .white
button.addTarget(self, action: #selector(optionTapped(_:)), for: .touchUpInside)
button.snp.makeConstraints { make in make.height.equalTo(28) }
stackView.addArrangedSubview(button)
}
isHidden = false
container.bringSubviewToFront(self)
layoutIfNeeded()
let anchorFrame = anchor.convert(anchor.bounds, to: self)
let menuHeight = min(maximumHeight, CGFloat(options.count) * 28)
cardView.frame = CGRect(x: anchorFrame.minX, y: anchorFrame.maxY, width: anchorFrame.width, height: menuHeight)
scrollView.frame = cardView.bounds
stackView.frame = CGRect(x: 0, y: 0, width: cardView.bounds.width, height: CGFloat(options.count) * 28)
scrollView.contentSize = stackView.frame.size
}
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
isHidden ? false : true
}
@objc private func optionTapped(_ sender: UIButton) {
isHidden = true
onSelect?(sender.tag)
}
@objc private func dismissMenu() {
isHidden = true
}
}
/// ///
/// ///
private final class MaterialUploadGridView: UIView { private final class MaterialUploadGridView: UIView {
private let stack = UIStackView() private let stack = UIStackView()
var onAdd: (() -> Void)? var onAdd: (() -> Void)?
var onDelete: ((Int) -> Void)? var onDelete: ((Int) -> Void)?
var onPreview: ((Int) -> Void)?
override init(frame: CGRect) { override init(frame: CGRect) {
super.init(frame: frame) super.init(frame: frame)
@ -550,6 +648,7 @@ private final class MaterialUploadGridView: UIView {
if let item { if let item {
cell.apply(item: item) cell.apply(item: item)
cell.onDelete = { [weak self] in self?.onDelete?(index) } cell.onDelete = { [weak self] in self?.onDelete?(index) }
cell.onPreview = { [weak self] in self?.onPreview?(index) }
} else { } else {
cell.applyAdd() cell.applyAdd()
cell.onAdd = { [weak self] in self?.onAdd?() } cell.onAdd = { [weak self] in self?.onAdd?() }
@ -563,14 +662,17 @@ private final class MaterialUploadGridView: UIView {
private final class MaterialUploadThumbView: UIControl { private final class MaterialUploadThumbView: UIControl {
private let imageView = UIImageView() private let imageView = UIImageView()
private let addCircle = UIView() private let addCircle = UIView()
private let addIcon = UIImageView(image: UIImage(systemName: "plus")) private let addIcon = UIImageView(image: UIImage(named: "material_ic_add"))
private let textLabel = UILabel() private let textLabel = UILabel()
private let deleteButton = UIButton(type: .system) private let deleteButton = UIButton(type: .system)
private let playIcon = UIImageView(image: UIImage(systemName: "play.fill")) private let playIcon = UIImageView(image: UIImage(named: "material_ic_play"))
private let mediaType: MaterialMediaType private let mediaType: MaterialMediaType
private let dashedLayer = CAShapeLayer()
private var previewEnabled = false
var onAdd: (() -> Void)? var onAdd: (() -> Void)?
var onDelete: (() -> Void)? var onDelete: (() -> Void)?
var onPreview: (() -> Void)?
init(mediaType: MaterialMediaType) { init(mediaType: MaterialMediaType) {
self.mediaType = mediaType self.mediaType = mediaType
@ -586,7 +688,7 @@ private final class MaterialUploadThumbView: UIControl {
textLabel.font = .systemFont(ofSize: 12) textLabel.font = .systemFont(ofSize: 12)
textLabel.textColor = UIColor(hex: 0x4B5563) textLabel.textColor = UIColor(hex: 0x4B5563)
textLabel.textAlignment = .center textLabel.textAlignment = .center
deleteButton.setImage(UIImage(systemName: "xmark"), for: .normal) deleteButton.setImage(UIImage(named: "material_ic_clear"), for: .normal)
deleteButton.tintColor = .white deleteButton.tintColor = .white
deleteButton.backgroundColor = UIColor.black.withAlphaComponent(0.6) deleteButton.backgroundColor = UIColor.black.withAlphaComponent(0.6)
deleteButton.layer.cornerRadius = 10 deleteButton.layer.cornerRadius = 10
@ -622,7 +724,12 @@ private final class MaterialUploadThumbView: UIControl {
make.center.equalToSuperview() make.center.equalToSuperview()
make.size.equalTo(32) make.size.equalTo(32)
} }
addTarget(self, action: #selector(addTapped), for: .touchUpInside) dashedLayer.strokeColor = UIColor(hex: 0xE5E7EB).cgColor
dashedLayer.fillColor = UIColor.clear.cgColor
dashedLayer.lineWidth = 1
dashedLayer.lineDashPattern = [6, 6]
layer.addSublayer(dashedLayer)
addTarget(self, action: #selector(cellTapped), for: .touchUpInside)
deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside) deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside)
} }
@ -631,12 +738,19 @@ private final class MaterialUploadThumbView: UIControl {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
override func layoutSubviews() {
super.layoutSubviews()
dashedLayer.frame = bounds
dashedLayer.path = UIBezierPath(roundedRect: bounds.insetBy(dx: 0.5, dy: 0.5), cornerRadius: 8).cgPath
}
func applyAdd() { func applyAdd() {
imageView.image = nil imageView.image = nil
imageView.kf.cancelDownloadTask() imageView.kf.cancelDownloadTask()
backgroundColor = .white backgroundColor = .white
layer.borderColor = UIColor(hex: 0xE5E7EB).cgColor layer.borderWidth = 0
layer.borderWidth = 1 dashedLayer.isHidden = false
previewEnabled = false
addCircle.isHidden = false addCircle.isHidden = false
textLabel.isHidden = false textLabel.isHidden = false
textLabel.text = mediaType == .image ? "点击上传图片" : "点击上传视频" textLabel.text = mediaType == .image ? "点击上传图片" : "点击上传视频"
@ -646,7 +760,8 @@ private final class MaterialUploadThumbView: UIControl {
} }
func apply(item: MaterialUploadedMediaItem) { func apply(item: MaterialUploadedMediaItem) {
layer.borderWidth = 0 dashedLayer.isHidden = true
previewEnabled = !item.ossUrl.isEmpty && !item.isUploading
backgroundColor = UIColor(hex: 0xF5F5F5) backgroundColor = UIColor(hex: 0xF5F5F5)
addCircle.isHidden = true addCircle.isHidden = true
deleteButton.isHidden = item.ossUrl.isEmpty || item.isUploading deleteButton.isHidden = item.ossUrl.isEmpty || item.isUploading
@ -658,7 +773,14 @@ private final class MaterialUploadThumbView: UIControl {
textLabel.numberOfLines = 2 textLabel.numberOfLines = 2
} else { } else {
textLabel.isHidden = true textLabel.isHidden = true
if let urlText = item.previewURL, let url = URL(string: urlText), !urlText.isEmpty { if mediaType == .video {
if let thumbnailData = item.thumbnailData, let image = UIImage(data: thumbnailData) {
imageView.image = image
} else if let urlText = (item.previewURL?.isEmpty == false ? item.previewURL : item.ossUrl),
!urlText.isEmpty, let url = URL(string: urlText) {
loadVideoThumbnail(url: url)
}
} else if let urlText = item.previewURL, let url = URL(string: urlText), !urlText.isEmpty {
imageView.kf.setImage(with: url) imageView.kf.setImage(with: url)
} else { } else {
imageView.image = UIImage(data: item.thumbnailData ?? item.data) imageView.image = UIImage(data: item.thumbnailData ?? item.data)
@ -666,14 +788,27 @@ private final class MaterialUploadThumbView: UIControl {
} }
} }
@objc private func addTapped() { @objc private func cellTapped() {
guard !addCircle.isHidden else { return } if !addCircle.isHidden {
onAdd?() onAdd?()
} else if previewEnabled {
onPreview?()
}
} }
@objc private func deleteTapped() { @objc private func deleteTapped() {
onDelete?() onDelete?()
} }
private func loadVideoThumbnail(url: URL) {
imageView.image = nil
let generator = AVAssetImageGenerator(asset: AVURLAsset(url: url))
generator.appliesPreferredTrackTransform = true
generator.generateCGImagesAsynchronously(forTimes: [NSValue(time: .zero)]) { [weak self] _, image, _, _, _ in
guard let image else { return }
DispatchQueue.main.async { self?.imageView.image = UIImage(cgImage: image) }
}
}
} }
/// ///
@ -681,18 +816,22 @@ private final class MaterialUploadThumbView: UIControl {
private final class MaterialCoverUploadView: UIControl { private final class MaterialCoverUploadView: UIControl {
private let imageView = UIImageView() private let imageView = UIImageView()
private let addCircle = UIView() private let addCircle = UIView()
private let addIcon = UIImageView(image: UIImage(systemName: "plus")) private let addIcon = UIImageView(image: UIImage(named: "material_ic_add"))
private let titleLabel = UILabel() private let titleLabel = UILabel()
private let hintLabel = UILabel() private let hintLabel = UILabel()
private let deleteButton = UIButton(type: .system) private let deleteButton = UIButton(type: .system)
private let dashedLayer = CAShapeLayer()
var onAdd: (() -> Void)? var onAdd: (() -> Void)?
var onDelete: (() -> Void)? var onDelete: (() -> Void)?
override init(frame: CGRect) { override init(frame: CGRect) {
super.init(frame: frame) super.init(frame: frame)
layer.cornerRadius = 8 layer.cornerRadius = 8
layer.borderColor = UIColor(hex: 0xE5E7EB).cgColor dashedLayer.strokeColor = UIColor(hex: 0xE5E7EB).cgColor
layer.borderWidth = 1 dashedLayer.fillColor = UIColor.clear.cgColor
dashedLayer.lineWidth = 1
dashedLayer.lineDashPattern = [6, 6]
layer.addSublayer(dashedLayer)
clipsToBounds = true clipsToBounds = true
imageView.contentMode = .scaleAspectFill imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true imageView.clipsToBounds = true
@ -707,7 +846,7 @@ private final class MaterialCoverUploadView: UIControl {
hintLabel.font = .systemFont(ofSize: 12) hintLabel.font = .systemFont(ofSize: 12)
hintLabel.textColor = UIColor(hex: 0xB6BECA) hintLabel.textColor = UIColor(hex: 0xB6BECA)
hintLabel.textAlignment = .center hintLabel.textAlignment = .center
deleteButton.setImage(UIImage(systemName: "xmark"), for: .normal) deleteButton.setImage(UIImage(named: "material_ic_clear"), for: .normal)
deleteButton.tintColor = .white deleteButton.tintColor = .white
deleteButton.backgroundColor = UIColor.black.withAlphaComponent(0.6) deleteButton.backgroundColor = UIColor.black.withAlphaComponent(0.6)
deleteButton.layer.cornerRadius = 12 deleteButton.layer.cornerRadius = 12
@ -753,6 +892,12 @@ private final class MaterialCoverUploadView: UIControl {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
override func layoutSubviews() {
super.layoutSubviews()
dashedLayer.frame = bounds
dashedLayer.path = UIBezierPath(roundedRect: bounds.insetBy(dx: 0.5, dy: 0.5), cornerRadius: 8).cgPath
}
func apply(item: MaterialUploadedMediaItem?) { func apply(item: MaterialUploadedMediaItem?) {
guard let item else { guard let item else {
imageView.image = nil imageView.image = nil
@ -763,6 +908,7 @@ private final class MaterialCoverUploadView: UIControl {
titleLabel.numberOfLines = 1 titleLabel.numberOfLines = 1
hintLabel.isHidden = false hintLabel.isHidden = false
deleteButton.isHidden = true deleteButton.isHidden = true
dashedLayer.isHidden = false
return return
} }
if item.isUploading { if item.isUploading {
@ -773,6 +919,7 @@ private final class MaterialCoverUploadView: UIControl {
titleLabel.numberOfLines = 2 titleLabel.numberOfLines = 2
hintLabel.isHidden = true hintLabel.isHidden = true
deleteButton.isHidden = true deleteButton.isHidden = true
dashedLayer.isHidden = true
} else { } else {
if let urlText = item.previewURL, let url = URL(string: urlText), !urlText.isEmpty { if let urlText = item.previewURL, let url = URL(string: urlText), !urlText.isEmpty {
imageView.kf.setImage(with: url) imageView.kf.setImage(with: url)
@ -783,6 +930,7 @@ private final class MaterialCoverUploadView: UIControl {
titleLabel.isHidden = true titleLabel.isHidden = true
hintLabel.isHidden = true hintLabel.isHidden = true
deleteButton.isHidden = item.ossUrl.isEmpty deleteButton.isHidden = item.ossUrl.isEmpty
dashedLayer.isHidden = true
} }
} }
@ -819,17 +967,17 @@ private final class MaterialTagEditorView: UIView {
tagsStack.alignment = .leading tagsStack.alignment = .leading
inputRow.axis = .horizontal inputRow.axis = .horizontal
inputRow.spacing = 8 inputRow.spacing = 8
textField.placeholder = "请输入标签"
textField.font = .systemFont(ofSize: 14) textField.font = .systemFont(ofSize: 14)
textField.layer.borderColor = UIColor(hex: 0xEEEEEE).cgColor textField.layer.borderColor = UIColor(hex: 0xEEEEEE).cgColor
textField.layer.borderWidth = 1 textField.layer.borderWidth = 1
textField.layer.cornerRadius = 8 textField.layer.cornerRadius = 4
textField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 44)) textField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 44))
textField.leftViewMode = .always textField.leftViewMode = .always
addButton.setTitle("添加", for: .normal) textField.placeholder = "请输入标签,最低5个字符"
addButton.setTitle("添加标签", for: .normal)
addButton.setTitleColor(.white, for: .normal) addButton.setTitleColor(.white, for: .normal)
addButton.backgroundColor = AppColor.primary addButton.backgroundColor = AppColor.primary
addButton.layer.cornerRadius = 8 addButton.layer.cornerRadius = 4
addSubview(stack) addSubview(stack)
stack.addArrangedSubview(tagsStack) stack.addArrangedSubview(tagsStack)
stack.addArrangedSubview(inputRow) stack.addArrangedSubview(inputRow)
@ -839,11 +987,11 @@ private final class MaterialTagEditorView: UIView {
make.edges.equalToSuperview() make.edges.equalToSuperview()
} }
textField.snp.makeConstraints { make in textField.snp.makeConstraints { make in
make.height.equalTo(44) make.height.equalTo(36)
} }
addButton.snp.makeConstraints { make in addButton.snp.makeConstraints { make in
make.width.equalTo(72) make.width.equalTo(80)
make.height.equalTo(44) make.height.equalTo(36)
} }
textField.addTarget(self, action: #selector(textChanged), for: .editingChanged) textField.addTarget(self, action: #selector(textChanged), for: .editingChanged)
addButton.addTarget(self, action: #selector(addTapped), for: .touchUpInside) addButton.addTarget(self, action: #selector(addTapped), for: .touchUpInside)
@ -859,10 +1007,13 @@ private final class MaterialTagEditorView: UIView {
if textField.text != input { if textField.text != input {
textField.text = input textField.text = input
} }
let canAdd = (5 ... 15).contains(input.count)
addButton.isEnabled = canAdd
addButton.backgroundColor = canAdd ? AppColor.primary : AppColor.buttonDisabled
tagsStack.arrangedSubviews.forEach { $0.removeFromSuperview() } tagsStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
tags.forEach { tag in tags.forEach { tag in
let button = UIButton(type: .system) let button = UIButton(type: .system)
button.setTitle("\(tag.name) ×", for: .normal) button.setTitle(tag.name, for: .normal)
button.setTitleColor(AppColor.primary, for: .normal) button.setTitleColor(AppColor.primary, for: .normal)
button.titleLabel?.font = .systemFont(ofSize: 12) button.titleLabel?.font = .systemFont(ofSize: 12)
button.backgroundColor = UIColor(hex: 0xEFF6FF) button.backgroundColor = UIColor(hex: 0xEFF6FF)

View File

@ -15,16 +15,15 @@ final class MaterialListViewController: BaseViewController {
private let searchContainer = UIView() private let searchContainer = UIView()
private let searchBox = UIView() private let searchBox = UIView()
private let searchIconView = UIImageView(image: UIImage(systemName: "magnifyingglass")) private let searchIconView = UIImageView(image: UIImage(named: "material_ic_search"))
private let searchTextField = UITextField() private let searchTextField = UITextField()
private let filterContainer = UIView() private let filterContainer = UIView()
private let filterButton = UIButton(type: .system) private let filterButton = UIButton(type: .system)
private let filterTitleLabel = UILabel() private let filterTitleLabel = UILabel()
private let filterChevronView = UIImageView(image: UIImage(systemName: "chevron.down")) private let filterChevronView = UIImageView(image: UIImage(named: "material_ic_arrow_down"))
private let filterDropdownView = UIView() private let filterDropdownView = UIView()
private let filterDropdownStack = UIStackView() private let filterDropdownStack = UIStackView()
private let tableView = UITableView(frame: .zero, style: .plain) private let tableView = UITableView(frame: .zero, style: .plain)
private let emptyLabel = UILabel()
private let bottomBar = UIView() private let bottomBar = UIView()
private let addButton = UIButton(type: .system) private let addButton = UIButton(type: .system)
private var dataSource: UITableViewDiffableDataSource<Int, MaterialListRow>! private var dataSource: UITableViewDiffableDataSource<Int, MaterialListRow>!
@ -115,12 +114,6 @@ final class MaterialListViewController: BaseViewController {
} }
} }
emptyLabel.text = "暂无素材"
emptyLabel.font = .systemFont(ofSize: 16)
emptyLabel.textColor = AppColor.textSecondary
emptyLabel.textAlignment = .center
emptyLabel.isHidden = true
bottomBar.backgroundColor = .white bottomBar.backgroundColor = .white
addButton.setTitle("添加", for: .normal) addButton.setTitle("添加", for: .normal)
addButton.setTitleColor(.white, for: .normal) addButton.setTitleColor(.white, for: .normal)
@ -137,7 +130,6 @@ final class MaterialListViewController: BaseViewController {
filterButton.addSubview(filterTitleLabel) filterButton.addSubview(filterTitleLabel)
filterButton.addSubview(filterChevronView) filterButton.addSubview(filterChevronView)
view.addSubview(tableView) view.addSubview(tableView)
view.addSubview(emptyLabel)
view.addSubview(bottomBar) view.addSubview(bottomBar)
bottomBar.addSubview(addButton) bottomBar.addSubview(addButton)
view.addSubview(filterDropdownView) view.addSubview(filterDropdownView)
@ -206,11 +198,6 @@ final class MaterialListViewController: BaseViewController {
filterDropdownStack.snp.makeConstraints { make in filterDropdownStack.snp.makeConstraints { make in
make.edges.equalToSuperview() make.edges.equalToSuperview()
} }
emptyLabel.snp.makeConstraints { make in
make.centerX.equalToSuperview()
make.centerY.equalTo(tableView)
make.leading.trailing.equalToSuperview().inset(24)
}
} }
override func bindActions() { override func bindActions() {
@ -242,7 +229,7 @@ final class MaterialListViewController: BaseViewController {
@MainActor @MainActor
private func applyViewModel() { private func applyViewModel() {
filterTitleLabel.text = viewModel.filterStatus == .all ? "筛选" : viewModel.filterStatus.title filterTitleLabel.text = "筛选"
filterOptionButtons.forEach { status, button in filterOptionButtons.forEach { status, button in
let selected = status == viewModel.filterStatus let selected = status == viewModel.filterStatus
button.setTitleColor(selected ? AppColor.primary : AppColor.textPrimary, for: .normal) button.setTitleColor(selected ? AppColor.primary : AppColor.textPrimary, for: .normal)
@ -256,7 +243,6 @@ final class MaterialListViewController: BaseViewController {
snapshot.appendSections([0]) snapshot.appendSections([0])
snapshot.appendItems([.stats(viewModel.orderInfo)] + viewModel.items.map(MaterialListRow.item)) snapshot.appendItems([.stats(viewModel.orderInfo)] + viewModel.items.map(MaterialListRow.item))
dataSource.apply(snapshot, animatingDifferences: true) dataSource.apply(snapshot, animatingDifferences: true)
emptyLabel.isHidden = !viewModel.items.isEmpty || viewModel.isLoading || viewModel.isRefreshing
viewModel.isLoading && viewModel.items.isEmpty ? showLoading() : hideLoading() viewModel.isLoading && viewModel.items.isEmpty ? showLoading() : hideLoading()
if !viewModel.isRefreshing { if !viewModel.isRefreshing {
tableView.refreshControl?.endRefreshing() tableView.refreshControl?.endRefreshing()
@ -371,6 +357,10 @@ private final class MaterialStatsCell: UITableViewCell {
contentView.backgroundColor = AppColor.pageBackground contentView.backgroundColor = AppColor.pageBackground
cardView.backgroundColor = .white cardView.backgroundColor = .white
cardView.layer.cornerRadius = 12 cardView.layer.cornerRadius = 12
cardView.layer.shadowColor = UIColor.black.cgColor
cardView.layer.shadowOpacity = 0.12
cardView.layer.shadowRadius = 2
cardView.layer.shadowOffset = CGSize(width: 0, height: 1)
stack.axis = .horizontal stack.axis = .horizontal
stack.spacing = 12 stack.spacing = 12
stack.distribution = .fillEqually stack.distribution = .fillEqually
@ -451,7 +441,7 @@ private final class MaterialListCell: UITableViewCell {
private let statusBadge = MaterialStatusBadgeView() private let statusBadge = MaterialStatusBadgeView()
private let listingSwitch = UISwitch() private let listingSwitch = UISwitch()
private let statsStack = UIStackView() private let statsStack = UIStackView()
private let downloadStatView = MaterialStatView(systemIconName: "arrow.down.circle") private let downloadStatView = MaterialStatView(iconName: "material_ic_download_count")
private let likeStatView = MaterialStatView(iconName: "sample_ic_like_count") private let likeStatView = MaterialStatView(iconName: "sample_ic_like_count")
private let collectStatView = MaterialStatView(iconName: "sample_ic_collect_count") private let collectStatView = MaterialStatView(iconName: "sample_ic_collect_count")
private var isApplying = false private var isApplying = false

View File

@ -0,0 +1,258 @@
//
// MaterialManagementDialogs.swift
// suixinkan
//
import AVKit
import Kingfisher
import SnapKit
import UIKit
/// Android
final class MaterialDeleteDialogViewController: UIViewController {
private let dialogTitle: String
private let itemName: String
private let itemSuffix: String
private let onConfirm: () -> Void
private let onCancel: () -> Void
private let dimView = UIView()
private let cardView = UIView()
///
init(
title: String = "删除素材",
itemName: String,
itemSuffix: String = "素材吗?",
onCancel: @escaping () -> Void = {},
onConfirm: @escaping () -> Void
) {
dialogTitle = title
self.itemName = itemName
self.itemSuffix = itemSuffix
self.onConfirm = onConfirm
self.onCancel = onCancel
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .overFullScreen
modalTransitionStyle = .crossDissolve
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .clear
dimView.backgroundColor = UIColor.black.withAlphaComponent(0.32)
cardView.backgroundColor = .white
cardView.layer.cornerRadius = 16
let stack = UIStackView()
stack.axis = .vertical
stack.spacing = 16
let titleLabel = UILabel()
titleLabel.text = dialogTitle
titleLabel.font = .systemFont(ofSize: 18, weight: .bold)
titleLabel.textColor = UIColor(hex: 0xFF0000)
let messageLabel = UILabel()
messageLabel.numberOfLines = 0
let prefix = "您确定要删除"
let suffix = itemSuffix
let message = NSMutableAttributedString(
string: prefix + itemName + suffix,
attributes: [
.font: UIFont.systemFont(ofSize: 16),
.foregroundColor: UIColor(hex: 0x333333),
]
)
if !itemName.isEmpty {
let prefixLength = (prefix as NSString).length
let itemLength = (itemName as NSString).length
message.addAttribute(
.font,
value: UIFont.systemFont(ofSize: 16, weight: .bold),
range: NSRange(location: prefixLength, length: itemLength)
)
}
messageLabel.attributedText = message
let buttons = UIStackView()
buttons.axis = .horizontal
buttons.spacing = 12
buttons.distribution = .fillEqually
let cancelButton = makeButton(title: "取消", titleColor: UIColor(hex: 0xFF0000), background: UIColor(hex: 0xFFE5E5))
let confirmButton = makeButton(title: "确认", titleColor: .white, background: UIColor(hex: 0xFF0000))
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
[cancelButton, confirmButton].forEach { button in
button.snp.makeConstraints { make in make.height.equalTo(44) }
buttons.addArrangedSubview(button)
}
view.addSubview(dimView)
view.addSubview(cardView)
cardView.addSubview(stack)
[titleLabel, messageLabel, buttons].forEach(stack.addArrangedSubview)
stack.setCustomSpacing(24, after: messageLabel)
dimView.snp.makeConstraints { make in make.edges.equalToSuperview() }
cardView.snp.makeConstraints { make in
make.center.equalToSuperview()
make.leading.trailing.equalToSuperview().inset(32)
}
stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(24) }
}
private func makeButton(title: String, titleColor: UIColor, background: UIColor) -> UIButton {
let button = UIButton(type: .system)
button.setTitle(title, for: .normal)
button.setTitleColor(titleColor, for: .normal)
button.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
button.backgroundColor = background
button.layer.cornerRadius = 8
return button
}
@objc private func cancelTapped() {
dismiss(animated: true) { [onCancel] in onCancel() }
}
@objc private func confirmTapped() {
dismiss(animated: true) { [onConfirm] in onConfirm() }
}
}
///
struct MaterialPreviewItem: Equatable {
let url: String
let mediaType: MaterialMediaType
}
/// Android `MediaPreviewDialog`
final class MaterialMediaPreviewViewController: UIViewController, UIScrollViewDelegate {
private let items: [MaterialPreviewItem]
private let startIndex: Int
private let scrollView = UIScrollView()
private let closeButton = UIButton(type: .system)
private var pages: [UIView] = []
private var players: [Int: AVPlayerViewController] = [:]
private var currentIndex = 0
///
init(items: [MaterialPreviewItem], startIndex: Int) {
self.items = items
self.startIndex = max(0, min(startIndex, max(0, items.count - 1)))
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .overFullScreen
modalTransitionStyle = .crossDissolve
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .black
scrollView.backgroundColor = .black
scrollView.isPagingEnabled = true
scrollView.showsHorizontalScrollIndicator = false
scrollView.bounces = false
scrollView.delegate = self
closeButton.setImage(UIImage(named: "material_ic_clear"), for: .normal)
closeButton.tintColor = .white
closeButton.backgroundColor = UIColor.black.withAlphaComponent(0.5)
closeButton.layer.cornerRadius = 22
closeButton.accessibilityLabel = "关闭"
closeButton.addTarget(self, action: #selector(closeTapped), for: .touchUpInside)
view.addSubview(scrollView)
view.addSubview(closeButton)
scrollView.snp.makeConstraints { make in make.edges.equalToSuperview() }
closeButton.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(5)
make.trailing.equalToSuperview().inset(16)
make.size.equalTo(44)
}
buildPages()
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
let size = scrollView.bounds.size
guard size.width > 0, size.height > 0 else { return }
for (index, page) in pages.enumerated() {
page.frame = CGRect(x: CGFloat(index) * size.width, y: 0, width: size.width, height: size.height)
}
scrollView.contentSize = CGSize(width: CGFloat(items.count) * size.width, height: size.height)
scrollView.contentOffset = CGPoint(x: CGFloat(currentIndex) * size.width, y: 0)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
currentIndex = startIndex
view.setNeedsLayout()
view.layoutIfNeeded()
playCurrentVideo()
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
players.values.forEach { $0.player?.pause() }
}
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
updateCurrentIndex()
}
private func buildPages() {
for (index, item) in items.enumerated() {
let page = UIView()
page.backgroundColor = .black
scrollView.addSubview(page)
pages.append(page)
if item.mediaType == .video, let url = URL(string: item.url) {
let controller = AVPlayerViewController()
controller.player = AVPlayer(url: url)
controller.showsPlaybackControls = true
addChild(controller)
page.addSubview(controller.view)
controller.view.snp.makeConstraints { make in make.edges.equalToSuperview() }
controller.didMove(toParent: self)
players[index] = controller
} else {
let imageView = UIImageView()
imageView.contentMode = .scaleAspectFit
imageView.backgroundColor = .black
page.addSubview(imageView)
imageView.snp.makeConstraints { make in make.edges.equalToSuperview() }
if let url = URL(string: item.url) {
imageView.kf.setImage(with: url)
}
}
}
}
private func updateCurrentIndex() {
guard scrollView.bounds.width > 0 else { return }
currentIndex = max(0, min(Int(round(scrollView.contentOffset.x / scrollView.bounds.width)), items.count - 1))
playCurrentVideo()
}
private func playCurrentVideo() {
players.forEach { index, controller in
if index == currentIndex {
controller.player?.play()
} else {
controller.player?.pause()
}
}
}
@objc private func closeTapped() {
dismiss(animated: true)
}
}

View File

@ -41,7 +41,7 @@ final class DepositOrderDetailViewController: BaseViewController, UITableViewDat
} }
private func loadDetail() async { private func loadDetail() async {
let storeId = AppStore.shared.currentStoreId let storeId = AppStore.shared.session.currentStoreId
guard storeId > 0 else { guard storeId > 0 else {
showToast("店铺信息异常") showToast("店铺信息异常")
return return

View File

@ -18,7 +18,7 @@ final class OrdersViewController: BaseViewController, UITableViewDataSource, UIT
private let listView = OrderListView() private let listView = OrderListView()
private var hasInitialized = false private var hasInitialized = false
private var isStoreMode: Bool { AppStore.shared.currentAppRole == .storeAdmin } private var isStoreMode: Bool { AppStore.shared.session.currentAppRole == .storeAdmin }
private var pendingOrderSourceNumber: String? private var pendingOrderSourceNumber: String?
private weak var orderSourcePicker: OrderSourcePickerViewController? private weak var orderSourcePicker: OrderSourcePickerViewController?
private var pendingGiftOrderNumber: String? private var pendingGiftOrderNumber: String?

View File

@ -97,13 +97,13 @@ final class AccountSwitchViewController: BaseViewController, UITableViewDelegate
private var currentAccountId: String? { private var currentAccountId: String? {
let store = AppStore.shared let store = AppStore.shared
if store.currentStoreId > 0 { if store.session.currentStoreId > 0 {
return "\(V9StoreUser.accountTypeValue)_\(store.currentStoreId)" return "\(V9StoreUser.accountTypeValue)_\(store.session.currentStoreId)"
} }
if store.currentScenicId > 0 { if store.session.currentScenicId > 0 {
return "\(V9ScenicUser.accountTypeValue)_\(store.userId)" return "\(V9ScenicUser.accountTypeValue)_\(store.session.userId)"
} }
return store.userId.isEmpty ? nil : store.userId return store.session.userId.isEmpty ? nil : store.session.userId
} }
private func isCurrentAccount(_ account: AccountSwitchAccount) -> Bool { private func isCurrentAccount(_ account: AccountSwitchAccount) -> Bool {

View File

@ -156,7 +156,7 @@ final class ProfileEditViewController: BaseViewController {
} }
private func saveProfile() async { private func saveProfile() async {
let scenicId = AppStore.shared.currentScenicId let scenicId = AppStore.shared.session.currentScenicId
guard scenicId > 0 else { guard scenicId > 0 else {
showToast("当前景区信息缺失") showToast("当前景区信息缺失")
return return

View File

@ -0,0 +1,448 @@
//
// ProfileSpaceSettingsDialogs.swift
// suixinkan
//
import SnapKit
import UIKit
/// 使 Android
enum ProfileSpaceIcon {
/// 16pt
static var edit: UIImage? {
UIImage(named: "profile_edit")
}
}
/// 使 Android
enum ProfileNavigationStyle {
static func apply(to navigationController: UINavigationController?, showsDivider: Bool) {
guard let navigationBar = navigationController?.navigationBar else { return }
let appearance = UINavigationBarAppearance()
appearance.configureWithOpaqueBackground()
appearance.backgroundColor = .white
appearance.shadowColor = showsDivider ? AppColor.border : .clear
appearance.titleTextAttributes = [
.foregroundColor: UIColor(hex: 0x333333),
.font: UIFont.systemFont(ofSize: 18),
]
navigationBar.standardAppearance = appearance
navigationBar.scrollEdgeAppearance = appearance
navigationBar.compactAppearance = appearance
navigationBar.tintColor = .black
}
static func restore(_ navigationController: UINavigationController?) {
guard let navigationBar = navigationController?.navigationBar else { return }
let appearance = UINavigationBarAppearance()
appearance.configureWithOpaqueBackground()
appearance.backgroundColor = AppColor.cardBackground
appearance.shadowColor = .clear
appearance.titleTextAttributes = [
.foregroundColor: AppColor.textPrimary,
.font: UIFont.app(.title),
]
navigationBar.standardAppearance = appearance
navigationBar.scrollEdgeAppearance = appearance
navigationBar.compactAppearance = appearance
navigationBar.tintColor = AppColor.primary
}
}
/// Android 线
final class ProfileDashedBorderButton: UIButton {
private let dashedLayer = CAShapeLayer()
override init(frame: CGRect) {
super.init(frame: frame)
dashedLayer.strokeColor = AppColor.primary.cgColor
dashedLayer.fillColor = UIColor.clear.cgColor
dashedLayer.lineWidth = 1
dashedLayer.lineDashPattern = [2, 2]
layer.addSublayer(dashedLayer)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
dashedLayer.frame = bounds
dashedLayer.path = UIBezierPath(roundedRect: bounds.insetBy(dx: 0.5, dy: 0.5), cornerRadius: 4).cgPath
}
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
bounds.insetBy(dx: -8, dy: -8).contains(point)
}
}
/// Android
class ProfileBaseDialogViewController: UIViewController {
let cardView = UIView()
init() {
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .overFullScreen
modalTransitionStyle = .crossDissolve
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor.black.withAlphaComponent(0.2)
cardView.backgroundColor = .white
view.addSubview(cardView)
let outsideTap = UITapGestureRecognizer(target: self, action: #selector(outsideTapped(_:)))
outsideTap.cancelsTouchesInView = false
view.addGestureRecognizer(outsideTap)
cardView.snp.makeConstraints { make in
make.center.equalToSuperview()
make.leading.trailing.equalToSuperview().inset(32)
}
}
func makePrimaryButton(title: String, height: CGFloat = 45) -> UIButton {
let button = UIButton(type: .system)
button.setTitle(title, for: .normal)
button.setTitleColor(.white, for: .normal)
button.setTitleColor(.white, for: .disabled)
button.titleLabel?.font = .systemFont(ofSize: 17, weight: .medium)
button.backgroundColor = AppColor.primary
button.layer.cornerRadius = 10
button.snp.makeConstraints { make in make.height.equalTo(height) }
return button
}
@objc private func outsideTapped(_ gesture: UITapGestureRecognizer) {
let location = gesture.location(in: view)
guard !cardView.frame.contains(location) else { return }
dismiss(animated: false)
}
}
/// Android
final class ProfileTimePickerDialogViewController: ProfileBaseDialogViewController {
private let picker = UIDatePicker()
private let confirmButton: UIButton
private let onConfirm: (Date) -> Void
init(initialDate: Date, onConfirm: @escaping (Date) -> Void) {
self.onConfirm = onConfirm
confirmButton = UIButton(type: .system)
super.init()
picker.date = initialDate
}
override func viewDidLoad() {
super.viewDidLoad()
cardView.layer.cornerRadius = 10
picker.datePickerMode = .time
picker.preferredDatePickerStyle = .wheels
picker.locale = Locale(identifier: "zh_CN")
confirmButton.setTitle("确认", for: .normal)
confirmButton.setTitleColor(.white, for: .normal)
confirmButton.titleLabel?.font = .systemFont(ofSize: 17, weight: .medium)
confirmButton.backgroundColor = AppColor.primary
confirmButton.layer.cornerRadius = 10
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
cardView.addSubview(picker)
cardView.addSubview(confirmButton)
picker.snp.makeConstraints { make in
make.top.equalToSuperview().offset(24)
make.leading.trailing.equalToSuperview().inset(15)
make.height.equalTo(180)
}
confirmButton.snp.makeConstraints { make in
make.top.equalTo(picker.snp.bottom).offset(24)
make.leading.trailing.equalToSuperview().inset(15)
make.height.equalTo(45)
make.bottom.equalToSuperview().inset(24)
}
}
@objc private func confirmTapped() {
onConfirm(picker.date)
dismiss(animated: false)
}
}
/// Android
final class ProfileDeviceDialogViewController: ProfileBaseDialogViewController, UITextFieldDelegate {
private let textField = UITextField()
private let confirmButton = UIButton(type: .system)
private let onConfirm: (String) -> Void
init(onConfirm: @escaping (String) -> Void) {
self.onConfirm = onConfirm
super.init()
}
override func viewDidLoad() {
super.viewDidLoad()
cardView.layer.cornerRadius = 10
textField.placeholder = "请输入设备名"
textField.font = .systemFont(ofSize: 14)
textField.layer.borderColor = AppColor.border.cgColor
textField.layer.borderWidth = 1
textField.layer.cornerRadius = 8
textField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 1))
textField.leftViewMode = .always
textField.returnKeyType = .done
textField.delegate = self
textField.addTarget(self, action: #selector(textChanged), for: .editingChanged)
confirmButton.setTitle("确认", for: .normal)
confirmButton.setTitleColor(.white, for: .normal)
confirmButton.setTitleColor(.white, for: .disabled)
confirmButton.titleLabel?.font = .systemFont(ofSize: 17, weight: .medium)
confirmButton.layer.cornerRadius = 10
confirmButton.isEnabled = false
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
cardView.addSubview(textField)
cardView.addSubview(confirmButton)
textField.snp.makeConstraints { make in
make.top.equalToSuperview().offset(24)
make.leading.trailing.equalToSuperview().inset(15)
make.height.equalTo(46)
}
confirmButton.snp.makeConstraints { make in
make.top.equalTo(textField.snp.bottom).offset(24)
make.leading.trailing.equalToSuperview().inset(15)
make.height.equalTo(45)
make.bottom.equalToSuperview().inset(24)
}
textChanged()
DispatchQueue.main.async { [weak self] in self?.textField.becomeFirstResponder() }
}
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
guard confirmButton.isEnabled else { return false }
confirmTapped()
return true
}
@objc private func textChanged() {
let enabled = !(textField.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
confirmButton.isEnabled = enabled
confirmButton.backgroundColor = enabled ? AppColor.primary : AppColor.buttonDisabled
}
@objc private func confirmTapped() {
let value = (textField.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.isEmpty else { return }
onConfirm(value)
dismiss(animated: false)
}
}
/// Android
final class ProfileDeleteDialogViewController: ProfileBaseDialogViewController {
private let dialogTitle: String
private let dialogMessage: String
private let onConfirm: () -> Void
init(title: String, message: String, onConfirm: @escaping () -> Void) {
dialogTitle = title
dialogMessage = message
self.onConfirm = onConfirm
super.init()
}
override func viewDidLoad() {
super.viewDidLoad()
cardView.layer.cornerRadius = 16
let stack = UIStackView()
stack.axis = .vertical
stack.spacing = 16
stack.layoutMargins = UIEdgeInsets(top: 24, left: 24, bottom: 24, right: 24)
stack.isLayoutMarginsRelativeArrangement = true
let titleLabel = UILabel()
titleLabel.text = dialogTitle
titleLabel.font = .systemFont(ofSize: 18, weight: .bold)
titleLabel.textColor = UIColor(hex: 0xFF0000)
let messageLabel = UILabel()
messageLabel.font = .systemFont(ofSize: 16)
messageLabel.textColor = UIColor(hex: 0x333333)
messageLabel.numberOfLines = 0
let message = NSMutableAttributedString(
string: dialogMessage,
attributes: [.font: UIFont.systemFont(ofSize: 16), .foregroundColor: UIColor(hex: 0x333333)]
)
let prefix = "您确定要删除"
if dialogMessage.hasPrefix(prefix), dialogTitle != "删除日程" {
let suffixes = ["标签吗?", "拍摄说明吗?", "设备吗?"]
if let suffix = suffixes.first(where: dialogMessage.hasSuffix) {
let nameStart = prefix.count
let nameLength = dialogMessage.count - prefix.count - suffix.count
if nameLength > 0 {
message.addAttribute(.font, value: UIFont.systemFont(ofSize: 16, weight: .bold), range: NSRange(location: nameStart, length: nameLength))
}
}
}
messageLabel.attributedText = message
let buttons = UIStackView()
buttons.axis = .horizontal
buttons.spacing = 12
buttons.distribution = .fillEqually
let cancel = UIButton(type: .system)
cancel.setTitle("取消", for: .normal)
cancel.setTitleColor(UIColor(hex: 0xFF0000), for: .normal)
cancel.backgroundColor = UIColor(hex: 0xFFE5E5)
cancel.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
cancel.layer.cornerRadius = 8
cancel.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
let confirm = UIButton(type: .system)
confirm.setTitle("确认", for: .normal)
confirm.setTitleColor(.white, for: .normal)
confirm.backgroundColor = UIColor(hex: 0xFF0000)
confirm.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
confirm.layer.cornerRadius = 8
confirm.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
[cancel, confirm].forEach { button in
button.snp.makeConstraints { make in make.height.equalTo(44) }
buttons.addArrangedSubview(button)
}
stack.addArrangedSubview(titleLabel)
stack.addArrangedSubview(messageLabel)
stack.setCustomSpacing(24, after: messageLabel)
stack.addArrangedSubview(buttons)
cardView.addSubview(stack)
stack.snp.makeConstraints { make in make.edges.equalToSuperview() }
}
@objc private func cancelTapped() { dismiss(animated: false) }
@objc private func confirmTapped() {
dismiss(animated: false) { [onConfirm] in onConfirm() }
}
}
/// Android 480×480 JPEG
final class ProfileAvatarCropViewController: UIViewController, UIScrollViewDelegate {
private let sourceImage: UIImage
private let onConfirm: (Data) -> Void
private let scrollView = UIScrollView()
private let imageView = UIImageView()
private let cancelButton = UIButton(type: .system)
private let confirmButton = UIButton(type: .system)
private var didConfigureZoom = false
init(image: UIImage, onConfirm: @escaping (Data) -> Void) {
sourceImage = Self.normalized(image)
self.onConfirm = onConfirm
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .black
scrollView.delegate = self
scrollView.clipsToBounds = true
scrollView.layer.borderColor = UIColor.white.cgColor
scrollView.layer.borderWidth = 1
scrollView.showsHorizontalScrollIndicator = false
scrollView.showsVerticalScrollIndicator = false
imageView.image = sourceImage
imageView.contentMode = .scaleAspectFit
imageView.frame = CGRect(origin: .zero, size: sourceImage.size)
scrollView.addSubview(imageView)
scrollView.contentSize = sourceImage.size
cancelButton.setTitle("取消", for: .normal)
confirmButton.setTitle("确认", for: .normal)
[cancelButton, confirmButton].forEach {
$0.setTitleColor(.white, for: .normal)
$0.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
}
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
view.addSubview(scrollView)
view.addSubview(cancelButton)
view.addSubview(confirmButton)
scrollView.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview()
make.centerY.equalToSuperview()
make.height.equalTo(scrollView.snp.width)
}
cancelButton.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(20)
make.top.equalTo(view.safeAreaLayoutGuide).offset(10)
make.width.height.greaterThanOrEqualTo(44)
}
confirmButton.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(20)
make.top.equalTo(view.safeAreaLayoutGuide).offset(10)
make.width.height.greaterThanOrEqualTo(44)
}
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
guard !didConfigureZoom, scrollView.bounds.width > 0 else { return }
didConfigureZoom = true
let minimumZoom = max(
scrollView.bounds.width / sourceImage.size.width,
scrollView.bounds.height / sourceImage.size.height
)
scrollView.minimumZoomScale = minimumZoom
scrollView.maximumZoomScale = max(minimumZoom * 6, 6)
scrollView.zoomScale = minimumZoom
let contentWidth = sourceImage.size.width * minimumZoom
let contentHeight = sourceImage.size.height * minimumZoom
scrollView.contentOffset = CGPoint(
x: max(0, (contentWidth - scrollView.bounds.width) / 2),
y: max(0, (contentHeight - scrollView.bounds.height) / 2)
)
}
func viewForZooming(in scrollView: UIScrollView) -> UIView? { imageView }
@objc private func cancelTapped() { dismiss(animated: true) }
@objc private func confirmTapped() {
guard let data = renderedCropData() else { return }
dismiss(animated: true) { [onConfirm] in onConfirm(data) }
}
func renderedCropData() -> Data? {
let cropRectInImageView = scrollView.convert(scrollView.bounds, to: imageView)
let scaleX = sourceImage.size.width / imageView.bounds.width
let scaleY = sourceImage.size.height / imageView.bounds.height
var cropRect = CGRect(
x: cropRectInImageView.minX * scaleX,
y: cropRectInImageView.minY * scaleY,
width: cropRectInImageView.width * scaleX,
height: cropRectInImageView.height * scaleY
).integral
cropRect = cropRect.intersection(CGRect(origin: .zero, size: sourceImage.size))
guard !cropRect.isEmpty, let cgImage = sourceImage.cgImage?.cropping(to: cropRect) else { return nil }
let cropped = UIImage(cgImage: cgImage)
let renderer = UIGraphicsImageRenderer(size: CGSize(width: 480, height: 480))
return renderer.image { _ in
cropped.draw(in: CGRect(x: 0, y: 0, width: 480, height: 480))
}.jpegData(compressionQuality: 0.9)
}
private static func normalized(_ image: UIImage) -> UIImage {
guard image.imageOrientation != .up else { return image }
let renderer = UIGraphicsImageRenderer(size: image.size)
return renderer.image { _ in image.draw(in: CGRect(origin: .zero, size: image.size)) }
}
}

View File

@ -13,7 +13,6 @@ final class ProfileSpaceSettingsViewController: BaseViewController {
private let viewModel: ProfileSpaceSettingsViewModel private let viewModel: ProfileSpaceSettingsViewModel
private let profileAPI: ProfileAPI private let profileAPI: ProfileAPI
private let ossUploadService: OSSUploadService
private let scrollView = UIScrollView() private let scrollView = UIScrollView()
private let contentStack = UIStackView() private let contentStack = UIStackView()
@ -21,17 +20,22 @@ final class ProfileSpaceSettingsViewController: BaseViewController {
private let saveButton = AppButton(title: "保存") private let saveButton = AppButton(title: "保存")
private let avatarImageView = UIImageView() private let avatarImageView = UIImageView()
private let avatarChangeOverlay = UIView()
private let avatarChangeLabel = UILabel()
private let realNameLabel = UILabel() private let realNameLabel = UILabel()
private let nicknameField = UITextField() private let nicknameField = UITextField()
private let editProfileButton = UIButton(type: .system) private let editProfileButton = UIButton(type: .system)
private let introductionTextView = UITextView() private let introductionTextView = UITextView()
private let introductionPlaceholderLabel = UILabel() private let introductionPlaceholderLabel = UILabel()
private let certificationWrap = ProfileTagWrapView() private let certificationWrap = ProfileTagWrapView()
private let certificationExpandButton = UIButton(type: .system)
private let attrWrap = ProfileTagWrapView() private let attrWrap = ProfileTagWrapView()
private let shootWrap = ProfileTagWrapView() private let shootWrap = ProfileTagWrapView()
private let attrField = UITextField() private let attrField = UITextField()
private let shootField = UITextField() private let shootField = UITextField()
private let attrAddButton = UIButton(type: .system)
private let shootAddButton = UIButton(type: .system)
private let businessStartButton = ProfileTimeButton() private let businessStartButton = ProfileTimeButton()
private let businessEndButton = ProfileTimeButton() private let businessEndButton = ProfileTimeButton()
@ -39,7 +43,7 @@ final class ProfileSpaceSettingsViewController: BaseViewController {
private let holidayEndButton = ProfileTimeButton() private let holidayEndButton = ProfileTimeButton()
private let deviceWrap = ProfileTagWrapView() private let deviceWrap = ProfileTagWrapView()
private let addDeviceButton = UIButton(type: .system) private let addDeviceButton = ProfileDashedBorderButton(type: .system)
private var statusButtons: [UIButton] = [] private var statusButtons: [UIButton] = []
private let dateStack = UIStackView() private let dateStack = UIStackView()
@ -49,12 +53,10 @@ final class ProfileSpaceSettingsViewController: BaseViewController {
init( init(
viewModel: ProfileSpaceSettingsViewModel = ProfileSpaceSettingsViewModel(), viewModel: ProfileSpaceSettingsViewModel = ProfileSpaceSettingsViewModel(),
profileAPI: ProfileAPI = NetworkServices.shared.profileAPI, profileAPI: ProfileAPI = NetworkServices.shared.profileAPI
ossUploadService: OSSUploadService = NetworkServices.shared.ossUploadService
) { ) {
self.viewModel = viewModel self.viewModel = viewModel
self.profileAPI = profileAPI self.profileAPI = profileAPI
self.ossUploadService = ossUploadService
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
} }
@ -65,6 +67,17 @@ final class ProfileSpaceSettingsViewController: BaseViewController {
override func setupNavigationBar() { override func setupNavigationBar() {
title = "个人空间配置" title = "个人空间配置"
navigationItem.backButtonDisplayMode = .minimal
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
ProfileNavigationStyle.apply(to: navigationController, showsDivider: false)
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
ProfileNavigationStyle.restore(navigationController)
} }
override func setupUI() { override func setupUI() {
@ -82,6 +95,7 @@ final class ProfileSpaceSettingsViewController: BaseViewController {
bottomBar.backgroundColor = .white bottomBar.backgroundColor = .white
saveButton.isEnabled = false saveButton.isEnabled = false
saveButton.snp.updateConstraints { make in make.height.equalTo(48) }
configureProfileCard() configureProfileCard()
configureLabelCard() configureLabelCard()
@ -96,7 +110,9 @@ final class ProfileSpaceSettingsViewController: BaseViewController {
make.leading.trailing.bottom.equalToSuperview() make.leading.trailing.bottom.equalToSuperview()
} }
saveButton.snp.makeConstraints { make in saveButton.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 16, left: 15, bottom: 16, right: 15)) make.top.equalToSuperview().offset(16)
make.leading.trailing.equalToSuperview().inset(15)
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(16)
} }
scrollView.snp.makeConstraints { make in scrollView.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview() make.top.leading.trailing.equalToSuperview()
@ -117,8 +133,11 @@ final class ProfileSpaceSettingsViewController: BaseViewController {
} }
saveButton.addTarget(self, action: #selector(saveTapped), for: .touchUpInside) saveButton.addTarget(self, action: #selector(saveTapped), for: .touchUpInside)
editProfileButton.addTarget(self, action: #selector(editProfileTapped), for: .touchUpInside) editProfileButton.addTarget(self, action: #selector(editProfileTapped), for: .touchUpInside)
certificationExpandButton.addTarget(self, action: #selector(certificationExpandTapped), for: .touchUpInside)
addDeviceButton.addTarget(self, action: #selector(addDeviceTapped), for: .touchUpInside) addDeviceButton.addTarget(self, action: #selector(addDeviceTapped), for: .touchUpInside)
addScheduleButton.addTarget(self, action: #selector(addScheduleTapped), for: .touchUpInside) addScheduleButton.addTarget(self, action: #selector(addScheduleTapped), for: .touchUpInside)
attrField.addTarget(self, action: #selector(labelInputChanged), for: .editingChanged)
shootField.addTarget(self, action: #selector(labelInputChanged), for: .editingChanged)
[businessStartButton, businessEndButton, holidayStartButton, holidayEndButton].forEach { [businessStartButton, businessEndButton, holidayStartButton, holidayEndButton].forEach {
$0.addTarget(self, action: #selector(timeButtonTapped(_:)), for: .touchUpInside) $0.addTarget(self, action: #selector(timeButtonTapped(_:)), for: .touchUpInside)
} }
@ -146,17 +165,23 @@ final class ProfileSpaceSettingsViewController: BaseViewController {
nicknameField.isEnabled = viewModel.isEditingProfile nicknameField.isEnabled = viewModel.isEditingProfile
introductionTextView.isEditable = viewModel.isEditingProfile introductionTextView.isEditable = viewModel.isEditingProfile
introductionTextView.text = viewModel.introduction.isEmpty && !viewModel.isEditingProfile ? "暂无简介" : viewModel.introduction introductionTextView.text = viewModel.introduction.isEmpty && !viewModel.isEditingProfile ? "暂无简介" : viewModel.introduction
introductionTextView.textColor = viewModel.introduction.isEmpty && !viewModel.isEditingProfile ? UIColor(hex: 0xB6BECA) : UIColor(hex: 0x565656) introductionTextView.textColor = viewModel.introduction.isEmpty && !viewModel.isEditingProfile ? UIColor(hex: 0xB6BECA) : UIColor(hex: 0x4B5563)
updateIntroductionPlaceholder() updateIntroductionPlaceholder()
editProfileButton.setImage(UIImage(systemName: viewModel.isEditingProfile ? "checkmark" : "square.and.pencil"), for: .normal) editProfileButton.setImage(ProfileSpaceIcon.edit, for: .normal)
certificationWrap.setItems(viewModel.scenicCertification, style: .certification, removable: false) avatarChangeOverlay.isHidden = !viewModel.isEditingProfile
let visibleCertifications = viewModel.isCertificationExpanded
? viewModel.scenicCertification
: Array(viewModel.scenicCertification.prefix(3))
certificationWrap.setItems(visibleCertifications, style: .certification, removable: false)
certificationExpandButton.isHidden = viewModel.scenicCertification.count <= 3
certificationExpandButton.setTitle(viewModel.isCertificationExpanded ? "收起" : "展开", for: .normal)
attrWrap.setItems(viewModel.attrLabels, style: .label, removable: true) { [weak self] label in attrWrap.setItems(viewModel.attrLabels, style: .label, removable: true) { [weak self] label in
self?.confirmDelete(title: "删除确认", message: "确认删除标签“\(label)”吗?") { self?.confirmDelete(title: "删除标签", message: "您确定要删除\(label)标签吗?") {
self?.viewModel.removeAttrLabel(label) self?.viewModel.removeAttrLabel(label)
} }
} }
shootWrap.setItems(viewModel.shootLabels, style: .label, removable: true) { [weak self] label in shootWrap.setItems(viewModel.shootLabels, style: .label, removable: true) { [weak self] label in
self?.confirmDelete(title: "删除确认", message: "确认删除拍摄说明“\(label)”吗?") { self?.confirmDelete(title: "删除拍摄说明", message: "您确定要删除\(label)拍摄说明吗?") {
self?.viewModel.removeShootLabel(label) self?.viewModel.removeShootLabel(label)
} }
} }
@ -165,12 +190,15 @@ final class ProfileSpaceSettingsViewController: BaseViewController {
holidayStartButton.setTitle(viewModel.holidayStartTime ?? "00:00", for: .normal) holidayStartButton.setTitle(viewModel.holidayStartTime ?? "00:00", for: .normal)
holidayEndButton.setTitle(viewModel.holidayEndTime ?? "00:00", for: .normal) holidayEndButton.setTitle(viewModel.holidayEndTime ?? "00:00", for: .normal)
deviceWrap.setItems(viewModel.cameraDevices, style: .device, removable: true) { [weak self] device in deviceWrap.setItems(viewModel.cameraDevices, style: .device, removable: true) { [weak self] device in
self?.confirmDelete(title: "删除确认", message: "确认删除设备“\(device)”吗?") { self?.confirmDelete(title: "删除设备", message: "您确定要删除\(device)设备吗?") {
self?.viewModel.removeDevice(device) self?.viewModel.removeDevice(device)
} }
} }
addDeviceButton.isHidden = viewModel.cameraDevices.count > 10 addDeviceButton.isHidden = viewModel.cameraDevices.count > 10
deviceWrap.invalidateIntrinsicContentSize()
deviceWrap.setNeedsLayout()
updateStatusButtons() updateStatusButtons()
updateLabelAddButtons()
rebuildDateButtons() rebuildDateButtons()
rebuildSchedules() rebuildSchedules()
} }
@ -184,20 +212,34 @@ final class ProfileSpaceSettingsViewController: BaseViewController {
avatarImageView.layer.cornerRadius = 58 avatarImageView.layer.cornerRadius = 58
avatarImageView.clipsToBounds = true avatarImageView.clipsToBounds = true
avatarImageView.backgroundColor = AppColor.inputBackground avatarImageView.backgroundColor = AppColor.primaryLight
avatarImageView.contentMode = .scaleAspectFill avatarImageView.contentMode = .scaleAspectFill
avatarImageView.image = UIImage(systemName: "person.crop.circle.fill") avatarImageView.image = UIImage(named: "profile_avatar_default")
avatarImageView.tintColor = UIColor(hex: 0xB6BECA) avatarImageView.tintColor = UIColor(hex: 0xB6BECA)
avatarImageView.accessibilityLabel = "头像"
avatarImageView.snp.makeConstraints { make in avatarImageView.snp.makeConstraints { make in
make.width.height.equalTo(116) make.width.height.equalTo(116)
} }
avatarImageView.addSubview(avatarChangeOverlay)
avatarChangeOverlay.backgroundColor = UIColor.black.withAlphaComponent(0.35)
avatarChangeOverlay.isHidden = true
avatarChangeOverlay.addSubview(avatarChangeLabel)
avatarChangeLabel.text = "点击更换"
avatarChangeLabel.textColor = .white
avatarChangeLabel.font = .systemFont(ofSize: 12, weight: .medium)
avatarChangeOverlay.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
avatarChangeLabel.snp.makeConstraints { make in
make.center.equalToSuperview()
}
let textStack = UIStackView() let textStack = UIStackView()
textStack.axis = .vertical textStack.axis = .vertical
textStack.spacing = 8 textStack.spacing = 0
realNameLabel.font = .systemFont(ofSize: 14) realNameLabel.font = .systemFont(ofSize: 14)
realNameLabel.textColor = UIColor(hex: 0x565656) realNameLabel.textColor = UIColor(hex: 0x4B5563)
let nicknameRow = UIStackView() let nicknameRow = UIStackView()
nicknameRow.axis = .horizontal nicknameRow.axis = .horizontal
@ -211,8 +253,9 @@ final class ProfileSpaceSettingsViewController: BaseViewController {
nicknameField.delegate = self nicknameField.delegate = self
nicknameField.addTarget(self, action: #selector(nicknameChanged), for: .editingChanged) nicknameField.addTarget(self, action: #selector(nicknameChanged), for: .editingChanged)
editProfileButton.tintColor = AppColor.primary editProfileButton.tintColor = AppColor.primary
editProfileButton.accessibilityLabel = "编辑个人信息"
editProfileButton.snp.makeConstraints { make in editProfileButton.snp.makeConstraints { make in
make.width.height.equalTo(28) make.width.height.equalTo(44)
} }
nicknameRow.addArrangedSubview(nicknameField) nicknameRow.addArrangedSubview(nicknameField)
nicknameRow.addArrangedSubview(editProfileButton) nicknameRow.addArrangedSubview(editProfileButton)
@ -236,8 +279,18 @@ final class ProfileSpaceSettingsViewController: BaseViewController {
textStack.addArrangedSubview(realNameLabel) textStack.addArrangedSubview(realNameLabel)
textStack.addArrangedSubview(nicknameRow) textStack.addArrangedSubview(nicknameRow)
textStack.setCustomSpacing(8, after: nicknameRow)
textStack.addArrangedSubview(introductionTextView) textStack.addArrangedSubview(introductionTextView)
textStack.setCustomSpacing(4, after: introductionTextView)
textStack.addArrangedSubview(certificationWrap) textStack.addArrangedSubview(certificationWrap)
certificationExpandButton.setTitleColor(AppColor.primary, for: .normal)
certificationExpandButton.titleLabel?.font = .systemFont(ofSize: 14)
certificationExpandButton.contentHorizontalAlignment = .left
certificationExpandButton.isHidden = true
certificationExpandButton.snp.makeConstraints { make in
make.height.greaterThanOrEqualTo(20)
}
textStack.addArrangedSubview(certificationExpandButton)
row.addArrangedSubview(avatarImageView) row.addArrangedSubview(avatarImageView)
row.addArrangedSubview(textStack) row.addArrangedSubview(textStack)
@ -247,11 +300,30 @@ final class ProfileSpaceSettingsViewController: BaseViewController {
private func configureLabelCard() { private func configureLabelCard() {
let card = makeCard() let card = makeCard()
card.addArrangedSubview(makeSectionTitle("标签")) card.spacing = 0
let title = makeSectionTitle("标签")
card.addArrangedSubview(title)
card.setCustomSpacing(12, after: title)
card.addArrangedSubview(attrWrap) card.addArrangedSubview(attrWrap)
card.addArrangedSubview(makeAddRow(field: attrField, placeholder: "添加标签", action: #selector(addAttrTapped))) card.setCustomSpacing(8, after: attrWrap)
let attrRow = makeAddRow(
field: attrField,
button: attrAddButton,
placeholder: "添加标签",
action: #selector(addAttrTapped)
)
card.addArrangedSubview(attrRow)
card.setCustomSpacing(20, after: attrRow)
card.addArrangedSubview(shootWrap) card.addArrangedSubview(shootWrap)
card.addArrangedSubview(makeAddRow(field: shootField, placeholder: "添加拍摄说明", action: #selector(addShootTapped))) card.setCustomSpacing(12, after: shootWrap)
let shootRow = makeAddRow(
field: shootField,
button: shootAddButton,
placeholder: "添加拍摄说明",
action: #selector(addShootTapped)
)
card.addArrangedSubview(shootRow)
card.setCustomSpacing(8, after: shootRow)
let hint = UILabel() let hint = UILabel()
hint.text = "每个标签最多20个字符,最多8个标签" hint.text = "每个标签最多20个字符,最多8个标签"
hint.font = .systemFont(ofSize: 12) hint.font = .systemFont(ofSize: 12)
@ -262,8 +334,11 @@ final class ProfileSpaceSettingsViewController: BaseViewController {
private func configureBusinessHoursCard() { private func configureBusinessHoursCard() {
let card = makeCard() let card = makeCard()
card.addArrangedSubview(makeSectionTitle("营业时间")) let title = makeSectionTitle("营业时间")
card.addArrangedSubview(makeTimeRow(title: "工作日营业时间", start: businessStartButton, end: businessEndButton)) let workdayRow = makeTimeRow(title: "工作日营业时间", start: businessStartButton, end: businessEndButton)
card.addArrangedSubview(title)
card.addArrangedSubview(workdayRow)
card.setCustomSpacing(20, after: workdayRow)
card.addArrangedSubview(makeTimeRow(title: "法定节假日营业时间", start: holidayStartButton, end: holidayEndButton)) card.addArrangedSubview(makeTimeRow(title: "法定节假日营业时间", start: holidayStartButton, end: holidayEndButton))
contentStack.addArrangedSubview(card) contentStack.addArrangedSubview(card)
} }
@ -272,16 +347,14 @@ final class ProfileSpaceSettingsViewController: BaseViewController {
let card = makeCard() let card = makeCard()
card.addArrangedSubview(makeSectionTitle("相机设备")) card.addArrangedSubview(makeSectionTitle("相机设备"))
card.addArrangedSubview(deviceWrap) card.addArrangedSubview(deviceWrap)
addDeviceButton.setTitle("+ 添加设备", for: .normal) addDeviceButton.setTitle(" 添加设备", for: .normal)
addDeviceButton.setTitleColor(AppColor.primary, for: .normal) addDeviceButton.setTitleColor(AppColor.primary, for: .normal)
addDeviceButton.titleLabel?.font = .systemFont(ofSize: 14) addDeviceButton.titleLabel?.font = .systemFont(ofSize: 14)
addDeviceButton.layer.borderColor = AppColor.primary.cgColor
addDeviceButton.layer.borderWidth = 1
addDeviceButton.layer.cornerRadius = 4 addDeviceButton.layer.cornerRadius = 4
addDeviceButton.snp.makeConstraints { make in addDeviceButton.contentEdgeInsets = UIEdgeInsets(top: 4, left: 8, bottom: 4, right: 8)
make.height.equalTo(32) addDeviceButton.contentHorizontalAlignment = .left
} addDeviceButton.snp.makeConstraints { make in make.height.equalTo(28) }
card.addArrangedSubview(addDeviceButton) deviceWrap.setTrailingView(addDeviceButton)
contentStack.addArrangedSubview(card) contentStack.addArrangedSubview(card)
} }
@ -335,6 +408,7 @@ final class ProfileSpaceSettingsViewController: BaseViewController {
header.addArrangedSubview(UIView()) header.addArrangedSubview(UIView())
header.addArrangedSubview(addScheduleButton) header.addArrangedSubview(addScheduleButton)
card.addArrangedSubview(header) card.addArrangedSubview(header)
card.setCustomSpacing(8, after: header)
scheduleStack.axis = .vertical scheduleStack.axis = .vertical
scheduleStack.spacing = 12 scheduleStack.spacing = 12
@ -358,11 +432,18 @@ final class ProfileSpaceSettingsViewController: BaseViewController {
let label = UILabel() let label = UILabel()
label.text = title label.text = title
label.font = .systemFont(ofSize: 14, weight: .medium) label.font = .systemFont(ofSize: 14, weight: .medium)
label.textColor = UIColor(hex: 0x252525) label.textColor = ["相机设备", "接单状态", "日程安排"].contains(title)
? UIColor(hex: 0x4B5563)
: UIColor(hex: 0x252525)
return label return label
} }
private func makeAddRow(field: UITextField, placeholder: String, action: Selector) -> UIView { private func makeAddRow(
field: UITextField,
button: UIButton,
placeholder: String,
action: Selector
) -> UIView {
let row = UIStackView() let row = UIStackView()
row.axis = .horizontal row.axis = .horizontal
row.spacing = 8 row.spacing = 8
@ -379,13 +460,14 @@ final class ProfileSpaceSettingsViewController: BaseViewController {
field.snp.makeConstraints { make in field.snp.makeConstraints { make in
make.height.equalTo(36) make.height.equalTo(36)
} }
let button = UIButton(type: .system)
button.setTitle("添加标签", for: .normal) button.setTitle("添加标签", for: .normal)
button.setTitleColor(.white, for: .normal) button.setTitleColor(.white, for: .normal)
button.setTitleColor(.white, for: .disabled)
button.backgroundColor = AppColor.primary button.backgroundColor = AppColor.primary
button.titleLabel?.font = .systemFont(ofSize: 14) button.titleLabel?.font = .systemFont(ofSize: 14)
button.layer.cornerRadius = 4 button.layer.cornerRadius = 4
button.addTarget(self, action: action, for: .touchUpInside) button.addTarget(self, action: action, for: .touchUpInside)
button.isEnabled = false
button.snp.makeConstraints { make in button.snp.makeConstraints { make in
make.width.equalTo(80) make.width.equalTo(80)
make.height.equalTo(36) make.height.equalTo(36)
@ -403,12 +485,12 @@ final class ProfileSpaceSettingsViewController: BaseViewController {
let titleLabel = UILabel() let titleLabel = UILabel()
titleLabel.text = title titleLabel.text = title
titleLabel.font = .systemFont(ofSize: 14) titleLabel.font = .systemFont(ofSize: 14)
titleLabel.textColor = UIColor(hex: 0x565656) titleLabel.textColor = UIColor(hex: 0x4B5563)
titleLabel.setContentCompressionResistancePriority(.required, for: .horizontal) titleLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
let toLabel = UILabel() let toLabel = UILabel()
toLabel.text = "" toLabel.text = ""
toLabel.font = .systemFont(ofSize: 14) toLabel.font = .systemFont(ofSize: 14)
toLabel.textColor = UIColor(hex: 0x565656) toLabel.textColor = UIColor(hex: 0x4B5563)
let spacer = UIView() let spacer = UIView()
row.addArrangedSubview(titleLabel) row.addArrangedSubview(titleLabel)
row.addArrangedSubview(spacer) row.addArrangedSubview(spacer)
@ -417,7 +499,7 @@ final class ProfileSpaceSettingsViewController: BaseViewController {
row.addArrangedSubview(end) row.addArrangedSubview(end)
[start, end].forEach { button in [start, end].forEach { button in
button.snp.makeConstraints { make in button.snp.makeConstraints { make in
make.width.equalTo(70) make.width.equalTo(60)
make.height.equalTo(36) make.height.equalTo(36)
} }
} }
@ -428,8 +510,8 @@ final class ProfileSpaceSettingsViewController: BaseViewController {
let trimmed = urlString?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let trimmed = urlString?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !trimmed.isEmpty else { guard !trimmed.isEmpty else {
avatarImageView.kf.cancelDownloadTask() avatarImageView.kf.cancelDownloadTask()
avatarImageView.backgroundColor = AppColor.inputBackground avatarImageView.backgroundColor = AppColor.primaryLight
avatarImageView.image = UIImage(systemName: "person.crop.circle.fill") avatarImageView.image = UIImage(named: "profile_avatar_default")
avatarImageView.tintColor = UIColor(hex: 0xB6BECA) avatarImageView.tintColor = UIColor(hex: 0xB6BECA)
avatarImageView.contentMode = .scaleAspectFit avatarImageView.contentMode = .scaleAspectFit
return return
@ -440,6 +522,26 @@ final class ProfileSpaceSettingsViewController: BaseViewController {
private func updateIntroductionPlaceholder() { private func updateIntroductionPlaceholder() {
introductionPlaceholderLabel.isHidden = !viewModel.isEditingProfile || !introductionTextView.text.isEmpty introductionPlaceholderLabel.isHidden = !viewModel.isEditingProfile || !introductionTextView.text.isEmpty
introductionTextView.layer.borderWidth = viewModel.isEditingProfile ? 1 : 0
introductionTextView.layer.borderColor = UIColor(hex: 0xEEEEEE).cgColor
introductionTextView.layer.cornerRadius = 4
introductionTextView.textContainerInset = viewModel.isEditingProfile
? UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 8)
: .zero
introductionPlaceholderLabel.snp.remakeConstraints { make in
make.top.equalToSuperview().offset(viewModel.isEditingProfile ? 8 : 0)
make.leading.equalToSuperview().offset(viewModel.isEditingProfile ? 8 : 0)
make.trailing.equalToSuperview().inset(viewModel.isEditingProfile ? 8 : 0)
}
}
private func updateLabelAddButtons() {
let pairs = [(attrField, attrAddButton), (shootField, shootAddButton)]
for (field, button) in pairs {
let enabled = !(field.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
button.isEnabled = enabled
button.backgroundColor = enabled ? AppColor.primary : AppColor.buttonDisabled
}
} }
private func updateStatusButtons() { private func updateStatusButtons() {
@ -447,7 +549,8 @@ final class ProfileSpaceSettingsViewController: BaseViewController {
let selected = button.tag == viewModel.acceptOrderStatus let selected = button.tag == viewModel.acceptOrderStatus
button.backgroundColor = selected ? AppColor.primary : .white button.backgroundColor = selected ? AppColor.primary : .white
button.layer.borderColor = (selected ? AppColor.primary : UIColor(hex: 0xB6BECA)).cgColor button.layer.borderColor = (selected ? AppColor.primary : UIColor(hex: 0xB6BECA)).cgColor
button.setTitleColor(selected ? .white : UIColor(hex: 0x565656), for: .normal) button.setTitleColor(selected ? .white : UIColor(hex: 0x4B5563), for: .normal)
button.accessibilityTraits = selected ? [.button, .selected] : .button
} }
} }
@ -485,7 +588,7 @@ final class ProfileSpaceSettingsViewController: BaseViewController {
for schedule in schedules { for schedule in schedules {
let item = ProfileScheduleItemView(schedule: schedule) let item = ProfileScheduleItemView(schedule: schedule)
item.onDelete = { [weak self] in item.onDelete = { [weak self] in
self?.confirmDelete(title: "删除确认", message: "确认删除该日程吗") { self?.confirmDelete(title: "删除日程", message: "您确定要删除该日程吗?") {
guard let self else { return } guard let self else { return }
Task { await self.viewModel.deleteSchedule(id: schedule.id, api: self.profileAPI) } Task { await self.viewModel.deleteSchedule(id: schedule.id, api: self.profileAPI) }
} }
@ -498,32 +601,20 @@ final class ProfileSpaceSettingsViewController: BaseViewController {
} }
private func confirmDelete(title: String, message: String, onConfirm: @escaping () -> Void) { private func confirmDelete(title: String, message: String, onConfirm: @escaping () -> Void) {
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) let dialog = ProfileDeleteDialogViewController(title: title, message: message, onConfirm: onConfirm)
alert.addAction(UIAlertAction(title: "取消", style: .cancel)) present(dialog, animated: false)
alert.addAction(UIAlertAction(title: "确定", style: .destructive) { _ in onConfirm() })
present(alert, animated: true)
} }
private func showTimePicker(initialTime: String?, onConfirm: @escaping (String) -> Void) { private func showTimePicker(initialTime: String?, onConfirm: @escaping (String) -> Void) {
let alert = UIAlertController(title: "选择时间", message: "\n\n\n\n\n\n\n", preferredStyle: .actionSheet) let dialog = ProfileTimePickerDialogViewController(
let picker = UIDatePicker() initialDate: date(fromTime: "00:00"),
picker.datePickerMode = .time onConfirm: { [weak self] date in
picker.preferredDatePickerStyle = .wheels guard let self else { return }
picker.locale = Locale(identifier: "zh_CN") onConfirm(timeString(from: date))
if let initialTime {
picker.date = date(fromTime: initialTime)
} }
alert.view.addSubview(picker) )
picker.snp.makeConstraints { make in present(dialog, animated: false)
make.leading.trailing.equalToSuperview().inset(16) _ = initialTime
make.top.equalToSuperview().offset(42)
make.height.equalTo(160)
}
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
alert.addAction(UIAlertAction(title: "确定", style: .default) { _ in
onConfirm(self.timeString(from: picker.date))
})
present(alert, animated: true)
} }
private func date(fromTime value: String) -> Date { private func date(fromTime value: String) -> Date {
@ -564,11 +655,21 @@ final class ProfileSpaceSettingsViewController: BaseViewController {
@objc private func addAttrTapped() { @objc private func addAttrTapped() {
viewModel.addAttrLabel(attrField.text ?? "") viewModel.addAttrLabel(attrField.text ?? "")
attrField.text = "" attrField.text = ""
updateLabelAddButtons()
} }
@objc private func addShootTapped() { @objc private func addShootTapped() {
viewModel.addShootLabel(shootField.text ?? "") viewModel.addShootLabel(shootField.text ?? "")
shootField.text = "" shootField.text = ""
updateLabelAddButtons()
}
@objc private func labelInputChanged() {
updateLabelAddButtons()
}
@objc private func certificationExpandTapped() {
viewModel.toggleCertificationExpanded()
} }
@objc private func timeButtonTapped(_ sender: ProfileTimeButton) { @objc private func timeButtonTapped(_ sender: ProfileTimeButton) {
@ -584,17 +685,10 @@ final class ProfileSpaceSettingsViewController: BaseViewController {
} }
@objc private func addDeviceTapped() { @objc private func addDeviceTapped() {
let alert = UIAlertController(title: "添加设备", message: nil, preferredStyle: .alert) let dialog = ProfileDeviceDialogViewController { [weak self] value in
alert.addTextField { field in self?.viewModel.addDevice(value)
field.placeholder = "请输入设备名称"
field.returnKeyType = .done
field.delegate = self
} }
alert.addAction(UIAlertAction(title: "取消", style: .cancel)) present(dialog, animated: false)
alert.addAction(UIAlertAction(title: "确定", style: .default) { [weak self] _ in
self?.viewModel.addDevice(alert.textFields?.first?.text ?? "")
})
present(alert, animated: true)
} }
@objc private func statusTapped(_ sender: UIButton) { @objc private func statusTapped(_ sender: UIButton) {
@ -632,6 +726,17 @@ extension ProfileSpaceSettingsViewController: UITextFieldDelegate, UITextViewDel
return true return true
} }
func textField(
_ textField: UITextField,
shouldChangeCharactersIn range: NSRange,
replacementString string: String
) -> Bool {
guard textField === attrField || textField === shootField else { return true }
let current = textField.text ?? ""
guard let swiftRange = Range(range, in: current) else { return false }
return current.replacingCharacters(in: swiftRange, with: string).count <= 20
}
func textViewDidChange(_ textView: UITextView) { func textViewDidChange(_ textView: UITextView) {
viewModel.updateIntroduction(textView.text) viewModel.updateIntroduction(textView.text)
updateIntroductionPlaceholder() updateIntroductionPlaceholder()
@ -654,17 +759,19 @@ extension ProfileSpaceSettingsViewController: PHPickerViewControllerDelegate {
guard let provider = results.first?.itemProvider else { return } guard let provider = results.first?.itemProvider else { return }
Task { Task {
do { do {
let data = try await ProfileAvatarPickerLoader.loadImageData(from: provider) let image = try await ProfileAvatarPickerLoader.loadImage(from: provider)
let scenicId = AppStore.shared.currentScenicId await MainActor.run {
let fileURL = try await ossUploadService.uploadUserAvatar( let crop = ProfileAvatarCropViewController(image: image) { [weak self] data in
guard let self else { return }
Task {
await self.viewModel.uploadAvatar(
data: data, data: data,
fileName: "avatar_\(UUID().uuidString).jpg", fileName: "avatar_\(UUID().uuidString).jpg",
scenicId: scenicId api: self.profileAPI
) { _ in } )
try await profileAPI.updateUserInfo(nickname: nil, password: nil, avatar: fileURL) }
await MainActor.run { }
viewModel.updateAvatarURL(fileURL) self.present(crop, animated: true)
showToast("修改成功")
} }
} catch { } catch {
await MainActor.run { showToast(error.localizedDescription) } await MainActor.run { showToast(error.localizedDescription) }
@ -690,6 +797,7 @@ final class ProfileAddScheduleViewController: BaseViewController {
private let remarkTextView = UITextView() private let remarkTextView = UITextView()
private let remarkPlaceholderLabel = UILabel() private let remarkPlaceholderLabel = UILabel()
private let countLabel = UILabel() private let countLabel = UILabel()
private var selectedOrderNumber: String?
init(selectedDate: Date, viewModel: ProfileSpaceSettingsViewModel, profileAPI: ProfileAPI) { init(selectedDate: Date, viewModel: ProfileSpaceSettingsViewModel, profileAPI: ProfileAPI) {
self.selectedDate = selectedDate self.selectedDate = selectedDate
@ -707,11 +815,24 @@ final class ProfileAddScheduleViewController: BaseViewController {
title = "添加日程" title = "添加日程"
} }
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
ProfileNavigationStyle.apply(to: navigationController, showsDivider: true)
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
ProfileNavigationStyle.restore(navigationController)
}
override func setupUI() { override func setupUI() {
view.addSubview(contentStack) view.addSubview(contentStack)
view.addSubview(bottomBar) view.addSubview(bottomBar)
bottomBar.addSubview(saveButton) bottomBar.addSubview(saveButton)
bottomBar.backgroundColor = .white bottomBar.backgroundColor = .white
view.backgroundColor = AppColor.pageBackground
saveButton.isEnabled = false
saveButton.snp.updateConstraints { make in make.height.equalTo(48) }
contentStack.axis = .vertical contentStack.axis = .vertical
contentStack.spacing = 20 contentStack.spacing = 20
@ -736,7 +857,9 @@ final class ProfileAddScheduleViewController: BaseViewController {
make.leading.trailing.bottom.equalToSuperview() make.leading.trailing.bottom.equalToSuperview()
} }
saveButton.snp.makeConstraints { make in saveButton.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 16, left: 15, bottom: 16, right: 15)) make.top.equalToSuperview().offset(16)
make.leading.trailing.equalToSuperview().inset(15)
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(16)
} }
contentStack.snp.makeConstraints { make in contentStack.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(16) make.top.equalTo(view.safeAreaLayoutGuide).offset(16)
@ -759,6 +882,7 @@ final class ProfileAddScheduleViewController: BaseViewController {
endButton.addTarget(self, action: #selector(endTapped), for: .touchUpInside) endButton.addTarget(self, action: #selector(endTapped), for: .touchUpInside)
orderButton.addTarget(self, action: #selector(orderTapped), for: .touchUpInside) orderButton.addTarget(self, action: #selector(orderTapped), for: .touchUpInside)
remarkTextView.delegate = self remarkTextView.delegate = self
nameField.addTarget(self, action: #selector(formValueChanged), for: .editingChanged)
} }
private func makeTitle(_ title: String) -> UILabel { private func makeTitle(_ title: String) -> UILabel {
@ -789,15 +913,15 @@ final class ProfileAddScheduleViewController: BaseViewController {
let title = UILabel() let title = UILabel()
title.text = "时间" title.text = "时间"
title.font = .systemFont(ofSize: 14) title.font = .systemFont(ofSize: 14)
title.textColor = UIColor(hex: 0x565656) title.textColor = UIColor(hex: 0x4B5563)
let toLabel = UILabel() let toLabel = UILabel()
toLabel.text = "" toLabel.text = ""
toLabel.font = .systemFont(ofSize: 14) toLabel.font = .systemFont(ofSize: 14)
toLabel.textColor = UIColor(hex: 0x565656) toLabel.textColor = UIColor(hex: 0x4B5563)
[startButton, endButton].forEach { button in [startButton, endButton].forEach { button in
button.setTitle("请选择", for: .normal) button.setTitle("请选择", for: .normal)
button.snp.makeConstraints { make in button.snp.makeConstraints { make in
make.width.equalTo(78) make.width.equalTo(60)
make.height.equalTo(36) make.height.equalTo(36)
} }
} }
@ -818,17 +942,25 @@ final class ProfileAddScheduleViewController: BaseViewController {
orderButton.layer.borderWidth = 1 orderButton.layer.borderWidth = 1
orderButton.layer.cornerRadius = 8 orderButton.layer.cornerRadius = 8
orderButton.contentEdgeInsets = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16) orderButton.contentEdgeInsets = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16)
var configuration = UIButton.Configuration.plain()
configuration.title = "关联订单 (可选)"
configuration.image = UIImage(systemName: "chevron.down")
configuration.imagePlacement = .trailing
configuration.imagePadding = 8
configuration.baseForegroundColor = .black
configuration.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)
orderButton.configuration = configuration
} }
private func configureRemarkView() { private func configureRemarkView() {
remarkTextView.font = .systemFont(ofSize: 14) remarkTextView.font = .systemFont(ofSize: 14)
remarkTextView.textColor = UIColor(hex: 0x565656) remarkTextView.textColor = UIColor(hex: 0x4B5563)
remarkTextView.layer.borderColor = UIColor(hex: 0xE5E7EB).cgColor remarkTextView.layer.borderColor = UIColor(hex: 0xE5E7EB).cgColor
remarkTextView.layer.borderWidth = 1 remarkTextView.layer.borderWidth = 1
remarkTextView.layer.cornerRadius = 8 remarkTextView.layer.cornerRadius = 8
remarkTextView.textContainerInset = UIEdgeInsets(top: 10, left: 12, bottom: 10, right: 12) remarkTextView.textContainerInset = UIEdgeInsets(top: 10, left: 12, bottom: 10, right: 12)
remarkTextView.textContainer.lineFragmentPadding = 0 remarkTextView.textContainer.lineFragmentPadding = 0
remarkPlaceholderLabel.text = "请输入备注信息" remarkPlaceholderLabel.text = "请输入日程内容"
remarkPlaceholderLabel.font = remarkTextView.font remarkPlaceholderLabel.font = remarkTextView.font
remarkPlaceholderLabel.textColor = UIColor(hex: 0xB6BECA) remarkPlaceholderLabel.textColor = UIColor(hex: 0xB6BECA)
remarkTextView.addSubview(remarkPlaceholderLabel) remarkTextView.addSubview(remarkPlaceholderLabel)
@ -844,25 +976,14 @@ final class ProfileAddScheduleViewController: BaseViewController {
} }
private func showTimePicker(initialTime: String?, onConfirm: @escaping (String) -> Void) { private func showTimePicker(initialTime: String?, onConfirm: @escaping (String) -> Void) {
let alert = UIAlertController(title: "选择时间", message: "\n\n\n\n\n\n\n", preferredStyle: .actionSheet) let date = Self.date(from: "00:00")
let picker = UIDatePicker() let dialog = ProfileTimePickerDialogViewController(initialDate: date) { selectedDate in
picker.datePickerMode = .time
picker.preferredDatePickerStyle = .wheels
picker.locale = Locale(identifier: "zh_CN")
alert.view.addSubview(picker)
picker.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview().inset(16)
make.top.equalToSuperview().offset(42)
make.height.equalTo(160)
}
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
alert.addAction(UIAlertAction(title: "确定", style: .default) { _ in
let formatter = DateFormatter() let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX") formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "HH:mm" formatter.dateFormat = "HH:mm"
onConfirm(formatter.string(from: picker.date)) onConfirm(formatter.string(from: selectedDate))
}) }
present(alert, animated: true) present(dialog, animated: false)
_ = initialTime _ = initialTime
} }
@ -873,6 +994,7 @@ final class ProfileAddScheduleViewController: BaseViewController {
self?.endButton.setTitle("请选择", for: .normal) self?.endButton.setTitle("请选择", for: .normal)
} }
self?.startButton.setTitle(value, for: .normal) self?.startButton.setTitle(value, for: .normal)
self?.updateSaveEnabled()
} }
} }
@ -883,11 +1005,17 @@ final class ProfileAddScheduleViewController: BaseViewController {
return return
} }
self?.endButton.setTitle(value, for: .normal) self?.endButton.setTitle(value, for: .normal)
self?.updateSaveEnabled()
} }
} }
@objc private func orderTapped() { @objc private func orderTapped() {
showToast("关联订单 (可选)") let controller = TaskOrderSelectViewController(initialOrderNumber: selectedOrderNumber)
controller.onConfirmed = { [weak self] orderNumber in
self?.selectedOrderNumber = orderNumber
self?.applySelectedOrder()
}
navigationController?.pushViewController(controller, animated: true)
} }
@objc private func saveTapped() { @objc private func saveTapped() {
@ -901,7 +1029,7 @@ final class ProfileAddScheduleViewController: BaseViewController {
startTime: start, startTime: start,
endTime: end, endTime: end,
scheduleDate: selectedDate, scheduleDate: selectedDate,
orderNumber: nil, orderNumber: selectedOrderNumber,
api: profileAPI api: profileAPI
) )
if success { if success {
@ -909,6 +1037,34 @@ final class ProfileAddScheduleViewController: BaseViewController {
} }
} }
} }
@objc private func formValueChanged() {
updateSaveEnabled()
}
private func updateSaveEnabled() {
let name = (nameField.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let start = startButton.title(for: .normal)
let end = endButton.title(for: .normal)
saveButton.isEnabled = !name.isEmpty
&& start != nil && start != "请选择"
&& end != nil && end != "请选择"
&& ProfileSpaceSettingsViewModel.isStart(start ?? "", before: end ?? "")
}
private func applySelectedOrder() {
var configuration = orderButton.configuration ?? .plain()
configuration.title = selectedOrderNumber ?? "关联订单 (可选)"
configuration.baseForegroundColor = .black
orderButton.configuration = configuration
}
private static func date(from time: String?) -> Date {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "HH:mm"
return formatter.date(from: time ?? "00:00") ?? Date()
}
} }
extension ProfileAddScheduleViewController: UITextFieldDelegate, UITextViewDelegate { extension ProfileAddScheduleViewController: UITextFieldDelegate, UITextViewDelegate {
@ -941,35 +1097,69 @@ private final class ProfileTimeButton: UIButton {
override init(frame: CGRect) { override init(frame: CGRect) {
super.init(frame: frame) super.init(frame: frame)
titleLabel?.font = .systemFont(ofSize: 14) titleLabel?.font = .systemFont(ofSize: 14)
setTitleColor(AppColor.textPrimary, for: .normal) titleLabel?.lineBreakMode = .byClipping
backgroundColor = .white setTitleColor(UIColor(hex: 0x4B5563), for: .normal)
layer.cornerRadius = 8 backgroundColor = AppColor.inputBackground
layer.borderColor = AppColor.border.cgColor layer.cornerRadius = 4
layer.borderWidth = 1 contentEdgeInsets = UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 8)
contentHorizontalAlignment = .center
} }
@available(*, unavailable) @available(*, unavailable)
required init?(coder: NSCoder) { required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
bounds.insetBy(dx: -4, dy: -4).contains(point)
}
} }
/// ///
private final class ProfileDateButton: UIButton { private final class ProfileDateButton: UIButton {
var date: Date? var date: Date?
func configure(weekday: String, day: String, selected: Bool) { private let weekdayLabel = UILabel()
let title = "\(weekday)\n\(day)" private let dayContainer = UIView()
setTitle(title, for: .normal) private let dayLabel = UILabel()
titleLabel?.numberOfLines = 2
titleLabel?.textAlignment = .center override init(frame: CGRect) {
titleLabel?.font = .systemFont(ofSize: 14) super.init(frame: frame)
setTitleColor(selected ? .white : UIColor(hex: 0x565656), for: .normal) weekdayLabel.font = .systemFont(ofSize: 14)
backgroundColor = selected ? AppColor.primary : AppColor.inputBackground weekdayLabel.textAlignment = .center
layer.cornerRadius = 4 weekdayLabel.textColor = UIColor(hex: 0x4B5563)
snp.makeConstraints { make in dayContainer.layer.cornerRadius = 4
make.height.equalTo(58) dayContainer.isUserInteractionEnabled = false
dayLabel.font = .systemFont(ofSize: 14)
dayLabel.textAlignment = .center
dayContainer.addSubview(dayLabel)
addSubview(weekdayLabel)
addSubview(dayContainer)
weekdayLabel.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.height.equalTo(18)
} }
dayContainer.snp.makeConstraints { make in
make.top.equalTo(weekdayLabel.snp.bottom).offset(12)
make.centerX.equalToSuperview()
make.width.height.equalTo(36)
make.bottom.equalToSuperview()
}
dayLabel.snp.makeConstraints { make in make.center.equalToSuperview() }
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func configure(weekday: String, day: String, selected: Bool) {
weekdayLabel.text = weekday
dayLabel.text = day
dayLabel.textColor = selected ? .white : UIColor(hex: 0x4B5563)
dayContainer.backgroundColor = selected ? AppColor.primary : AppColor.inputBackground
accessibilityLabel = "星期\(weekday)\(day)"
accessibilityTraits = selected ? [.button, .selected] : .button
} }
} }
@ -983,6 +1173,17 @@ private final class ProfileTagWrapView: UIView {
private var itemViews: [UIView] = [] private var itemViews: [UIView] = []
private var removeHandler: ((String) -> Void)? private var removeHandler: ((String) -> Void)?
private weak var trailingView: UIView?
func setTrailingView(_ view: UIView) {
trailingView = view
if view.superview !== self {
addSubview(view)
}
itemViews.append(view)
invalidateIntrinsicContentSize()
setNeedsLayout()
}
func setItems(_ items: [String], style: Style, removable: Bool, onRemove: ((String) -> Void)? = nil) { func setItems(_ items: [String], style: Style, removable: Bool, onRemove: ((String) -> Void)? = nil) {
subviews.forEach { $0.removeFromSuperview() } subviews.forEach { $0.removeFromSuperview() }
@ -991,6 +1192,10 @@ private final class ProfileTagWrapView: UIView {
addSubview(view) addSubview(view)
return view return view
} }
if let trailingView {
addSubview(trailingView)
itemViews.append(trailingView)
}
removeHandler = onRemove removeHandler = onRemove
invalidateIntrinsicContentSize() invalidateIntrinsicContentSize()
setNeedsLayout() setNeedsLayout()
@ -1011,6 +1216,7 @@ private final class ProfileTagWrapView: UIView {
var y: CGFloat = 0 var y: CGFloat = 0
var rowHeight: CGFloat = 0 var rowHeight: CGFloat = 0
for view in itemViews { for view in itemViews {
guard !view.isHidden else { continue }
let size = view.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) let size = view.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
if x > 0, x + size.width > width { if x > 0, x + size.width > width {
x = 0 x = 0
@ -1030,6 +1236,7 @@ private final class ProfileTagWrapView: UIView {
var height: CGFloat = 0 var height: CGFloat = 0
var rowHeight: CGFloat = 0 var rowHeight: CGFloat = 0
for view in itemViews { for view in itemViews {
guard !view.isHidden else { continue }
let size = view.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) let size = view.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
if x > 0, x + size.width > width { if x > 0, x + size.width > width {
x = 0 x = 0
@ -1045,8 +1252,8 @@ private final class ProfileTagWrapView: UIView {
private func makeItem(title: String, style: Style, removable: Bool) -> UIView { private func makeItem(title: String, style: Style, removable: Bool) -> UIView {
let button = UIButton(type: .system) let button = UIButton(type: .system)
button.setTitle(removable ? "\(title) ×" : title, for: .normal) button.setTitle(removable ? "\(title) ×" : title, for: .normal)
button.titleLabel?.font = .systemFont(ofSize: style == .certification ? 12 : 14) button.titleLabel?.font = .systemFont(ofSize: style == .device ? 14 : 12)
button.contentEdgeInsets = UIEdgeInsets(top: 4, left: 8, bottom: 4, right: 8) button.contentEdgeInsets = UIEdgeInsets(top: 2, left: style == .device ? 8 : 4, bottom: 2, right: style == .device ? 8 : 4)
button.layer.cornerRadius = 4 button.layer.cornerRadius = 4
switch style { switch style {
case .label: case .label:
@ -1057,7 +1264,7 @@ private final class ProfileTagWrapView: UIView {
button.setTitleColor(AppColor.warning, for: .normal) button.setTitleColor(AppColor.warning, for: .normal)
case .device: case .device:
button.backgroundColor = AppColor.inputBackground button.backgroundColor = AppColor.inputBackground
button.setTitleColor(UIColor(hex: 0x565656), for: .normal) button.setTitleColor(UIColor(hex: 0x4B5563), for: .normal)
} }
if removable { if removable {
button.addAction(UIAction { [weak self] _ in button.addAction(UIAction { [weak self] _ in
@ -1100,11 +1307,11 @@ private final class ProfileScheduleItemView: UIStackView {
let title = UILabel() let title = UILabel()
title.text = "\(schedule.startTime) - \(schedule.endTime) \(schedule.name)" title.text = "\(schedule.startTime) - \(schedule.endTime) \(schedule.name)"
title.font = .systemFont(ofSize: 14) title.font = .systemFont(ofSize: 14)
title.textColor = UIColor(hex: 0x565656) title.textColor = UIColor(hex: 0x4B5563)
title.numberOfLines = 1 title.numberOfLines = 1
let delete = UIButton(type: .system) let delete = UIButton(type: .system)
delete.setTitle("删除", for: .normal) delete.setTitle("删除", for: .normal)
delete.setTitleColor(AppColor.danger, for: .normal) delete.setTitleColor(UIColor(hex: 0xFF5656), for: .normal)
delete.titleLabel?.font = .systemFont(ofSize: 14) delete.titleLabel?.font = .systemFont(ofSize: 14)
delete.addAction(UIAction { [weak self] _ in self?.onDelete?() }, for: .touchUpInside) delete.addAction(UIAction { [weak self] _ in self?.onDelete?() }, for: .touchUpInside)
top.addArrangedSubview(title) top.addArrangedSubview(title)
@ -1136,7 +1343,7 @@ private final class ProfileScheduleItemView: UIStackView {
let label = UILabel() let label = UILabel()
label.text = text label.text = text
label.font = .systemFont(ofSize: 14) label.font = .systemFont(ofSize: 14)
label.textColor = UIColor(hex: 0x565656) label.textColor = UIColor(hex: 0x4B5563)
label.numberOfLines = 1 label.numberOfLines = 1
return label return label
} }
@ -1151,7 +1358,7 @@ private final class ProfileScheduleItemView: UIStackView {
/// ///
private enum ProfileAvatarPickerLoader { private enum ProfileAvatarPickerLoader {
static func loadImageData(from provider: NSItemProvider) async throws -> Data { static func loadImage(from provider: NSItemProvider) async throws -> UIImage {
try await withCheckedThrowingContinuation { continuation in try await withCheckedThrowingContinuation { continuation in
guard provider.canLoadObject(ofClass: UIImage.self) else { guard provider.canLoadObject(ofClass: UIImage.self) else {
continuation.resume(throwing: OSSUploadError.unsupportedFileType) continuation.resume(throwing: OSSUploadError.unsupportedFileType)
@ -1162,11 +1369,11 @@ private enum ProfileAvatarPickerLoader {
continuation.resume(throwing: error) continuation.resume(throwing: error)
return return
} }
guard let image = object as? UIImage, let data = image.jpegData(compressionQuality: 0.9) else { guard let image = object as? UIImage else {
continuation.resume(throwing: OSSUploadError.emptyFile) continuation.resume(throwing: OSSUploadError.emptyFile)
return return
} }
continuation.resume(returning: data) continuation.resume(returning: image)
} }
} }
} }

View File

@ -197,7 +197,7 @@ final class RealNameAuthViewController: BaseViewController {
} }
private func submit() async { private func submit() async {
let scenicId = AppStore.shared.currentScenicId let scenicId = AppStore.shared.session.currentScenicId
guard scenicId > 0 else { guard scenicId > 0 else {
showToast("当前景区信息缺失") showToast("当前景区信息缺失")
return return

View File

@ -221,7 +221,7 @@ final class WithdrawalSettingsViewController: BaseViewController {
} }
private func submit() async { private func submit() async {
let scenicId = AppStore.shared.currentScenicId let scenicId = AppStore.shared.session.currentScenicId
guard scenicId > 0 else { guard scenicId > 0 else {
showToast("当前景区信息缺失") showToast("当前景区信息缺失")
return return

View File

@ -302,7 +302,7 @@ final class ScenicApplicationViewController: BaseViewController {
} }
private func startUploadIfNeeded() async { private func startUploadIfNeeded() async {
let scenicId = AppStore.shared.currentScenicId let scenicId = AppStore.shared.session.currentScenicId
await viewModel.processPendingUploads(uploader: ossUploadService, scenicId: scenicId) await viewModel.processPendingUploads(uploader: ossUploadService, scenicId: scenicId)
} }
} }

View File

@ -307,7 +307,7 @@ final class TaskAddViewController: BaseViewController {
} }
private func uploadPickedResults(_ results: [PHPickerResult], isImage: Bool) { private func uploadPickedResults(_ results: [PHPickerResult], isImage: Bool) {
let scenicId = AppStore.shared.currentScenicId let scenicId = AppStore.shared.session.currentScenicId
guard scenicId > 0 else { guard scenicId > 0 else {
showToast("请先选择景区") showToast("请先选择景区")
return return
@ -344,7 +344,7 @@ final class TaskAddViewController: BaseViewController {
} }
private func uploadCapturedPayload(_ payload: TaskMediaLoader.Payload, fileType: Int) { private func uploadCapturedPayload(_ payload: TaskMediaLoader.Payload, fileType: Int) {
let scenicId = AppStore.shared.currentScenicId let scenicId = AppStore.shared.session.currentScenicId
guard scenicId > 0 else { guard scenicId > 0 else {
showToast("请先选择景区") showToast("请先选择景区")
return return

View File

@ -33,12 +33,23 @@ final class TaskOrderSelectViewController: BaseViewController {
title = "订单列表" title = "订单列表"
} }
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
ProfileNavigationStyle.apply(to: navigationController, showsDivider: false)
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
ProfileNavigationStyle.restore(navigationController)
}
override func setupUI() { override func setupUI() {
tableView.backgroundColor = AppColor.pageBackground tableView.backgroundColor = AppColor.pageBackground
tableView.separatorStyle = .none tableView.separatorStyle = .none
tableView.register(TaskOrderCell.self, forCellReuseIdentifier: TaskOrderCell.reuseIdentifier) tableView.register(TaskOrderCell.self, forCellReuseIdentifier: TaskOrderCell.reuseIdentifier)
tableView.dataSource = self tableView.dataSource = self
tableView.delegate = self tableView.delegate = self
tableView.contentInset = UIEdgeInsets(top: 12, left: 0, bottom: 0, right: 0)
emptyLabel.text = "无有效订单" emptyLabel.text = "无有效订单"
emptyLabel.font = .app(.body) emptyLabel.font = .app(.body)
@ -51,6 +62,7 @@ final class TaskOrderSelectViewController: BaseViewController {
view.addSubview(emptyLabel) view.addSubview(emptyLabel)
view.addSubview(bottomBar) view.addSubview(bottomBar)
bottomBar.addSubview(confirmButton) bottomBar.addSubview(confirmButton)
confirmButton.snp.updateConstraints { make in make.height.equalTo(48) }
} }
override func setupConstraints() { override func setupConstraints() {
@ -58,7 +70,9 @@ final class TaskOrderSelectViewController: BaseViewController {
make.leading.trailing.bottom.equalToSuperview() make.leading.trailing.bottom.equalToSuperview()
} }
confirmButton.snp.makeConstraints { make in confirmButton.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(AppSpacing.md) make.top.equalToSuperview().offset(16)
make.leading.trailing.equalToSuperview().inset(16)
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(16)
} }
tableView.snp.makeConstraints { make in tableView.snp.makeConstraints { make in
make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide) make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
@ -135,6 +149,8 @@ private final class TaskOrderCell: UITableViewCell {
private let statusRow = TaskInfoRowView(label: "订单状态:") private let statusRow = TaskInfoRowView(label: "订单状态:")
private let numberRow = TaskInfoRowView(label: "订单编号:") private let numberRow = TaskInfoRowView(label: "订单编号:")
private let payTimeRow = TaskInfoRowView(label: "付款时间:") private let payTimeRow = TaskInfoRowView(label: "付款时间:")
private let userIDRow = TaskInfoRowView(label: "用户 UID:")
private let phoneRow = TaskInfoRowView(label: "用户手机号:")
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier) super.init(style: style, reuseIdentifier: reuseIdentifier)
@ -152,6 +168,8 @@ private final class TaskOrderCell: UITableViewCell {
cardView.addSubview(statusRow) cardView.addSubview(statusRow)
cardView.addSubview(numberRow) cardView.addSubview(numberRow)
cardView.addSubview(payTimeRow) cardView.addSubview(payTimeRow)
cardView.addSubview(userIDRow)
cardView.addSubview(phoneRow)
cardView.snp.makeConstraints { make in cardView.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: AppSpacing.md, bottom: AppSpacing.sm, right: AppSpacing.md)) make.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: AppSpacing.md, bottom: AppSpacing.sm, right: AppSpacing.md))
@ -169,6 +187,14 @@ private final class TaskOrderCell: UITableViewCell {
} }
payTimeRow.snp.makeConstraints { make in payTimeRow.snp.makeConstraints { make in
make.top.equalTo(numberRow.snp.bottom).offset(AppSpacing.xs) make.top.equalTo(numberRow.snp.bottom).offset(AppSpacing.xs)
make.leading.trailing.equalTo(projectRow)
}
userIDRow.snp.makeConstraints { make in
make.top.equalTo(payTimeRow.snp.bottom).offset(AppSpacing.xs)
make.leading.trailing.equalTo(projectRow)
}
phoneRow.snp.makeConstraints { make in
make.top.equalTo(userIDRow.snp.bottom).offset(AppSpacing.xs)
make.leading.trailing.bottom.equalToSuperview().inset(AppSpacing.md) make.leading.trailing.bottom.equalToSuperview().inset(AppSpacing.md)
} }
} }
@ -183,8 +209,12 @@ private final class TaskOrderCell: UITableViewCell {
statusRow.apply(value: order.orderStatusLabel.isEmpty ? "--" : order.orderStatusLabel) statusRow.apply(value: order.orderStatusLabel.isEmpty ? "--" : order.orderStatusLabel)
numberRow.apply(value: order.orderNumber) numberRow.apply(value: order.orderNumber)
payTimeRow.apply(value: order.payTime.isEmpty ? "--" : order.payTime) payTimeRow.apply(value: order.payTime.isEmpty ? "--" : order.payTime)
cardView.layer.borderColor = isSelected ? AppColor.danger.cgColor : AppColor.border.cgColor userIDRow.apply(value: "U\(order.userId)")
phoneRow.apply(value: order.userPhone)
cardView.layer.borderColor = isSelected ? UIColor(hex: 0xFF0000).cgColor : AppColor.border.cgColor
cardView.layer.borderWidth = isSelected ? 2 : 1 cardView.layer.borderWidth = isSelected ? 2 : 1
accessibilityLabel = "订单\(order.orderNumber)"
accessibilityTraits = isSelected ? [.button, .selected] : .button
} }
} }
@ -197,17 +227,15 @@ private final class TaskInfoRowView: UIView {
super.init(frame: .zero) super.init(frame: .zero)
labelView.text = label labelView.text = label
labelView.font = .app(.body) labelView.font = .app(.body)
labelView.textColor = AppColor.textSecondary labelView.textColor = AppColor.textPrimary
valueView.font = .app(.body) valueView.font = .app(.body)
valueView.textColor = AppColor.textPrimary valueView.textColor = AppColor.textPrimary
valueView.numberOfLines = 0 valueView.numberOfLines = 0
addSubview(labelView) addSubview(labelView)
addSubview(valueView) addSubview(valueView)
labelView.snp.makeConstraints { make in labelView.setContentCompressionResistancePriority(.required, for: .horizontal)
make.leading.top.bottom.equalToSuperview() labelView.snp.makeConstraints { make in make.leading.top.bottom.equalToSuperview() }
make.width.equalTo(80)
}
valueView.snp.makeConstraints { make in valueView.snp.makeConstraints { make in
make.leading.equalTo(labelView.snp.trailing).offset(AppSpacing.xs) make.leading.equalTo(labelView.snp.trailing).offset(AppSpacing.xs)
make.trailing.top.bottom.equalToSuperview() make.trailing.top.bottom.equalToSuperview()

View File

@ -164,7 +164,7 @@ final class WildPhotographerReportListViewController: BaseViewController {
} }
private var currentScenicId: Int? { private var currentScenicId: Int? {
let scenicId = AppStore.shared.currentScenicId let scenicId = AppStore.shared.session.currentScenicId
return scenicId > 0 ? scenicId : nil return scenicId > 0 ? scenicId : nil
} }
} }

View File

@ -556,8 +556,8 @@ final class WildPhotographerReportSubmitViewController: BaseViewController {
await viewModel.submitReport( await viewModel.submitReport(
api: api, api: api,
uploader: uploader, uploader: uploader,
scenicId: AppStore.shared.currentScenicId, scenicId: AppStore.shared.session.currentScenicId,
scenicName: AppStore.shared.currentScenicName scenicName: AppStore.shared.session.currentScenicName
) )
} }
} }

View File

@ -34,8 +34,8 @@ final class WildReportRiskMapViewController: BaseViewController {
init( init(
api: any WildPhotographerReportServing = NetworkServices.shared.wildPhotographerReportAPI, api: any WildPhotographerReportServing = NetworkServices.shared.wildPhotographerReportAPI,
locationProvider: any LocationProviding = LocationProvider.shared, locationProvider: any LocationProviding = LocationProvider.shared,
scenicIdProvider: @escaping () -> Int = { AppStore.shared.currentScenicId }, scenicIdProvider: @escaping () -> Int = { AppStore.shared.session.currentScenicId },
scenicNameProvider: @escaping () -> String = { AppStore.shared.currentScenicName } scenicNameProvider: @escaping () -> String = { AppStore.shared.session.currentScenicName }
) { ) {
self.viewModel = WildReportRiskMapViewModel() self.viewModel = WildReportRiskMapViewModel()
self.api = api self.api = api

View File

@ -343,7 +343,7 @@ final class WildReportSupplementEvidenceViewController: BaseViewController {
await viewModel.submit( await viewModel.submit(
api: api, api: api,
uploader: uploader, uploader: uploader,
scenicId: AppStore.shared.currentScenicId scenicId: AppStore.shared.session.currentScenicId
) )
} }
} }

Some files were not shown because too many files have changed in this diff Show More