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

View File

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

View File

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

View File

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

View File

@@ -9,6 +9,15 @@ struct SplashFeature {
var shouldShowMainApp = false
var authenticationStatus: UserInfoManager.AuthenticationStatus = .notFound
var isCheckingAuthentication = false
//
var navigationDestination: NavigationDestination?
}
//
enum NavigationDestination: Equatable {
case login //
case main //
}
enum Action: Equatable {
@@ -16,6 +25,10 @@ struct SplashFeature {
case splashFinished
case checkAuthentication
case authenticationChecked(UserInfoManager.AuthenticationStatus)
// actions
case navigateToLogin
case navigateToMain
}
var body: some ReducerOf<Self> {
@@ -26,6 +39,7 @@ struct SplashFeature {
state.shouldShowMainApp = false
state.authenticationStatus = UserInfoManager.AuthenticationStatus.notFound
state.isCheckingAuthentication = false
state.navigationDestination = nil
// 1 (iOS 15.5+ )
return .run { send in
@@ -34,7 +48,6 @@ struct SplashFeature {
}
case .splashFinished:
state.isLoading = false
state.shouldShowMainApp = true
// Splash
return .send(.checkAuthentication)
@@ -49,20 +62,25 @@ struct SplashFeature {
}
case let .authenticationChecked(status):
#if DEBUG
debugInfoSync("🔑 需要手动登录")
return .none
#endif
state.isCheckingAuthentication = false
state.authenticationStatus = status
//
//
if status.canAutoLogin {
debugInfoSync("🎉 自动登录成功,进入主页")
return .send(.navigateToMain)
} else {
debugInfoSync("🔑 需要手动登录")
return .send(.navigateToLogin)
}
case .navigateToLogin:
state.navigationDestination = .login
return .none
case .navigateToMain:
state.navigationDestination = .main
state.shouldShowMainApp = true
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>
var body: some View {
WithPerceptionTracking {
NavigationStack {
GeometryReader { geometry in
ZStack {
//
LinearGradient(
gradient: Gradient(colors: [
Color(red: 0.1, green: 0.1, blue: 0.2),
Color(red: 0.2, green: 0.1, blue: 0.3)
]),
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.ignoresSafeArea()
ScrollView {
VStack(spacing: 20) {
//
VStack(alignment: .leading, spacing: 12) {
//
ZStack(alignment: .topLeading) {
RoundedRectangle(cornerRadius: 12)
.fill(Color.white.opacity(0.1))
.frame(minHeight: 120)
if store.content.isEmpty {
Text("Enter Content")
.foregroundColor(.white.opacity(0.5))
.padding(.horizontal, 16)
.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)
NavigationStack {
GeometryReader { geometry in
ZStack {
//
LinearGradient(
gradient: Gradient(colors: [
Color(red: 0.1, green: 0.1, blue: 0.2),
Color(red: 0.2, green: 0.1, blue: 0.3)
]),
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.ignoresSafeArea()
ScrollView {
VStack(spacing: 20) {
//
VStack(alignment: .leading, spacing: 12) {
//
ZStack(alignment: .topLeading) {
RoundedRectangle(cornerRadius: 12)
.fill(Color.white.opacity(0.1))
.frame(minHeight: 120)
if store.content.isEmpty {
Text("Enter Content")
.foregroundColor(.white.opacity(0.5))
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
//
HStack {
Spacer()
Text("\(store.characterCount)/500")
.font(.system(size: 12))
.foregroundColor(
store.characterCount > 500 ? .red : .white.opacity(0.6)
)
}
}
.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)
TextEditor(text: .init(
get: { store.content },
set: { store.send(.contentChanged($0)) }
))
.foregroundColor(.white)
.background(Color.clear)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.scrollContentBackground(.hidden)
}
//
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 {
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)
}
Spacer()
Text("\(store.characterCount)/500")
.font(.system(size: 12))
.foregroundColor(
store.characterCount > 500 ? .red : .white.opacity(0.6)
)
}
.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(.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)
}) {
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))
.disabled(!store.canPublish || store.isLoading)
.padding(.horizontal, 20)
.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 {
let store: StoreOf<EMailLoginFeature>
let onBack: () -> Void
@Binding var showEmailLogin: Bool
// 使@StateUI
@State private var email: String = ""
@State private var verificationCode: String = ""
@State private var codeCountdown: Int = 0
@State private var timerCancellable: AnyCancellable?
//
@FocusState private var focusedField: Field?
enum Field {
@@ -20,12 +18,9 @@ struct EMailLoginView: View {
case verificationCode
}
//
private var isLoginButtonEnabled: Bool {
return !store.isLoading && !email.isEmpty && !verificationCode.isEmpty
}
//
private var getCodeButtonText: String {
if store.isCodeLoading {
return ""
@@ -35,188 +30,38 @@ struct EMailLoginView: View {
return NSLocalizedString("email_login.get_code", comment: "")
}
}
//
private var isCodeButtonEnabled: Bool {
return !store.isCodeLoading && codeCountdown == 0
}
var body: some View {
// WithViewStore(store, observe: { $0 }) { _ in
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)
.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()
}
WithViewStore(store, observe: { $0.loginStep }) { viewStore in
LoginContentView(
store: store,
onBack: onBack,
email: $email,
verificationCode: $verificationCode,
codeCountdown: $codeCountdown,
focusedField: $focusedField,
isLoginButtonEnabled: isLoginButtonEnabled,
getCodeButtonText: getCodeButtonText,
isCodeButtonEnabled: isCodeButtonEnabled
)
.onChange(of: viewStore.state) { newStep in
debugInfoSync("🔄 EMailLoginView: loginStep 变化为 \(newStep)")
if newStep == .completed {
debugInfoSync("✅ EMailLoginView: 登录成功,准备关闭自身")
showEmailLogin = false
}
}
}
// }
.onAppear {
let _ = WithPerceptionTracking {
//
store.send(.resetState)
email = ""
verificationCode = ""
codeCountdown = 0
stopCountdown()
#if DEBUG
email = "exzero@126.com"
store.send(.emailChanged(email))
@@ -240,7 +85,6 @@ struct EMailLoginView: View {
}
.onChange(of: store.isCodeLoading) { isCodeLoading in
let _ = WithPerceptionTracking {
// API
if !isCodeLoading && store.errorMessage == nil {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
focusedField = .verificationCode
@@ -250,14 +94,9 @@ struct EMailLoginView: View {
}
}
// MARK: -
private func startCountdown() {
stopCountdown()
//
codeCountdown = 60
// 使 SwiftUI Timer.publish
timerCancellable = Timer.publish(every: 1.0, on: .main, in: .common)
.autoconnect()
.sink { _ in
@@ -268,13 +107,159 @@ struct EMailLoginView: View {
}
}
}
private func stopCountdown() {
timerCancellable?.cancel()
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 {
// EMailLoginView(
// store: Store(
@@ -282,6 +267,7 @@ struct EMailLoginView: View {
// ) {
// EMailLoginFeature()
// },
// onBack: {}
// onBack: {},
// showEmailLogin: .constant(true)
// )
//}

View File

@@ -24,34 +24,32 @@ struct FeedTopBarView: View {
struct FeedMomentsListView: View {
let store: StoreOf<FeedFeature>
var body: some View {
WithPerceptionTracking {
LazyVStack(spacing: 16) {
if store.moments.isEmpty {
VStack(spacing: 12) {
Image(systemName: "heart.text.square")
.font(.system(size: 40))
.foregroundColor(.white.opacity(0.6))
Text("暂无动态内容")
.font(.system(size: 16))
.foregroundColor(.white.opacity(0.8))
if let error = store.error {
Text("错误: \(error)")
.font(.system(size: 12))
.foregroundColor(.red.opacity(0.8))
.multilineTextAlignment(.center)
.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
)
LazyVStack(spacing: 16) {
if store.moments.isEmpty {
VStack(spacing: 12) {
Image(systemName: "heart.text.square")
.font(.system(size: 40))
.foregroundColor(.white.opacity(0.6))
Text("暂无动态内容")
.font(.system(size: 16))
.foregroundColor(.white.opacity(0.8))
if let error = store.error {
Text("错误: \(error)")
.font(.system(size: 12))
.foregroundColor(.red.opacity(0.8))
.multilineTextAlignment(.center)
.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(.horizontal, 16)
@@ -63,50 +61,52 @@ struct FeedView: View {
let store: StoreOf<FeedFeature>
var body: some View {
WithPerceptionTracking {
GeometryReader { geometry in
ScrollView {
VStack(spacing: 20) {
FeedTopBarView(store: store)
Image(systemName: "heart.fill")
.font(.system(size: 60))
.foregroundColor(.red)
.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.")
.font(.system(size: 16))
.multilineTextAlignment(.center)
.foregroundColor(.white.opacity(0.9))
.padding(.horizontal, 30)
.padding(.top, 20)
FeedMomentsListView(store: store)
if store.isLoading {
HStack {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
Text("加载中...")
.font(.system(size: 14))
.foregroundColor(.white.opacity(0.8))
}
.padding(.top, 20)
GeometryReader { geometry in
ScrollView {
VStack(spacing: 20) {
FeedTopBarView(store: store)
Image(systemName: "heart.fill")
.font(.system(size: 60))
.foregroundColor(.red)
.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.")
.font(.system(size: 16))
.multilineTextAlignment(.center)
.foregroundColor(.white.opacity(0.9))
.padding(.horizontal, 30)
.padding(.top, 20)
FeedMomentsListView(store: store)
if store.isLoading {
HStack {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
Text("加载中...")
.font(.system(size: 14))
.foregroundColor(.white.opacity(0.8))
}
Color.clear.frame(height: geometry.safeAreaInsets.bottom + 100)
}
}
.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)
.padding(.top, 20)
}
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 columns = Array(repeating: GridItem(.fixed(imageSize), spacing: spacing), count: 3)
WithPerceptionTracking {
LazyVGrid(columns: columns, spacing: spacing) {
ForEach(images.prefix(9), id: \.id) { image in
SquareImageView(image: image, size: imageSize)
}
LazyVGrid(columns: columns, spacing: spacing) {
ForEach(images.prefix(9), id: \.id) { image in
SquareImageView(image: image, size: imageSize)
}
}
}
@@ -407,25 +405,23 @@ struct RealDynamicCardView: View {
//
if let images = moment.dynamicResList, !images.isEmpty {
WithPerceptionTracking {
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: min(images.count, 3)), spacing: 8) {
ForEach(images.prefix(9), id: \.id) { image in
AsyncImage(url: URL(string: image.resUrl)) { imageView in
imageView
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
Rectangle()
.fill(Color.gray.opacity(0.3))
.overlay(
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white.opacity(0.6)))
)
}
.frame(height: 100)
.clipped()
.cornerRadius(8)
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: min(images.count, 3)), spacing: 8) {
ForEach(images.prefix(9), id: \.id) { image in
AsyncImage(url: URL(string: image.resUrl)) { imageView in
imageView
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
Rectangle()
.fill(Color.gray.opacity(0.3))
.overlay(
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white.opacity(0.6)))
)
}
.frame(height: 100)
.clipped()
.cornerRadius(8)
}
}
}
@@ -521,17 +517,15 @@ struct DynamicCardView: View {
.multilineTextAlignment(.leading)
//
WithPerceptionTracking {
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 3), spacing: 8) {
ForEach(0..<3) { imageIndex in
Rectangle()
.fill(Color.gray.opacity(0.3))
.aspectRatio(1, contentMode: .fit)
.overlay(
Image(systemName: "photo")
.foregroundColor(.white.opacity(0.6))
)
}
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 3), spacing: 8) {
ForEach(0..<3) { imageIndex in
Rectangle()
.fill(Color.gray.opacity(0.3))
.aspectRatio(1, contentMode: .fit)
.overlay(
Image(systemName: "photo")
.foregroundColor(.white.opacity(0.6))
)
}
}
@@ -569,10 +563,10 @@ struct DynamicCardView: View {
}
}
#Preview {
FeedView(
store: Store(initialState: FeedFeature.State()) {
FeedFeature()
}
)
}
//#Preview {
// FeedView(
// store: Store(initialState: FeedFeature.State()) {
// FeedFeature()
// }
// )
//}

View File

@@ -8,59 +8,59 @@ struct HomeView: View {
@State private var selectedTab: Tab = .feed
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 {
// 使 "bg" -
Image("bg")
.resizable()
.aspectRatio(contentMode: .fill)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.clipped()
.ignoresSafeArea(.all)
// -
ZStack {
switch selectedTab {
case .feed:
switch selectedTab {
case .feed:
NavigationStack {
FeedView(
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)
}
.sheet(isPresented: Binding(
get: { store.isSettingPresented },
set: { _ in store.send(.settingDismissed) }
)) {
SettingView(store: store.scope(state: \.settingState, action: \.setting))
}
}
.onAppear {
store.send(.onAppear)
}
.sheet(isPresented: Binding(
get: { store.isSettingPresented },
set: { _ in store.send(.settingDismissed) }
)) {
SettingView(store: store.scope(state: \.settingState, action: \.setting))
}
}
}
#Preview {
HomeView(
store: Store(
initialState: HomeFeature.State()
) {
HomeFeature()
}, onLogout: {}
)
}
//#Preview {
// HomeView(
// store: Store(
// initialState: HomeFeature.State()
// ) {
// HomeFeature()
// }, onLogout: {}
// )
//}

View File

@@ -5,6 +5,7 @@ import Perception
struct IDLoginView: View {
let store: StoreOf<IDLoginFeature>
let onBack: () -> Void
@Binding var showIDLogin: Bool //
// 使@StateUI
@State private var userID: String = ""
@@ -20,199 +21,209 @@ struct IDLoginView: View {
}
var body: some View {
GeometryReader { geometry in
WithPerceptionTracking {
ZStack {
// - 使"bg"
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)
WithViewStore(store, observe: { $0.loginStep }) { viewStore in
GeometryReader { geometry in
WithPerceptionTracking {
ZStack {
// - 使"bg"
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()
}
.padding(.horizontal, 16)
.padding(.top, 8)
Spacer()
.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))
}
.frame(height: 60)
//
Text(NSLocalizedString("id_login.title", comment: ""))
.font(.system(size: 28, weight: .medium))
.foregroundColor(.white)
.font(.system(size: 16))
.padding(.horizontal, 24)
.keyboardType(.numberPad)
.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)
.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()
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)
}
}
}
.navigationBarHidden(true)
// 使 LoginView navigationDestination
.navigationDestination(isPresented: $showRecoverPassword) {
WithPerceptionTracking {
RecoverPasswordView(
store: Store(
initialState: RecoverPasswordFeature.State()
) {
RecoverPasswordFeature()
},
onBack: {
showRecoverPassword = false
}
)
.navigationBarHidden(true)
.onAppear {
let _ = WithPerceptionTracking {
// TCA
userID = store.userID
password = store.password
isPasswordVisible = store.isPasswordVisible
#if DEBUG
//
debugInfoSync("🐛 Debug模式: 已移除硬编码测试凭据")
#endif
}
}
}
.onAppear {
let _ = WithPerceptionTracking {
// TCA
userID = store.userID
password = store.password
isPasswordVisible = store.isPasswordVisible
#if DEBUG
//
debugInfoSync("🐛 Debug模式: 已移除硬编码测试凭据")
#endif
//
.onChange(of: viewStore.state) { newStep in
debugInfoSync("🔄 IDLoginView: loginStep 变化为 \(newStep)")
if newStep == .completed {
debugInfoSync("✅ IDLoginView: 登录成功,准备关闭自身")
showIDLogin = false
}
}
}
}
@@ -225,6 +236,7 @@ struct IDLoginView: View {
// ) {
// IDLoginFeature()
// },
// onBack: {}
// onBack: {},
// showIDLogin: .constant(true)
// )
//}

View File

@@ -3,8 +3,12 @@ import ComposableArchitecture
struct LanguageSettingsView: View {
@ObservedObject private var localizationManager = LocalizationManager.shared
@StateObject private var cosManager = COSManager.shared
@Binding var isPresented: Bool
// 使 TCA API
@Dependency(\.apiService) private var apiService
init(isPresented: Binding<Bool> = .constant(true)) {
self._isPresented = isPresented
}
@@ -43,10 +47,66 @@ struct LanguageSettingsView: View {
.font(.caption)
.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")
.navigationBarTitleDisplayMode(.inline)
.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
#Preview {
LanguageSettingsView(isPresented: .constant(true))
}
//#Preview {
// LanguageSettingsView(isPresented: .constant(true))
//}

View File

@@ -133,7 +133,8 @@ struct LoginView: View {
),
onBack: {
showIDLogin = false
}
},
showIDLogin: $showIDLogin // Binding
)
.navigationBarHidden(true)
}
@@ -147,7 +148,8 @@ struct LoginView: View {
),
onBack: {
showEmailLogin = false
}
},
showEmailLogin: $showEmailLogin // Binding
)
.navigationBarHidden(true)
}
@@ -169,10 +171,20 @@ struct LoginView: View {
)
//
.onChange(of: viewStore.state) { completed in
WithPerceptionTracking {
if completed {
onLoginSuccess()
}
if completed {
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 {
WithPerceptionTracking {
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()
Group {
//
if let navigationDestination = store.navigationDestination {
switch navigationDestination {
case .login:
//
LoginView(
store: Store(
initialState: LoginFeature.State()
) {
LoginFeature()
},
onLoginSuccess: {
//
store.send(.navigateToMain)
}
)
case .main:
//
HomeView(
store: Store(
initialState: HomeFeature.State()
) {
HomeFeature()
},
onLogout: {
//
store.send(.navigateToLogin)
}
)
}
} else {
//
splashContent
}
}
.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 {
SplashView(
store: Store(
initialState: SplashFeature.State()
) {
SplashFeature()
}
)
}
//#Preview {
// SplashView(
// store: Store(
// initialState: SplashFeature.State()
// ) {
// SplashFeature()
// }
// )
//}

View File

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