Compare commits
7 Commits
6b575dab27
...
e-party/re
Author | SHA1 | Date | |
---|---|---|---|
![]() |
327d4fd218 | ||
![]() |
d97de8455a | ||
![]() |
07265c01db | ||
![]() |
6b960f53b4 | ||
![]() |
90a840c5f3 | ||
![]() |
8b4eb9cb7e | ||
![]() |
c57bde4525 |
@@ -16,7 +16,7 @@
|
|||||||
| 环境 | 地址 | 说明 |
|
| 环境 | 地址 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| 生产环境 | `https://api.epartylive.com` | 正式服务器 |
|
| 生产环境 | `https://api.epartylive.com` | 正式服务器 |
|
||||||
| 测试环境 | `http://beta.api.molistar.xyz` | 开发测试服务器 |
|
| 测试环境 | `http://beta.api.pekolive.com` | 开发测试服务器 |
|
||||||
| 图片服务 | `https://image.hfighting.com` | 静态资源服务器 |
|
| 图片服务 | `https://image.hfighting.com` | 静态资源服务器 |
|
||||||
|
|
||||||
**环境切换机制:**
|
**环境切换机制:**
|
||||||
|
@@ -102,7 +102,7 @@ struct APIConfiguration {
|
|||||||
"Accept-Encoding": "gzip, br",
|
"Accept-Encoding": "gzip, br",
|
||||||
"Accept-Language": Locale.current.language.languageCode?.identifier ?? "en",
|
"Accept-Language": Locale.current.language.languageCode?.identifier ?? "en",
|
||||||
"App-Version": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0",
|
"App-Version": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0",
|
||||||
"User-Agent": "YuMi/20.20.61 (iPhone; iOS 17+; Scale/2.00)"
|
"User-Agent": await UserAgentProvider.userAgent()
|
||||||
]
|
]
|
||||||
// 检查用户认证状态并添加相关 headers
|
// 检查用户认证状态并添加相关 headers
|
||||||
let authStatus = await UserInfoManager.checkAuthenticationStatus()
|
let authStatus = await UserInfoManager.checkAuthenticationStatus()
|
||||||
|
@@ -1,218 +1,286 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
// MARK: - API Logger
|
// MARK: - API Logger
|
||||||
@MainActor
|
|
||||||
class APILogger {
|
class APILogger {
|
||||||
enum LogLevel {
|
enum LogLevel {
|
||||||
case none
|
case none
|
||||||
case basic
|
case basic
|
||||||
case detailed
|
case detailed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 使用 actor 封装可变全局状态以保证并发安全
|
||||||
|
actor Config {
|
||||||
|
static let shared = Config()
|
||||||
|
#if DEBUG
|
||||||
|
private var level: LogLevel = .detailed
|
||||||
|
#else
|
||||||
|
private var level: LogLevel = .none
|
||||||
|
#endif
|
||||||
|
func get() -> LogLevel { level }
|
||||||
|
func set(_ newLevel: LogLevel) { level = newLevel }
|
||||||
|
}
|
||||||
|
|
||||||
#if DEBUG
|
private static let logQueue = DispatchQueue(label: "com.yana.api.logger", qos: .utility)
|
||||||
static var logLevel: LogLevel = .detailed
|
|
||||||
#else
|
|
||||||
static var logLevel: LogLevel = .none
|
|
||||||
#endif
|
|
||||||
|
|
||||||
private static let dateFormatter: DateFormatter = {
|
private static let dateFormatter: DateFormatter = {
|
||||||
let formatter = DateFormatter()
|
let formatter = DateFormatter()
|
||||||
formatter.dateFormat = "HH:mm:ss.SSS"
|
formatter.dateFormat = "HH:mm:ss.SSS"
|
||||||
return formatter
|
return formatter
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
// MARK: - Redaction
|
||||||
|
/// 需要脱敏的敏感字段(统一小写匹配)
|
||||||
|
private static let sensitiveKeys: Set<String> = [
|
||||||
|
"authorization", "token", "access-token", "access_token", "refresh-token", "refresh_token",
|
||||||
|
"password", "passwd", "secret", "pub_ticket", "ticket", "set-cookie", "cookie"
|
||||||
|
]
|
||||||
|
/// 对字符串做中间遮罩,保留前后若干字符
|
||||||
|
private static func maskString(_ value: String, keepPrefix: Int = 3, keepSuffix: Int = 2) -> String {
|
||||||
|
guard !value.isEmpty else { return value }
|
||||||
|
if value.count <= keepPrefix + keepSuffix { return String(repeating: "*", count: value.count) }
|
||||||
|
let start = value.startIndex
|
||||||
|
let prefixEnd = value.index(start, offsetBy: keepPrefix)
|
||||||
|
let suffixStart = value.index(value.endIndex, offsetBy: -keepSuffix)
|
||||||
|
let prefix = value[start..<prefixEnd]
|
||||||
|
let suffix = value[suffixStart..<value.endIndex]
|
||||||
|
return String(prefix) + String(repeating: "*", count: max(0, value.distance(from: prefixEnd, to: suffixStart))) + String(suffix)
|
||||||
|
}
|
||||||
|
/// 对 headers 进行脱敏
|
||||||
|
private static func maskHeaders(_ headers: [String: String]) -> [String: String] {
|
||||||
|
var masked: [String: String] = [:]
|
||||||
|
for (key, value) in headers {
|
||||||
|
if sensitiveKeys.contains(key.lowercased()) {
|
||||||
|
masked[key] = maskString(value)
|
||||||
|
} else {
|
||||||
|
masked[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return masked
|
||||||
|
}
|
||||||
|
/// 递归地对 JSON 对象进行脱敏
|
||||||
|
private static func redactJSONObject(_ obj: Any) -> Any {
|
||||||
|
if let dict = obj as? [String: Any] {
|
||||||
|
var newDict: [String: Any] = [:]
|
||||||
|
for (k, v) in dict {
|
||||||
|
if sensitiveKeys.contains(k.lowercased()) {
|
||||||
|
if let str = v as? String { newDict[k] = maskString(str) }
|
||||||
|
else { newDict[k] = "<redacted>" }
|
||||||
|
} else {
|
||||||
|
newDict[k] = redactJSONObject(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return newDict
|
||||||
|
} else if let arr = obj as? [Any] {
|
||||||
|
return arr.map { redactJSONObject($0) }
|
||||||
|
} else {
|
||||||
|
return obj
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// 将请求体 Data 以 Pretty JSON(脱敏后)或摘要形式输出
|
||||||
|
private static func maskedBodyString(from body: Data?) -> String {
|
||||||
|
guard let body = body, !body.isEmpty else { return "No body" }
|
||||||
|
if let json = try? JSONSerialization.jsonObject(with: body, options: []) {
|
||||||
|
let redacted = redactJSONObject(json)
|
||||||
|
if let pretty = try? JSONSerialization.data(withJSONObject: redacted, options: [.prettyPrinted]),
|
||||||
|
let prettyString = String(data: pretty, encoding: .utf8) {
|
||||||
|
return prettyString
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "<non-json body> (\(body.count) bytes)"
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Request Logging
|
// MARK: - Request Logging
|
||||||
@MainActor static func logRequest<T: APIRequestProtocol>(
|
static func logRequest<T: APIRequestProtocol>(
|
||||||
_ request: T,
|
_ request: T,
|
||||||
url: URL,
|
url: URL,
|
||||||
body: Data?,
|
body: Data?,
|
||||||
finalHeaders: [String: String]? = nil
|
finalHeaders: [String: String]? = nil
|
||||||
) {
|
) {
|
||||||
#if DEBUG
|
#if !DEBUG
|
||||||
guard logLevel != .none else { return }
|
|
||||||
#else
|
|
||||||
return
|
return
|
||||||
#endif
|
#else
|
||||||
|
Task {
|
||||||
let timestamp = dateFormatter.string(from: Date())
|
let level = await Config.shared.get()
|
||||||
|
guard level != .none else { return }
|
||||||
print("\n🚀 [API Request] [\(timestamp)] ==================")
|
logQueue.async {
|
||||||
print("📍 Endpoint: \(request.endpoint)")
|
let timestamp = dateFormatter.string(from: Date())
|
||||||
print("🔗 Full URL: \(url.absoluteString)")
|
debugInfoSync("\n🚀 [API Request] [\(timestamp)] ==================")
|
||||||
print("📝 Method: \(request.method.rawValue)")
|
debugInfoSync("📍 Endpoint: \(request.endpoint)")
|
||||||
print("⏰ Timeout: \(request.timeout)s")
|
debugInfoSync("🔗 Full URL: \(url.absoluteString)")
|
||||||
|
debugInfoSync("📝 Method: \(request.method.rawValue)")
|
||||||
// 显示最终的完整 headers(包括默认 headers 和自定义 headers)
|
debugInfoSync("⏰ Timeout: \(request.timeout)s")
|
||||||
if let headers = finalHeaders, !headers.isEmpty {
|
|
||||||
if logLevel == .detailed {
|
// 显示最终的完整 headers(包括默认 headers 和自定义 headers)
|
||||||
print("📋 Final Headers (包括默认 + 自定义):")
|
if let headers = finalHeaders, !headers.isEmpty {
|
||||||
for (key, value) in headers.sorted(by: { $0.key < $1.key }) {
|
if level == .detailed {
|
||||||
print(" \(key): \(value)")
|
debugInfoSync("📋 Final Headers (包括默认 + 自定义):")
|
||||||
}
|
let masked = maskHeaders(headers)
|
||||||
} else if logLevel == .basic {
|
for (key, value) in masked.sorted(by: { $0.key < $1.key }) {
|
||||||
print("📋 Headers: \(headers.count) 个 headers")
|
debugInfoSync(" \(key): \(value)")
|
||||||
// 只显示重要的 headers
|
}
|
||||||
let importantHeaders = ["Content-Type", "Accept", "User-Agent", "Authorization"]
|
} else if level == .basic {
|
||||||
for key in importantHeaders {
|
debugInfoSync("📋 Headers: \(headers.count) 个 headers")
|
||||||
if let value = headers[key] {
|
// 只显示重要的 headers
|
||||||
print(" \(key): \(value)")
|
let importantHeaders = ["Content-Type", "Accept", "User-Agent", "Authorization"]
|
||||||
|
let masked = maskHeaders(headers)
|
||||||
|
for key in importantHeaders {
|
||||||
|
if let value = masked[key] {
|
||||||
|
debugInfoSync(" \(key): \(value)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
} else if let customHeaders = request.headers, !customHeaders.isEmpty {
|
||||||
} else if let customHeaders = request.headers, !customHeaders.isEmpty {
|
debugInfoSync("📋 Custom Headers:")
|
||||||
print("📋 Custom Headers:")
|
let masked = maskHeaders(customHeaders)
|
||||||
for (key, value) in customHeaders.sorted(by: { $0.key < $1.key }) {
|
for (key, value) in masked.sorted(by: { $0.key < $1.key }) {
|
||||||
print(" \(key): \(value)")
|
debugInfoSync(" \(key): \(value)")
|
||||||
}
|
|
||||||
} else {
|
|
||||||
print("📋 Headers: 使用默认 headers")
|
|
||||||
}
|
|
||||||
|
|
||||||
if let queryParams = request.queryParameters, !queryParams.isEmpty {
|
|
||||||
print("🔍 Query Parameters:")
|
|
||||||
for (key, value) in queryParams.sorted(by: { $0.key < $1.key }) {
|
|
||||||
print(" \(key): \(value)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if logLevel == .detailed {
|
|
||||||
if let body = body {
|
|
||||||
print("📦 Request Body (\(body.count) bytes):")
|
|
||||||
if let jsonObject = try? JSONSerialization.jsonObject(with: body, options: []),
|
|
||||||
let prettyData = try? JSONSerialization.data(withJSONObject: jsonObject, options: .prettyPrinted),
|
|
||||||
let prettyString = String(data: prettyData, encoding: .utf8) {
|
|
||||||
print(prettyString)
|
|
||||||
} else if let rawString = String(data: body, encoding: .utf8) {
|
|
||||||
print(rawString)
|
|
||||||
} else {
|
|
||||||
print("Binary data")
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
print("📦 Request Body: No body")
|
debugInfoSync("📋 Headers: 使用默认 headers")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 显示基础参数信息(仅详细模式)
|
if let queryParams = request.queryParameters, !queryParams.isEmpty {
|
||||||
if request.includeBaseParameters {
|
debugInfoSync("🔍 Query Parameters:")
|
||||||
print("📱 Base Parameters: 自动注入设备和应用信息")
|
for (key, value) in queryParams.sorted(by: { $0.key < $1.key }) {
|
||||||
let baseParams = BaseRequest()
|
let masked = sensitiveKeys.contains(key.lowercased()) ? maskString(value) : value
|
||||||
print(" Device: \(baseParams.model), OS: \(baseParams.os) \(baseParams.osVersion)")
|
debugInfoSync(" \(key): \(masked)")
|
||||||
print(" App: \(baseParams.app) v\(baseParams.appVersion)")
|
}
|
||||||
print(" Language: \(baseParams.acceptLanguage)")
|
|
||||||
}
|
|
||||||
} else if logLevel == .basic {
|
|
||||||
if let body = body {
|
|
||||||
print("📦 Request Body: \(formatBytes(body.count))")
|
|
||||||
} else {
|
|
||||||
print("📦 Request Body: No body")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 基础模式也显示是否包含基础参数
|
if level == .detailed {
|
||||||
if request.includeBaseParameters {
|
let pretty = maskedBodyString(from: body)
|
||||||
print("📱 Base Parameters: 已自动注入")
|
debugInfoSync("📦 Request Body: \n\(pretty)")
|
||||||
|
|
||||||
|
// 仅提示包含基础参数,避免跨 actor 读取 UIKit 信息
|
||||||
|
if request.includeBaseParameters {
|
||||||
|
debugInfoSync("📱 Base Parameters: 已自动注入")
|
||||||
|
}
|
||||||
|
} else if level == .basic {
|
||||||
|
let size = body?.count ?? 0
|
||||||
|
debugInfoSync("📦 Request Body: \(formatBytes(size))")
|
||||||
|
|
||||||
|
// 基础模式也显示是否包含基础参数
|
||||||
|
if request.includeBaseParameters {
|
||||||
|
debugInfoSync("📱 Base Parameters: 已自动注入")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
debugInfoSync("=====================================")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
print("=====================================")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Response Logging
|
// MARK: - Response Logging
|
||||||
static func logResponse(data: Data, response: HTTPURLResponse, duration: TimeInterval) {
|
static func logResponse(data: Data, response: HTTPURLResponse, duration: TimeInterval) {
|
||||||
#if DEBUG
|
#if !DEBUG
|
||||||
guard logLevel != .none else { return }
|
|
||||||
#else
|
|
||||||
return
|
return
|
||||||
#endif
|
#else
|
||||||
|
Task {
|
||||||
let timestamp = dateFormatter.string(from: Date())
|
let level = await Config.shared.get()
|
||||||
let statusEmoji = response.statusCode < 400 ? "✅" : "❌"
|
guard level != .none else { return }
|
||||||
|
logQueue.async {
|
||||||
print("\n\(statusEmoji) [API Response] [\(timestamp)] ===================")
|
let timestamp = dateFormatter.string(from: Date())
|
||||||
print("⏱️ Duration: \(String(format: "%.3f", duration))s")
|
let statusEmoji = response.statusCode < 400 ? "✅" : "❌"
|
||||||
print("📊 Status Code: \(response.statusCode)")
|
debugInfoSync("\n\(statusEmoji) [API Response] [\(timestamp)] ===================")
|
||||||
print("🔗 URL: \(response.url?.absoluteString ?? "Unknown")")
|
debugInfoSync("⏱️ Duration: \(String(format: "%.3f", duration))s")
|
||||||
print("📏 Data Size: \(formatBytes(data.count))")
|
debugInfoSync("📊 Status Code: \(response.statusCode)")
|
||||||
|
debugInfoSync("🔗 URL: \(response.url?.absoluteString ?? "Unknown")")
|
||||||
if logLevel == .detailed {
|
debugInfoSync("📏 Data Size: \(formatBytes(data.count))")
|
||||||
print("📋 Response Headers:")
|
|
||||||
for (key, value) in response.allHeaderFields.sorted(by: { "\($0.key)" < "\($1.key)" }) {
|
|
||||||
print(" \(key): \(value)")
|
|
||||||
}
|
|
||||||
|
|
||||||
print("📦 Response Data:")
|
if level == .detailed {
|
||||||
if data.isEmpty {
|
debugInfoSync("📋 Response Headers:")
|
||||||
print(" Empty response")
|
// 将 headers 转为 [String:String] 后脱敏
|
||||||
} else if let jsonObject = try? JSONSerialization.jsonObject(with: data, options: []),
|
var headers: [String: String] = [:]
|
||||||
let prettyData = try? JSONSerialization.data(withJSONObject: jsonObject, options: .prettyPrinted),
|
for (k, v) in response.allHeaderFields { headers["\(k)"] = "\(v)" }
|
||||||
let prettyString = String(data: prettyData, encoding: .utf8) {
|
let masked = maskHeaders(headers)
|
||||||
print(prettyString)
|
for (key, value) in masked.sorted(by: { $0.key < $1.key }) {
|
||||||
} else if let rawString = String(data: data, encoding: .utf8) {
|
debugInfoSync(" \(key): \(value)")
|
||||||
print(rawString)
|
}
|
||||||
} else {
|
|
||||||
print(" Binary data (\(data.count) bytes)")
|
debugInfoSync("📦 Response Data:")
|
||||||
|
if data.isEmpty {
|
||||||
|
debugInfoSync(" Empty response")
|
||||||
|
} else if let jsonObject = try? JSONSerialization.jsonObject(with: data, options: []),
|
||||||
|
let prettyData = try? JSONSerialization.data(withJSONObject: redactJSONObject(jsonObject), options: .prettyPrinted),
|
||||||
|
let prettyString = String(data: prettyData, encoding: .utf8) {
|
||||||
|
debugInfoSync(prettyString)
|
||||||
|
} else if let _ = String(data: data, encoding: .utf8) {
|
||||||
|
// 对非 JSON 文本响应不做内容回显,避免泄漏
|
||||||
|
debugInfoSync("<non-json text> (\(data.count) bytes)")
|
||||||
|
} else {
|
||||||
|
debugInfoSync(" Binary data (\(data.count) bytes)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
debugInfoSync("=====================================")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
print("=====================================")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Error Logging
|
// MARK: - Error Logging
|
||||||
static func logError(_ error: Error, url: URL?, duration: TimeInterval) {
|
static func logError(_ error: Error, url: URL?, duration: TimeInterval) {
|
||||||
#if DEBUG
|
#if !DEBUG
|
||||||
guard logLevel != .none else { return }
|
|
||||||
#else
|
|
||||||
return
|
return
|
||||||
#endif
|
#else
|
||||||
|
Task {
|
||||||
let timestamp = dateFormatter.string(from: Date())
|
let level = await Config.shared.get()
|
||||||
|
guard level != .none else { return }
|
||||||
print("\n❌ [API Error] [\(timestamp)] ======================")
|
logQueue.async {
|
||||||
print("⏱️ Duration: \(String(format: "%.3f", duration))s")
|
let timestamp = dateFormatter.string(from: Date())
|
||||||
if let url = url {
|
debugErrorSync("\n❌ [API Error] [\(timestamp)] ======================")
|
||||||
print("🔗 URL: \(url.absoluteString)")
|
debugErrorSync("⏱️ Duration: \(String(format: "%.3f", duration))s")
|
||||||
}
|
if let url = url {
|
||||||
|
debugErrorSync("🔗 URL: \(url.absoluteString)")
|
||||||
if let apiError = error as? APIError {
|
}
|
||||||
print("🚨 API Error: \(apiError.localizedDescription)")
|
|
||||||
} else {
|
if let apiError = error as? APIError {
|
||||||
print("🚨 System Error: \(error.localizedDescription)")
|
debugErrorSync("🚨 API Error: \(apiError.localizedDescription)")
|
||||||
}
|
} else {
|
||||||
|
debugErrorSync("🚨 System Error: \(error.localizedDescription)")
|
||||||
if logLevel == .detailed {
|
}
|
||||||
if let urlError = error as? URLError {
|
|
||||||
print("🔍 URLError Code: \(urlError.code.rawValue)")
|
if level == .detailed {
|
||||||
print("🔍 URLError Localized: \(urlError.localizedDescription)")
|
if let urlError = error as? URLError {
|
||||||
|
debugInfoSync("🔍 URLError Code: \(urlError.code.rawValue)")
|
||||||
// 详细的网络错误分析
|
debugInfoSync("🔍 URLError Localized: \(urlError.localizedDescription)")
|
||||||
switch urlError.code {
|
|
||||||
case .timedOut:
|
// 详细的网络错误分析
|
||||||
print("💡 建议:检查网络连接或增加超时时间")
|
switch urlError.code {
|
||||||
case .notConnectedToInternet:
|
case .timedOut:
|
||||||
print("💡 建议:检查网络连接")
|
debugWarnSync("💡 建议:检查网络连接或增加超时时间")
|
||||||
case .cannotConnectToHost:
|
case .notConnectedToInternet:
|
||||||
print("💡 建议:检查服务器地址和端口")
|
debugWarnSync("💡 建议:检查网络连接")
|
||||||
case .resourceUnavailable:
|
case .cannotConnectToHost:
|
||||||
print("💡 建议:检查 API 端点是否正确")
|
debugWarnSync("💡 建议:检查服务器地址和端口")
|
||||||
default:
|
case .resourceUnavailable:
|
||||||
break
|
debugWarnSync("💡 建议:检查 API 端点是否正确")
|
||||||
}
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
debugInfoSync("🔍 Full Error: \(error)")
|
||||||
|
}
|
||||||
|
debugErrorSync("=====================================\n")
|
||||||
}
|
}
|
||||||
print("🔍 Full Error: \(error)")
|
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
print("=====================================\n")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Decoded Response Logging
|
// MARK: - Decoded Response Logging
|
||||||
static func logDecodedResponse<T>(_ response: T, type: T.Type) {
|
static func logDecodedResponse<T>(_ response: T, type: T.Type) {
|
||||||
#if DEBUG
|
#if !DEBUG
|
||||||
guard logLevel == .detailed else { return }
|
|
||||||
#else
|
|
||||||
return
|
return
|
||||||
|
#else
|
||||||
|
Task {
|
||||||
|
let level = await Config.shared.get()
|
||||||
|
guard level == .detailed else { return }
|
||||||
|
logQueue.async {
|
||||||
|
let timestamp = dateFormatter.string(from: Date())
|
||||||
|
debugInfoSync("🎯 [Decoded Response] [\(timestamp)] Type: \(type)")
|
||||||
|
debugInfoSync("=====================================\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
let timestamp = dateFormatter.string(from: Date())
|
|
||||||
print("🎯 [Decoded Response] [\(timestamp)] Type: \(type)")
|
|
||||||
print("=====================================\n")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Helper Methods
|
// MARK: - Helper Methods
|
||||||
@@ -225,16 +293,20 @@ class APILogger {
|
|||||||
|
|
||||||
// MARK: - Performance Logging
|
// MARK: - Performance Logging
|
||||||
static func logPerformanceWarning(duration: TimeInterval, threshold: TimeInterval = 5.0) {
|
static func logPerformanceWarning(duration: TimeInterval, threshold: TimeInterval = 5.0) {
|
||||||
#if DEBUG
|
#if !DEBUG
|
||||||
guard logLevel != .none && duration > threshold else { return }
|
|
||||||
#else
|
|
||||||
return
|
return
|
||||||
|
#else
|
||||||
|
Task {
|
||||||
|
let level = await Config.shared.get()
|
||||||
|
guard level != .none && duration > threshold else { return }
|
||||||
|
logQueue.async {
|
||||||
|
let timestamp = dateFormatter.string(from: Date())
|
||||||
|
debugWarnSync("\n⚠️ [Performance Warning] [\(timestamp)] ============")
|
||||||
|
debugWarnSync("🐌 Request took \(String(format: "%.3f", duration))s (threshold: \(threshold)s)")
|
||||||
|
debugWarnSync("💡 建议:检查网络条件或优化 API 响应")
|
||||||
|
debugWarnSync("================================================\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
let timestamp = dateFormatter.string(from: Date())
|
|
||||||
print("\n⚠️ [Performance Warning] [\(timestamp)] ============")
|
|
||||||
print("🐌 Request took \(String(format: "%.3f", duration))s (threshold: \(threshold)s)")
|
|
||||||
print("💡 建议:检查网络条件或优化 API 响应")
|
|
||||||
print("================================================\n")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,5 +1,4 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import ComposableArchitecture
|
|
||||||
|
|
||||||
// MARK: - HTTP Method
|
// MARK: - HTTP Method
|
||||||
|
|
||||||
@@ -205,8 +204,9 @@ struct BaseRequest: Codable {
|
|||||||
"\(key)=\(String(describing: filteredParams[key] ?? ""))"
|
"\(key)=\(String(describing: filteredParams[key] ?? ""))"
|
||||||
}.joined(separator: "&")
|
}.joined(separator: "&")
|
||||||
|
|
||||||
// 4. 添加密钥
|
// 4. 添加密钥(从配置提供者获取)
|
||||||
let keyString = "key=rpbs6us1m8r2j9g6u06ff2bo18orwaya"
|
let key = SigningKeyProvider.signingKey()
|
||||||
|
let keyString = "key=\(key)"
|
||||||
let finalString = paramString.isEmpty ? keyString : "\(paramString)&\(keyString)"
|
let finalString = paramString.isEmpty ? keyString : "\(paramString)&\(keyString)"
|
||||||
|
|
||||||
// 5. 生成大写 MD5 签名
|
// 5. 生成大写 MD5 签名
|
||||||
@@ -217,9 +217,8 @@ struct BaseRequest: Codable {
|
|||||||
// MARK: - Network Type Detector
|
// MARK: - Network Type Detector
|
||||||
struct NetworkTypeDetector {
|
struct NetworkTypeDetector {
|
||||||
static func getCurrentNetworkType() -> Int {
|
static func getCurrentNetworkType() -> Int {
|
||||||
// WiFi = 2, 蜂窝网络 = 1
|
// WiFi = 2, 蜂窝网络 = 1, 其他/无网络 = 0
|
||||||
// 这里是简化实现,实际应该检测网络状态
|
return NetworkMonitor.shared.currentType
|
||||||
return 2 // 默认蜂窝网络
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,7 +237,6 @@ struct CarrierInfoManager {
|
|||||||
|
|
||||||
// MARK: - User Info Manager (for Headers)
|
// MARK: - User Info Manager (for Headers)
|
||||||
struct UserInfoManager {
|
struct UserInfoManager {
|
||||||
@MainActor
|
|
||||||
private static let keychain = KeychainManager.shared
|
private static let keychain = KeychainManager.shared
|
||||||
|
|
||||||
// MARK: - Storage Keys
|
// MARK: - Storage Keys
|
||||||
@@ -287,7 +285,7 @@ struct UserInfoManager {
|
|||||||
// MARK: - User Info Management
|
// MARK: - User Info Management
|
||||||
static func saveUserInfo(_ userInfo: UserInfo) async {
|
static func saveUserInfo(_ userInfo: UserInfo) async {
|
||||||
do {
|
do {
|
||||||
try await keychain.store(userInfo, forKey: StorageKeys.userInfo)
|
try keychain.store(userInfo, forKey: StorageKeys.userInfo)
|
||||||
await cacheActor.setUserInfo(userInfo)
|
await cacheActor.setUserInfo(userInfo)
|
||||||
debugInfoSync("💾 保存用户信息成功")
|
debugInfoSync("💾 保存用户信息成功")
|
||||||
} catch {
|
} catch {
|
||||||
@@ -302,7 +300,7 @@ struct UserInfoManager {
|
|||||||
}
|
}
|
||||||
// 从 Keychain 读取
|
// 从 Keychain 读取
|
||||||
do {
|
do {
|
||||||
let userInfo = try await keychain.retrieve(UserInfo.self, forKey: StorageKeys.userInfo)
|
let userInfo = try keychain.retrieve(UserInfo.self, forKey: StorageKeys.userInfo)
|
||||||
await cacheActor.setUserInfo(userInfo)
|
await cacheActor.setUserInfo(userInfo)
|
||||||
return userInfo
|
return userInfo
|
||||||
} catch {
|
} catch {
|
||||||
@@ -377,7 +375,7 @@ struct UserInfoManager {
|
|||||||
/// - Parameter accountModel: 要保存的账户模型
|
/// - Parameter accountModel: 要保存的账户模型
|
||||||
static func saveAccountModel(_ accountModel: AccountModel) async {
|
static func saveAccountModel(_ accountModel: AccountModel) async {
|
||||||
do {
|
do {
|
||||||
try await keychain.store(accountModel, forKey: StorageKeys.accountModel)
|
try keychain.store(accountModel, forKey: StorageKeys.accountModel)
|
||||||
await cacheActor.setAccountModel(accountModel)
|
await cacheActor.setAccountModel(accountModel)
|
||||||
|
|
||||||
// 同步更新 ticket 到内存
|
// 同步更新 ticket 到内存
|
||||||
@@ -400,7 +398,7 @@ struct UserInfoManager {
|
|||||||
}
|
}
|
||||||
// 从 Keychain 读取
|
// 从 Keychain 读取
|
||||||
do {
|
do {
|
||||||
let accountModel = try await keychain.retrieve(
|
let accountModel = try keychain.retrieve(
|
||||||
AccountModel.self,
|
AccountModel.self,
|
||||||
forKey: StorageKeys.accountModel
|
forKey: StorageKeys.accountModel
|
||||||
)
|
)
|
||||||
@@ -448,7 +446,7 @@ struct UserInfoManager {
|
|||||||
/// 清除 AccountModel
|
/// 清除 AccountModel
|
||||||
static func clearAccountModel() async {
|
static func clearAccountModel() async {
|
||||||
do {
|
do {
|
||||||
try await keychain.delete(forKey: StorageKeys.accountModel)
|
try keychain.delete(forKey: StorageKeys.accountModel)
|
||||||
await cacheActor.clearAccountModel()
|
await cacheActor.clearAccountModel()
|
||||||
debugInfoSync("🗑️ AccountModel 已清除")
|
debugInfoSync("🗑️ AccountModel 已清除")
|
||||||
} catch {
|
} catch {
|
||||||
@@ -459,7 +457,7 @@ struct UserInfoManager {
|
|||||||
/// 清除用户信息
|
/// 清除用户信息
|
||||||
static func clearUserInfo() async {
|
static func clearUserInfo() async {
|
||||||
do {
|
do {
|
||||||
try await keychain.delete(forKey: StorageKeys.userInfo)
|
try keychain.delete(forKey: StorageKeys.userInfo)
|
||||||
await cacheActor.clearUserInfo()
|
await cacheActor.clearUserInfo()
|
||||||
debugInfoSync("🗑️ UserInfo 已清除")
|
debugInfoSync("🗑️ UserInfo 已清除")
|
||||||
} catch {
|
} catch {
|
||||||
|
@@ -1,5 +1,4 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import ComposableArchitecture
|
|
||||||
|
|
||||||
// MARK: - API Service Protocol
|
// MARK: - API Service Protocol
|
||||||
|
|
||||||
@@ -136,10 +135,7 @@ struct LiveAPIService: APIServiceProtocol, Sendable {
|
|||||||
requestBody = try JSONSerialization.data(withJSONObject: finalBody, options: [])
|
requestBody = try JSONSerialization.data(withJSONObject: finalBody, options: [])
|
||||||
urlRequest.httpBody = requestBody
|
urlRequest.httpBody = requestBody
|
||||||
// urlRequest.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
// urlRequest.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||||
if let httpBody = urlRequest.httpBody,
|
// HTTP Body 的详细输出由 APILogger 统一处理(带脱敏)。这里不再重复输出。
|
||||||
let bodyString = String(data: httpBody, encoding: .utf8) {
|
|
||||||
debugInfoSync("HTTP Body: \(bodyString)")
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
let encodingError = APIError.decodingError("请求体编码失败: \(error.localizedDescription)")
|
let encodingError = APIError.decodingError("请求体编码失败: \(error.localizedDescription)")
|
||||||
await APILoadingManager.shared.setError(loadingId, errorMessage: encodingError.localizedDescription)
|
await APILoadingManager.shared.setError(loadingId, errorMessage: encodingError.localizedDescription)
|
||||||
@@ -148,8 +144,7 @@ struct LiveAPIService: APIServiceProtocol, Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 记录请求日志,传递完整的 headers 信息
|
// 记录请求日志,传递完整的 headers 信息
|
||||||
await APILogger
|
APILogger.logRequest(request, url: url, body: requestBody, finalHeaders: headers)
|
||||||
.logRequest(request, url: url, body: requestBody, finalHeaders: headers)
|
|
||||||
|
|
||||||
do {
|
do {
|
||||||
// 发起请求
|
// 发起请求
|
||||||
@@ -165,18 +160,16 @@ struct LiveAPIService: APIServiceProtocol, Sendable {
|
|||||||
|
|
||||||
// 检查数据大小
|
// 检查数据大小
|
||||||
if data.count > APIConfiguration.maxDataSize {
|
if data.count > APIConfiguration.maxDataSize {
|
||||||
await APILogger
|
APILogger.logError(APIError.resourceTooLarge, url: url, duration: duration)
|
||||||
.logError(APIError.resourceTooLarge, url: url, duration: duration)
|
|
||||||
await APILoadingManager.shared.setError(loadingId, errorMessage: APIError.resourceTooLarge.localizedDescription)
|
await APILoadingManager.shared.setError(loadingId, errorMessage: APIError.resourceTooLarge.localizedDescription)
|
||||||
throw APIError.resourceTooLarge
|
throw APIError.resourceTooLarge
|
||||||
}
|
}
|
||||||
|
|
||||||
// 记录响应日志
|
// 记录响应日志
|
||||||
await APILogger
|
APILogger.logResponse(data: data, response: httpResponse, duration: duration)
|
||||||
.logResponse(data: data, response: httpResponse, duration: duration)
|
|
||||||
|
|
||||||
// 性能警告
|
// 性能警告
|
||||||
await APILogger.logPerformanceWarning(duration: duration)
|
APILogger.logPerformanceWarning(duration: duration)
|
||||||
|
|
||||||
// 检查 HTTP 状态码
|
// 检查 HTTP 状态码
|
||||||
guard 200...299 ~= httpResponse.statusCode else {
|
guard 200...299 ~= httpResponse.statusCode else {
|
||||||
@@ -196,7 +189,7 @@ struct LiveAPIService: APIServiceProtocol, Sendable {
|
|||||||
do {
|
do {
|
||||||
let decoder = JSONDecoder()
|
let decoder = JSONDecoder()
|
||||||
let decodedResponse = try decoder.decode(T.Response.self, from: data)
|
let decodedResponse = try decoder.decode(T.Response.self, from: data)
|
||||||
await APILogger.logDecodedResponse(decodedResponse, type: T.Response.self)
|
APILogger.logDecodedResponse(decodedResponse, type: T.Response.self)
|
||||||
|
|
||||||
// 请求成功,完成 loading
|
// 请求成功,完成 loading
|
||||||
await APILoadingManager.shared.finishLoading(loadingId)
|
await APILoadingManager.shared.finishLoading(loadingId)
|
||||||
@@ -210,13 +203,13 @@ struct LiveAPIService: APIServiceProtocol, Sendable {
|
|||||||
|
|
||||||
} catch let error as APIError {
|
} catch let error as APIError {
|
||||||
let duration = Date().timeIntervalSince(startTime)
|
let duration = Date().timeIntervalSince(startTime)
|
||||||
await APILogger.logError(error, url: url, duration: duration)
|
APILogger.logError(error, url: url, duration: duration)
|
||||||
await APILoadingManager.shared.setError(loadingId, errorMessage: error.localizedDescription)
|
await APILoadingManager.shared.setError(loadingId, errorMessage: error.localizedDescription)
|
||||||
throw error
|
throw error
|
||||||
} catch {
|
} catch {
|
||||||
let duration = Date().timeIntervalSince(startTime)
|
let duration = Date().timeIntervalSince(startTime)
|
||||||
let apiError = mapSystemError(error)
|
let apiError = mapSystemError(error)
|
||||||
await APILogger.logError(apiError, url: url, duration: duration)
|
APILogger.logError(apiError, url: url, duration: duration)
|
||||||
await APILoadingManager.shared.setError(loadingId, errorMessage: apiError.localizedDescription)
|
await APILoadingManager.shared.setError(loadingId, errorMessage: apiError.localizedDescription)
|
||||||
throw apiError
|
throw apiError
|
||||||
}
|
}
|
||||||
@@ -300,6 +293,14 @@ struct LiveAPIService: APIServiceProtocol, Sendable {
|
|||||||
return error
|
return error
|
||||||
} else if let msg = json["msg"] as? String {
|
} else if let msg = json["msg"] as? String {
|
||||||
return msg
|
return msg
|
||||||
|
} else if let detail = json["detail"] as? String {
|
||||||
|
return detail
|
||||||
|
} else if let errorDescription = json["error_description"] as? String {
|
||||||
|
return errorDescription
|
||||||
|
} else if let errorDict = json["error"] as? [String: Any], let nestedMsg = errorDict["message"] as? String {
|
||||||
|
return nestedMsg
|
||||||
|
} else if let errors = json["errors"] as? [[String: Any]], let firstMsg = errors.first? ["message"] as? String {
|
||||||
|
return firstMsg
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -349,7 +350,9 @@ actor MockAPIServiceActor: APIServiceProtocol, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - TCA Dependency Integration
|
// MARK: - TCA Dependency Integration (optional)
|
||||||
|
#if canImport(ComposableArchitecture)
|
||||||
|
import ComposableArchitecture
|
||||||
private enum APIServiceKey: DependencyKey {
|
private enum APIServiceKey: DependencyKey {
|
||||||
static let liveValue: any APIServiceProtocol & Sendable = LiveAPIService()
|
static let liveValue: any APIServiceProtocol & Sendable = LiveAPIService()
|
||||||
static let testValue: any APIServiceProtocol & Sendable = MockAPIServiceActor()
|
static let testValue: any APIServiceProtocol & Sendable = MockAPIServiceActor()
|
||||||
@@ -361,6 +364,7 @@ extension DependencyValues {
|
|||||||
set { self[APIServiceKey.self] = newValue }
|
set { self[APIServiceKey.self] = newValue }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
// MARK: - BaseRequest Dictionary Conversion
|
// MARK: - BaseRequest Dictionary Conversion
|
||||||
extension BaseRequest {
|
extension BaseRequest {
|
||||||
|
@@ -1,5 +1,4 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import ComposableArchitecture
|
|
||||||
|
|
||||||
// MARK: - 响应数据模型
|
// MARK: - 响应数据模型
|
||||||
|
|
||||||
@@ -18,7 +17,7 @@ struct MomentsListData: Codable, Equatable, Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 动态信息结构
|
/// 动态信息结构
|
||||||
public struct MomentsInfo: Codable, Equatable, Sendable {
|
public struct MomentsInfo: Codable, Equatable, Sendable, Identifiable {
|
||||||
let dynamicId: Int
|
let dynamicId: Int
|
||||||
let uid: Int
|
let uid: Int
|
||||||
let nick: String
|
let nick: String
|
||||||
@@ -52,6 +51,7 @@ public struct MomentsInfo: Codable, Equatable, Sendable {
|
|||||||
let isCustomWord: Bool?
|
let isCustomWord: Bool?
|
||||||
let labelList: [String]?
|
let labelList: [String]?
|
||||||
// 计算属性
|
// 计算属性
|
||||||
|
public var id: Int { dynamicId } // Identifiable 协议要求
|
||||||
var isSquareTop: Bool { (squareTop ?? 0) != 0 }
|
var isSquareTop: Bool { (squareTop ?? 0) != 0 }
|
||||||
var isTopicTop: Bool { (topicTop ?? 0) != 0 }
|
var isTopicTop: Bool { (topicTop ?? 0) != 0 }
|
||||||
var formattedPublishTime: Date {
|
var formattedPublishTime: Date {
|
||||||
@@ -243,28 +243,40 @@ struct PublishFeedData: Codable, Equatable {
|
|||||||
|
|
||||||
/// 我的动态信息结构 - 专门用于 /dynamic/getMyDynamic 接口
|
/// 我的动态信息结构 - 专门用于 /dynamic/getMyDynamic 接口
|
||||||
struct MyMomentInfo: Codable, Equatable, Sendable {
|
struct MyMomentInfo: Codable, Equatable, Sendable {
|
||||||
let content: String
|
// 服务器可能返回的完整字段(均用可选兼容不同版本)
|
||||||
|
let dynamicId: Int?
|
||||||
let uid: Int
|
let uid: Int
|
||||||
let publishTime: Int64
|
let nick: String?
|
||||||
|
let avatar: String?
|
||||||
let type: Int
|
let type: Int
|
||||||
|
let content: String
|
||||||
|
let likeCount: Int?
|
||||||
|
let isLike: Bool?
|
||||||
|
let commentCount: Int?
|
||||||
|
let publishTime: Int64
|
||||||
|
let worldId: Int?
|
||||||
|
let status: Int?
|
||||||
|
let playCount: Int?
|
||||||
|
let dynamicResList: [MomentsPicture]? // 资源列表(图片/视频)
|
||||||
|
|
||||||
// 转换为 MomentsInfo 的辅助方法
|
// 转换为 MomentsInfo 的辅助方法
|
||||||
func toMomentsInfo() -> MomentsInfo {
|
func toMomentsInfo() -> MomentsInfo {
|
||||||
return MomentsInfo(
|
return MomentsInfo(
|
||||||
dynamicId: 0, // 我的动态接口没有返回 dynamicId
|
dynamicId: dynamicId ?? 0,
|
||||||
uid: uid,
|
uid: uid,
|
||||||
nick: "", // 需要从用户信息中获取
|
nick: nick ?? "",
|
||||||
avatar: "", // 需要从用户信息中获取
|
avatar: avatar ?? "",
|
||||||
type: type,
|
type: type,
|
||||||
content: content,
|
content: content,
|
||||||
likeCount: 0, // 我的动态接口没有返回点赞数
|
likeCount: likeCount ?? 0,
|
||||||
isLike: false, // 我的动态接口没有返回点赞状态
|
isLike: isLike ?? false,
|
||||||
commentCount: 0, // 我的动态接口没有返回评论数
|
commentCount: commentCount ?? 0,
|
||||||
publishTime: Int(publishTime / 1000), // 转换为秒
|
// 注意:UI 的 formatDisplayTime 期望毫秒,这里不做 /1000 转换
|
||||||
worldId: 0, // 我的动态接口没有返回 worldId
|
publishTime: Int(publishTime),
|
||||||
status: 1, // 默认状态
|
worldId: worldId ?? 0,
|
||||||
playCount: nil,
|
status: status ?? 1,
|
||||||
dynamicResList: nil,
|
playCount: playCount,
|
||||||
|
dynamicResList: dynamicResList,
|
||||||
gender: nil,
|
gender: nil,
|
||||||
squareTop: nil,
|
squareTop: nil,
|
||||||
topicTop: nil,
|
topicTop: nil,
|
||||||
|
@@ -392,7 +392,7 @@ struct LoginHelper {
|
|||||||
debugInfoSync(" 加密后密码: \(encryptedPassword)")
|
debugInfoSync(" 加密后密码: \(encryptedPassword)")
|
||||||
|
|
||||||
return IDLoginAPIRequest(
|
return IDLoginAPIRequest(
|
||||||
phone: userID,
|
phone: encryptedID,
|
||||||
password: encryptedPassword
|
password: encryptedPassword
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@@ -2,12 +2,16 @@ import UIKit
|
|||||||
//import NIMSDK
|
//import NIMSDK
|
||||||
|
|
||||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||||
private func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) async -> Bool {
|
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||||
|
debugInfoSync("🚀 UIApplication didFinishLaunching")
|
||||||
// 预加载用户信息缓存
|
|
||||||
await UserInfoManager.preloadCache()
|
|
||||||
|
|
||||||
// NIMConfigurationManager.setupNimSDK()
|
// 异步预加载用户信息缓存与初始化逻辑(不阻塞启动)
|
||||||
|
Task { @MainActor in
|
||||||
|
await UserInfoManager.preloadCache()
|
||||||
|
// 如需集成 IM/其他 SDK,在此处异步初始化,避免阻塞:
|
||||||
|
// NIMConfigurationManager.setupNimSDK()
|
||||||
|
debugInfoSync("✅ App 启动预热完成")
|
||||||
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
@@ -5,17 +5,17 @@ enum AppEnvironment {
|
|||||||
|
|
||||||
struct AppConfig {
|
struct AppConfig {
|
||||||
static let current: AppEnvironment = {
|
static let current: AppEnvironment = {
|
||||||
// #if DEBUG
|
#if DEBUG
|
||||||
// return .development
|
return .development
|
||||||
// #else
|
#else
|
||||||
return .production
|
return .production
|
||||||
// #endif
|
#endif
|
||||||
}()
|
}()
|
||||||
|
|
||||||
static var baseURL: String {
|
static var baseURL: String {
|
||||||
switch current {
|
switch current {
|
||||||
case .development:
|
case .development:
|
||||||
return "http://beta.api.molistar.xyz"
|
return "http://beta.api.pekolive.com"
|
||||||
case .production:
|
case .production:
|
||||||
return "https://api.epartylive.com"
|
return "https://api.epartylive.com"
|
||||||
}
|
}
|
||||||
|
@@ -170,7 +170,14 @@ struct ContentView: View {
|
|||||||
let store: StoreOf<LoginFeature>
|
let store: StoreOf<LoginFeature>
|
||||||
let initStore: StoreOf<InitFeature>
|
let initStore: StoreOf<InitFeature>
|
||||||
let configStore: StoreOf<ConfigFeature>
|
let configStore: StoreOf<ConfigFeature>
|
||||||
@State private var selectedLogLevel: APILogger.LogLevel = APILogger.logLevel
|
@State private var selectedLogLevel: APILogger.LogLevel = {
|
||||||
|
// 以编译期默认值初始化(与 APILogger.Config 一致)
|
||||||
|
#if DEBUG
|
||||||
|
return .detailed
|
||||||
|
#else
|
||||||
|
return .none
|
||||||
|
#endif
|
||||||
|
}()
|
||||||
@State private var selectedTab = 0
|
@State private var selectedTab = 0
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -188,7 +195,7 @@ struct ContentView: View {
|
|||||||
.tag(1)
|
.tag(1)
|
||||||
}
|
}
|
||||||
.onChange(of: selectedLogLevel) { _, selectedLogLevel in
|
.onChange(of: selectedLogLevel) { _, selectedLogLevel in
|
||||||
APILogger.logLevel = selectedLogLevel
|
Task { await APILogger.Config.shared.set(selectedLogLevel) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,114 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import ComposableArchitecture
|
|
||||||
|
|
||||||
@Reducer
|
|
||||||
struct SplashFeature {
|
|
||||||
@ObservableState
|
|
||||||
struct State: Equatable {
|
|
||||||
var isLoading = true
|
|
||||||
var shouldShowMainApp = false
|
|
||||||
var authenticationStatus: UserInfoManager.AuthenticationStatus = .notFound
|
|
||||||
var isCheckingAuthentication = false
|
|
||||||
|
|
||||||
// 新增:导航目标
|
|
||||||
var navigationDestination: NavigationDestination?
|
|
||||||
|
|
||||||
init() {
|
|
||||||
// 默认初始化
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 新增:导航目标枚举
|
|
||||||
enum NavigationDestination: Equatable {
|
|
||||||
case login // 跳转到登录页面
|
|
||||||
case main // 跳转到主页面
|
|
||||||
}
|
|
||||||
|
|
||||||
enum Action: Equatable {
|
|
||||||
case onAppear
|
|
||||||
case splashFinished
|
|
||||||
case checkAuthentication
|
|
||||||
case authenticationChecked(UserInfoManager.AuthenticationStatus)
|
|
||||||
|
|
||||||
// 新增:用户信息获取 actions
|
|
||||||
case fetchUserInfo
|
|
||||||
case userInfoFetched(Bool)
|
|
||||||
|
|
||||||
// 新增:导航 actions
|
|
||||||
case navigateToLogin
|
|
||||||
case navigateToMain
|
|
||||||
}
|
|
||||||
|
|
||||||
@Dependency(\.apiService) var apiService // 新增:API服务依赖
|
|
||||||
|
|
||||||
var body: some ReducerOf<Self> {
|
|
||||||
Reduce { state, action in
|
|
||||||
switch action {
|
|
||||||
case .onAppear:
|
|
||||||
state.isLoading = true
|
|
||||||
state.shouldShowMainApp = false
|
|
||||||
state.authenticationStatus = UserInfoManager.AuthenticationStatus.notFound
|
|
||||||
state.isCheckingAuthentication = false
|
|
||||||
state.navigationDestination = nil
|
|
||||||
|
|
||||||
// 1秒延迟后显示主应用 (iOS 15.5+ 兼容)
|
|
||||||
return .run { send in
|
|
||||||
try await Task.sleep(nanoseconds: 1_000_000_000) // 1秒 = 1,000,000,000 纳秒
|
|
||||||
await send(.splashFinished)
|
|
||||||
}
|
|
||||||
case .splashFinished:
|
|
||||||
state.isLoading = false
|
|
||||||
|
|
||||||
// Splash 完成后,开始检查认证状态
|
|
||||||
return .send(.checkAuthentication)
|
|
||||||
|
|
||||||
case .checkAuthentication:
|
|
||||||
state.isCheckingAuthentication = true
|
|
||||||
|
|
||||||
// 异步检查认证状态
|
|
||||||
return .run { send in
|
|
||||||
let authStatus = await UserInfoManager.checkAuthenticationStatus()
|
|
||||||
await send(.authenticationChecked(authStatus))
|
|
||||||
}
|
|
||||||
|
|
||||||
case let .authenticationChecked(status):
|
|
||||||
state.isCheckingAuthentication = false
|
|
||||||
state.authenticationStatus = status
|
|
||||||
|
|
||||||
// 根据认证状态决定下一步操作
|
|
||||||
if status.canAutoLogin {
|
|
||||||
debugInfoSync("🎉 自动登录成功,开始获取用户信息")
|
|
||||||
// 新增:认证成功后自动获取用户信息
|
|
||||||
return .send(.fetchUserInfo)
|
|
||||||
} else {
|
|
||||||
debugInfoSync("🔑 需要手动登录")
|
|
||||||
return .send(.navigateToLogin)
|
|
||||||
}
|
|
||||||
|
|
||||||
case .fetchUserInfo:
|
|
||||||
// 新增:获取用户信息
|
|
||||||
return .run { send in
|
|
||||||
let success = await UserInfoManager.autoFetchUserInfoOnAppLaunch(apiService: apiService)
|
|
||||||
await send(.userInfoFetched(success))
|
|
||||||
}
|
|
||||||
|
|
||||||
case let .userInfoFetched(success):
|
|
||||||
if success {
|
|
||||||
debugInfoSync("✅ 用户信息获取成功,进入主页")
|
|
||||||
} else {
|
|
||||||
debugInfoSync("⚠️ 用户信息获取失败,但仍进入主页")
|
|
||||||
}
|
|
||||||
return .send(.navigateToMain)
|
|
||||||
|
|
||||||
case .navigateToLogin:
|
|
||||||
state.navigationDestination = .login
|
|
||||||
return .none
|
|
||||||
|
|
||||||
case .navigateToMain:
|
|
||||||
state.navigationDestination = .main
|
|
||||||
state.shouldShowMainApp = true
|
|
||||||
return .none
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@@ -15,5 +15,7 @@
|
|||||||
<array>
|
<array>
|
||||||
<string>Bayon-Regular.ttf</string>
|
<string>Bayon-Regular.ttf</string>
|
||||||
</array>
|
</array>
|
||||||
|
<key>API_SIGNING_KEY</key>
|
||||||
|
<string></string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
@@ -6,13 +6,154 @@ enum AppImageSource: Equatable {
|
|||||||
case photoLibrary
|
case photoLibrary
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - 通用底部 Tab 栏组件
|
||||||
|
public struct TabBarItem: Identifiable, Equatable {
|
||||||
|
public let id: String
|
||||||
|
public let title: String
|
||||||
|
public let systemIconName: String
|
||||||
|
public init(id: String, title: String, systemIconName: String) {
|
||||||
|
self.id = id
|
||||||
|
self.title = title
|
||||||
|
self.systemIconName = systemIconName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BottomTabBar: View {
|
||||||
|
let items: [TabBarItem]
|
||||||
|
@Binding var selectedId: String
|
||||||
|
let onSelect: (String) -> Void
|
||||||
|
var contentPadding: EdgeInsets = EdgeInsets(top: 12, leading: 0, bottom: 12, trailing: 0)
|
||||||
|
var horizontalPadding: CGFloat = 0
|
||||||
|
|
||||||
|
// 便捷初始化:内部固定 tabs,避免外部重复声明
|
||||||
|
init(
|
||||||
|
selectedId: Binding<String>,
|
||||||
|
onSelect: @escaping (String) -> Void,
|
||||||
|
contentPadding: EdgeInsets = EdgeInsets(top: 12, leading: 0, bottom: 12, trailing: 0),
|
||||||
|
horizontalPadding: CGFloat = 0
|
||||||
|
) {
|
||||||
|
self.items = BottomTabBar.defaultItems()
|
||||||
|
self._selectedId = selectedId
|
||||||
|
self.onSelect = onSelect
|
||||||
|
self.contentPadding = contentPadding
|
||||||
|
self.horizontalPadding = horizontalPadding
|
||||||
|
}
|
||||||
|
|
||||||
|
// 最简初始化:直接接受 viewModel,内部处理所有逻辑
|
||||||
|
init(viewModel: MainViewModel) {
|
||||||
|
self.items = BottomTabBar.defaultItems()
|
||||||
|
self._selectedId = Binding(
|
||||||
|
get: { viewModel.selectedTab.rawValue },
|
||||||
|
set: { raw in
|
||||||
|
if let tab = MainViewModel.Tab(rawValue: raw) {
|
||||||
|
viewModel.onTabChanged(tab)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.onSelect = { _ in } // 保留但不再使用
|
||||||
|
self.contentPadding = EdgeInsets(top: 12, leading: 0, bottom: 12, trailing: 0)
|
||||||
|
self.horizontalPadding = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 BottomTabView.swift 中的图片资源名进行映射
|
||||||
|
private func assetIconName(for item: TabBarItem, isSelected: Bool) -> String? {
|
||||||
|
switch item.id {
|
||||||
|
case "feed":
|
||||||
|
return isSelected ? "feed selected" : "feed unselected"
|
||||||
|
case "me":
|
||||||
|
return isSelected ? "me selected" : "me unselected"
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 内部默认 items(与资源映射保持一致)
|
||||||
|
private static func defaultItems() -> [TabBarItem] {
|
||||||
|
return [
|
||||||
|
TabBarItem(id: "feed", title: "Feed", systemIconName: "list.bullet"),
|
||||||
|
TabBarItem(id: "me", title: "Me", systemIconName: "person.circle")
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ForEach(items) { item in
|
||||||
|
Button(action: {
|
||||||
|
selectedId = item.id
|
||||||
|
onSelect(item.id)
|
||||||
|
}) {
|
||||||
|
Group {
|
||||||
|
if let name = assetIconName(for: item, isSelected: selectedId == item.id) {
|
||||||
|
Image(name)
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fit)
|
||||||
|
.frame(width: 30, height: 30)
|
||||||
|
} else {
|
||||||
|
Image(systemName: item.systemIconName)
|
||||||
|
.font(.system(size: 24))
|
||||||
|
.foregroundColor(selectedId == item.id ? .white : .white.opacity(0.6))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(contentPadding)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 8) // 按钮与边缘保持 8 间距
|
||||||
|
.padding(.horizontal, horizontalPadding)
|
||||||
|
.background(LiquidGlassBackground())
|
||||||
|
.clipShape(Capsule())
|
||||||
|
.contentShape(Capsule())
|
||||||
|
.onTapGesture { /* 吸收空白区域点击,避免穿透 */ }
|
||||||
|
.overlay(
|
||||||
|
Capsule()
|
||||||
|
.stroke(Color.white.opacity(0.12), lineWidth: 0.5)
|
||||||
|
)
|
||||||
|
.shadow(color: Color.black.opacity(0.2), radius: 12, x: 0, y: 6)
|
||||||
|
.safeAreaInset(edge: .bottom) {
|
||||||
|
Color.clear.frame(height: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Liquid Glass Background (iOS 26 优先,向下优雅降级)
|
||||||
|
struct LiquidGlassBackground: View {
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if #available(iOS 26.0, *) {
|
||||||
|
// iOS 26+:使用系统液态玻璃效果
|
||||||
|
Rectangle()
|
||||||
|
.fill(Color.clear)
|
||||||
|
.glassEffect()
|
||||||
|
} else
|
||||||
|
if #available(iOS 17.0, *) {
|
||||||
|
// iOS 17-25:使用超薄材质 + 轻微高光层
|
||||||
|
ZStack {
|
||||||
|
Rectangle().fill(.ultraThinMaterial)
|
||||||
|
LinearGradient(
|
||||||
|
colors: [Color.white.opacity(0.06), Color.clear, Color.white.opacity(0.04)],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
.blendMode(.softLight)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 更低版本:半透明备选
|
||||||
|
Rectangle()
|
||||||
|
.fill(Color.black.opacity(0.2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - 背景视图组件
|
// MARK: - 背景视图组件
|
||||||
struct LoginBackgroundView: View {
|
struct LoginBackgroundView: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Image("bg")
|
Image("bg")
|
||||||
.resizable()
|
.resizable()
|
||||||
.aspectRatio(contentMode: .fill)
|
.aspectRatio(contentMode: .fill)
|
||||||
.ignoresSafeArea(.all)
|
// .ignoresSafeArea(.all)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,4 +426,4 @@ struct CameraPicker: UIViewControllerRepresentable {
|
|||||||
onTap: {}
|
onTap: {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
230
yana/MVVM/CreateFeedPage.swift
Normal file
230
yana/MVVM/CreateFeedPage.swift
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import PhotosUI
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class CreateFeedViewModel: ObservableObject {
|
||||||
|
@Published var content: String = ""
|
||||||
|
@Published var selectedImages: [UIImage] = []
|
||||||
|
@Published var isPublishing: Bool = false
|
||||||
|
@Published var errorMessage: String? = nil
|
||||||
|
// 仅当有文本时才允许发布
|
||||||
|
var canPublish: Bool { !content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CreateFeedPage: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@StateObject private var viewModel = CreateFeedViewModel()
|
||||||
|
let onDismiss: () -> Void
|
||||||
|
|
||||||
|
// MARK: - UI State
|
||||||
|
@FocusState private var isTextEditorFocused: Bool
|
||||||
|
@State private var isShowingPreview: Bool = false
|
||||||
|
@State private var previewIndex: Int = 0
|
||||||
|
|
||||||
|
private let maxCharacters: Int = 500
|
||||||
|
private let gridSpacing: CGFloat = 8
|
||||||
|
private let gridCornerRadius: CGFloat = 16
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { geometry in
|
||||||
|
ZStack {
|
||||||
|
Color(hex: 0x0C0527)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
.onTapGesture {
|
||||||
|
// 点击背景收起键盘
|
||||||
|
isTextEditorFocused = false
|
||||||
|
}
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
HStack {
|
||||||
|
Button(action: {
|
||||||
|
onDismiss()
|
||||||
|
dismiss()
|
||||||
|
}) {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.font(.system(size: 18, weight: .medium))
|
||||||
|
.frame(width: 44, height: 44, alignment: .center)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Text(LocalizedString ("createFeed.title", comment: "Image & Text Publish"))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.font(.system(size: 18, weight: .medium))
|
||||||
|
Spacer()
|
||||||
|
Button(action: publish) {
|
||||||
|
if viewModel.isPublishing {
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||||
|
} else {
|
||||||
|
Text(LocalizedString("createFeed.publish", comment: "Publish"))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(!viewModel.canPublish || viewModel.isPublishing)
|
||||||
|
.opacity((!viewModel.canPublish || viewModel.isPublishing) ? 0.6 : 1)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 12)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.zIndex(10)
|
||||||
|
|
||||||
|
ZStack(alignment: .topLeading) {
|
||||||
|
RoundedRectangle(cornerRadius: 8).fill(Color(hex: 0x1C143A))
|
||||||
|
if viewModel.content.isEmpty {
|
||||||
|
Text(LocalizedString("createFeed.enterContent", comment: "Enter Content"))
|
||||||
|
.foregroundColor(.white.opacity(0.5))
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
TextEditor(text: $viewModel.content)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
.focused($isTextEditorFocused)
|
||||||
|
.frame(height: 200)
|
||||||
|
.zIndex(1) // 确保编辑器不会遮挡顶部栏的点击
|
||||||
|
|
||||||
|
// 字数统计(右下角)
|
||||||
|
VStack { Spacer() }
|
||||||
|
.overlay(alignment: .bottomTrailing) {
|
||||||
|
Text("\(viewModel.content.count)/\(maxCharacters)")
|
||||||
|
.foregroundColor(.white.opacity(0.6))
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.padding(.trailing, 8)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 200)
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.onChange(of: viewModel.content) { _, newValue in
|
||||||
|
// 限制最大字数
|
||||||
|
if newValue.count > maxCharacters {
|
||||||
|
viewModel.content = String(newValue.prefix(maxCharacters))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
NineGridImagePicker(
|
||||||
|
images: $viewModel.selectedImages,
|
||||||
|
maxCount: 9,
|
||||||
|
cornerRadius: gridCornerRadius,
|
||||||
|
spacing: gridSpacing,
|
||||||
|
horizontalPadding: 20,
|
||||||
|
onTapImage: { index in
|
||||||
|
previewIndex = index
|
||||||
|
isShowingPreview = true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if let error = viewModel.errorMessage {
|
||||||
|
Text(error)
|
||||||
|
.foregroundColor(.red)
|
||||||
|
.font(.system(size: 14))
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationBarBackButtonHidden(true)
|
||||||
|
.fullScreenCover(isPresented: $isShowingPreview) {
|
||||||
|
ZStack {
|
||||||
|
Color.black.ignoresSafeArea()
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Button {
|
||||||
|
isShowingPreview = false
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.font(.system(size: 18, weight: .medium))
|
||||||
|
.padding(12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
|
|
||||||
|
TabView(selection: $previewIndex) {
|
||||||
|
ForEach(viewModel.selectedImages.indices, id: \.self) { idx in
|
||||||
|
ZStack {
|
||||||
|
Color.black
|
||||||
|
Image(uiImage: viewModel.selectedImages[idx])
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
.tag(idx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tabViewStyle(.page(indexDisplayMode: .automatic))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func publish() {
|
||||||
|
viewModel.isPublishing = true
|
||||||
|
viewModel.errorMessage = nil
|
||||||
|
Task { @MainActor in
|
||||||
|
let apiService: any APIServiceProtocol & Sendable = LiveAPIService()
|
||||||
|
do {
|
||||||
|
// 1) 上传图片(如有)
|
||||||
|
var resList: [ResListItem] = []
|
||||||
|
if !viewModel.selectedImages.isEmpty {
|
||||||
|
for image in viewModel.selectedImages {
|
||||||
|
if let url = await COSManager.shared.uploadUIImage(image, apiService: apiService) {
|
||||||
|
if let cg = image.cgImage {
|
||||||
|
let item = ResListItem(resUrl: url, width: cg.width, height: cg.height, format: "jpeg")
|
||||||
|
resList.append(item)
|
||||||
|
} else {
|
||||||
|
// 无法获取尺寸也允许发布,尺寸置为 0
|
||||||
|
let item = ResListItem(resUrl: url, width: 0, height: 0, format: "jpeg")
|
||||||
|
resList.append(item)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
viewModel.isPublishing = false
|
||||||
|
viewModel.errorMessage = "图片上传失败"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) 组装并发送发布请求
|
||||||
|
let trimmed = viewModel.content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let userId = await UserInfoManager.getCurrentUserId() ?? ""
|
||||||
|
let type = resList.isEmpty ? "0" : "2" // 0: 纯文字, 2: 图片/图文
|
||||||
|
let request = await PublishFeedRequest.make(
|
||||||
|
content: trimmed,
|
||||||
|
uid: userId,
|
||||||
|
type: type,
|
||||||
|
resList: resList.isEmpty ? nil : resList
|
||||||
|
)
|
||||||
|
let response = try await apiService.request(request)
|
||||||
|
|
||||||
|
// 3) 结果处理
|
||||||
|
if response.code == 200 {
|
||||||
|
viewModel.isPublishing = false
|
||||||
|
NotificationCenter.default.post(name: .init("CreateFeedPublished"), object: nil)
|
||||||
|
onDismiss()
|
||||||
|
dismiss()
|
||||||
|
} else {
|
||||||
|
viewModel.isPublishing = false
|
||||||
|
viewModel.errorMessage = response.message.isEmpty ? "发布失败" : response.message
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
viewModel.isPublishing = false
|
||||||
|
viewModel.errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func removeImage(at index: Int) {
|
||||||
|
guard viewModel.selectedImages.indices.contains(index) else { return }
|
||||||
|
viewModel.selectedImages.remove(at: index)
|
||||||
|
if isShowingPreview {
|
||||||
|
if previewIndex >= viewModel.selectedImages.count { previewIndex = max(0, viewModel.selectedImages.count - 1) }
|
||||||
|
if viewModel.selectedImages.isEmpty { isShowingPreview = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -16,6 +16,7 @@ class LoginViewModel: ObservableObject {
|
|||||||
|
|
||||||
// MARK: - Callbacks
|
// MARK: - Callbacks
|
||||||
var onLoginSuccess: (() -> Void)?
|
var onLoginSuccess: (() -> Void)?
|
||||||
|
private var hasSentSuccess: Bool = false
|
||||||
|
|
||||||
// MARK: - Public Methods
|
// MARK: - Public Methods
|
||||||
func onIDLoginTapped() {
|
func onIDLoginTapped() {
|
||||||
@@ -47,22 +48,20 @@ class LoginViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func onLoginCompleted() {
|
func onLoginCompleted() {
|
||||||
|
guard !hasSentSuccess else { return }
|
||||||
isAnyLoginCompleted = true
|
isAnyLoginCompleted = true
|
||||||
|
showIDLogin = false
|
||||||
|
showEmailLogin = false
|
||||||
|
hasSentSuccess = true
|
||||||
onLoginSuccess?()
|
onLoginSuccess?()
|
||||||
}
|
}
|
||||||
|
|
||||||
func onBackFromIDLogin() {
|
func onBackFromIDLogin() {
|
||||||
showIDLogin = false
|
showIDLogin = false
|
||||||
if isAnyLoginCompleted {
|
|
||||||
onLoginSuccess?()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func onBackFromEmailLogin() {
|
func onBackFromEmailLogin() {
|
||||||
showEmailLogin = false
|
showEmailLogin = false
|
||||||
if isAnyLoginCompleted {
|
|
||||||
onLoginSuccess?()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,78 +72,76 @@ struct LoginPage: View {
|
|||||||
let onLoginSuccess: () -> Void
|
let onLoginSuccess: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
GeometryReader { geometry in
|
||||||
GeometryReader { geometry in
|
ZStack {
|
||||||
ZStack {
|
backgroundView
|
||||||
backgroundView
|
|
||||||
|
VStack(spacing: 0) {
|
||||||
VStack(spacing: 0) {
|
Image("top")
|
||||||
Image("top")
|
.resizable()
|
||||||
.resizable()
|
.aspectRatio(375/400, contentMode: .fit)
|
||||||
.aspectRatio(375/400, contentMode: .fit)
|
.frame(maxWidth: .infinity)
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
Text(LocalizedString("login.app_title", comment: ""))
|
Text(LocalizedString("login.app_title", comment: ""))
|
||||||
.font(FontManager.adaptedFont(.bayonRegular, designSize: 56, for: geometry.size.width))
|
.font(FontManager.adaptedFont(.bayonRegular, designSize: 56, for: geometry.size.width))
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.padding(.leading, 20)
|
.padding(.leading, 20)
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
.padding(.bottom, 20) // 距离 top 图片底部的间距
|
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
bottomSection
|
|
||||||
}
|
}
|
||||||
|
.padding(.bottom, 20)
|
||||||
|
|
||||||
// 语言设置按钮 - 固定在页面右上角
|
Spacer()
|
||||||
languageSettingsButton
|
|
||||||
.position(x: geometry.size.width - 40, y: 60)
|
|
||||||
|
|
||||||
APILoadingEffectView()
|
bottomSection
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 语言设置按钮 - 固定在页面右上角
|
||||||
|
languageSettingsButton
|
||||||
|
.position(x: geometry.size.width - 40, y: 60)
|
||||||
|
|
||||||
|
APILoadingEffectView()
|
||||||
}
|
}
|
||||||
.ignoresSafeArea()
|
}
|
||||||
|
.ignoresSafeArea()
|
||||||
|
.navigationBarHidden(true)
|
||||||
|
.navigationDestination(isPresented: $viewModel.showIDLogin) {
|
||||||
|
IDLoginPage(
|
||||||
|
onBack: {
|
||||||
|
viewModel.onBackFromIDLogin()
|
||||||
|
},
|
||||||
|
onLoginSuccess: {
|
||||||
|
viewModel.onLoginCompleted()
|
||||||
|
}
|
||||||
|
)
|
||||||
.navigationBarHidden(true)
|
.navigationBarHidden(true)
|
||||||
.navigationDestination(isPresented: $viewModel.showIDLogin) {
|
}
|
||||||
IDLoginPage(
|
.navigationDestination(isPresented: $viewModel.showEmailLogin) {
|
||||||
onBack: {
|
EMailLoginPage(
|
||||||
viewModel.onBackFromIDLogin()
|
onBack: {
|
||||||
},
|
viewModel.onBackFromEmailLogin()
|
||||||
onLoginSuccess: {
|
},
|
||||||
viewModel.onLoginCompleted()
|
onLoginSuccess: {
|
||||||
}
|
viewModel.onLoginCompleted()
|
||||||
)
|
}
|
||||||
.navigationBarHidden(true)
|
|
||||||
}
|
|
||||||
.navigationDestination(isPresented: $viewModel.showEmailLogin) {
|
|
||||||
EMailLoginPage(
|
|
||||||
onBack: {
|
|
||||||
viewModel.onBackFromEmailLogin()
|
|
||||||
},
|
|
||||||
onLoginSuccess: {
|
|
||||||
viewModel.onLoginCompleted()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.navigationBarHidden(true)
|
|
||||||
}
|
|
||||||
.sheet(isPresented: $viewModel.showLanguageSettings) {
|
|
||||||
LanguageSettingsView(isPresented: $viewModel.showLanguageSettings)
|
|
||||||
}
|
|
||||||
.webView(
|
|
||||||
isPresented: $viewModel.showUserAgreement,
|
|
||||||
url: APIConfiguration.webURL(for: .userAgreement)
|
|
||||||
)
|
)
|
||||||
.webView(
|
.navigationBarHidden(true)
|
||||||
isPresented: $viewModel.showPrivacyPolicy,
|
}
|
||||||
url: APIConfiguration.webURL(for: .privacyPolicy)
|
.sheet(isPresented: $viewModel.showLanguageSettings) {
|
||||||
)
|
LanguageSettingsView(isPresented: $viewModel.showLanguageSettings)
|
||||||
.alert(LocalizedString("login.agreement_alert_title", comment: ""), isPresented: $viewModel.showAgreementAlert) {
|
}
|
||||||
Button(LocalizedString("login.agreement_alert_confirm", comment: "")) { }
|
.webView(
|
||||||
} message: {
|
isPresented: $viewModel.showUserAgreement,
|
||||||
Text(LocalizedString("login.agreement_alert_message", comment: ""))
|
url: APIConfiguration.webURL(for: .userAgreement)
|
||||||
}
|
)
|
||||||
|
.webView(
|
||||||
|
isPresented: $viewModel.showPrivacyPolicy,
|
||||||
|
url: APIConfiguration.webURL(for: .privacyPolicy)
|
||||||
|
)
|
||||||
|
.alert(LocalizedString("login.agreement_alert_title", comment: ""), isPresented: $viewModel.showAgreementAlert) {
|
||||||
|
Button(LocalizedString("login.agreement_alert_confirm", comment: "")) { }
|
||||||
|
} message: {
|
||||||
|
Text(LocalizedString("login.agreement_alert_message", comment: ""))
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
viewModel.onLoginSuccess = onLoginSuccess
|
viewModel.onLoginSuccess = onLoginSuccess
|
||||||
|
@@ -5,6 +5,7 @@ import SwiftUI
|
|||||||
struct MainPage: View {
|
struct MainPage: View {
|
||||||
@StateObject private var viewModel = MainViewModel()
|
@StateObject private var viewModel = MainViewModel()
|
||||||
let onLogout: () -> Void
|
let onLogout: () -> Void
|
||||||
|
@State private var isPresentingCreatePage: Bool = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack(path: $viewModel.navigationPath) {
|
NavigationStack(path: $viewModel.navigationPath) {
|
||||||
@@ -12,41 +13,28 @@ struct MainPage: View {
|
|||||||
ZStack {
|
ZStack {
|
||||||
// 背景图片
|
// 背景图片
|
||||||
LoginBackgroundView()
|
LoginBackgroundView()
|
||||||
// 主内容
|
|
||||||
mainContentView(geometry: geometry)
|
// 主内容:使用 TabView 常驻子树
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
TabView(selection: $viewModel.selectedTab) {
|
||||||
|
MomentListHomePage(onCreateTapped: { isPresentingCreatePage = true })
|
||||||
|
.tag(MainViewModel.Tab.feed)
|
||||||
|
MePage(onLogout: onLogout)
|
||||||
|
.tag(MainViewModel.Tab.me)
|
||||||
|
}
|
||||||
|
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
|
||||||
VStack {
|
VStack {
|
||||||
HStack {
|
|
||||||
Spacer()
|
|
||||||
// 右上角按钮
|
|
||||||
topRightButton
|
|
||||||
}
|
|
||||||
Spacer()
|
Spacer()
|
||||||
// 底部导航栏
|
// 底部导航栏(组件化)
|
||||||
bottomTabView
|
BottomTabBar(viewModel: viewModel)
|
||||||
.frame(height: 80)
|
.frame(height: 80)
|
||||||
.padding(.horizontal, 24)
|
.padding(.horizontal, 24)
|
||||||
.padding(.bottom, 100)
|
.padding(.bottom)
|
||||||
}
|
}
|
||||||
}
|
}.ignoresSafeArea(.all)
|
||||||
}
|
|
||||||
.navigationDestination(for: String.self) { destination in
|
|
||||||
switch destination {
|
|
||||||
case "setting":
|
|
||||||
SettingPage(
|
|
||||||
onBack: {
|
|
||||||
viewModel.navigationPath.removeLast()
|
|
||||||
},
|
|
||||||
onLogout: {
|
|
||||||
viewModel.onLogoutTapped()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.navigationBarHidden(true)
|
|
||||||
default:
|
|
||||||
EmptyView()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
.toolbar(.hidden)
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
viewModel.onLogout = onLogout
|
viewModel.onLogout = onLogout
|
||||||
@@ -56,101 +44,15 @@ struct MainPage: View {
|
|||||||
}
|
}
|
||||||
viewModel.onAppear()
|
viewModel.onAppear()
|
||||||
}
|
}
|
||||||
|
.fullScreenCover(isPresented: $isPresentingCreatePage) {
|
||||||
|
CreateFeedPage {
|
||||||
|
isPresentingCreatePage = false
|
||||||
|
}
|
||||||
|
}
|
||||||
.onChange(of: viewModel.isLoggedOut) { _, isLoggedOut in
|
.onChange(of: viewModel.isLoggedOut) { _, isLoggedOut in
|
||||||
if isLoggedOut {
|
if isLoggedOut {
|
||||||
onLogout()
|
onLogout()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - UI Components
|
|
||||||
|
|
||||||
private func mainContentView(geometry: GeometryProxy) -> some View {
|
|
||||||
Group {
|
|
||||||
switch viewModel.selectedTab {
|
|
||||||
case .feed:
|
|
||||||
MomentListHomePage()
|
|
||||||
case .me:
|
|
||||||
TempMePage()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var bottomTabView: some View {
|
|
||||||
HStack(spacing: 0) {
|
|
||||||
ForEach(MainViewModel.Tab.allCases, id: \.self) { tab in
|
|
||||||
Button(action: {
|
|
||||||
viewModel.onTabChanged(tab)
|
|
||||||
}) {
|
|
||||||
VStack(spacing: 4) {
|
|
||||||
Image(systemName: tab.iconName)
|
|
||||||
.font(.system(size: 24))
|
|
||||||
.foregroundColor(viewModel.selectedTab == tab ? .white : .white.opacity(0.6))
|
|
||||||
|
|
||||||
Text(tab.title)
|
|
||||||
.font(.system(size: 12))
|
|
||||||
.foregroundColor(viewModel.selectedTab == tab ? .white : .white.opacity(0.6))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.vertical, 12)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.background(
|
|
||||||
Rectangle()
|
|
||||||
.fill(Color.black.opacity(0.3))
|
|
||||||
.background(.ultraThinMaterial)
|
|
||||||
)
|
|
||||||
.safeAreaInset(edge: .bottom) {
|
|
||||||
Color.clear.frame(height: 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - 右上角按钮
|
|
||||||
private var topRightButton: some View {
|
|
||||||
Button(action: {
|
|
||||||
viewModel.onTopRightButtonTapped()
|
|
||||||
}) {
|
|
||||||
Group {
|
|
||||||
switch viewModel.selectedTab {
|
|
||||||
case .feed:
|
|
||||||
Image("add icon")
|
|
||||||
.resizable()
|
|
||||||
.aspectRatio(contentMode: .fit)
|
|
||||||
.frame(width: 40, height: 40)
|
|
||||||
case .me:
|
|
||||||
Image(systemName: "gearshape")
|
|
||||||
.font(.system(size: 24, weight: .medium))
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.frame(width: 40, height: 40)
|
|
||||||
.background(Color.black.opacity(0.3))
|
|
||||||
.clipShape(Circle())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.trailing, 16)
|
|
||||||
.padding(.top, 8)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// MARK: - MeView (简化版本)
|
|
||||||
|
|
||||||
struct TempMePage: View {
|
|
||||||
var body: some View {
|
|
||||||
VStack {
|
|
||||||
Text("Me View")
|
|
||||||
.font(.title)
|
|
||||||
.foregroundColor(.white)
|
|
||||||
|
|
||||||
Text("This is a simplified MeView")
|
|
||||||
.font(.body)
|
|
||||||
.foregroundColor(.white.opacity(0.8))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//#Preview {
|
|
||||||
// MainPage(onLogout: {})
|
|
||||||
//}
|
|
||||||
|
186
yana/MVVM/MePage.swift
Normal file
186
yana/MVVM/MePage.swift
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MePage: View {
|
||||||
|
let onLogout: () -> Void
|
||||||
|
@State private var isShowingSettings: Bool = false
|
||||||
|
@StateObject private var viewModel = MePageViewModel()
|
||||||
|
|
||||||
|
// 图片预览状态
|
||||||
|
@State private var previewItem: PreviewItem? = nil
|
||||||
|
@State private var previewCurrentIndex: Int = 0
|
||||||
|
|
||||||
|
// 详情页状态
|
||||||
|
@State private var selectedMoment: MomentsInfo? = nil
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
// 背景
|
||||||
|
// MomentListBackgroundView()
|
||||||
|
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// 顶部:大头像 + 姓名 + ID + 右上角设置
|
||||||
|
ZStack(alignment: .topTrailing) {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
AsyncImage(url: URL(string: viewModel.avatarURL)) { image in
|
||||||
|
image.resizable().scaledToFill()
|
||||||
|
} placeholder: {
|
||||||
|
Image(systemName: "person.circle.fill")
|
||||||
|
.resizable()
|
||||||
|
.scaledToFill()
|
||||||
|
.foregroundColor(.gray)
|
||||||
|
}
|
||||||
|
.frame(width: 132, height: 132)
|
||||||
|
.clipShape(Circle())
|
||||||
|
.overlay(Circle().stroke(Color.white, lineWidth: 3))
|
||||||
|
.shadow(color: .black.opacity(0.25), radius: 10, x: 0, y: 6)
|
||||||
|
|
||||||
|
Text(viewModel.nickname.isEmpty ? "未知用户" : viewModel.nickname)
|
||||||
|
.font(.system(size: 34, weight: .semibold))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.6)
|
||||||
|
|
||||||
|
if viewModel.userId > 0 {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text("ID:\(viewModel.userId)")
|
||||||
|
.font(.system(size: 16))
|
||||||
|
.foregroundColor(.white.opacity(0.8))
|
||||||
|
Image(systemName: "doc.on.doc")
|
||||||
|
.foregroundColor(.white.opacity(0.8))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.top, 24)
|
||||||
|
|
||||||
|
Button(action: { isShowingSettings = true }) {
|
||||||
|
Image(systemName: "gearshape")
|
||||||
|
.font(.system(size: 24, weight: .medium))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.frame(width: 40, height: 40)
|
||||||
|
.background(Color.black.opacity(0.3))
|
||||||
|
.clipShape(Circle())
|
||||||
|
}
|
||||||
|
.padding(.trailing, 16)
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
|
||||||
|
// 下部:只显示当前用户的动态列表
|
||||||
|
if !viewModel.moments.isEmpty {
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(spacing: 16) {
|
||||||
|
ForEach(Array(viewModel.moments.enumerated()), id: \.offset) { index, moment in
|
||||||
|
MomentListItem(
|
||||||
|
moment: moment,
|
||||||
|
onImageTap: { images, tappedIndex in
|
||||||
|
previewCurrentIndex = tappedIndex
|
||||||
|
previewItem = PreviewItem(images: images, index: tappedIndex)
|
||||||
|
},
|
||||||
|
onMomentTap: { tapped in
|
||||||
|
selectedMoment = tapped
|
||||||
|
debugInfoSync("➡️ MePage: 动态被点击 - ID: \(tapped.dynamicId)")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.onAppear {
|
||||||
|
if index == viewModel.moments.count - 3 {
|
||||||
|
viewModel.loadMoreData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if viewModel.isLoadingMore {
|
||||||
|
HStack {
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||||
|
.scaleEffect(0.8)
|
||||||
|
Text("加载更多...")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundColor(.white.opacity(0.8))
|
||||||
|
}
|
||||||
|
.padding(.vertical, 20)
|
||||||
|
}
|
||||||
|
if !viewModel.hasMore && !viewModel.moments.isEmpty {
|
||||||
|
Text("没有更多数据了")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundColor(.white.opacity(0.6))
|
||||||
|
.padding(.vertical, 20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.bottom, 160)
|
||||||
|
}
|
||||||
|
.refreshable { await viewModel.refreshData() }
|
||||||
|
} else if viewModel.isLoading {
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||||
|
.padding(.top, 20)
|
||||||
|
} else if let error = viewModel.errorMessage {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Text(error)
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundColor(.red)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
Button(action: { Task { await viewModel.refreshData() } }) {
|
||||||
|
Text("重试")
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(Color.white.opacity(0.2))
|
||||||
|
.cornerRadius(8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, 20)
|
||||||
|
} else {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Image(systemName: "doc.text")
|
||||||
|
.font(.system(size: 32))
|
||||||
|
.foregroundColor(.white.opacity(0.5))
|
||||||
|
Text("暂无动态")
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
.foregroundColor(.white.opacity(0.7))
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.safeAreaPadding(.top, 8)
|
||||||
|
}
|
||||||
|
.onAppear { viewModel.onAppear() }
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: .init("CreateFeedPublished"))) { _ in
|
||||||
|
Task { await viewModel.refreshData() }
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $isShowingSettings) {
|
||||||
|
SettingPage(
|
||||||
|
onBack: { isShowingSettings = false },
|
||||||
|
onLogout: {
|
||||||
|
isShowingSettings = false
|
||||||
|
onLogout()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.navigationBarHidden(true)
|
||||||
|
}
|
||||||
|
// 图片预览
|
||||||
|
.sheet(item: $previewItem) { item in
|
||||||
|
ImagePreviewPager(
|
||||||
|
images: item.images as [String],
|
||||||
|
currentIndex: $previewCurrentIndex
|
||||||
|
) {
|
||||||
|
previewItem = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 详情页
|
||||||
|
.sheet(item: $selectedMoment) { moment in
|
||||||
|
MomentDetailPage(moment: moment) {
|
||||||
|
selectedMoment = nil
|
||||||
|
debugInfoSync("📱 MePage: 详情页已关闭")
|
||||||
|
}
|
||||||
|
.navigationBarHidden(true)
|
||||||
|
.presentationDetents([.large])
|
||||||
|
.presentationDragIndicator(.visible)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
245
yana/MVVM/MomentDetailPage.swift
Normal file
245
yana/MVVM/MomentDetailPage.swift
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - MomentDetailPage
|
||||||
|
|
||||||
|
struct MomentDetailPage: View {
|
||||||
|
@StateObject private var viewModel: MomentDetailViewModel
|
||||||
|
let onClose: () -> Void
|
||||||
|
|
||||||
|
init(moment: MomentsInfo, onClose: @escaping () -> Void) {
|
||||||
|
_viewModel = StateObject(wrappedValue: MomentDetailViewModel(moment: moment))
|
||||||
|
self.onClose = onClose
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
// 背景
|
||||||
|
LoginBackgroundView()
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// 顶部导航栏
|
||||||
|
HStack {
|
||||||
|
Button {
|
||||||
|
onClose()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "chevron.left")
|
||||||
|
.font(.system(size: 20, weight: .medium))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
.background(Color.black.opacity(0.3))
|
||||||
|
.clipShape(Circle())
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text(LocalizedString("detail.title", comment: "Detail page title"))
|
||||||
|
.font(.system(size: 18, weight: .semibold))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.shadow(color: .black.opacity(0.5), radius: 2, x: 0, y: 1)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// 占位,保持标题居中
|
||||||
|
Color.clear
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.safeAreaPadding(.top, 60)
|
||||||
|
.padding(.bottom, 12)
|
||||||
|
.background(
|
||||||
|
LinearGradient(
|
||||||
|
gradient: Gradient(colors: [
|
||||||
|
Color.black.opacity(0.4),
|
||||||
|
Color.black.opacity(0.2),
|
||||||
|
Color.clear
|
||||||
|
]),
|
||||||
|
startPoint: .top,
|
||||||
|
endPoint: .bottom
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// 内容区域
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
// 用户信息
|
||||||
|
HStack(alignment: .top) {
|
||||||
|
// 头像
|
||||||
|
CachedAsyncImage(url: viewModel.moment.avatar) { image in
|
||||||
|
image
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fill)
|
||||||
|
} placeholder: {
|
||||||
|
Circle()
|
||||||
|
.fill(Color.gray.opacity(0.3))
|
||||||
|
.overlay(
|
||||||
|
Text(String(viewModel.moment.nick.prefix(1)))
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
.clipShape(Circle())
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(viewModel.moment.nick)
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
UserIDDisplay(uid: viewModel.moment.uid, fontSize: 12, textColor: .white.opacity(0.6))
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// 时间
|
||||||
|
Text(formatDisplayTime(viewModel.moment.publishTime))
|
||||||
|
.font(.system(size: 12, weight: .bold))
|
||||||
|
.foregroundColor(.white.opacity(0.8))
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(Color.white.opacity(0.15))
|
||||||
|
.cornerRadius(4)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 动态内容
|
||||||
|
if !viewModel.moment.content.isEmpty {
|
||||||
|
Text(viewModel.moment.content)
|
||||||
|
.font(.system(size: 16))
|
||||||
|
.foregroundColor(.white.opacity(0.95))
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 图片网格
|
||||||
|
if let images = viewModel.moment.dynamicResList, !images.isEmpty {
|
||||||
|
MomentImageGrid(
|
||||||
|
images: images,
|
||||||
|
onImageTap: { images, index in
|
||||||
|
viewModel.onImageTap(index)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 互动按钮
|
||||||
|
HStack(spacing: 20) {
|
||||||
|
Button {
|
||||||
|
viewModel.like()
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
if viewModel.isLikeLoading {
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(CircularProgressViewStyle(tint: viewModel.localIsLike ? .red : .white.opacity(0.8)))
|
||||||
|
.scaleEffect(0.8)
|
||||||
|
} else {
|
||||||
|
Image(systemName: viewModel.localIsLike ? "heart.fill" : "heart")
|
||||||
|
.font(.system(size: 18))
|
||||||
|
}
|
||||||
|
Text("\(viewModel.localLikeCount)")
|
||||||
|
.font(.system(size: 16))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.foregroundColor(viewModel.localIsLike ? .red : .white.opacity(0.9))
|
||||||
|
.disabled(viewModel.isLikeLoading || viewModel.moment.status == 0)
|
||||||
|
.opacity(viewModel.moment.status == 0 ? 0.5 : 1.0)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// 审核中状态角标 - 与外部列表保持一致:右侧对齐并与点赞按钮居中对齐
|
||||||
|
if viewModel.moment.status == 0 {
|
||||||
|
Text("reviewing")
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(Color.orange.opacity(0.85))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.bottom, 16)
|
||||||
|
.safeAreaPadding(.top, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
.navigationBarHidden(true)
|
||||||
|
.fullScreenCover(isPresented: $viewModel.showImagePreview) {
|
||||||
|
ImagePreviewPager(
|
||||||
|
images: viewModel.images,
|
||||||
|
currentIndex: $viewModel.currentIndex
|
||||||
|
) {
|
||||||
|
viewModel.showImagePreview = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
debugInfoSync("📱 MomentDetailPage: 显示详情页")
|
||||||
|
debugInfoSync(" 动态ID: \(viewModel.moment.dynamicId)")
|
||||||
|
debugInfoSync(" 用户: \(viewModel.moment.nick)")
|
||||||
|
debugInfoSync(" 审核状态: \(viewModel.moment.status)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 时间显示逻辑
|
||||||
|
private func formatDisplayTime(_ timestamp: Int) -> String {
|
||||||
|
let date = Date(timeIntervalSince1970: TimeInterval(timestamp) / 1000.0)
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.locale = Locale(identifier: "zh_CN")
|
||||||
|
let now = Date()
|
||||||
|
let interval = now.timeIntervalSince(date)
|
||||||
|
let calendar = Calendar.current
|
||||||
|
if calendar.isDateInToday(date) {
|
||||||
|
if interval < 60 {
|
||||||
|
return "刚刚"
|
||||||
|
} else if interval < 3600 {
|
||||||
|
return "\(Int(interval / 60))分钟前"
|
||||||
|
} else {
|
||||||
|
return "\(Int(interval / 3600))小时前"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
formatter.dateFormat = "MM/dd"
|
||||||
|
return formatter.string(from: date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//#Preview {
|
||||||
|
// let testMoment = MomentsInfo(
|
||||||
|
// dynamicId: 1,
|
||||||
|
// uid: 123456,
|
||||||
|
// nick: "测试用户",
|
||||||
|
// avatar: "",
|
||||||
|
// type: 0,
|
||||||
|
// content: "这是一条测试动态内容,用来测试 MomentDetailPage 的显示效果。",
|
||||||
|
// likeCount: 42,
|
||||||
|
// isLike: false,
|
||||||
|
// commentCount: 5,
|
||||||
|
// publishTime: Int(Date().timeIntervalSince1970 * 1000),
|
||||||
|
// worldId: 1,
|
||||||
|
// status: 0, // 审核中状态
|
||||||
|
// playCount: nil,
|
||||||
|
// dynamicResList: [
|
||||||
|
// MomentsPicture(id: 1, resUrl: "https://picsum.photos/300/300", format: "jpg", width: 300, height: 300, resDuration: nil),
|
||||||
|
// MomentsPicture(id: 2, resUrl: "https://picsum.photos/301/301", format: "jpg", width: 301, height: 301, resDuration: nil)
|
||||||
|
// ],
|
||||||
|
// gender: nil,
|
||||||
|
// squareTop: nil,
|
||||||
|
// topicTop: nil,
|
||||||
|
// newUser: nil,
|
||||||
|
// defUser: nil,
|
||||||
|
// scene: nil,
|
||||||
|
// userVipInfoVO: nil,
|
||||||
|
// headwearPic: nil,
|
||||||
|
// headwearEffect: nil,
|
||||||
|
// headwearType: nil,
|
||||||
|
// headwearName: nil,
|
||||||
|
// headwearId: nil,
|
||||||
|
// experLevelPic: nil,
|
||||||
|
// charmLevelPic: nil,
|
||||||
|
// isCustomWord: nil,
|
||||||
|
// labelList: nil
|
||||||
|
// )
|
||||||
|
//
|
||||||
|
// MomentDetailPage(moment: testMoment) {
|
||||||
|
// print("关闭详情页")
|
||||||
|
// }
|
||||||
|
//}
|
@@ -1,182 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
import Combine
|
|
||||||
|
|
||||||
// MARK: - Splash ViewModel
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
class SplashViewModel: ObservableObject {
|
|
||||||
// MARK: - Published Properties
|
|
||||||
@Published var isLoading = true
|
|
||||||
@Published var shouldShowMainApp = false
|
|
||||||
@Published var authenticationStatus: UserInfoManager.AuthenticationStatus = .notFound
|
|
||||||
@Published var isCheckingAuthentication = false
|
|
||||||
@Published var navigationDestination: NavigationDestination?
|
|
||||||
|
|
||||||
// MARK: - Private Properties
|
|
||||||
private var cancellables = Set<AnyCancellable>()
|
|
||||||
|
|
||||||
// MARK: - Navigation Destination
|
|
||||||
enum NavigationDestination: Equatable {
|
|
||||||
case login
|
|
||||||
case main
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Initialization
|
|
||||||
init() {
|
|
||||||
setupBindings()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Public Methods
|
|
||||||
func onAppear() {
|
|
||||||
isLoading = true
|
|
||||||
shouldShowMainApp = false
|
|
||||||
authenticationStatus = .notFound
|
|
||||||
isCheckingAuthentication = false
|
|
||||||
navigationDestination = nil
|
|
||||||
|
|
||||||
// 1秒延迟后显示主应用
|
|
||||||
Task {
|
|
||||||
try await Task.sleep(nanoseconds: 1_000_000_000)
|
|
||||||
await MainActor.run {
|
|
||||||
self.splashFinished()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func splashFinished() {
|
|
||||||
isLoading = false
|
|
||||||
checkAuthentication()
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkAuthentication() {
|
|
||||||
isCheckingAuthentication = true
|
|
||||||
|
|
||||||
Task {
|
|
||||||
let authStatus = await UserInfoManager.checkAuthenticationStatus()
|
|
||||||
await MainActor.run {
|
|
||||||
self.authenticationChecked(authStatus)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func authenticationChecked(_ status: UserInfoManager.AuthenticationStatus) {
|
|
||||||
isCheckingAuthentication = false
|
|
||||||
authenticationStatus = status
|
|
||||||
|
|
||||||
if status.canAutoLogin {
|
|
||||||
debugInfoSync("🎉 自动登录成功,开始获取用户信息")
|
|
||||||
fetchUserInfo()
|
|
||||||
} else {
|
|
||||||
debugInfoSync("🔑 需要手动登录")
|
|
||||||
navigateToLogin()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetchUserInfo() {
|
|
||||||
Task {
|
|
||||||
let success = await UserInfoManager.autoFetchUserInfoOnAppLaunch(apiService: LiveAPIService())
|
|
||||||
await MainActor.run {
|
|
||||||
self.userInfoFetched(success)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func userInfoFetched(_ success: Bool) {
|
|
||||||
if success {
|
|
||||||
debugInfoSync("✅ 用户信息获取成功,进入主页")
|
|
||||||
} else {
|
|
||||||
debugInfoSync("⚠️ 用户信息获取失败,但仍进入主页")
|
|
||||||
}
|
|
||||||
navigateToMain()
|
|
||||||
}
|
|
||||||
|
|
||||||
func navigateToLogin() {
|
|
||||||
navigationDestination = .login
|
|
||||||
}
|
|
||||||
|
|
||||||
func navigateToMain() {
|
|
||||||
navigationDestination = .main
|
|
||||||
shouldShowMainApp = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private Methods
|
|
||||||
private func setupBindings() {
|
|
||||||
// 可以在这里设置 Combine 绑定
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Splash View
|
|
||||||
|
|
||||||
struct Splash: View {
|
|
||||||
@StateObject private var viewModel = SplashViewModel()
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ZStack {
|
|
||||||
Group {
|
|
||||||
// 根据导航目标显示不同页面
|
|
||||||
if let navigationDestination = viewModel.navigationDestination {
|
|
||||||
switch navigationDestination {
|
|
||||||
case .login:
|
|
||||||
// 显示登录页面
|
|
||||||
LoginPage(
|
|
||||||
onLoginSuccess: {
|
|
||||||
// 登录成功后导航到主页面
|
|
||||||
viewModel.navigateToMain()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
case .main:
|
|
||||||
// 显示主应用页面
|
|
||||||
MainPage(
|
|
||||||
onLogout: {
|
|
||||||
viewModel.navigateToLogin()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 显示启动画面
|
|
||||||
splashContent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onAppear {
|
|
||||||
viewModel.onAppear()
|
|
||||||
}
|
|
||||||
|
|
||||||
// API Loading 效果视图 - 显示在所有内容之上
|
|
||||||
APILoadingEffectView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 启动画面内容
|
|
||||||
private var splashContent: some View {
|
|
||||||
ZStack {
|
|
||||||
// 背景图片 - 全屏显示
|
|
||||||
LoginBackgroundView()
|
|
||||||
|
|
||||||
VStack(spacing: 32) {
|
|
||||||
Spacer()
|
|
||||||
.frame(height: 200) // 与 storyboard 中的约束对应
|
|
||||||
|
|
||||||
// Logo 图片 - 100x100
|
|
||||||
Image("logo")
|
|
||||||
.resizable()
|
|
||||||
.aspectRatio(contentMode: .fit)
|
|
||||||
.frame(width: 100, height: 100)
|
|
||||||
|
|
||||||
// 应用标题 - 白色,40pt字体
|
|
||||||
Text(LocalizedString("splash.title", comment: "E-Parti"))
|
|
||||||
.font(.system(size: 40, weight: .regular))
|
|
||||||
.foregroundColor(.white)
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//#Preview {
|
|
||||||
// Splash()
|
|
||||||
//}
|
|
||||||
|
|
||||||
#Preview {
|
|
||||||
Splash()
|
|
||||||
}
|
|
62
yana/MVVM/SplashPage.swift
Normal file
62
yana/MVVM/SplashPage.swift
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SplashPage: View {
|
||||||
|
@State private var showLogin = false
|
||||||
|
@State private var showMain = false
|
||||||
|
@State private var hasCheckedAuth = false
|
||||||
|
private let splashTransitionAnimation: Animation = .easeInOut(duration: 0.5)
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if showMain {
|
||||||
|
MainPage(onLogout: {
|
||||||
|
showMain = false
|
||||||
|
showLogin = true
|
||||||
|
})
|
||||||
|
} else if showLogin {
|
||||||
|
NavigationStack {
|
||||||
|
LoginPage(onLoginSuccess: {
|
||||||
|
showMain = true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ZStack {
|
||||||
|
LoginBackgroundView()
|
||||||
|
VStack(spacing: 32) {
|
||||||
|
Spacer().frame(height: 200)
|
||||||
|
Image("logo")
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fit)
|
||||||
|
.frame(width: 100, height: 100)
|
||||||
|
Text(LocalizedString("splash.title", comment: "E-Parti"))
|
||||||
|
.font(.system(size: 40, weight: .regular))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
guard !hasCheckedAuth else { return }
|
||||||
|
hasCheckedAuth = true
|
||||||
|
Task { @MainActor in
|
||||||
|
debugInfoSync("🚀 SplashV2 启动,开始检查登录缓存")
|
||||||
|
let status = await UserInfoManager.checkAuthenticationStatus()
|
||||||
|
if status.canAutoLogin {
|
||||||
|
debugInfoSync("✅ 检测到可自动登录,尝试预取用户信息")
|
||||||
|
_ = await UserInfoManager.autoFetchUserInfoOnAppLaunch(apiService: LiveAPIService())
|
||||||
|
withAnimation(splashTransitionAnimation) {
|
||||||
|
showMain = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
debugInfoSync("🔑 未登录或缓存无效,进入登录页")
|
||||||
|
withAnimation(splashTransitionAnimation) {
|
||||||
|
showLogin = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@@ -8,72 +8,102 @@ struct MomentListBackgroundView: View {
|
|||||||
.aspectRatio(contentMode: .fill)
|
.aspectRatio(contentMode: .fill)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.clipped()
|
.clipped()
|
||||||
.ignoresSafeArea(.all)
|
.ignoresSafeArea(.all)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - MomentListHomePage
|
// MARK: - MomentListHomePage
|
||||||
struct MomentListHomePage: View {
|
struct MomentListHomePage: View {
|
||||||
@StateObject private var viewModel = MomentListHomeViewModel()
|
@StateObject private var viewModel = MomentListHomeViewModel()
|
||||||
|
let onCreateTapped: () -> Void
|
||||||
|
|
||||||
// MARK: - 图片预览状态
|
// MARK: - 图片预览状态
|
||||||
@State private var previewItem: PreviewItem? = nil
|
@State private var previewItem: PreviewItem? = nil
|
||||||
@State private var previewCurrentIndex: Int = 0
|
@State private var previewCurrentIndex: Int = 0
|
||||||
|
|
||||||
|
// MARK: - 详情页状态
|
||||||
|
@State private var selectedMoment: MomentsInfo? = nil
|
||||||
|
|
||||||
|
// MARK: - 创建动态发布页弹窗
|
||||||
|
// 迁移到上层(MainPage)统一管理,避免与 TabView 全屏弹窗冲突
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
GeometryReader { geometry in
|
ZStack {
|
||||||
ZStack {
|
// 背景
|
||||||
// 背景
|
// MomentListBackgroundView()
|
||||||
MomentListBackgroundView()
|
|
||||||
|
VStack(alignment: .center, spacing: 0) {
|
||||||
VStack(alignment: .center, spacing: 0) {
|
// 顶部标题居中 + 右上角添加按钮(垂直居中对齐)
|
||||||
// 标题
|
ZStack {
|
||||||
|
// 居中标题
|
||||||
Text(LocalizedString("feedList.title", comment: "Enjoy your Life Time"))
|
Text(LocalizedString("feedList.title", comment: "Enjoy your Life Time"))
|
||||||
.font(.system(size: 22, weight: .semibold))
|
.font(.system(size: 22, weight: .semibold))
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
.padding(.top, 60)
|
|
||||||
|
// 右上角 “+” 按钮
|
||||||
// Volume 图标
|
HStack {
|
||||||
Image("Volume")
|
Spacer()
|
||||||
.frame(width: 56, height: 41)
|
Button {
|
||||||
.padding(.top, 16)
|
debugInfoSync("➕ MomentListHomePage: 点击添加按钮")
|
||||||
|
onCreateTapped()
|
||||||
// 标语
|
} label: {
|
||||||
Text(LocalizedString("feedList.slogan",
|
Image("add icon")
|
||||||
comment: ""))
|
.resizable()
|
||||||
.font(.system(size: 16))
|
.aspectRatio(contentMode: .fit)
|
||||||
.multilineTextAlignment(.leading)
|
.frame(width: 40, height: 40)
|
||||||
.foregroundColor(.white.opacity(0.9))
|
}
|
||||||
.padding(.horizontal, 30)
|
.padding(.trailing, 16)
|
||||||
.padding(.bottom, 30)
|
}
|
||||||
|
}
|
||||||
// 动态列表内容
|
.frame(height: 56)
|
||||||
if !viewModel.moments.isEmpty {
|
|
||||||
ScrollView {
|
// 动态列表内容(Volume 与标语随列表滚动)
|
||||||
|
if !viewModel.moments.isEmpty {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Volume 图标 + 标语(随列表滚动)
|
||||||
|
Image("Volume")
|
||||||
|
.frame(width: 56, height: 41)
|
||||||
|
.padding(.top, 16)
|
||||||
|
Text(LocalizedString("feedList.slogan",
|
||||||
|
comment: ""))
|
||||||
|
.font(.system(size: 16))
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
.foregroundColor(.white.opacity(0.9))
|
||||||
|
.padding(.horizontal, 30)
|
||||||
|
.padding(.bottom, 30)
|
||||||
|
|
||||||
LazyVStack(spacing: 16) {
|
LazyVStack(spacing: 16) {
|
||||||
ForEach(Array(viewModel.moments.enumerated()), id: \.element.dynamicId) { index, moment in
|
ForEach(Array(viewModel.moments.enumerated()), id: \.element.dynamicId) { index, moment in
|
||||||
MomentListItem(
|
MomentListItem(
|
||||||
moment: moment,
|
moment: moment,
|
||||||
onImageTap: { images, tappedIndex in
|
onImageTap: { images, tappedIndex in
|
||||||
// 处理图片点击事件
|
// 处理图片点击事件
|
||||||
previewCurrentIndex = tappedIndex
|
previewCurrentIndex = tappedIndex
|
||||||
previewItem = PreviewItem(images: images, index: tappedIndex)
|
previewItem = PreviewItem(images: images, index: tappedIndex)
|
||||||
debugInfoSync("📸 MomentListHomePage: 图片被点击")
|
debugInfoSync("📸 MomentListHomePage: 图片被点击")
|
||||||
debugInfoSync(" 动态索引: \(index)")
|
debugInfoSync(" 动态索引: \(index)")
|
||||||
debugInfoSync(" 图片索引: \(tappedIndex)")
|
debugInfoSync(" 图片索引: \(tappedIndex)")
|
||||||
debugInfoSync(" 图片数量: \(images.count)")
|
debugInfoSync(" 图片数量: \(images.count)")
|
||||||
}
|
},
|
||||||
)
|
onMomentTap: { tappedMoment in
|
||||||
.padding(.leading, 16)
|
// 处理整体点击事件 - 打开详情页
|
||||||
.padding(.trailing, 32)
|
selectedMoment = tappedMoment
|
||||||
.onAppear {
|
debugInfoSync("➡️ MomentListHomePage: 动态被点击")
|
||||||
// 当显示倒数第三个项目时,开始加载更多
|
debugInfoSync(" 动态ID: \(tappedMoment.dynamicId)")
|
||||||
if index == viewModel.moments.count - 3 {
|
debugInfoSync(" 用户: \(tappedMoment.nick)")
|
||||||
viewModel.loadMoreData()
|
}
|
||||||
}
|
)
|
||||||
|
.padding(.leading, 16)
|
||||||
|
.padding(.trailing, 32)
|
||||||
|
.onAppear {
|
||||||
|
// 当显示倒数第三个项目时,开始加载更多
|
||||||
|
if index == viewModel.moments.count - 3 {
|
||||||
|
viewModel.loadMoreData()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 加载更多状态指示器
|
// 加载更多状态指示器
|
||||||
if viewModel.isLoadingMore {
|
if viewModel.isLoadingMore {
|
||||||
@@ -98,55 +128,58 @@ struct MomentListHomePage: View {
|
|||||||
}
|
}
|
||||||
.padding(.bottom, 160) // 为底部导航栏留出空间
|
.padding(.bottom, 160) // 为底部导航栏留出空间
|
||||||
}
|
}
|
||||||
.refreshable {
|
|
||||||
// 下拉刷新
|
|
||||||
viewModel.refreshData()
|
|
||||||
}
|
|
||||||
.onAppear {
|
|
||||||
// 调试信息
|
|
||||||
debugInfoSync("📱 MomentListHomePage: 显示动态列表")
|
|
||||||
debugInfoSync(" 动态数量: \(viewModel.moments.count)")
|
|
||||||
debugInfoSync(" 是否有更多: \(viewModel.hasMore)")
|
|
||||||
debugInfoSync(" 是否正在加载更多: \(viewModel.isLoadingMore)")
|
|
||||||
}
|
|
||||||
} else if viewModel.isLoading {
|
|
||||||
ProgressView()
|
|
||||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
|
||||||
.padding(.top, 20)
|
|
||||||
} else if let error = viewModel.error {
|
|
||||||
VStack(spacing: 16) {
|
|
||||||
Text(error)
|
|
||||||
.font(.system(size: 14))
|
|
||||||
.foregroundColor(.red)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
.padding(.horizontal, 20)
|
|
||||||
|
|
||||||
// 重试按钮
|
|
||||||
Button(action: {
|
|
||||||
viewModel.refreshData()
|
|
||||||
}) {
|
|
||||||
Text("重试")
|
|
||||||
.font(.system(size: 14, weight: .medium))
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.padding(.horizontal, 20)
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
.background(Color.white.opacity(0.2))
|
|
||||||
.cornerRadius(8)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.top, 20)
|
|
||||||
}
|
}
|
||||||
|
.refreshable {
|
||||||
Spacer()
|
// 下拉刷新
|
||||||
|
viewModel.refreshData()
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
// 调试信息
|
||||||
|
debugInfoSync("📱 MomentListHomePage: 显示动态列表")
|
||||||
|
debugInfoSync(" 动态数量: \(viewModel.moments.count)")
|
||||||
|
debugInfoSync(" 是否有更多: \(viewModel.hasMore)")
|
||||||
|
debugInfoSync(" 是否正在加载更多: \(viewModel.isLoadingMore)")
|
||||||
|
}
|
||||||
|
} else if viewModel.isLoading {
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||||
|
.padding(.top, 20)
|
||||||
|
} else if let error = viewModel.error {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Text(error)
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundColor(.red)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
|
||||||
|
// 重试按钮
|
||||||
|
Button(action: {
|
||||||
|
viewModel.refreshData()
|
||||||
|
}) {
|
||||||
|
Text("重试")
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(Color.white.opacity(0.2))
|
||||||
|
.cornerRadius(8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, 20)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
}
|
}
|
||||||
|
.safeAreaPadding(.top, 8)
|
||||||
}
|
}
|
||||||
.ignoresSafeArea()
|
|
||||||
.onAppear {
|
.onAppear {
|
||||||
viewModel.onAppear()
|
viewModel.onAppear()
|
||||||
}
|
}
|
||||||
// MARK: - 图片预览弹窗
|
.onReceive(NotificationCenter.default.publisher(for: .init("CreateFeedPublished"))) { _ in
|
||||||
.fullScreenCover(item: $previewItem) { item in
|
viewModel.refreshData()
|
||||||
|
}
|
||||||
|
// MARK: - 图片预览弹窗(使用 sheet 以避免与发布页全屏弹窗冲突)
|
||||||
|
.sheet(item: $previewItem) { item in
|
||||||
ImagePreviewPager(
|
ImagePreviewPager(
|
||||||
images: item.images as [String],
|
images: item.images as [String],
|
||||||
currentIndex: $previewCurrentIndex
|
currentIndex: $previewCurrentIndex
|
||||||
@@ -155,5 +188,16 @@ struct MomentListHomePage: View {
|
|||||||
debugInfoSync("📸 MomentListHomePage: 图片预览已关闭")
|
debugInfoSync("📸 MomentListHomePage: 图片预览已关闭")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// MARK: - 详情页弹窗
|
||||||
|
.sheet(item: $selectedMoment) { moment in
|
||||||
|
MomentDetailPage(moment: moment) {
|
||||||
|
selectedMoment = nil
|
||||||
|
debugInfoSync("📱 MomentListHomePage: 详情页已关闭")
|
||||||
|
}
|
||||||
|
.navigationBarHidden(true)
|
||||||
|
.presentationDetents([.large])
|
||||||
|
.presentationDragIndicator(.visible)
|
||||||
|
}
|
||||||
|
// 发布页由上层统一控制
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -4,6 +4,7 @@ import SwiftUI
|
|||||||
struct MomentListItem: View {
|
struct MomentListItem: View {
|
||||||
let moment: MomentsInfo
|
let moment: MomentsInfo
|
||||||
let onImageTap: (([String], Int)) -> Void // 新增:图片点击回调
|
let onImageTap: (([String], Int)) -> Void // 新增:图片点击回调
|
||||||
|
let onMomentTap: (MomentsInfo) -> Void // 新增:整体点击回调
|
||||||
|
|
||||||
// 新增:点赞相关状态
|
// 新增:点赞相关状态
|
||||||
@State private var isLikeLoading = false
|
@State private var isLikeLoading = false
|
||||||
@@ -12,112 +13,134 @@ struct MomentListItem: View {
|
|||||||
|
|
||||||
init(
|
init(
|
||||||
moment: MomentsInfo,
|
moment: MomentsInfo,
|
||||||
onImageTap: @escaping (([String], Int)) -> Void = { (arg) in let (_, _) = arg; }
|
onImageTap: @escaping (([String], Int)) -> Void = { (arg) in let (_, _) = arg; },
|
||||||
|
onMomentTap: @escaping (MomentsInfo) -> Void = { _ in }
|
||||||
) {
|
) {
|
||||||
self.moment = moment
|
self.moment = moment
|
||||||
self.onImageTap = onImageTap
|
self.onImageTap = onImageTap
|
||||||
|
self.onMomentTap = onMomentTap
|
||||||
// 初始化本地状态
|
// 初始化本地状态
|
||||||
self._localIsLike = State(initialValue: moment.isLike)
|
self._localIsLike = State(initialValue: moment.isLike)
|
||||||
self._localLikeCount = State(initialValue: moment.likeCount)
|
self._localLikeCount = State(initialValue: moment.likeCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
let isReviewing = moment.status == 0
|
||||||
// 背景层
|
|
||||||
RoundedRectangle(cornerRadius: 12)
|
ZStack(alignment: .bottomTrailing) {
|
||||||
.fill(Color.clear)
|
ZStack {
|
||||||
.overlay(
|
RoundedRectangle(cornerRadius: 12)
|
||||||
RoundedRectangle(cornerRadius: 12)
|
.fill(Color.clear)
|
||||||
.stroke(Color.white.opacity(0.1), lineWidth: 1)
|
.overlay(
|
||||||
)
|
RoundedRectangle(cornerRadius: 12)
|
||||||
.shadow(color: Color(red: 0.43, green: 0.43, blue: 0.43, opacity: 0.34), radius: 10.7, x: 0, y: 1.9)
|
.stroke(Color.white.opacity(0.1), lineWidth: 1)
|
||||||
|
|
||||||
// 内容层
|
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
|
||||||
// 用户信息
|
|
||||||
HStack(alignment: .top) {
|
|
||||||
// 头像
|
|
||||||
CachedAsyncImage(url: moment.avatar) { image in
|
|
||||||
image
|
|
||||||
.resizable()
|
|
||||||
.aspectRatio(contentMode: .fill)
|
|
||||||
} placeholder: {
|
|
||||||
Circle()
|
|
||||||
.fill(Color.gray.opacity(0.3))
|
|
||||||
.overlay(
|
|
||||||
Text(String(moment.nick.prefix(1)))
|
|
||||||
.font(.system(size: 16, weight: .medium))
|
|
||||||
.foregroundColor(.white)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.frame(width: 40, height: 40)
|
|
||||||
.clipShape(Circle())
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
|
||||||
Text(moment.nick)
|
|
||||||
.font(.system(size: 16, weight: .medium))
|
|
||||||
.foregroundColor(.white)
|
|
||||||
UserIDDisplay(uid: moment.uid, fontSize: 12, textColor: .white.opacity(0.6))
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
// 时间
|
|
||||||
Text(formatDisplayTime(moment.publishTime))
|
|
||||||
.font(.system(size: 12, weight: .bold))
|
|
||||||
.foregroundColor(.white.opacity(0.8))
|
|
||||||
.padding(.horizontal, 6)
|
|
||||||
.padding(.vertical, 2)
|
|
||||||
.background(Color.white.opacity(0.15))
|
|
||||||
.cornerRadius(4)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 动态内容
|
|
||||||
if !moment.content.isEmpty {
|
|
||||||
Text(moment.content)
|
|
||||||
.font(.system(size: 14))
|
|
||||||
.foregroundColor(.white.opacity(0.9))
|
|
||||||
.multilineTextAlignment(.leading)
|
|
||||||
.padding(.leading, 40 + 8) // 与用户名左边对齐
|
|
||||||
}
|
|
||||||
|
|
||||||
// 图片网格
|
|
||||||
if let images = moment.dynamicResList, !images.isEmpty {
|
|
||||||
MomentImageGrid(
|
|
||||||
images: images,
|
|
||||||
onImageTap: onImageTap
|
|
||||||
)
|
)
|
||||||
.padding(.leading, 40 + 8)
|
.shadow(color: Color(red: 0.43, green: 0.43, blue: 0.43, opacity: 0.34), radius: 10.7, x: 0, y: 1.9)
|
||||||
.padding(.bottom, images.count == 2 ? 30 : 0) // 两张图片时增加底部间距
|
|
||||||
}
|
// 内容层
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
// 互动按钮
|
// 用户信息
|
||||||
HStack(spacing: 20) {
|
HStack(alignment: .top) {
|
||||||
// Like 按钮与用户名左侧对齐
|
// 头像
|
||||||
Button(action: {
|
CachedAsyncImage(url: moment.avatar) { image in
|
||||||
if !isLikeLoading {
|
image
|
||||||
handleLikeTap()
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fill)
|
||||||
|
} placeholder: {
|
||||||
|
Circle()
|
||||||
|
.fill(Color.gray.opacity(0.3))
|
||||||
|
.overlay(
|
||||||
|
Text(String(moment.nick.prefix(1)))
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}) {
|
.frame(width: 40, height: 40)
|
||||||
HStack(spacing: 4) {
|
.clipShape(Circle())
|
||||||
if isLikeLoading {
|
|
||||||
ProgressView()
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
.progressViewStyle(CircularProgressViewStyle(tint: localIsLike ? .red : .white.opacity(0.8)))
|
Text(moment.nick)
|
||||||
.scaleEffect(0.8)
|
.font(.system(size: 16, weight: .medium))
|
||||||
} else {
|
.foregroundColor(.white)
|
||||||
Image(systemName: localIsLike ? "heart.fill" : "heart")
|
UserIDDisplay(uid: moment.uid, fontSize: 12, textColor: .white.opacity(0.6))
|
||||||
.font(.system(size: 16))
|
|
||||||
}
|
|
||||||
Text("\(localLikeCount)")
|
|
||||||
.font(.system(size: 14))
|
|
||||||
}
|
}
|
||||||
.foregroundColor(localIsLike ? .red : .white.opacity(0.8))
|
Spacer()
|
||||||
|
// 时间
|
||||||
|
Text(formatDisplayTime(moment.publishTime))
|
||||||
|
.font(.system(size: 12, weight: .bold))
|
||||||
|
.foregroundColor(.white.opacity(0.8))
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(Color.white.opacity(0.15))
|
||||||
|
.cornerRadius(4)
|
||||||
}
|
}
|
||||||
.disabled(isLikeLoading)
|
|
||||||
.padding(.leading, 40 + 8) // 与用户名左侧对齐(头像宽度 + 间距)
|
// 动态内容
|
||||||
Spacer()
|
if !moment.content.isEmpty {
|
||||||
|
Text(moment.content)
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundColor(.white.opacity(0.9))
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
.padding(.leading, 40 + 8) // 与用户名左边对齐
|
||||||
|
}
|
||||||
|
|
||||||
|
// 图片网格
|
||||||
|
if let images = moment.dynamicResList, !images.isEmpty {
|
||||||
|
MomentImageGrid(
|
||||||
|
images: images,
|
||||||
|
onImageTap: onImageTap
|
||||||
|
)
|
||||||
|
.padding(.leading, 40 + 8)
|
||||||
|
.padding(.bottom, images.count == 2 ? 30 : 0) // 两张图片时增加底部间距
|
||||||
|
}
|
||||||
|
|
||||||
|
// 互动按钮
|
||||||
|
HStack(spacing: 20) {
|
||||||
|
// Like 按钮与用户名左侧对齐
|
||||||
|
Button(action: {
|
||||||
|
if !isLikeLoading && !isReviewing {
|
||||||
|
handleLikeTap()
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
if isLikeLoading {
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(CircularProgressViewStyle(tint: localIsLike ? .red : .white.opacity(0.8)))
|
||||||
|
.scaleEffect(0.8)
|
||||||
|
} else {
|
||||||
|
Image(systemName: localIsLike ? "heart.fill" : "heart")
|
||||||
|
.font(.system(size: 16))
|
||||||
|
}
|
||||||
|
Text("\(localLikeCount)")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
}
|
||||||
|
.foregroundColor(localIsLike ? .red : .white.opacity(0.8))
|
||||||
|
}
|
||||||
|
.disabled(isLikeLoading || isReviewing)
|
||||||
|
.opacity(isReviewing ? 0.5 : 1.0)
|
||||||
|
.padding(.leading, 40 + 8) // 与用户名左侧对齐(头像宽度 + 间距)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// 审核中状态角标 - 右侧对齐日期,垂直居中对齐点赞按钮
|
||||||
|
if isReviewing {
|
||||||
|
Text("reviewing")
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(Color.orange.opacity(0.85))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
}
|
}
|
||||||
.padding(.top, 8)
|
.padding(16)
|
||||||
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture {
|
||||||
|
onMomentTap(moment)
|
||||||
}
|
}
|
||||||
.padding(16)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
123
yana/MVVM/View/NineGridImagePicker.swift
Normal file
123
yana/MVVM/View/NineGridImagePicker.swift
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import PhotosUI
|
||||||
|
|
||||||
|
struct NineGridImagePicker: View {
|
||||||
|
@Binding var images: [UIImage]
|
||||||
|
var maxCount: Int = 9
|
||||||
|
var cornerRadius: CGFloat = 16
|
||||||
|
var spacing: CGFloat = 8
|
||||||
|
var horizontalPadding: CGFloat = 20
|
||||||
|
var onTapImage: (Int) -> Void = { _ in }
|
||||||
|
|
||||||
|
@State private var pickerItems: [PhotosPickerItem] = []
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { geometry in
|
||||||
|
let columns = Array(repeating: GridItem(.flexible(), spacing: spacing), count: 3)
|
||||||
|
let columnsCount: CGFloat = 3
|
||||||
|
let totalSpacing = spacing * (columnsCount - 1)
|
||||||
|
let availableWidth = geometry.size.width - horizontalPadding * 2
|
||||||
|
let cellSide = (availableWidth - totalSpacing) / columnsCount
|
||||||
|
|
||||||
|
LazyVGrid(columns: columns, spacing: spacing) {
|
||||||
|
ForEach(0..<maxCount, id: \.self) { index in
|
||||||
|
ZStack {
|
||||||
|
// 占位背景(仅 DEBUG 可见)
|
||||||
|
#if DEBUG
|
||||||
|
if index >= images.count && !(index == images.count && images.count < maxCount) {
|
||||||
|
RoundedRectangle(cornerRadius: cornerRadius)
|
||||||
|
.fill(Color.white.opacity(0.08))
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
if index < images.count {
|
||||||
|
// 图片格子
|
||||||
|
ZStack(alignment: .topTrailing) {
|
||||||
|
Image(uiImage: images[index])
|
||||||
|
.resizable()
|
||||||
|
.scaledToFill()
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.contentShape(RoundedRectangle(cornerRadius: cornerRadius))
|
||||||
|
.onTapGesture { onTapImage(index) }
|
||||||
|
|
||||||
|
Button {
|
||||||
|
removeImage(at: index)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.background(Circle().fill(Color.black.opacity(0.4)))
|
||||||
|
.font(.system(size: 16, weight: .bold))
|
||||||
|
}
|
||||||
|
.padding(6)
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
} else if index == images.count && images.count < maxCount {
|
||||||
|
// 添加按钮格子
|
||||||
|
PhotosPicker(
|
||||||
|
selection: $pickerItems,
|
||||||
|
maxSelectionCount: maxCount - images.count,
|
||||||
|
selectionBehavior: .ordered,
|
||||||
|
matching: .images
|
||||||
|
) {
|
||||||
|
ZStack {
|
||||||
|
RoundedRectangle(cornerRadius: cornerRadius)
|
||||||
|
.fill(Color(hex: 0x1C143A))
|
||||||
|
Image(systemName: "plus")
|
||||||
|
.foregroundColor(.white.opacity(0.6))
|
||||||
|
.font(.system(size: 32, weight: .semibold))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: pickerItems) { _, newItems in
|
||||||
|
handlePickerItems(newItems)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: cellSide)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
|
||||||
|
.contentShape(RoundedRectangle(cornerRadius: cornerRadius))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, horizontalPadding)
|
||||||
|
}
|
||||||
|
.frame(height: gridHeight(forCount: max(images.count, 1)))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func gridHeight(forCount count: Int) -> CGFloat {
|
||||||
|
// 通过一个近似:用屏幕宽度估算高度以确保父布局正确测量。
|
||||||
|
// 每行 3 个,行数 = ceil(count / 3.0)。在 GeometryReader 中真实高度会覆盖此近似。
|
||||||
|
let screenWidth = UIScreen.main.bounds.width
|
||||||
|
let columnsCount: CGFloat = 3
|
||||||
|
let totalSpacing = spacing * (columnsCount - 1)
|
||||||
|
let availableWidth = screenWidth - horizontalPadding * 2
|
||||||
|
let side = (availableWidth - totalSpacing) / columnsCount
|
||||||
|
let rows = ceil(CGFloat(count) / 3.0)
|
||||||
|
let totalRowSpacing = spacing * max(rows - 1, 0)
|
||||||
|
return side * rows + totalRowSpacing
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handlePickerItems(_ items: [PhotosPickerItem]) {
|
||||||
|
guard !items.isEmpty else { return }
|
||||||
|
Task { @MainActor in
|
||||||
|
var appended: [UIImage] = []
|
||||||
|
for item in items {
|
||||||
|
if images.count + appended.count >= maxCount { break }
|
||||||
|
if let data = try? await item.loadTransferable(type: Data.self),
|
||||||
|
let image = UIImage(data: data) {
|
||||||
|
appended.append(image)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !appended.isEmpty {
|
||||||
|
images.append(contentsOf: appended)
|
||||||
|
}
|
||||||
|
pickerItems = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func removeImage(at index: Int) {
|
||||||
|
guard images.indices.contains(index) else { return }
|
||||||
|
images.remove(at: index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@@ -56,9 +56,9 @@ class MainViewModel: ObservableObject {
|
|||||||
func onTopRightButtonTapped() {
|
func onTopRightButtonTapped() {
|
||||||
switch selectedTab {
|
switch selectedTab {
|
||||||
case .feed:
|
case .feed:
|
||||||
onAddButtonTapped?()
|
navigationPath.append(AppRoute.publish)
|
||||||
case .me:
|
case .me:
|
||||||
navigationPath.append("setting")
|
navigationPath.append(AppRoute.setting)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
88
yana/MVVM/ViewModel/MePageViewModel.swift
Normal file
88
yana/MVVM/ViewModel/MePageViewModel.swift
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class MePageViewModel: ObservableObject {
|
||||||
|
@Published var userId: Int = 0
|
||||||
|
@Published var nickname: String = ""
|
||||||
|
@Published var avatarURL: String = ""
|
||||||
|
|
||||||
|
@Published var moments: [MomentsInfo] = []
|
||||||
|
@Published var isLoading: Bool = false
|
||||||
|
@Published var isLoadingMore: Bool = false
|
||||||
|
@Published var errorMessage: String? = nil
|
||||||
|
@Published var hasMore: Bool = true
|
||||||
|
|
||||||
|
private var page: Int = 1
|
||||||
|
private let pageSize: Int = 20
|
||||||
|
|
||||||
|
func onAppear() {
|
||||||
|
Task { @MainActor in
|
||||||
|
await loadCurrentUser()
|
||||||
|
// 仅首次或空列表时加载,避免每次 Tab 切换重复请求
|
||||||
|
if moments.isEmpty {
|
||||||
|
await refreshData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshData() async {
|
||||||
|
page = 1
|
||||||
|
hasMore = true
|
||||||
|
errorMessage = nil
|
||||||
|
isLoading = true
|
||||||
|
moments.removeAll()
|
||||||
|
defer { isLoading = false }
|
||||||
|
await fetchMyMoments(page: page)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadMoreData() {
|
||||||
|
guard !isLoadingMore, hasMore else { return }
|
||||||
|
isLoadingMore = true
|
||||||
|
Task { @MainActor in
|
||||||
|
defer { isLoadingMore = false }
|
||||||
|
page += 1
|
||||||
|
await fetchMyMoments(page: page)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadCurrentUser() async {
|
||||||
|
// 从缓存/Keychain 获取当前登录用户信息
|
||||||
|
if let account = await UserInfoManager.getAccountModel() {
|
||||||
|
if let uidString = account.uid, let uid = Int(uidString) {
|
||||||
|
userId = uid
|
||||||
|
}
|
||||||
|
// 优先从缓存的 UserInfo 获取更完整的信息
|
||||||
|
if let info = await UserInfoManager.getUserInfo() {
|
||||||
|
nickname = info.nick ?? nickname
|
||||||
|
avatarURL = info.avatar ?? avatarURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 兜底
|
||||||
|
if nickname.isEmpty { nickname = "未知用户" }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchMyMoments(page: Int) async {
|
||||||
|
guard userId > 0 else {
|
||||||
|
errorMessage = "未登录或用户ID无效"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let api: any APIServiceProtocol & Sendable = LiveAPIService()
|
||||||
|
let request = GetMyDynamicRequest(fromUid: userId, uid: userId, page: page, pageSize: pageSize)
|
||||||
|
do {
|
||||||
|
let response = try await api.request(request)
|
||||||
|
if let list = response.data {
|
||||||
|
let items = list.map { $0.toMomentsInfo() }
|
||||||
|
if items.isEmpty { hasMore = false }
|
||||||
|
moments.append(contentsOf: items)
|
||||||
|
} else {
|
||||||
|
hasMore = false
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
114
yana/MVVM/ViewModel/MomentDetailViewModel.swift
Normal file
114
yana/MVVM/ViewModel/MomentDetailViewModel.swift
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
// MARK: - MomentDetailViewModel
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class MomentDetailViewModel: ObservableObject {
|
||||||
|
// MARK: - Published Properties
|
||||||
|
@Published var moment: MomentsInfo
|
||||||
|
@Published var isLikeLoading = false
|
||||||
|
@Published var localIsLike: Bool
|
||||||
|
@Published var localLikeCount: Int
|
||||||
|
@Published var showImagePreview = false
|
||||||
|
@Published var images: [String] = []
|
||||||
|
@Published var currentIndex: Int = 0
|
||||||
|
|
||||||
|
// MARK: - Private Properties
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
init(moment: MomentsInfo) {
|
||||||
|
self.moment = moment
|
||||||
|
self.localIsLike = moment.isLike
|
||||||
|
self.localLikeCount = moment.likeCount
|
||||||
|
self.images = moment.dynamicResList?.compactMap { $0.resUrl } ?? []
|
||||||
|
|
||||||
|
debugInfoSync("📱 MomentDetailViewModel: 初始化")
|
||||||
|
debugInfoSync(" 动态ID: \(moment.dynamicId)")
|
||||||
|
debugInfoSync(" 用户: \(moment.nick)")
|
||||||
|
debugInfoSync(" 图片数量: \(images.count)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Public Methods
|
||||||
|
func onImageTap(_ index: Int) {
|
||||||
|
currentIndex = index
|
||||||
|
showImagePreview = true
|
||||||
|
debugInfoSync("📸 MomentDetailViewModel: 图片被点击,索引: \(index)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func like() {
|
||||||
|
guard !isLikeLoading, moment.status != 0 else {
|
||||||
|
debugInfoSync("⏸️ MomentDetailViewModel: 跳过点赞 - 正在加载: \(isLikeLoading), 审核中: \(moment.status == 0)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isLikeLoading = true
|
||||||
|
debugInfoSync("📡 MomentDetailViewModel: 开始点赞操作")
|
||||||
|
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
// 获取当前用户ID
|
||||||
|
guard let uidStr = await UserInfoManager.getCurrentUserId(),
|
||||||
|
let uid = Int(uidStr) else {
|
||||||
|
await MainActor.run {
|
||||||
|
isLikeLoading = false
|
||||||
|
}
|
||||||
|
setAPILoadingErrorSync(UUID(), errorMessage: "无法获取用户信息,请重新登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确定请求参数
|
||||||
|
let status = localIsLike ? 0 : 1 // 0: 取消点赞, 1: 点赞
|
||||||
|
|
||||||
|
// 创建 API 服务实例
|
||||||
|
let api = LiveAPIService()
|
||||||
|
|
||||||
|
// 创建请求
|
||||||
|
let request = LikeDynamicRequest(
|
||||||
|
dynamicId: moment.dynamicId,
|
||||||
|
uid: uid,
|
||||||
|
status: status,
|
||||||
|
likedUid: moment.uid,
|
||||||
|
worldId: moment.worldId
|
||||||
|
)
|
||||||
|
|
||||||
|
debugInfoSync("📡 MomentDetailViewModel: 发送点赞请求")
|
||||||
|
debugInfoSync(" 动态ID: \(moment.dynamicId)")
|
||||||
|
debugInfoSync(" 当前状态: \(localIsLike)")
|
||||||
|
debugInfoSync(" 请求状态: \(status)")
|
||||||
|
|
||||||
|
// 发起请求
|
||||||
|
let response: LikeDynamicResponse = try await api.request(request)
|
||||||
|
|
||||||
|
await MainActor.run {
|
||||||
|
isLikeLoading = false
|
||||||
|
// 处理响应
|
||||||
|
if response.code == 200 {
|
||||||
|
localIsLike.toggle()
|
||||||
|
localLikeCount += localIsLike ? 1 : -1
|
||||||
|
debugInfoSync("✅ MomentDetailViewModel: 点赞操作成功")
|
||||||
|
debugInfoSync(" 动态ID: \(moment.dynamicId)")
|
||||||
|
debugInfoSync(" 新状态: \(localIsLike)")
|
||||||
|
debugInfoSync(" 新数量: \(localLikeCount)")
|
||||||
|
} else {
|
||||||
|
let errorMessage = response.message.isEmpty ? "点赞失败,请重试" : response.message
|
||||||
|
setAPILoadingErrorSync(UUID(), errorMessage: errorMessage)
|
||||||
|
debugErrorSync("❌ MomentDetailViewModel: 点赞操作失败")
|
||||||
|
debugErrorSync(" 动态ID: \(moment.dynamicId)")
|
||||||
|
debugErrorSync(" 错误: \(errorMessage)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
await MainActor.run {
|
||||||
|
isLikeLoading = false
|
||||||
|
}
|
||||||
|
setAPILoadingErrorSync(UUID(), errorMessage: error.localizedDescription)
|
||||||
|
debugErrorSync("❌ MomentDetailViewModel: 点赞请求异常")
|
||||||
|
debugErrorSync(" 动态ID: \(moment.dynamicId)")
|
||||||
|
debugErrorSync(" 错误: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -90,10 +90,10 @@
|
|||||||
"createFeed.processingImages" = "Processing images...";
|
"createFeed.processingImages" = "Processing images...";
|
||||||
"createFeed.publishing" = "Publishing...";
|
"createFeed.publishing" = "Publishing...";
|
||||||
"createFeed.publish" = "Publish";
|
"createFeed.publish" = "Publish";
|
||||||
"createFeed.title" = "Image & Text Publish";
|
"createFeed.title" = "Image & Text";
|
||||||
|
|
||||||
// MARK: - Edit Feed
|
// MARK: - Edit Feed
|
||||||
"editFeed.title" = "Image & Text Edit";
|
"editFeed.title" = "Image & Text";
|
||||||
"editFeed.publish" = "Publish";
|
"editFeed.publish" = "Publish";
|
||||||
"editFeed.enterContent" = "Enter Content";
|
"editFeed.enterContent" = "Enter Content";
|
||||||
|
|
||||||
@@ -225,4 +225,4 @@
|
|||||||
"config.version" = "Version";
|
"config.version" = "Version";
|
||||||
"config.debug_mode" = "Debug Mode";
|
"config.debug_mode" = "Debug Mode";
|
||||||
"config.api_timeout" = "API Timeout";
|
"config.api_timeout" = "API Timeout";
|
||||||
"config.max_retries" = "Max Retries";
|
"config.max_retries" = "Max Retries";
|
||||||
|
11
yana/Utils/Navigation/AppRoute.swift
Normal file
11
yana/Utils/Navigation/AppRoute.swift
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 应用统一路由定义
|
||||||
|
enum AppRoute: Hashable {
|
||||||
|
case login
|
||||||
|
case main
|
||||||
|
case setting
|
||||||
|
case publish
|
||||||
|
}
|
||||||
|
|
||||||
|
|
38
yana/Utils/Network/APIService+Combine.swift
Normal file
38
yana/Utils/Network/APIService+Combine.swift
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import Foundation
|
||||||
|
@preconcurrency import Combine
|
||||||
|
|
||||||
|
// 以 @unchecked Sendable 包装 Future 的 promise,安全地跨并发域传递
|
||||||
|
private final class PromiseBox<Output, Failure: Error>: @unchecked Sendable {
|
||||||
|
private let fulfill: (Result<Output, Failure>) -> Void
|
||||||
|
init(_ fulfill: @escaping (Result<Output, Failure>) -> Void) { self.fulfill = fulfill }
|
||||||
|
func complete(_ result: Result<Output, Failure>) { fulfill(result) }
|
||||||
|
}
|
||||||
|
|
||||||
|
extension APIServiceProtocol {
|
||||||
|
/// 将 async/await 的请求桥接为 Combine Publisher
|
||||||
|
/// - Parameter request: 符合 APIRequestProtocol 的请求对象
|
||||||
|
/// - Returns: AnyPublisher<T.Response, APIError>
|
||||||
|
func requestPublisher<T: APIRequestProtocol>(_ request: T) -> AnyPublisher<T.Response, APIError> {
|
||||||
|
Deferred {
|
||||||
|
Future { promise in
|
||||||
|
let box = PromiseBox<T.Response, APIError>(promise)
|
||||||
|
Task(priority: .userInitiated) {
|
||||||
|
let result: Result<T.Response, APIError>
|
||||||
|
do {
|
||||||
|
let value = try await self.request(request)
|
||||||
|
result = .success(value)
|
||||||
|
} catch let apiError as APIError {
|
||||||
|
result = .failure(apiError)
|
||||||
|
} catch {
|
||||||
|
result = .failure(.unknown(error.localizedDescription))
|
||||||
|
}
|
||||||
|
box.complete(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
54
yana/Utils/Network/DeviceContext.swift
Normal file
54
yana/Utils/Network/DeviceContext.swift
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import Foundation
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
struct DeviceContext: Sendable {
|
||||||
|
let languageCode: String
|
||||||
|
let osName: String
|
||||||
|
let osVersion: String
|
||||||
|
let deviceModel: String
|
||||||
|
let deviceId: String
|
||||||
|
let appName: String
|
||||||
|
let appVersion: String
|
||||||
|
let channel: String
|
||||||
|
let screenScale: String
|
||||||
|
|
||||||
|
static let shared: DeviceContext = {
|
||||||
|
// 仅在主线程读取一次 UIKit/Bundle 信息
|
||||||
|
let language = Locale.current.language.languageCode?.identifier ?? "en"
|
||||||
|
let osName = "iOS"
|
||||||
|
let osVersion = UIDevice.current.systemVersion
|
||||||
|
let deviceModel = UIDevice.current.model
|
||||||
|
let deviceId = UIDevice.current.identifierForVendor?.uuidString ?? UUID().uuidString
|
||||||
|
let appName = Bundle.main.infoDictionary?["CFBundleName"] as? String ?? "eparty"
|
||||||
|
let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0"
|
||||||
|
#if DEBUG
|
||||||
|
let channel = "molistar_enterprise"
|
||||||
|
#else
|
||||||
|
let channel = "appstore"
|
||||||
|
#endif
|
||||||
|
let scale = String(format: "%.2f", Double(UIScreen.main.scale))
|
||||||
|
|
||||||
|
return DeviceContext(
|
||||||
|
languageCode: language,
|
||||||
|
osName: osName,
|
||||||
|
osVersion: osVersion,
|
||||||
|
deviceModel: deviceModel,
|
||||||
|
deviceId: deviceId,
|
||||||
|
appName: appName,
|
||||||
|
appVersion: appVersion,
|
||||||
|
channel: channel,
|
||||||
|
screenScale: scale
|
||||||
|
)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
enum UserAgentProvider {
|
||||||
|
@MainActor
|
||||||
|
static func userAgent() -> String {
|
||||||
|
let ctx = DeviceContext.shared
|
||||||
|
return "\(ctx.appName)/\(ctx.appVersion) (\(ctx.deviceModel); \(ctx.osName) \(ctx.osVersion); Scale/\(ctx.screenScale))"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
33
yana/Utils/Network/NetworkMonitor.swift
Normal file
33
yana/Utils/Network/NetworkMonitor.swift
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import Foundation
|
||||||
|
import Network
|
||||||
|
|
||||||
|
/// 监听系统网络状态并缓存最近结果
|
||||||
|
/// WiFi=2, 蜂窝=1, 未知/无网络=0(与现有代码语义对齐)
|
||||||
|
final class NetworkMonitor: @unchecked Sendable {
|
||||||
|
static let shared = NetworkMonitor()
|
||||||
|
private let monitor = NWPathMonitor()
|
||||||
|
private let queue = DispatchQueue(label: "com.yana.network.monitor")
|
||||||
|
private var _currentType: Int = 2 // 默认与历史保持一致
|
||||||
|
var currentType: Int { _currentType }
|
||||||
|
private init() {
|
||||||
|
monitor.pathUpdateHandler = { [weak self] path in
|
||||||
|
guard let self = self else { return }
|
||||||
|
let type: Int
|
||||||
|
if path.status == .satisfied {
|
||||||
|
if path.usesInterfaceType(.wifi) { type = 2 }
|
||||||
|
else if path.usesInterfaceType(.cellular) { type = 1 }
|
||||||
|
else { type = 0 }
|
||||||
|
} else {
|
||||||
|
type = 0
|
||||||
|
}
|
||||||
|
// 更新缓存(主线程或任一队列均可,这里选择主线程与 UI 一致)
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
self?._currentType = type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
monitor.start(queue: queue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@@ -12,11 +12,10 @@ import Security
|
|||||||
/// - 完善的错误处理
|
/// - 完善的错误处理
|
||||||
/// - 线程安全操作
|
/// - 线程安全操作
|
||||||
/// - 可配置的访问控制级别
|
/// - 可配置的访问控制级别
|
||||||
@MainActor
|
final class KeychainManager: @unchecked Sendable {
|
||||||
final class KeychainManager {
|
|
||||||
|
|
||||||
// MARK: - 单例
|
// MARK: - 单例
|
||||||
@MainActor static let shared = KeychainManager()
|
static let shared = KeychainManager()
|
||||||
private init() {}
|
private init() {}
|
||||||
|
|
||||||
// MARK: - 配置常量
|
// MARK: - 配置常量
|
||||||
|
32
yana/Utils/Security/SigningKeyProvider.swift
Normal file
32
yana/Utils/Security/SigningKeyProvider.swift
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 提供 API 签名密钥的统一入口
|
||||||
|
/// - 优先从 Info.plist 读取键 `API_SIGNING_KEY`
|
||||||
|
/// - Debug 环境下若缺失,回退到历史密钥以避免开发阶段中断,同时输出告警
|
||||||
|
/// - Release 环境下若缺失,输出错误并返回空字符串(应在发布前配置)
|
||||||
|
enum SigningKeyProvider {
|
||||||
|
/// Info.plist 中的键名
|
||||||
|
private static let plistKey = "API_SIGNING_KEY"
|
||||||
|
|
||||||
|
/// 获取签名密钥
|
||||||
|
static func signingKey() -> String {
|
||||||
|
if let key = Bundle.main.object(forInfoDictionaryKey: plistKey) as? String,
|
||||||
|
!key.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
// 仅在 Debug 回退,避免打断本地调试;请尽快在 Info.plist 配置 API_SIGNING_KEY
|
||||||
|
let legacy = "rpbs6us1m8r2j9g6u06ff2bo18orwaya"
|
||||||
|
debugWarnSync("⚠️ API_SIGNING_KEY 未配置,Debug 使用历史回退密钥(请尽快配置 Info.plist)")
|
||||||
|
return legacy
|
||||||
|
#else
|
||||||
|
debugErrorSync("❌ 缺少 API_SIGNING_KEY,请在 Info.plist 中配置")
|
||||||
|
assertionFailure("Missing API_SIGNING_KEY in Info.plist")
|
||||||
|
return ""
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@@ -1,63 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
import ComposableArchitecture
|
|
||||||
|
|
||||||
struct AppRootView: View {
|
|
||||||
@State private var isLoggedIn = false
|
|
||||||
@State private var mainStore: StoreOf<MainFeature>?
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Group {
|
|
||||||
if isLoggedIn {
|
|
||||||
if let mainStore = mainStore {
|
|
||||||
MainView(store: mainStore)
|
|
||||||
.onAppear {
|
|
||||||
debugInfoSync("🔄 AppRootView: 使用已存在的MainStore")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 修复:确保store被正确创建和保存
|
|
||||||
let store = createMainStore()
|
|
||||||
MainView(store: store)
|
|
||||||
.onAppear {
|
|
||||||
debugInfoSync("💾 AppRootView: MainStore已创建并保存")
|
|
||||||
// 确保在onAppear中保存store
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.mainStore = store
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
LoginView(
|
|
||||||
store: Store(
|
|
||||||
initialState: LoginFeature.State()
|
|
||||||
) {
|
|
||||||
LoginFeature()
|
|
||||||
},
|
|
||||||
onLoginSuccess: {
|
|
||||||
debugInfoSync("🔐 AppRootView: 登录成功,准备创建MainStore")
|
|
||||||
isLoggedIn = true
|
|
||||||
// 登录成功后立即创建store
|
|
||||||
mainStore = createMainStore()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onAppear {
|
|
||||||
debugInfoSync("🚀 AppRootView onAppear")
|
|
||||||
debugInfoSync(" isLoggedIn: \(isLoggedIn)")
|
|
||||||
debugInfoSync(" mainStore存在: \(mainStore != nil)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func createMainStore() -> StoreOf<MainFeature> {
|
|
||||||
debugInfoSync("🏗️ AppRootView: 创建新的MainStore实例")
|
|
||||||
return Store(
|
|
||||||
initialState: MainFeature.State()
|
|
||||||
) {
|
|
||||||
MainFeature()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
//
|
|
||||||
//#Preview {
|
|
||||||
// AppRootView()
|
|
||||||
//}
|
|
@@ -1,78 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
|
|
||||||
// MARK: - Tab 枚举
|
|
||||||
enum Tab: Int, CaseIterable {
|
|
||||||
case feed = 0
|
|
||||||
case me = 1
|
|
||||||
|
|
||||||
var title: String {
|
|
||||||
switch self {
|
|
||||||
case .feed:
|
|
||||||
return "动态"
|
|
||||||
case .me:
|
|
||||||
return "我的"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var iconName: String {
|
|
||||||
switch self {
|
|
||||||
case .feed:
|
|
||||||
return "feed unselected"
|
|
||||||
case .me:
|
|
||||||
return "me unselected"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var selectedIconName: String {
|
|
||||||
switch self {
|
|
||||||
case .feed:
|
|
||||||
return "feed selected"
|
|
||||||
case .me:
|
|
||||||
return "me selected"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - BottomTabView 组件
|
|
||||||
struct BottomTabView: View {
|
|
||||||
@Binding var selectedTab: Tab
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack(spacing: 0) {
|
|
||||||
ForEach(Tab.allCases, id: \.rawValue) { tab in
|
|
||||||
Button(action: {
|
|
||||||
selectedTab = tab
|
|
||||||
}) {
|
|
||||||
Image(selectedTab == tab ? tab.selectedIconName : tab.iconName)
|
|
||||||
.resizable()
|
|
||||||
.aspectRatio(contentMode: .fit)
|
|
||||||
.frame(width: 30, height: 30)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
}
|
|
||||||
.buttonStyle(PlainButtonStyle())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(height: 60)
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 30)
|
|
||||||
.fill(.ultraThinMaterial)
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: 30)
|
|
||||||
.stroke(Color.white.opacity(0.1), lineWidth: 0.5)
|
|
||||||
)
|
|
||||||
.shadow(
|
|
||||||
color: Color.black.opacity(0.34),
|
|
||||||
radius: 10.7,
|
|
||||||
x: 0,
|
|
||||||
y: 1.9
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.padding(.horizontal, 15)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview {
|
|
||||||
BottomTabView(selectedTab: .constant(.feed))
|
|
||||||
.background(Color.purple) // 预览时添加背景色以便查看效果
|
|
||||||
}
|
|
@@ -1,152 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
import ComposableArchitecture
|
|
||||||
|
|
||||||
struct MainView: View {
|
|
||||||
let store: StoreOf<MainFeature>
|
|
||||||
var onLogout: (() -> Void)? = nil
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
WithPerceptionTracking {
|
|
||||||
InternalMainView(store: store)
|
|
||||||
.onChange(of: store.isLoggedOut) { _, isLoggedOut in
|
|
||||||
if isLoggedOut {
|
|
||||||
onLogout?()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct InternalMainView: View {
|
|
||||||
let store: StoreOf<MainFeature>
|
|
||||||
@State private var path: [MainFeature.Destination] = []
|
|
||||||
init(store: StoreOf<MainFeature>) {
|
|
||||||
self.store = store
|
|
||||||
_path = State(initialValue: store.withState { $0.navigationPath })
|
|
||||||
}
|
|
||||||
var body: some View {
|
|
||||||
WithPerceptionTracking {
|
|
||||||
NavigationStack(path: $path) {
|
|
||||||
GeometryReader { geometry in
|
|
||||||
mainContentView(geometry: geometry)
|
|
||||||
.navigationDestination(for: MainFeature.Destination.self) { destination in
|
|
||||||
DestinationView(destination: destination, store: self.store)
|
|
||||||
}
|
|
||||||
.onChange(of: path) { _, path in
|
|
||||||
store.send(.navigationPathChanged(path))
|
|
||||||
}
|
|
||||||
.onChange(of: store.navigationPath) { _, navigationPath in
|
|
||||||
if path != navigationPath {
|
|
||||||
path = navigationPath
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onAppear {
|
|
||||||
debugInfoSync("🚀 MainView onAppear")
|
|
||||||
debugInfoSync(" 当前selectedTab: \(store.selectedTab)")
|
|
||||||
store.send(.onAppear)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct DestinationView: View {
|
|
||||||
let destination: MainFeature.Destination
|
|
||||||
let store: StoreOf<MainFeature>
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
switch destination {
|
|
||||||
case .appSetting:
|
|
||||||
IfLetStore(
|
|
||||||
store.scope(state: \.appSettingState, action: \.appSettingAction),
|
|
||||||
then: { store in
|
|
||||||
WithPerceptionTracking {
|
|
||||||
AppSettingView(store: store)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
else: { Text("appSettingState is nil") }
|
|
||||||
)
|
|
||||||
case .testView:
|
|
||||||
TestView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func mainContentView(geometry: GeometryProxy) -> some View {
|
|
||||||
WithPerceptionTracking {
|
|
||||||
ZStack {
|
|
||||||
// 背景图片
|
|
||||||
Image("bg")
|
|
||||||
.resizable()
|
|
||||||
.aspectRatio(contentMode: .fill)
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
.clipped()
|
|
||||||
.ignoresSafeArea(.all)
|
|
||||||
// 主内容
|
|
||||||
MainContentView(
|
|
||||||
store: store,
|
|
||||||
selectedTab: store.selectedTab
|
|
||||||
)
|
|
||||||
.onChange(of: store.selectedTab) { _, newTab in
|
|
||||||
debugInfoSync("🔄 MainView selectedTab changed: \(newTab)")
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
.padding(.bottom, 80) // 为底部导航栏留出空间
|
|
||||||
|
|
||||||
// 底部导航栏 - 固定在底部
|
|
||||||
VStack {
|
|
||||||
Spacer()
|
|
||||||
BottomTabView(selectedTab: Binding(
|
|
||||||
get: {
|
|
||||||
// 将MainFeature.Tab转换为BottomTabView.Tab
|
|
||||||
let currentTab = store.selectedTab == .feed ? Tab.feed : Tab.me
|
|
||||||
debugInfoSync("🔍 BottomTabView get: MainFeature.Tab.\(store.selectedTab) → BottomTabView.Tab.\(currentTab)")
|
|
||||||
return currentTab
|
|
||||||
},
|
|
||||||
set: { newTab in
|
|
||||||
// 将BottomTabView.Tab转换为MainFeature.Tab
|
|
||||||
let mainTab: MainFeature.Tab = newTab == .feed ? .feed : .other
|
|
||||||
debugInfoSync("🔍 BottomTabView set: BottomTabView.Tab.\(newTab) → MainFeature.Tab.\(mainTab)")
|
|
||||||
store.send(.selectTab(mainTab))
|
|
||||||
}
|
|
||||||
))
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
|
|
||||||
.padding(.bottom, 100)
|
|
||||||
.ignoresSafeArea(.keyboard, edges: .bottom)
|
|
||||||
|
|
||||||
// 添加API Loading和错误处理视图
|
|
||||||
APILoadingEffectView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct MainContentView: View {
|
|
||||||
let store: StoreOf<MainFeature>
|
|
||||||
let selectedTab: MainFeature.Tab
|
|
||||||
var body: some View {
|
|
||||||
WithPerceptionTracking {
|
|
||||||
let _ = debugInfoSync("📱 MainContentView selectedTab: \(selectedTab)")
|
|
||||||
let _ = debugInfoSync(" 与store.selectedTab一致: \(selectedTab == store.selectedTab)")
|
|
||||||
Group {
|
|
||||||
if selectedTab == .feed {
|
|
||||||
FeedListView(store: store.scope(
|
|
||||||
state: \.feedList,
|
|
||||||
action: \.feedList
|
|
||||||
))
|
|
||||||
} else if selectedTab == .other {
|
|
||||||
MeView(
|
|
||||||
store: store.scope(
|
|
||||||
state: \.me,
|
|
||||||
action: \.me
|
|
||||||
),
|
|
||||||
showCloseButton: false // MainView中不需要关闭按钮
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
CustomEmptyView(onRetry: {})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,91 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
import ComposableArchitecture
|
|
||||||
|
|
||||||
struct SplashView: View {
|
|
||||||
let store: StoreOf<SplashFeature>
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ZStack {
|
|
||||||
Group {
|
|
||||||
// 根据导航目标显示不同页面
|
|
||||||
if let navigationDestination = store.navigationDestination {
|
|
||||||
switch navigationDestination {
|
|
||||||
case .login:
|
|
||||||
// 显示登录页面
|
|
||||||
LoginView(
|
|
||||||
store: Store(
|
|
||||||
initialState: LoginFeature.State()
|
|
||||||
) {
|
|
||||||
LoginFeature()
|
|
||||||
},
|
|
||||||
onLoginSuccess: {
|
|
||||||
// 登录成功后导航到主页面
|
|
||||||
store.send(.navigateToMain)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
case .main:
|
|
||||||
// 显示主应用页面
|
|
||||||
MainView(
|
|
||||||
store: Store(
|
|
||||||
initialState: MainFeature.State()
|
|
||||||
) {
|
|
||||||
MainFeature()
|
|
||||||
},
|
|
||||||
onLogout: {
|
|
||||||
store.send(.navigateToLogin)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 显示启动画面
|
|
||||||
splashContent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onAppear {
|
|
||||||
store.send(.onAppear)
|
|
||||||
}
|
|
||||||
|
|
||||||
// API Loading 效果视图 - 显示在所有内容之上
|
|
||||||
APILoadingEffectView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 启动画面内容
|
|
||||||
private var splashContent: some View {
|
|
||||||
ZStack {
|
|
||||||
// 背景图片 - 全屏显示
|
|
||||||
Image("bg")
|
|
||||||
.resizable()
|
|
||||||
.aspectRatio(contentMode: .fill)
|
|
||||||
.ignoresSafeArea(.all)
|
|
||||||
|
|
||||||
VStack(spacing: 32) {
|
|
||||||
Spacer()
|
|
||||||
.frame(height: 200) // 与 storyboard 中的约束对应
|
|
||||||
|
|
||||||
// Logo 图片 - 100x100
|
|
||||||
Image("logo")
|
|
||||||
.resizable()
|
|
||||||
.aspectRatio(contentMode: .fit)
|
|
||||||
.frame(width: 100, height: 100)
|
|
||||||
|
|
||||||
// 应用标题 - 白色,40pt字体
|
|
||||||
Text(LocalizedString("splash.title", comment: "E-Parti"))
|
|
||||||
.font(.system(size: 40, weight: .regular))
|
|
||||||
.foregroundColor(.white)
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//#Preview {
|
|
||||||
// SplashView(
|
|
||||||
// store: Store(
|
|
||||||
// initialState: SplashFeature.State()
|
|
||||||
// ) {
|
|
||||||
// SplashFeature()
|
|
||||||
// }
|
|
||||||
// )
|
|
||||||
//}
|
|
@@ -6,7 +6,6 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import ComposableArchitecture
|
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct yanaApp: App {
|
struct yanaApp: App {
|
||||||
@@ -24,7 +23,8 @@ struct yanaApp: App {
|
|||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
Splash()
|
SplashPage()
|
||||||
|
.ignoresSafeArea(.all)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user