模块化 AppStore 并完善素材管理与个人空间设置。
将会话、权限、位置、收款与排队存储拆分为独立模块,同步更新各 ViewModel 与单元测试,并补充素材管理 UI 与个人空间设置交互。 Co-authored-by: Cursor <cursoragent@cursor.com>
@ -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";
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
1
suixinkan/Assets.xcassets/MaterialManagement/material_ic_add.imageset/Contents.json
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"images":[{"filename":"material_ic_add.svg","idiom":"universal"}],"info":{"author":"xcode","version":1}}
|
||||||
1
suixinkan/Assets.xcassets/MaterialManagement/material_ic_add.imageset/material_ic_add.svg
vendored
Normal 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 |
1
suixinkan/Assets.xcassets/MaterialManagement/material_ic_arrow_down.imageset/Contents.json
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"images":[{"filename":"material_ic_arrow_down.svg","idiom":"universal"}],"info":{"author":"xcode","version":1}}
|
||||||
@ -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 |
1
suixinkan/Assets.xcassets/MaterialManagement/material_ic_clear.imageset/Contents.json
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"images":[{"filename":"material_ic_clear.svg","idiom":"universal"}],"info":{"author":"xcode","version":1}}
|
||||||
@ -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 |
12
suixinkan/Assets.xcassets/MaterialManagement/material_ic_download_count.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "material_ic_download_count.svg",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 |
1
suixinkan/Assets.xcassets/MaterialManagement/material_ic_play.imageset/Contents.json
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"images":[{"filename":"material_ic_play.svg","idiom":"universal"}],"info":{"author":"xcode","version":1}}
|
||||||
1
suixinkan/Assets.xcassets/MaterialManagement/material_ic_play.imageset/material_ic_play.svg
vendored
Normal 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 |
1
suixinkan/Assets.xcassets/MaterialManagement/material_ic_search.imageset/Contents.json
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"images":[{"filename":"material_ic_search.svg","idiom":"universal"}],"info":{"author":"xcode","version":1}}
|
||||||
@ -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 |
12
suixinkan/Assets.xcassets/home_menu_material.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "home_menu_material.svg",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
3
suixinkan/Assets.xcassets/home_menu_material.imageset/home_menu_material.svg
vendored
Normal 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 |
17
suixinkan/Assets.xcassets/home_menu_space.imageset/Contents.json
vendored
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
suixinkan/Assets.xcassets/home_menu_space.imageset/home_menu_space.svg
vendored
Normal 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 |
17
suixinkan/Assets.xcassets/profile_avatar_default.imageset/Contents.json
vendored
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
suixinkan/Assets.xcassets/profile_avatar_default.imageset/profile_avatar_default.svg
vendored
Normal 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 |
17
suixinkan/Assets.xcassets/profile_edit.imageset/Contents.json
vendored
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
suixinkan/Assets.xcassets/profile_edit.imageset/profile_edit.svg
vendored
Normal 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 |
@ -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() }
|
||||||
|
|||||||
@ -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,并注入公共 Header、token 和请求体。
|
/// 将业务请求模型转换为 URLRequest,并注入公共 Header、token 和请求体。
|
||||||
private func makeURLRequest<Response: Decodable>(
|
private func makeURLRequest<Response: Decodable>(
|
||||||
_ apiRequest: APIRequest<Response>,
|
_ apiRequest: APIRequest<Response>,
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
57
suixinkan/DataStore/AppLocationStore.swift
Normal 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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
38
suixinkan/DataStore/AppPaymentStore.swift
Normal 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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
91
suixinkan/DataStore/AppPermissionStore.swift
Normal 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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
97
suixinkan/DataStore/AppScenicQueueStore.swift
Normal 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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
233
suixinkan/DataStore/AppSessionStore.swift
Normal 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) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前景区 ID,0 表示未选择。
|
||||||
|
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) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前店铺 ID,0 表示未选择。
|
||||||
|
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) ?? ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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] = []
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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"),
|
||||||
|
|||||||
@ -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) })
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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() }
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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`。
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 = []
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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?("请先选择景区")
|
||||||
|
|||||||
@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 }
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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")
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
258
suixinkan/UI/MaterialManagement/MaterialManagementDialogs.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|||||||
@ -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?
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
448
suixinkan/UI/Profile/ProfileSpaceSettingsDialogs.swift
Normal 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)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
)
|
||||||
}
|
present(dialog, animated: false)
|
||||||
alert.view.addSubview(picker)
|
_ = initialTime
|
||||||
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
|
|
||||||
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
|
|
||||||
let fileURL = try await ossUploadService.uploadUserAvatar(
|
|
||||||
data: data,
|
|
||||||
fileName: "avatar_\(UUID().uuidString).jpg",
|
|
||||||
scenicId: scenicId
|
|
||||||
) { _ in }
|
|
||||||
try await profileAPI.updateUserInfo(nickname: nil, password: nil, avatar: fileURL)
|
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
viewModel.updateAvatarURL(fileURL)
|
let crop = ProfileAvatarCropViewController(image: image) { [weak self] data in
|
||||||
showToast("修改成功")
|
guard let self else { return }
|
||||||
|
Task {
|
||||||
|
await self.viewModel.uploadAvatar(
|
||||||
|
data: data,
|
||||||
|
fileName: "avatar_\(UUID().uuidString).jpg",
|
||||||
|
api: self.profileAPI
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.present(crop, animated: true)
|
||||||
}
|
}
|
||||||
} 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||