feat: 添加腾讯云COS Token管理功能及相关视图更新

- 在APIEndpoints.swift中新增tcToken端点以支持腾讯云COS Token获取。
- 在APIModels.swift中新增TcTokenRequest和TcTokenResponse模型,处理Token请求和响应。
- 在COSManager.swift中实现Token的获取、缓存和过期管理逻辑,提升API请求的安全性。
- 在LanguageSettingsView中添加调试功能,允许测试COS Token获取。
- 在多个视图中更新状态管理和导航逻辑,确保用户体验一致性。
- 在FeedFeature和HomeFeature中优化状态管理,简化视图逻辑。
This commit is contained in:
edwinQQQ
2025-07-18 20:50:25 +08:00
parent fb7ae9e0ad
commit 9a49d591c3
17 changed files with 1090 additions and 767 deletions

View File

@@ -20,6 +20,7 @@ enum APIEndpoint: String, CaseIterable {
case ticket = "/oauth/ticket" case ticket = "/oauth/ticket"
case emailGetCode = "/email/getCode" // case emailGetCode = "/email/getCode" //
case latestDynamics = "/dynamic/square/latestDynamics" // case latestDynamics = "/dynamic/square/latestDynamics" //
case tcToken = "/tencent/cos/getToken" // COS Token
// Web // Web
case userAgreement = "/modules/rule/protocol.html" case userAgreement = "/modules/rule/protocol.html"
case privacyPolicy = "/modules/rule/privacy-wap.html" case privacyPolicy = "/modules/rule/privacy-wap.html"

View File

@@ -661,3 +661,64 @@ struct APIResponse<T: Codable>: Codable {
// String+MD5 Utils/Extensions/String+MD5.swift // String+MD5 Utils/Extensions/String+MD5.swift
// MARK: - COS Token
/// COS Token
struct TcTokenRequest: APIRequestProtocol {
typealias Response = TcTokenResponse
let endpoint: String = APIEndpoint.tcToken.path
let method: HTTPMethod = .GET
let queryParameters: [String: String]? = nil
var bodyParameters: [String: Any]? { nil }
let timeout: TimeInterval = 30.0
let includeBaseParameters: Bool = true
let shouldShowLoading: Bool = false // loading
let shouldShowError: Bool = false //
}
/// COS Token
struct TcTokenResponse: Codable, Equatable {
let code: Int
let message: String
let data: TcTokenData?
let timestamp: Int64
}
/// COS Token
/// COS
struct TcTokenData: Codable, Equatable {
let bucket: String //
let sessionToken: String //
let region: String //
let customDomain: String //
let accelerate: Bool //
let appId: String // ID
let secretKey: String //
let expireTime: Int64 //
let startTime: Int64 //
let secretId: String // ID
/// Token
var isExpired: Bool {
let currentTime = Int64(Date().timeIntervalSince1970)
return currentTime >= expireTime
}
///
var expirationDate: Date {
return Date(timeIntervalSince1970: TimeInterval(expireTime))
}
///
var startDate: Date {
return Date(timeIntervalSince1970: TimeInterval(startTime))
}
///
var remainingTime: Int64 {
let currentTime = Int64(Date().timeIntervalSince1970)
return max(0, expireTime - currentTime)
}
}

View File

@@ -11,12 +11,11 @@ struct FeedFeature {
var error: String? var error: String?
var nextDynamicId: Int = 0 var nextDynamicId: Int = 0
// // -
var isInitialized = false var isInitialized = false
// CreateFeedView // CreateFeedView -
var isShowingCreateFeed = false var isCreateFeedPresented = false
var createFeedState: CreateFeedFeature.State? = nil
} }
enum Action { enum Action {
@@ -27,11 +26,10 @@ struct FeedFeature {
case clearError case clearError
case retryLoad case retryLoad
// CreateFeedView Action // CreateFeedView Action -
case showCreateFeed case showCreateFeed
case dismissCreateFeed
case createFeedCompleted case createFeedCompleted
indirect case createFeed(CreateFeedFeature.Action) case createFeedDismissed
} }
@Dependency(\.apiService) var apiService @Dependency(\.apiService) var apiService
@@ -40,15 +38,19 @@ struct FeedFeature {
Reduce { state, action in Reduce { state, action in
switch action { switch action {
case .onAppear: case .onAppear:
#if DEBUG //
return .none guard !state.isInitialized else {
#endif return .none
// }
guard !state.isInitialized else { return .none }
state.isInitialized = true
return .send(.loadLatestMoments) return .send(.loadLatestMoments)
case .loadLatestMoments: case .loadLatestMoments:
//
guard !state.isLoading else {
return .none
}
// //
state.isLoading = true state.isLoading = true
state.error = nil state.error = nil
@@ -58,7 +60,6 @@ struct FeedFeature {
pageSize: 20, pageSize: 20,
types: [.text, .picture] types: [.text, .picture]
) )
return .run { send in return .run { send in
await send(.momentsResponse(TaskResult { await send(.momentsResponse(TaskResult {
try await apiService.request(request) try await apiService.request(request)
@@ -87,50 +88,38 @@ struct FeedFeature {
case let .momentsResponse(.success(response)): case let .momentsResponse(.success(response)):
state.isLoading = false state.isLoading = false
// //
debugInfoSync("📱 FeedFeature: API 响应成功") if !state.isInitialized {
debugInfoSync("📱 FeedFeature: response.code = \(response.code)") state.isInitialized = true
debugInfoSync("📱 FeedFeature: response.message = \(response.message)") }
debugInfoSync("📱 FeedFeature: response.data = \(response.data != nil ? "有数据" : "无数据")")
// //
guard response.code == 200, let data = response.data else { guard response.code == 200, let data = response.data else {
let errorMsg = response.message.isEmpty ? "获取动态失败" : response.message let errorMsg = response.message.isEmpty ? "获取动态失败" : response.message
state.error = errorMsg state.error = errorMsg
debugErrorSync("❌ FeedFeature: API 响应失败 - code: \(response.code), message: \(errorMsg)")
return .none return .none
} }
//
debugInfoSync("📱 FeedFeature: data.dynamicList.count = \(data.dynamicList.count)")
debugInfoSync("📱 FeedFeature: data.nextDynamicId = \(data.nextDynamicId)")
// //
let isRefresh = state.nextDynamicId == 0 let isRefresh = state.nextDynamicId == 0
debugInfoSync("📱 FeedFeature: isRefresh = \(isRefresh)")
if isRefresh { if isRefresh {
// //
state.moments = data.dynamicList state.moments = data.dynamicList
debugInfoSync(" FeedFeature: 刷新数据moments.count = \(state.moments.count)")
} else { } else {
// //
let oldCount = state.moments.count
state.moments.append(contentsOf: data.dynamicList) state.moments.append(contentsOf: data.dynamicList)
debugInfoSync(" FeedFeature: 加载更多moments.count: \(oldCount) -> \(state.moments.count)")
} }
// //
state.nextDynamicId = data.nextDynamicId state.nextDynamicId = data.nextDynamicId
state.hasMoreData = !data.dynamicList.isEmpty state.hasMoreData = !data.dynamicList.isEmpty
debugInfoSync("📱 FeedFeature: 更新完成 - nextDynamicId: \(state.nextDynamicId), hasMoreData: \(state.hasMoreData)")
return .none return .none
case let .momentsResponse(.failure(error)): case let .momentsResponse(.failure(error)):
state.isLoading = false state.isLoading = false
state.error = error.localizedDescription state.error = error.localizedDescription
debugErrorSync("❌ FeedFeature: API 请求失败 - \(error.localizedDescription)")
return .none return .none
case .clearError: case .clearError:
@@ -145,30 +134,20 @@ struct FeedFeature {
return .send(.loadMoreMoments) return .send(.loadMoreMoments)
} }
// CreateFeedView Action -
case .showCreateFeed: case .showCreateFeed:
state.isShowingCreateFeed = true state.isCreateFeedPresented = true
// createFeedState
state.createFeedState = CreateFeedFeature.State()
return .none
case .dismissCreateFeed:
state.isShowingCreateFeed = false
state.createFeedState = nil
return .none return .none
case .createFeedCompleted: case .createFeedCompleted:
state.isShowingCreateFeed = false
state.createFeedState = nil
// //
state.isCreateFeedPresented = false
return .send(.loadLatestMoments) return .send(.loadLatestMoments)
case .createFeed:
// Action reducer case .createFeedDismissed:
state.isCreateFeedPresented = false
return .none return .none
} }
} }
// reducer
self.ifLet(\.createFeedState, action: \.createFeed) {
CreateFeedFeature()
}
} }
} }

View File

@@ -42,21 +42,23 @@ struct HomeFeature {
} }
var body: some ReducerOf<Self> { var body: some ReducerOf<Self> {
Scope(state: \ .settingState, action: \ .setting) { Scope(state: \.settingState, action: \.setting) {
SettingFeature() SettingFeature()
} }
// Feed Scope // Feed Scope
Scope(state: \ .feedState, action: \ .feed) { Scope(state: \.feedState, action: \.feed) {
FeedFeature() FeedFeature()
} }
Reduce { state, action in Reduce { state, action in
switch action { switch action {
case .onAppear: case .onAppear:
#if DEBUG //
return .none guard !state.isInitialized else {
#endif return .none
}
state.isInitialized = true state.isInitialized = true
return .concatenate( return .concatenate(
.send(.loadUserInfo), .send(.loadUserInfo),
@@ -106,8 +108,9 @@ struct HomeFeature {
case .setting: case .setting:
// reducer // reducer
return .none return .none
case .feed(_): case .feed(_):
// FeedFeature action Scope // FeedFeature action Scope
return .none return .none
} }
} }

View File

@@ -27,9 +27,8 @@ struct IDLoginFeature {
#if DEBUG #if DEBUG
init() { init() {
// self.userID = "2356814"
self.userID = "" self.password = "a123456"
self.password = ""
} }
#endif #endif
} }

View File

@@ -20,6 +20,9 @@ struct LoginFeature {
var ticketError: String? var ticketError: String?
var loginStep: LoginStep = .initial var loginStep: LoginStep = .initial
// -
var isInitialized = false
// true // true
var isAnyLoginCompleted: Bool { var isAnyLoginCompleted: Bool {
idLoginState.loginStep == .completed || emailLoginState.loginStep == .completed idLoginState.loginStep == .completed || emailLoginState.loginStep == .completed
@@ -43,6 +46,7 @@ struct LoginFeature {
} }
enum Action { enum Action {
case onAppear
case updateAccount(String) case updateAccount(String)
case updatePassword(String) case updatePassword(String)
case login case login
@@ -75,6 +79,19 @@ struct LoginFeature {
Reduce { state, action in Reduce { state, action in
switch action { switch action {
case .onAppear:
//
guard !state.isInitialized else {
debugInfoSync("🚀 LoginFeature: 已初始化,跳过重复执行")
return .none
}
state.isInitialized = true
debugInfoSync("🚀 LoginFeature: 首次初始化")
//
return .none
case let .updateAccount(account): case let .updateAccount(account):
state.account = account state.account = account
return .none return .none
@@ -213,7 +230,9 @@ struct LoginFeature {
state.accountModel = nil // AccountModel state.accountModel = nil // AccountModel
state.loginStep = .initial state.loginStep = .initial
// Effect // Effect
return .run { _ in await UserInfoManager.clearAllAuthenticationData() } return .run { _ in
await UserInfoManager.clearAllAuthenticationData()
}
case .idLogin: case .idLogin:
// IDLoginfeature // IDLoginfeature

View File

@@ -9,6 +9,15 @@ struct SplashFeature {
var shouldShowMainApp = false var shouldShowMainApp = false
var authenticationStatus: UserInfoManager.AuthenticationStatus = .notFound var authenticationStatus: UserInfoManager.AuthenticationStatus = .notFound
var isCheckingAuthentication = false var isCheckingAuthentication = false
//
var navigationDestination: NavigationDestination?
}
//
enum NavigationDestination: Equatable {
case login //
case main //
} }
enum Action: Equatable { enum Action: Equatable {
@@ -16,6 +25,10 @@ struct SplashFeature {
case splashFinished case splashFinished
case checkAuthentication case checkAuthentication
case authenticationChecked(UserInfoManager.AuthenticationStatus) case authenticationChecked(UserInfoManager.AuthenticationStatus)
// actions
case navigateToLogin
case navigateToMain
} }
var body: some ReducerOf<Self> { var body: some ReducerOf<Self> {
@@ -26,6 +39,7 @@ struct SplashFeature {
state.shouldShowMainApp = false state.shouldShowMainApp = false
state.authenticationStatus = UserInfoManager.AuthenticationStatus.notFound state.authenticationStatus = UserInfoManager.AuthenticationStatus.notFound
state.isCheckingAuthentication = false state.isCheckingAuthentication = false
state.navigationDestination = nil
// 1 (iOS 15.5+ ) // 1 (iOS 15.5+ )
return .run { send in return .run { send in
@@ -34,7 +48,6 @@ struct SplashFeature {
} }
case .splashFinished: case .splashFinished:
state.isLoading = false state.isLoading = false
state.shouldShowMainApp = true
// Splash // Splash
return .send(.checkAuthentication) return .send(.checkAuthentication)
@@ -49,20 +62,25 @@ struct SplashFeature {
} }
case let .authenticationChecked(status): case let .authenticationChecked(status):
#if DEBUG
debugInfoSync("🔑 需要手动登录")
return .none
#endif
state.isCheckingAuthentication = false state.isCheckingAuthentication = false
state.authenticationStatus = status state.authenticationStatus = status
// //
if status.canAutoLogin { if status.canAutoLogin {
debugInfoSync("🎉 自动登录成功,进入主页") debugInfoSync("🎉 自动登录成功,进入主页")
return .send(.navigateToMain)
} else { } else {
debugInfoSync("🔑 需要手动登录") debugInfoSync("🔑 需要手动登录")
return .send(.navigateToLogin)
} }
case .navigateToLogin:
state.navigationDestination = .login
return .none
case .navigateToMain:
state.navigationDestination = .main
state.shouldShowMainApp = true
return .none return .none
} }
} }

137
yana/Utils/COSManager.swift Normal file
View File

@@ -0,0 +1,137 @@
import Foundation
import ComposableArchitecture
// MARK: - COS
/// COS
///
/// COS
/// - Token
/// -
/// -
@MainActor
class COSManager: ObservableObject {
static let shared = COSManager()
private init() {}
// MARK: - Token
/// Token
private var cachedToken: TcTokenData?
private var tokenExpirationDate: Date?
/// COS Token
/// - Parameter apiService: API
/// - Returns: Token nil
func getToken(apiService: any APIServiceProtocol & Sendable) async -> TcTokenData? {
//
if let cached = cachedToken, let expiration = tokenExpirationDate, Date() < expiration {
debugInfoSync("🔐 使用缓存的 COS Token")
return cached
}
//
clearCachedToken()
// Token
debugInfoSync("🔐 开始请求腾讯云 COS Token...")
do {
let request = TcTokenRequest()
let response: TcTokenResponse = try await apiService.request(request)
guard response.code == 200, let tokenData = response.data else {
debugInfoSync("❌ COS Token 请求失败: \(response.message)")
return nil
}
// Token
cachedToken = tokenData
tokenExpirationDate = tokenData.expirationDate
debugInfoSync("✅ COS Token 获取成功")
debugInfoSync(" - 存储桶: \(tokenData.bucket)")
debugInfoSync(" - 地域: \(tokenData.region)")
debugInfoSync(" - 过期时间: \(tokenData.expirationDate)")
debugInfoSync(" - 剩余时间: \(tokenData.remainingTime)")
return tokenData
} catch {
debugInfoSync("❌ COS Token 请求异常: \(error.localizedDescription)")
return nil
}
}
/// Token
/// - Parameter tokenData: Token
private func cacheToken(_ tokenData: TcTokenData) async {
cachedToken = tokenData
// expiration ISO 8601
if let expirationDate = ISO8601DateFormatter().date(from: String(tokenData.expireTime)) {
// 5
tokenExpirationDate = expirationDate.addingTimeInterval(-300)
} else {
// 1
tokenExpirationDate = Date().addingTimeInterval(3600)
}
debugInfoSync("💾 COS Token 已缓存,过期时间: \(tokenExpirationDate?.description ?? "未知")")
}
/// Token
private func clearCachedToken() {
cachedToken = nil
tokenExpirationDate = nil
debugInfoSync("🗑️ 清除缓存的 COS Token")
}
/// Token
func refreshToken(apiService: any APIServiceProtocol & Sendable) async -> TcTokenData? {
clearCachedToken()
return await getToken(apiService: apiService)
}
// MARK: -
/// 访 Token
var token: TcTokenData? { cachedToken }
// MARK: -
/// Token
func getTokenStatus() -> String {
if let cached = cachedToken, let expiration = tokenExpirationDate {
let isExpired = Date() >= expiration
return "Token 状态: \(isExpired ? "已过期" : "有效"), 过期时间: \(expiration)"
} else {
return "Token 状态: 未缓存"
}
}
}
// MARK: -
extension COSManager {
/// Token
func testTokenRetrieval(apiService: any APIServiceProtocol & Sendable) async {
#if DEBUG
debugInfoSync("\n🧪 开始测试腾讯云 COS Token 获取功能")
let token = await getToken(apiService: apiService)
if let tokenData = token {
debugInfoSync("✅ Token 获取成功")
debugInfoSync(" bucket: \(tokenData.bucket)")
debugInfoSync(" Expiration: \(tokenData.expireTime)")
debugInfoSync(" Token: \(tokenData.sessionToken.prefix(20))...")
debugInfoSync(" SecretId: \(tokenData.secretId.prefix(20))...")
} else {
debugInfoSync("❌ Token 获取失败")
}
debugInfoSync("📊 Token 状态: \(getTokenStatus())")
debugInfoSync("✅ 腾讯云 COS Token 测试完成\n")
#endif
}
}

View File

@@ -6,170 +6,168 @@ struct CreateFeedView: View {
let store: StoreOf<CreateFeedFeature> let store: StoreOf<CreateFeedFeature>
var body: some View { var body: some View {
WithPerceptionTracking { NavigationStack {
NavigationStack { GeometryReader { geometry in
GeometryReader { geometry in ZStack {
ZStack { //
// LinearGradient(
LinearGradient( gradient: Gradient(colors: [
gradient: Gradient(colors: [ Color(red: 0.1, green: 0.1, blue: 0.2),
Color(red: 0.1, green: 0.1, blue: 0.2), Color(red: 0.2, green: 0.1, blue: 0.3)
Color(red: 0.2, green: 0.1, blue: 0.3) ]),
]), startPoint: .topLeading,
startPoint: .topLeading, endPoint: .bottomTrailing
endPoint: .bottomTrailing )
) .ignoresSafeArea()
.ignoresSafeArea()
ScrollView {
ScrollView { VStack(spacing: 20) {
VStack(spacing: 20) { //
// VStack(alignment: .leading, spacing: 12) {
VStack(alignment: .leading, spacing: 12) { //
// ZStack(alignment: .topLeading) {
ZStack(alignment: .topLeading) { RoundedRectangle(cornerRadius: 12)
RoundedRectangle(cornerRadius: 12) .fill(Color.white.opacity(0.1))
.fill(Color.white.opacity(0.1)) .frame(minHeight: 120)
.frame(minHeight: 120)
if store.content.isEmpty {
if store.content.isEmpty { Text("Enter Content")
Text("Enter Content") .foregroundColor(.white.opacity(0.5))
.foregroundColor(.white.opacity(0.5)) .padding(.horizontal, 16)
.padding(.horizontal, 16) .padding(.vertical, 12)
.padding(.vertical, 12)
}
TextEditor(text: .init(
get: { store.content },
set: { store.send(.contentChanged($0)) }
))
.foregroundColor(.white)
.background(Color.clear)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.scrollContentBackground(.hidden)
} }
// TextEditor(text: .init(
HStack { get: { store.content },
Spacer() set: { store.send(.contentChanged($0)) }
Text("\(store.characterCount)/500") ))
.font(.system(size: 12)) .foregroundColor(.white)
.foregroundColor( .background(Color.clear)
store.characterCount > 500 ? .red : .white.opacity(0.6) .padding(.horizontal, 12)
) .padding(.vertical, 8)
} .scrollContentBackground(.hidden)
}
.padding(.horizontal, 20)
.padding(.top, 20)
//
VStack(alignment: .leading, spacing: 12) {
if !store.processedImages.isEmpty || store.canAddMoreImages {
ModernImageSelectionGrid(
images: store.processedImages,
selectedItems: store.selectedImages,
canAddMore: store.canAddMoreImages,
onItemsChanged: { items in
store.send(.photosPickerItemsChanged(items))
},
onRemoveImage: { index in
store.send(.removeImage(index))
}
)
}
}
.padding(.horizontal, 20)
//
if store.isLoading {
HStack {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
Text("处理图片中...")
.font(.system(size: 14))
.foregroundColor(.white.opacity(0.8))
}
.padding(.top, 10)
} }
// //
if let error = store.errorMessage {
Text(error)
.font(.system(size: 14))
.foregroundColor(.red)
.padding(.horizontal, 20)
.multilineTextAlignment(.center)
}
//
Color.clear.frame(height: geometry.safeAreaInsets.bottom + 100)
}
}
//
VStack {
Spacer()
Button(action: {
store.send(.publishButtonTapped)
}) {
HStack { HStack {
if store.isLoading { Spacer()
ProgressView() Text("\(store.characterCount)/500")
.progressViewStyle(CircularProgressViewStyle(tint: .white)) .font(.system(size: 12))
.scaleEffect(0.8) .foregroundColor(
Text("发布中...") store.characterCount > 500 ? .red : .white.opacity(0.6)
.font(.system(size: 16, weight: .medium)) )
.foregroundColor(.white)
} else {
Text("发布")
.font(.system(size: 16, weight: .medium))
.foregroundColor(.white)
}
} }
.frame(maxWidth: .infinity)
.frame(height: 50)
.background(
LinearGradient(
gradient: Gradient(colors: [
Color.purple,
Color.blue
]),
startPoint: .leading,
endPoint: .trailing
)
)
.cornerRadius(25)
.disabled(store.isLoading || !store.canPublish)
.opacity(store.isLoading || !store.canPublish ? 0.6 : 1.0)
} }
.padding(.horizontal, 20) .padding(.horizontal, 20)
.padding(.bottom, geometry.safeAreaInsets.bottom + 20) .padding(.top, 20)
//
VStack(alignment: .leading, spacing: 12) {
if !store.processedImages.isEmpty || store.canAddMoreImages {
ModernImageSelectionGrid(
images: store.processedImages,
selectedItems: store.selectedImages,
canAddMore: store.canAddMoreImages,
onItemsChanged: { items in
store.send(.photosPickerItemsChanged(items))
},
onRemoveImage: { index in
store.send(.removeImage(index))
}
)
}
}
.padding(.horizontal, 20)
//
if store.isLoading {
HStack {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
Text("处理图片中...")
.font(.system(size: 14))
.foregroundColor(.white.opacity(0.8))
}
.padding(.top, 10)
}
//
if let error = store.errorMessage {
Text(error)
.font(.system(size: 14))
.foregroundColor(.red)
.padding(.horizontal, 20)
.multilineTextAlignment(.center)
}
//
Color.clear.frame(height: geometry.safeAreaInsets.bottom + 100)
} }
} }
}
.navigationTitle("图文发布")
.navigationBarTitleDisplayMode(.inline)
.toolbarBackground(.hidden, for: .navigationBar)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("取消") {
store.send(.dismissView)
}
.foregroundColor(.white)
}
ToolbarItem(placement: .navigationBarTrailing) { //
Button("发布") { VStack {
Spacer()
Button(action: {
store.send(.publishButtonTapped) store.send(.publishButtonTapped)
}) {
HStack {
if store.isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.scaleEffect(0.8)
Text("发布中...")
.font(.system(size: 16, weight: .medium))
.foregroundColor(.white)
} else {
Text("发布")
.font(.system(size: 16, weight: .medium))
.foregroundColor(.white)
}
}
.frame(maxWidth: .infinity)
.frame(height: 50)
.background(
LinearGradient(
gradient: Gradient(colors: [
Color.purple,
Color.blue
]),
startPoint: .leading,
endPoint: .trailing
)
)
.cornerRadius(25)
.disabled(store.isLoading || !store.canPublish)
.opacity(store.isLoading || !store.canPublish ? 0.6 : 1.0)
} }
.foregroundColor(store.canPublish ? .white : .white.opacity(0.5)) .padding(.horizontal, 20)
.disabled(!store.canPublish || store.isLoading) .padding(.bottom, geometry.safeAreaInsets.bottom + 20)
} }
} }
} }
.preferredColorScheme(.dark) .navigationTitle("图文发布")
.navigationBarTitleDisplayMode(.inline)
.toolbarBackground(.hidden, for: .navigationBar)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("取消") {
store.send(.dismissView)
}
.foregroundColor(.white)
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("发布") {
store.send(.publishButtonTapped)
}
.foregroundColor(store.canPublish ? .white : .white.opacity(0.5))
.disabled(!store.canPublish || store.isLoading)
}
}
} }
.preferredColorScheme(.dark)
} }
} }

View File

@@ -5,14 +5,12 @@ import Combine
struct EMailLoginView: View { struct EMailLoginView: View {
let store: StoreOf<EMailLoginFeature> let store: StoreOf<EMailLoginFeature>
let onBack: () -> Void let onBack: () -> Void
@Binding var showEmailLogin: Bool
// 使@StateUI
@State private var email: String = "" @State private var email: String = ""
@State private var verificationCode: String = "" @State private var verificationCode: String = ""
@State private var codeCountdown: Int = 0 @State private var codeCountdown: Int = 0
@State private var timerCancellable: AnyCancellable? @State private var timerCancellable: AnyCancellable?
//
@FocusState private var focusedField: Field? @FocusState private var focusedField: Field?
enum Field { enum Field {
@@ -20,12 +18,9 @@ struct EMailLoginView: View {
case verificationCode case verificationCode
} }
//
private var isLoginButtonEnabled: Bool { private var isLoginButtonEnabled: Bool {
return !store.isLoading && !email.isEmpty && !verificationCode.isEmpty return !store.isLoading && !email.isEmpty && !verificationCode.isEmpty
} }
//
private var getCodeButtonText: String { private var getCodeButtonText: String {
if store.isCodeLoading { if store.isCodeLoading {
return "" return ""
@@ -35,188 +30,38 @@ struct EMailLoginView: View {
return NSLocalizedString("email_login.get_code", comment: "") return NSLocalizedString("email_login.get_code", comment: "")
} }
} }
//
private var isCodeButtonEnabled: Bool { private var isCodeButtonEnabled: Bool {
return !store.isCodeLoading && codeCountdown == 0 return !store.isCodeLoading && codeCountdown == 0
} }
var body: some View { var body: some View {
// WithViewStore(store, observe: { $0 }) { _ in WithViewStore(store, observe: { $0.loginStep }) { viewStore in
GeometryReader { geometry in LoginContentView(
WithPerceptionTracking { store: store,
ZStack { onBack: onBack,
// email: $email,
Image("bg") verificationCode: $verificationCode,
.resizable() codeCountdown: $codeCountdown,
.aspectRatio(contentMode: .fill) focusedField: $focusedField,
.ignoresSafeArea(.all) isLoginButtonEnabled: isLoginButtonEnabled,
getCodeButtonText: getCodeButtonText,
VStack(spacing: 0) { isCodeButtonEnabled: isCodeButtonEnabled
// )
HStack { .onChange(of: viewStore.state) { newStep in
Button(action: { debugInfoSync("🔄 EMailLoginView: loginStep 变化为 \(newStep)")
onBack() if newStep == .completed {
}) { debugInfoSync("✅ EMailLoginView: 登录成功,准备关闭自身")
Image(systemName: "chevron.left") showEmailLogin = false
.font(.system(size: 24, weight: .medium))
.foregroundColor(.white)
.frame(width: 44, height: 44)
}
Spacer()
}
.padding(.horizontal, 16)
.padding(.top, 8)
Spacer()
.frame(height: 60)
//
Text(NSLocalizedString("email_login.title", comment: ""))
.font(.system(size: 28, weight: .medium))
.foregroundColor(.white)
.padding(.bottom, 80)
//
VStack(spacing: 24) {
//
ZStack {
RoundedRectangle(cornerRadius: 25)
.fill(Color.white.opacity(0.1))
.overlay(
RoundedRectangle(cornerRadius: 25)
.stroke(Color.white.opacity(0.3), lineWidth: 1)
)
.frame(height: 56)
TextField("", text: $email)
.placeholder(when: email.isEmpty) {
Text(NSLocalizedString("placeholder.enter_email", comment: ""))
.foregroundColor(.white.opacity(0.6))
}
.foregroundColor(.white)
.font(.system(size: 16))
.padding(.horizontal, 24)
.keyboardType(.emailAddress)
.autocapitalization(.none)
.disableAutocorrection(true)
.focused($focusedField, equals: .email)
}
//
ZStack {
RoundedRectangle(cornerRadius: 25)
.fill(Color.white.opacity(0.1))
.overlay(
RoundedRectangle(cornerRadius: 25)
.stroke(Color.white.opacity(0.3), lineWidth: 1)
)
.frame(height: 56)
HStack {
TextField("", text: $verificationCode)
.placeholder(when: verificationCode.isEmpty) {
Text(NSLocalizedString("placeholder.enter_verification_code", comment: ""))
.foregroundColor(.white.opacity(0.6))
}
.foregroundColor(.white)
.font(.system(size: 16))
.keyboardType(.numberPad)
.focused($focusedField, equals: .verificationCode)
//
Button(action: {
// API
store.send(.getVerificationCodeTapped)
//
startCountdown()
}) {
ZStack {
if store.isCodeLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.scaleEffect(0.7)
} else {
Text(getCodeButtonText)
.font(.system(size: 14, weight: .medium))
.foregroundColor(.white)
}
}
.frame(width: 60, height: 36)
.background(
RoundedRectangle(cornerRadius: 18)
.fill(Color.white.opacity(isCodeButtonEnabled && !email.isEmpty ? 0.2 : 0.1))
)
}
.disabled(!isCodeButtonEnabled || email.isEmpty || store.isCodeLoading)
}
.padding(.horizontal, 24)
}
}
.padding(.horizontal, 32)
Spacer()
.frame(height: 60)
//
Button(action: {
store.send(.loginButtonTapped(email: email, verificationCode: verificationCode))
}) {
ZStack {
//
LinearGradient(
colors: [
Color(red: 0.85, green: 0.37, blue: 1.0), // #D85EFF
Color(red: 0.54, green: 0.31, blue: 1.0) // #8A4FFF
],
startPoint: .leading,
endPoint: .trailing
)
.clipShape(RoundedRectangle(cornerRadius: 28))
HStack {
if store.isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.scaleEffect(0.8)
}
Text(store.isLoading ? NSLocalizedString("email_login.logging_in", comment: "") : NSLocalizedString("email_login.login_button", comment: ""))
.font(.system(size: 18, weight: .semibold))
.foregroundColor(.white)
}
}
.frame(height: 56)
}
.disabled(store.isLoading || email.isEmpty || verificationCode.isEmpty)
.opacity(isLoginButtonEnabled ? 1.0 : 0.5)
.padding(.horizontal, 32)
//
if let errorMessage = store.errorMessage {
Text(errorMessage)
.font(.system(size: 14))
.foregroundColor(.red)
.padding(.top, 16)
.padding(.horizontal, 32)
}
Spacer()
}
} }
} }
} }
// }
.onAppear { .onAppear {
let _ = WithPerceptionTracking { let _ = WithPerceptionTracking {
//
store.send(.resetState) store.send(.resetState)
email = "" email = ""
verificationCode = "" verificationCode = ""
codeCountdown = 0 codeCountdown = 0
stopCountdown() stopCountdown()
#if DEBUG #if DEBUG
email = "exzero@126.com" email = "exzero@126.com"
store.send(.emailChanged(email)) store.send(.emailChanged(email))
@@ -240,7 +85,6 @@ struct EMailLoginView: View {
} }
.onChange(of: store.isCodeLoading) { isCodeLoading in .onChange(of: store.isCodeLoading) { isCodeLoading in
let _ = WithPerceptionTracking { let _ = WithPerceptionTracking {
// API
if !isCodeLoading && store.errorMessage == nil { if !isCodeLoading && store.errorMessage == nil {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
focusedField = .verificationCode focusedField = .verificationCode
@@ -250,14 +94,9 @@ struct EMailLoginView: View {
} }
} }
// MARK: -
private func startCountdown() { private func startCountdown() {
stopCountdown() stopCountdown()
//
codeCountdown = 60 codeCountdown = 60
// 使 SwiftUI Timer.publish
timerCancellable = Timer.publish(every: 1.0, on: .main, in: .common) timerCancellable = Timer.publish(every: 1.0, on: .main, in: .common)
.autoconnect() .autoconnect()
.sink { _ in .sink { _ in
@@ -268,13 +107,159 @@ struct EMailLoginView: View {
} }
} }
} }
private func stopCountdown() { private func stopCountdown() {
timerCancellable?.cancel() timerCancellable?.cancel()
timerCancellable = nil timerCancellable = nil
} }
} }
private struct LoginContentView: View {
let store: StoreOf<EMailLoginFeature>
let onBack: () -> Void
@Binding var email: String
@Binding var verificationCode: String
@Binding var codeCountdown: Int
@FocusState.Binding var focusedField: EMailLoginView.Field?
let isLoginButtonEnabled: Bool
let getCodeButtonText: String
let isCodeButtonEnabled: Bool
var body: some View {
GeometryReader { geometry in
WithPerceptionTracking {
ZStack {
Image("bg")
.resizable()
.aspectRatio(contentMode: .fill)
.ignoresSafeArea(.all)
VStack(spacing: 0) {
HStack {
Button(action: {
onBack()
}) {
Image(systemName: "chevron.left")
.font(.system(size: 24, weight: .medium))
.foregroundColor(.white)
.frame(width: 44, height: 44)
}
Spacer()
}
.padding(.horizontal, 16)
.padding(.top, 8)
Spacer().frame(height: 60)
Text(NSLocalizedString("email_login.title", comment: ""))
.font(.system(size: 28, weight: .medium))
.foregroundColor(.white)
.padding(.bottom, 80)
VStack(spacing: 24) {
ZStack {
RoundedRectangle(cornerRadius: 25)
.fill(Color.white.opacity(0.1))
.overlay(
RoundedRectangle(cornerRadius: 25)
.stroke(Color.white.opacity(0.3), lineWidth: 1)
)
.frame(height: 56)
TextField("", text: $email)
.placeholder(when: email.isEmpty) {
Text(NSLocalizedString("placeholder.enter_email", comment: ""))
.foregroundColor(.white.opacity(0.6))
}
.foregroundColor(.white)
.font(.system(size: 16))
.padding(.horizontal, 24)
.keyboardType(.emailAddress)
.focused($focusedField, equals: .email)
}
ZStack {
RoundedRectangle(cornerRadius: 25)
.fill(Color.white.opacity(0.1))
.overlay(
RoundedRectangle(cornerRadius: 25)
.stroke(Color.white.opacity(0.3), lineWidth: 1)
)
.frame(height: 56)
HStack {
TextField("", text: $verificationCode)
.placeholder(when: verificationCode.isEmpty) {
Text(NSLocalizedString("placeholder.enter_code", comment: ""))
.foregroundColor(.white.opacity(0.6))
}
.foregroundColor(.white)
.font(.system(size: 16))
.keyboardType(.numberPad)
.focused($focusedField, equals: .verificationCode)
Button(action: {
store.send(.getVerificationCodeTapped)
}) {
ZStack {
if store.isCodeLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.scaleEffect(0.7)
} else {
Text(getCodeButtonText)
.font(.system(size: 14, weight: .medium))
.foregroundColor(.white)
}
}
.frame(width: 60, height: 36)
.background(
RoundedRectangle(cornerRadius: 18)
.fill(Color.white.opacity(isCodeButtonEnabled && !email.isEmpty ? 0.2 : 0.1))
)
}
.disabled(!isCodeButtonEnabled || email.isEmpty || store.isCodeLoading)
}
.padding(.horizontal, 24)
}
}
.padding(.horizontal, 32)
Spacer().frame(height: 60)
Button(action: {
store.send(.loginButtonTapped(email: email, verificationCode: verificationCode))
}) {
ZStack {
LinearGradient(
colors: [
Color(red: 0.85, green: 0.37, blue: 1.0),
Color(red: 0.54, green: 0.31, blue: 1.0)
],
startPoint: .leading,
endPoint: .trailing
)
.clipShape(RoundedRectangle(cornerRadius: 28))
HStack {
if store.isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.scaleEffect(0.8)
}
Text(store.isLoading ? NSLocalizedString("email_login.logging_in", comment: "") : NSLocalizedString("email_login.login_button", comment: ""))
.font(.system(size: 18, weight: .semibold))
.foregroundColor(.white)
}
}
.frame(height: 56)
}
.disabled(store.isLoading || email.isEmpty || verificationCode.isEmpty)
.opacity(isLoginButtonEnabled ? 1.0 : 0.5)
.padding(.horizontal, 32)
if let errorMessage = store.errorMessage {
Text(errorMessage)
.font(.system(size: 14))
.foregroundColor(.red)
.padding(.top, 16)
.padding(.horizontal, 32)
}
Spacer()
}
}
}
}
}
}
//#Preview { //#Preview {
// EMailLoginView( // EMailLoginView(
// store: Store( // store: Store(
@@ -282,6 +267,7 @@ struct EMailLoginView: View {
// ) { // ) {
// EMailLoginFeature() // EMailLoginFeature()
// }, // },
// onBack: {} // onBack: {},
// showEmailLogin: .constant(true)
// ) // )
//} //}

View File

@@ -24,34 +24,32 @@ struct FeedTopBarView: View {
struct FeedMomentsListView: View { struct FeedMomentsListView: View {
let store: StoreOf<FeedFeature> let store: StoreOf<FeedFeature>
var body: some View { var body: some View {
WithPerceptionTracking { LazyVStack(spacing: 16) {
LazyVStack(spacing: 16) { if store.moments.isEmpty {
if store.moments.isEmpty { VStack(spacing: 12) {
VStack(spacing: 12) { Image(systemName: "heart.text.square")
Image(systemName: "heart.text.square") .font(.system(size: 40))
.font(.system(size: 40)) .foregroundColor(.white.opacity(0.6))
.foregroundColor(.white.opacity(0.6)) Text("暂无动态内容")
Text("暂无动态内容") .font(.system(size: 16))
.font(.system(size: 16)) .foregroundColor(.white.opacity(0.8))
.foregroundColor(.white.opacity(0.8)) if let error = store.error {
if let error = store.error { Text("错误: \(error)")
Text("错误: \(error)") .font(.system(size: 12))
.font(.system(size: 12)) .foregroundColor(.red.opacity(0.8))
.foregroundColor(.red.opacity(0.8)) .multilineTextAlignment(.center)
.multilineTextAlignment(.center) .padding(.horizontal, 20)
.padding(.horizontal, 20)
}
}
.padding(.top, 40)
} else {
ForEach(Array(store.moments.enumerated()), id: \.element.dynamicId) { index, moment in
OptimizedDynamicCardView(
moment: moment,
allMoments: store.moments,
currentIndex: index
)
} }
} }
.padding(.top, 40)
} else {
ForEach(Array(store.moments.enumerated()), id: \.element.dynamicId) { index, moment in
OptimizedDynamicCardView(
moment: moment,
allMoments: store.moments,
currentIndex: index
)
}
} }
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
@@ -63,50 +61,52 @@ struct FeedView: View {
let store: StoreOf<FeedFeature> let store: StoreOf<FeedFeature>
var body: some View { var body: some View {
WithPerceptionTracking { GeometryReader { geometry in
GeometryReader { geometry in ScrollView {
ScrollView { VStack(spacing: 20) {
VStack(spacing: 20) { FeedTopBarView(store: store)
FeedTopBarView(store: store) Image(systemName: "heart.fill")
Image(systemName: "heart.fill") .font(.system(size: 60))
.font(.system(size: 60)) .foregroundColor(.red)
.foregroundColor(.red) .padding(.top, 40)
.padding(.top, 40) Text("The disease is like a cruel ruler,\nand time is our most precious treasure.\nEvery moment we live is a victory\nagainst the inevitable.")
Text("The disease is like a cruel ruler,\nand time is our most precious treasure.\nEvery moment we live is a victory\nagainst the inevitable.") .font(.system(size: 16))
.font(.system(size: 16)) .multilineTextAlignment(.center)
.multilineTextAlignment(.center) .foregroundColor(.white.opacity(0.9))
.foregroundColor(.white.opacity(0.9)) .padding(.horizontal, 30)
.padding(.horizontal, 30) .padding(.top, 20)
.padding(.top, 20) FeedMomentsListView(store: store)
FeedMomentsListView(store: store) if store.isLoading {
if store.isLoading { HStack {
HStack { ProgressView()
ProgressView() .progressViewStyle(CircularProgressViewStyle(tint: .white))
.progressViewStyle(CircularProgressViewStyle(tint: .white)) Text("加载中...")
Text("加载中...") .font(.system(size: 14))
.font(.system(size: 14)) .foregroundColor(.white.opacity(0.8))
.foregroundColor(.white.opacity(0.8))
}
.padding(.top, 20)
} }
Color.clear.frame(height: geometry.safeAreaInsets.bottom + 100) .padding(.top, 20)
}
}
.refreshable {
store.send(.loadLatestMoments)
}
.onAppear {
// store.send(.onAppear)
}
.sheet(isPresented: .init(
get: { store.isShowingCreateFeed },
set: { _ in store.send(.dismissCreateFeed) }
)) {
if let createFeedStore = store.scope(state: \.createFeedState, action: \.createFeed) {
CreateFeedView(store: createFeedStore)
} }
Color.clear.frame(height: geometry.safeAreaInsets.bottom + 100)
} }
} }
.refreshable {
store.send(.loadLatestMoments)
}
.onAppear {
store.send(.onAppear)
}
}
.sheet(isPresented: Binding(
get: { store.isCreateFeedPresented },
set: { _ in store.send(.createFeedDismissed) }
)) {
CreateFeedView(
store: Store(
initialState: CreateFeedFeature.State()
) {
CreateFeedFeature()
}
)
} }
} }
} }
@@ -296,11 +296,9 @@ struct OptimizedImageGrid: View {
let imageSize: CGFloat = (availableWidth - spacing * 2) / 3 let imageSize: CGFloat = (availableWidth - spacing * 2) / 3
let columns = Array(repeating: GridItem(.fixed(imageSize), spacing: spacing), count: 3) let columns = Array(repeating: GridItem(.fixed(imageSize), spacing: spacing), count: 3)
WithPerceptionTracking { LazyVGrid(columns: columns, spacing: spacing) {
LazyVGrid(columns: columns, spacing: spacing) { ForEach(images.prefix(9), id: \.id) { image in
ForEach(images.prefix(9), id: \.id) { image in SquareImageView(image: image, size: imageSize)
SquareImageView(image: image, size: imageSize)
}
} }
} }
} }
@@ -407,25 +405,23 @@ struct RealDynamicCardView: View {
// //
if let images = moment.dynamicResList, !images.isEmpty { if let images = moment.dynamicResList, !images.isEmpty {
WithPerceptionTracking { LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: min(images.count, 3)), spacing: 8) {
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: min(images.count, 3)), spacing: 8) { ForEach(images.prefix(9), id: \.id) { image in
ForEach(images.prefix(9), id: \.id) { image in AsyncImage(url: URL(string: image.resUrl)) { imageView in
AsyncImage(url: URL(string: image.resUrl)) { imageView in imageView
imageView .resizable()
.resizable() .aspectRatio(contentMode: .fill)
.aspectRatio(contentMode: .fill) } placeholder: {
} placeholder: { Rectangle()
Rectangle() .fill(Color.gray.opacity(0.3))
.fill(Color.gray.opacity(0.3)) .overlay(
.overlay( ProgressView()
ProgressView() .progressViewStyle(CircularProgressViewStyle(tint: .white.opacity(0.6)))
.progressViewStyle(CircularProgressViewStyle(tint: .white.opacity(0.6))) )
)
}
.frame(height: 100)
.clipped()
.cornerRadius(8)
} }
.frame(height: 100)
.clipped()
.cornerRadius(8)
} }
} }
} }
@@ -521,17 +517,15 @@ struct DynamicCardView: View {
.multilineTextAlignment(.leading) .multilineTextAlignment(.leading)
// //
WithPerceptionTracking { LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 3), spacing: 8) {
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 3), spacing: 8) { ForEach(0..<3) { imageIndex in
ForEach(0..<3) { imageIndex in Rectangle()
Rectangle() .fill(Color.gray.opacity(0.3))
.fill(Color.gray.opacity(0.3)) .aspectRatio(1, contentMode: .fit)
.aspectRatio(1, contentMode: .fit) .overlay(
.overlay( Image(systemName: "photo")
Image(systemName: "photo") .foregroundColor(.white.opacity(0.6))
.foregroundColor(.white.opacity(0.6)) )
)
}
} }
} }
@@ -569,10 +563,10 @@ struct DynamicCardView: View {
} }
} }
#Preview { //#Preview {
FeedView( // FeedView(
store: Store(initialState: FeedFeature.State()) { // store: Store(initialState: FeedFeature.State()) {
FeedFeature() // FeedFeature()
} // }
) // )
} //}

View File

@@ -8,59 +8,59 @@ struct HomeView: View {
@State private var selectedTab: Tab = .feed @State private var selectedTab: Tab = .feed
var body: some View { var body: some View {
WithPerceptionTracking { GeometryReader { geometry in
GeometryReader { geometry in ZStack {
// 使 "bg" -
Image("bg")
.resizable()
.aspectRatio(contentMode: .fill)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.clipped()
.ignoresSafeArea(.all)
// -
ZStack { ZStack {
// 使 "bg" - switch selectedTab {
Image("bg") case .feed:
.resizable() NavigationStack {
.aspectRatio(contentMode: .fill)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.clipped()
.ignoresSafeArea(.all)
// -
ZStack {
switch selectedTab {
case .feed:
FeedView( FeedView(
store: store.scope(state: \.feedState, action: \.feed) store: store.scope(state: \.feedState, action: \.feed)
) )
.transition(.opacity)
case .me:
MeView(onLogout: onLogout)
.transition(.opacity)
} }
.transition(.opacity)
case .me:
MeView(onLogout: onLogout)
.transition(.opacity)
} }
.frame(maxWidth: .infinity, maxHeight: .infinity)
// -
VStack {
Spacer()
BottomTabView(selectedTab: $selectedTab)
}
.padding(.bottom, geometry.safeAreaInsets.bottom + 100)
} }
.frame(maxWidth: .infinity, maxHeight: .infinity)
// -
VStack {
Spacer()
BottomTabView(selectedTab: $selectedTab)
}
.padding(.bottom, geometry.safeAreaInsets.bottom + 100)
} }
.onAppear { }
store.send(.onAppear) .onAppear {
} store.send(.onAppear)
.sheet(isPresented: Binding( }
get: { store.isSettingPresented }, .sheet(isPresented: Binding(
set: { _ in store.send(.settingDismissed) } get: { store.isSettingPresented },
)) { set: { _ in store.send(.settingDismissed) }
SettingView(store: store.scope(state: \.settingState, action: \.setting)) )) {
} SettingView(store: store.scope(state: \.settingState, action: \.setting))
} }
} }
} }
#Preview { //#Preview {
HomeView( // HomeView(
store: Store( // store: Store(
initialState: HomeFeature.State() // initialState: HomeFeature.State()
) { // ) {
HomeFeature() // HomeFeature()
}, onLogout: {} // }, onLogout: {}
) // )
} //}

View File

@@ -5,6 +5,7 @@ import Perception
struct IDLoginView: View { struct IDLoginView: View {
let store: StoreOf<IDLoginFeature> let store: StoreOf<IDLoginFeature>
let onBack: () -> Void let onBack: () -> Void
@Binding var showIDLogin: Bool //
// 使@StateUI // 使@StateUI
@State private var userID: String = "" @State private var userID: String = ""
@@ -20,199 +21,209 @@ struct IDLoginView: View {
} }
var body: some View { var body: some View {
GeometryReader { geometry in WithViewStore(store, observe: { $0.loginStep }) { viewStore in
WithPerceptionTracking { GeometryReader { geometry in
ZStack { WithPerceptionTracking {
// - 使"bg" ZStack {
Image("bg") // - 使"bg"
.resizable() Image("bg")
.aspectRatio(contentMode: .fill) .resizable()
.ignoresSafeArea(.all) .aspectRatio(contentMode: .fill)
.ignoresSafeArea(.all)
VStack(spacing: 0) {
// VStack(spacing: 0) {
HStack { //
Button(action: { HStack {
onBack() Button(action: {
}) { onBack()
Image(systemName: "chevron.left") }) {
.font(.system(size: 24, weight: .medium)) Image(systemName: "chevron.left")
.foregroundColor(.white) .font(.system(size: 24, weight: .medium))
.frame(width: 44, height: 44) .foregroundColor(.white)
.frame(width: 44, height: 44)
}
Spacer()
} }
.padding(.horizontal, 16)
.padding(.top, 8)
Spacer() Spacer()
} .frame(height: 60)
.padding(.horizontal, 16)
.padding(.top, 8) //
Text(NSLocalizedString("id_login.title", comment: ""))
Spacer() .font(.system(size: 28, weight: .medium))
.frame(height: 60)
//
Text(NSLocalizedString("id_login.title", comment: ""))
.font(.system(size: 28, weight: .medium))
.foregroundColor(.white)
.padding(.bottom, 80)
//
VStack(spacing: 24) {
// ID
ZStack {
RoundedRectangle(cornerRadius: 25)
.fill(Color.white.opacity(0.1))
.overlay(
RoundedRectangle(cornerRadius: 25)
.stroke(Color.white.opacity(0.3), lineWidth: 1)
)
.frame(height: 56)
TextField("", text: $userID) // 使SwiftUI
.placeholder(when: userID.isEmpty) {
Text(NSLocalizedString("placeholder.enter_id", comment: ""))
.foregroundColor(.white.opacity(0.6))
}
.foregroundColor(.white) .foregroundColor(.white)
.font(.system(size: 16)) .padding(.bottom, 80)
.padding(.horizontal, 24)
.keyboardType(.numberPad) //
VStack(spacing: 24) {
// ID
ZStack {
RoundedRectangle(cornerRadius: 25)
.fill(Color.white.opacity(0.1))
.overlay(
RoundedRectangle(cornerRadius: 25)
.stroke(Color.white.opacity(0.3), lineWidth: 1)
)
.frame(height: 56)
TextField("", text: $userID) // 使SwiftUI
.placeholder(when: userID.isEmpty) {
Text(NSLocalizedString("placeholder.enter_id", comment: ""))
.foregroundColor(.white.opacity(0.6))
}
.foregroundColor(.white)
.font(.system(size: 16))
.padding(.horizontal, 24)
.keyboardType(.numberPad)
}
//
ZStack {
RoundedRectangle(cornerRadius: 25)
.fill(Color.white.opacity(0.1))
.overlay(
RoundedRectangle(cornerRadius: 25)
.stroke(Color.white.opacity(0.3), lineWidth: 1)
)
.frame(height: 56)
HStack {
if isPasswordVisible {
TextField("", text: $password) // 使SwiftUI
.placeholder(when: password.isEmpty) {
Text(NSLocalizedString("placeholder.enter_password", comment: ""))
.foregroundColor(.white.opacity(0.6))
}
.foregroundColor(.white)
.font(.system(size: 16))
} else {
SecureField("", text: $password) // 使SwiftUI
.placeholder(when: password.isEmpty) {
Text(NSLocalizedString("placeholder.enter_password", comment: ""))
.foregroundColor(.white.opacity(0.6))
}
.foregroundColor(.white)
.font(.system(size: 16))
}
Button(action: {
isPasswordVisible.toggle()
}) {
Image(systemName: isPasswordVisible ? "eye.slash" : "eye")
.foregroundColor(.white.opacity(0.7))
.font(.system(size: 18))
}
}
.padding(.horizontal, 24)
}
}
.padding(.horizontal, 32)
// Forgot Password
HStack {
Spacer()
Button(action: {
showRecoverPassword = true
}) {
Text(NSLocalizedString("id_login.forgot_password", comment: ""))
.font(.system(size: 14))
.foregroundColor(.white.opacity(0.8))
}
}
.padding(.horizontal, 32)
.padding(.top, 16)
Spacer()
.frame(height: 60)
//
Button(action: {
// action
store.send(.loginButtonTapped(userID: userID, password: password))
}) {
ZStack {
//
LinearGradient(
colors: [
Color(red: 0.85, green: 0.37, blue: 1.0), // #D85EFF
Color(red: 0.54, green: 0.31, blue: 1.0) // #8A4FFF
],
startPoint: .leading,
endPoint: .trailing
)
.clipShape(RoundedRectangle(cornerRadius: 28))
HStack {
if store.isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.scaleEffect(0.8)
}
Text(store.isLoading ? NSLocalizedString("id_login.logging_in", comment: "") : NSLocalizedString("id_login.login_button", comment: ""))
.font(.system(size: 18, weight: .semibold))
.foregroundColor(.white)
}
}
.frame(height: 56)
}
.disabled(store.isLoading || userID.isEmpty || password.isEmpty)
.opacity(isLoginButtonEnabled ? 1.0 : 0.5) // 50%
.padding(.horizontal, 32)
//
if let errorMessage = store.errorMessage {
Text(errorMessage)
.font(.system(size: 14))
.foregroundColor(.red)
.padding(.top, 16)
.padding(.horizontal, 32)
} }
//
ZStack {
RoundedRectangle(cornerRadius: 25)
.fill(Color.white.opacity(0.1))
.overlay(
RoundedRectangle(cornerRadius: 25)
.stroke(Color.white.opacity(0.3), lineWidth: 1)
)
.frame(height: 56)
HStack {
if isPasswordVisible {
TextField("", text: $password) // 使SwiftUI
.placeholder(when: password.isEmpty) {
Text(NSLocalizedString("placeholder.enter_password", comment: ""))
.foregroundColor(.white.opacity(0.6))
}
.foregroundColor(.white)
.font(.system(size: 16))
} else {
SecureField("", text: $password) // 使SwiftUI
.placeholder(when: password.isEmpty) {
Text(NSLocalizedString("placeholder.enter_password", comment: ""))
.foregroundColor(.white.opacity(0.6))
}
.foregroundColor(.white)
.font(.system(size: 16))
}
Button(action: {
isPasswordVisible.toggle()
}) {
Image(systemName: isPasswordVisible ? "eye.slash" : "eye")
.foregroundColor(.white.opacity(0.7))
.font(.system(size: 18))
}
}
.padding(.horizontal, 24)
}
}
.padding(.horizontal, 32)
// Forgot Password
HStack {
Spacer() Spacer()
Button(action: {
showRecoverPassword = true
}) {
Text(NSLocalizedString("id_login.forgot_password", comment: ""))
.font(.system(size: 14))
.foregroundColor(.white.opacity(0.8))
}
} }
.padding(.horizontal, 32) }
.padding(.top, 16)
Spacer()
.frame(height: 60)
//
Button(action: {
// action
store.send(.loginButtonTapped(userID: userID, password: password))
}) {
ZStack {
//
LinearGradient(
colors: [
Color(red: 0.85, green: 0.37, blue: 1.0), // #D85EFF
Color(red: 0.54, green: 0.31, blue: 1.0) // #8A4FFF
],
startPoint: .leading,
endPoint: .trailing
)
.clipShape(RoundedRectangle(cornerRadius: 28))
HStack {
if store.isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.scaleEffect(0.8)
}
Text(store.isLoading ? NSLocalizedString("id_login.logging_in", comment: "") : NSLocalizedString("id_login.login_button", comment: ""))
.font(.system(size: 18, weight: .semibold))
.foregroundColor(.white)
}
}
.frame(height: 56)
}
.disabled(store.isLoading || userID.isEmpty || password.isEmpty)
.opacity(isLoginButtonEnabled ? 1.0 : 0.5) // 50%
.padding(.horizontal, 32)
//
if let errorMessage = store.errorMessage {
Text(errorMessage)
.font(.system(size: 14))
.foregroundColor(.red)
.padding(.top, 16)
.padding(.horizontal, 32)
}
Spacer()
} }
} }
.navigationBarHidden(true)
// 使 LoginView navigationDestination
.navigationDestination(isPresented: $showRecoverPassword) {
WithPerceptionTracking {
RecoverPasswordView(
store: Store(
initialState: RecoverPasswordFeature.State()
) {
RecoverPasswordFeature()
},
onBack: {
showRecoverPassword = false
}
)
.navigationBarHidden(true)
}
} }
} .onAppear {
.navigationBarHidden(true) let _ = WithPerceptionTracking {
// 使 LoginView navigationDestination // TCA
.navigationDestination(isPresented: $showRecoverPassword) { userID = store.userID
WithPerceptionTracking { password = store.password
RecoverPasswordView( isPasswordVisible = store.isPasswordVisible
store: Store(
initialState: RecoverPasswordFeature.State() #if DEBUG
) { //
RecoverPasswordFeature() debugInfoSync("🐛 Debug模式: 已移除硬编码测试凭据")
}, #endif
onBack: { }
showRecoverPassword = false
}
)
.navigationBarHidden(true)
} }
} //
.onAppear { .onChange(of: viewStore.state) { newStep in
let _ = WithPerceptionTracking { debugInfoSync("🔄 IDLoginView: loginStep 变化为 \(newStep)")
// TCA if newStep == .completed {
userID = store.userID debugInfoSync("✅ IDLoginView: 登录成功,准备关闭自身")
password = store.password showIDLogin = false
isPasswordVisible = store.isPasswordVisible }
#if DEBUG
//
debugInfoSync("🐛 Debug模式: 已移除硬编码测试凭据")
#endif
} }
} }
} }
@@ -225,6 +236,7 @@ struct IDLoginView: View {
// ) { // ) {
// IDLoginFeature() // IDLoginFeature()
// }, // },
// onBack: {} // onBack: {},
// showIDLogin: .constant(true)
// ) // )
//} //}

View File

@@ -3,8 +3,12 @@ import ComposableArchitecture
struct LanguageSettingsView: View { struct LanguageSettingsView: View {
@ObservedObject private var localizationManager = LocalizationManager.shared @ObservedObject private var localizationManager = LocalizationManager.shared
@StateObject private var cosManager = COSManager.shared
@Binding var isPresented: Bool @Binding var isPresented: Bool
// 使 TCA API
@Dependency(\.apiService) private var apiService
init(isPresented: Binding<Bool> = .constant(true)) { init(isPresented: Binding<Bool> = .constant(true)) {
self._isPresented = isPresented self._isPresented = isPresented
} }
@@ -43,10 +47,66 @@ struct LanguageSettingsView: View {
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
#if DEBUG
Section("调试功能") {
Button("测试腾讯云 COS Token") {
Task {
await testCOToken()
}
}
.foregroundColor(.blue)
if let tokenData = cosManager.token {
VStack(alignment: .leading, spacing: 8) {
Text("✅ Token 获取成功")
.font(.headline)
.foregroundColor(.green)
Group {
Text("存储桶: \(tokenData.bucket)")
Text("地域: \(tokenData.region)")
Text("应用ID: \(tokenData.appId)")
Text("自定义域名: \(tokenData.customDomain)")
Text("加速: \(tokenData.accelerate ? "启用" : "禁用")")
Text("过期时间: \(tokenData.expirationDate, style: .date)")
Text("剩余时间: \(tokenData.remainingTime)")
}
.font(.caption)
.foregroundColor(.secondary)
}
.padding(.vertical, 4)
}
}
#endif
} }
.navigationTitle("语言设置 / Language") .navigationTitle("语言设置 / Language")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.navigationBarBackButtonHidden(true) .navigationBarBackButtonHidden(true)
.onAppear {
#if DEBUG
// tcToken API
Task {
await cosManager.testTokenRetrieval(apiService: apiService)
}
#endif
}
}
}
private func testCOToken() async {
do {
let token = await cosManager.getToken(apiService: apiService)
if let token = token {
print("✅ Token 测试成功")
print(" - 存储桶: \(token.bucket)")
print(" - 地域: \(token.region)")
print(" - 剩余时间: \(token.remainingTime)")
} else {
print("❌ Token 测试失败: 未能获取 Token")
}
} catch {
print("❌ Token 测试异常: \(error.localizedDescription)")
} }
} }
} }
@@ -84,6 +144,6 @@ struct LanguageRow: View {
} }
// MARK: - Preview // MARK: - Preview
#Preview { //#Preview {
LanguageSettingsView(isPresented: .constant(true)) // LanguageSettingsView(isPresented: .constant(true))
} //}

View File

@@ -133,7 +133,8 @@ struct LoginView: View {
), ),
onBack: { onBack: {
showIDLogin = false showIDLogin = false
} },
showIDLogin: $showIDLogin // Binding
) )
.navigationBarHidden(true) .navigationBarHidden(true)
} }
@@ -147,7 +148,8 @@ struct LoginView: View {
), ),
onBack: { onBack: {
showEmailLogin = false showEmailLogin = false
} },
showEmailLogin: $showEmailLogin // Binding
) )
.navigationBarHidden(true) .navigationBarHidden(true)
} }
@@ -169,10 +171,20 @@ struct LoginView: View {
) )
// //
.onChange(of: viewStore.state) { completed in .onChange(of: viewStore.state) { completed in
WithPerceptionTracking { if completed {
if completed { onLoginSuccess()
onLoginSuccess() }
} }
// showIDLogin
.onChange(of: showIDLogin) { newValue in
if newValue == false && viewStore.state {
onLoginSuccess()
}
}
// showEmailLogin
.onChange(of: showEmailLogin) { newValue in
if newValue == false && viewStore.state {
onLoginSuccess()
} }
} }
} }

View File

@@ -6,29 +6,40 @@ struct SplashView: View {
var body: some View { var body: some View {
WithPerceptionTracking { WithPerceptionTracking {
ZStack { Group {
// - //
Image("bg") if let navigationDestination = store.navigationDestination {
.resizable() switch navigationDestination {
.aspectRatio(contentMode: .fill) case .login:
.ignoresSafeArea(.all) //
LoginView(
VStack(spacing: 32) { store: Store(
Spacer() initialState: LoginFeature.State()
.frame(height: 200) // storyboard ) {
LoginFeature()
// Logo - 100x100 },
Image("logo") onLoginSuccess: {
.resizable() //
.aspectRatio(contentMode: .fit) store.send(.navigateToMain)
.frame(width: 100, height: 100) }
)
// - 40pt case .main:
Text("E-Parti") //
.font(.system(size: 40, weight: .regular)) HomeView(
.foregroundColor(.white) store: Store(
initialState: HomeFeature.State()
Spacer() ) {
HomeFeature()
},
onLogout: {
//
store.send(.navigateToLogin)
}
)
}
} else {
//
splashContent
} }
} }
.onAppear { .onAppear {
@@ -36,14 +47,43 @@ struct SplashView: View {
} }
} }
} }
//
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("E-Parti")
.font(.system(size: 40, weight: .regular))
.foregroundColor(.white)
Spacer()
}
}
}
} }
#Preview { //#Preview {
SplashView( // SplashView(
store: Store( // store: Store(
initialState: SplashFeature.State() // initialState: SplashFeature.State()
) { // ) {
SplashFeature() // SplashFeature()
} // }
) // )
} //}

View File

@@ -20,13 +20,17 @@ struct yanaApp: App {
// Previews // Previews
} }
#endif #endif
debugInfoSync("🛠 原生URLSession测试开始")
} }
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
AppRootView() SplashView(
store: Store(
initialState: SplashFeature.State()
) {
SplashFeature()
}
)
} }
} }
} }