From 02cd217053702d01a4be2374d104d577b29f19e7 Mon Sep 17 00:00:00 2001 From: jinyaqiu Date: Mon, 1 Sep 2025 19:49:52 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9A=82=E6=8F=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- wake/Assets/Svg/Free.svg | 24 ++ wake/ContentView.swift | 105 +------- wake/Models/MemberProfile.swift | 255 ++++++++++++++++++ wake/Utils/SVGImage.swift | 60 +++-- .../Components/SubscriptionStatusBar.swift | 100 ++++--- wake/View/Subscribe/SubscribeView.swift | 40 ++- 6 files changed, 403 insertions(+), 181 deletions(-) create mode 100644 wake/Assets/Svg/Free.svg create mode 100644 wake/Models/MemberProfile.swift diff --git a/wake/Assets/Svg/Free.svg b/wake/Assets/Svg/Free.svg new file mode 100644 index 0000000..fe90818 --- /dev/null +++ b/wake/Assets/Svg/Free.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/wake/ContentView.swift b/wake/ContentView.swift index 250c013..cf44360 100644 --- a/wake/ContentView.swift +++ b/wake/ContentView.swift @@ -105,36 +105,6 @@ struct BlindBoxView: View { case description } } - // 会员信息 - struct MemberProfile: Codable { - let materialCounter: MaterialCounter - let userInfo: UserInfo - let storiesCount: Int - let conversationsCount: Int - let remainPoints: Int - let totalPoints: Int - let usedBytes: Int - let totalBytes: Int - let titleRankings: [String] - let medalInfos: [MedalInfo] - let membershipLevel: String - let membershipEndAt: String - - enum CodingKeys: String, CodingKey { - case materialCounter = "material_counter" - case userInfo = "user_info" - case storiesCount = "stories_count" - case conversationsCount = "conversations_count" - case remainPoints = "remain_points" - case totalPoints = "total_points" - case usedBytes = "used_bytes" - case totalBytes = "total_bytes" - case titleRankings = "title_rankings" - case medalInfos = "medal_infos" - case membershipLevel = "membership_level" - case membershipEndAt = "membership_end_at" - } - } // 盲盒数量 struct BlindCount: Codable { let availableQuantity: Int @@ -144,59 +114,6 @@ struct BlindBoxView: View { } } - struct MaterialCounter: Codable { - let userId: Int64 - let totalCount: MediaCount - let categoryCount: [String: MediaCount] - - enum CodingKeys: String, CodingKey { - case userId = "user_id" - case totalCount = "total_count" - case categoryCount = "category_count" - } - } - - struct MediaCount: Codable { - let videoCount: Int - let photoCount: Int - let liveCount: Int - let videoLength: Double - let coverUrl: String? - - enum CodingKeys: String, CodingKey { - case videoCount = "video_count" - case photoCount = "photo_count" - case liveCount = "live_count" - case videoLength = "video_length" - case coverUrl = "cover_url" - } - } - - struct UserInfo: Codable { - let userId: String - let accessToken: String - let avatarFileUrl: String? - let nickname: String - let account: String - let email: String - let refreshToken: String? - - enum CodingKeys: String, CodingKey { - case userId = "user_id" - case accessToken = "access_token" - case avatarFileUrl = "avatar_file_url" - case nickname - case account - case email - case refreshToken = "refresh_token" - } - } - - struct MedalInfo: Codable, Identifiable { - let id: Int - let url: String - } - // MARK: - BlindBox Response Model struct BlindBoxData: Codable { @@ -353,15 +270,13 @@ struct BlindBoxView: View { NetworkService.shared.get( path: "/membership/personal-center-info", parameters: nil - ) { (result: Result, NetworkError>) in + ) { (result: Result) in DispatchQueue.main.async { switch result { case .success(let response): self.memberProfile = response.data print("✅ 成功获取会员信息:", response.data) print("✅ 用户ID:", response.data.userInfo.userId) - print("✅ 用户昵称:", response.data.userInfo.nickname) - print("✅ 用户邮箱:", response.data.userInfo.email) case .failure(let error): print("❌ 获取会员信息失败:", error) } @@ -472,24 +387,6 @@ struct BlindBoxView: View { } } - private func loadData() { - // 会员信息 - NetworkService.shared.get( - path: "/membership/personal-center-info", - parameters: nil - ) { (result: Result, NetworkError>) in - DispatchQueue.main.async { - switch result { - case .success(let response): - self.memberProfile = response.data - print("✅ Successfully fetched user info:", response.data) - case .failure(let error): - print("❌ Failed to fetch user info:", error) - } - } - } - } - private func loadImage() { guard !imageURL.isEmpty, let url = URL(string: imageURL) else { print("⚠️ 图片URL无效或为空") diff --git a/wake/Models/MemberProfile.swift b/wake/Models/MemberProfile.swift new file mode 100644 index 0000000..e69d6a4 --- /dev/null +++ b/wake/Models/MemberProfile.swift @@ -0,0 +1,255 @@ +import Foundation + +// MARK: - MemberProfile Response +struct MemberProfileResponse: Codable { + let code: Int + let data: MemberProfile + + enum CodingKeys: String, CodingKey { + case code, data + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(code, forKey: .code) + try container.encode(data, forKey: .data) + } +} + +// MARK: - MemberProfile +struct MemberProfile: Codable { + let materialCounter: MaterialCounter + let userInfo: MemberUserInfo + let storiesCount: Int + let conversationsCount: Int + let remainPoints: Int + let totalPoints: Int + let usedBytes: Int + let totalBytes: Int + let titleRankings: [String] + let medalInfos: [MedalInfo] + let membershipLevel: String + let membershipEndAt: String + + enum CodingKeys: String, CodingKey { + case materialCounter = "material_counter" + case userInfo = "user_info" + case storiesCount = "stories_count" + case conversationsCount = "conversations_count" + case remainPoints = "remain_points" + case totalPoints = "total_points" + case usedBytes = "used_bytes" + case totalBytes = "total_bytes" + case titleRankings = "title_rankings" + case medalInfos = "medal_infos" + case membershipLevel = "membership_level" + case membershipEndAt = "membership_end_at" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + materialCounter = try container.decode(MaterialCounter.self, forKey: .materialCounter) + userInfo = try container.decode(MemberUserInfo.self, forKey: .userInfo) + storiesCount = try container.decode(Int.self, forKey: .storiesCount) + conversationsCount = try container.decode(Int.self, forKey: .conversationsCount) + remainPoints = try container.decode(Int.self, forKey: .remainPoints) + totalPoints = try container.decode(Int.self, forKey: .totalPoints) + usedBytes = try container.decode(Int.self, forKey: .usedBytes) + totalBytes = try container.decode(Int.self, forKey: .totalBytes) + titleRankings = try container.decode([String].self, forKey: .titleRankings) + + if let medalInfos = try? container.decode([MedalInfo].self, forKey: .medalInfos) { + self.medalInfos = medalInfos + } else { + self.medalInfos = [] + } + + membershipLevel = try container.decode(String.self, forKey: .membershipLevel) + membershipEndAt = try container.decode(String.self, forKey: .membershipEndAt) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(materialCounter, forKey: .materialCounter) + try container.encode(userInfo, forKey: .userInfo) + try container.encode(storiesCount, forKey: .storiesCount) + try container.encode(conversationsCount, forKey: .conversationsCount) + try container.encode(remainPoints, forKey: .remainPoints) + try container.encode(totalPoints, forKey: .totalPoints) + try container.encode(usedBytes, forKey: .usedBytes) + try container.encode(totalBytes, forKey: .totalBytes) + try container.encode(titleRankings, forKey: .titleRankings) + try container.encode(medalInfos, forKey: .medalInfos) + try container.encode(membershipLevel, forKey: .membershipLevel) + try container.encode(membershipEndAt, forKey: .membershipEndAt) + } +} + +// MARK: - MemberUserInfo +struct MemberUserInfo: Codable { + let userId: String + let accessToken: String + let avatarFileUrl: String? + let nickname: String + let account: String + let email: String + let refreshToken: String? + + enum CodingKeys: String, CodingKey { + case userId = "user_id" + case accessToken = "access_token" + case avatarFileUrl = "avatar_file_url" + case nickname, account, email + case refreshToken = "refresh_token" + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(userId, forKey: .userId) + try container.encode(accessToken, forKey: .accessToken) + try container.encodeIfPresent(avatarFileUrl, forKey: .avatarFileUrl) + try container.encode(nickname, forKey: .nickname) + try container.encode(account, forKey: .account) + try container.encode(email, forKey: .email) + try container.encodeIfPresent(refreshToken, forKey: .refreshToken) + } +} + +// MARK: - MaterialCounter +struct MaterialCounter: Codable { + let userId: Int64 + let totalCount: TotalCount + let categoryCount: [String: CategoryCount] + + enum CodingKeys: String, CodingKey { + case userId = "user_id" + case totalCount = "total_count" + case categoryCount = "category_count" + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(userId, forKey: .userId) + try container.encode(totalCount, forKey: .totalCount) + try container.encode(categoryCount, forKey: .categoryCount) + } +} + +// MARK: - TotalCount +struct TotalCount: Codable { + let videoCount: Int + let photoCount: Int + let liveCount: Int + let videoLength: Double + let coverUrl: String? + + enum CodingKeys: String, CodingKey { + case videoCount = "video_count" + case photoCount = "photo_count" + case liveCount = "live_count" + case videoLength = "video_length" + case coverUrl = "cover_url" + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(videoCount, forKey: .videoCount) + try container.encode(photoCount, forKey: .photoCount) + try container.encode(liveCount, forKey: .liveCount) + try container.encode(videoLength, forKey: .videoLength) + try container.encodeIfPresent(coverUrl, forKey: .coverUrl) + } +} + +// MARK: - CategoryCount +struct CategoryCount: Codable { + let videoCount: Int + let photoCount: Int + let liveCount: Int + let videoLength: Double + let coverUrl: String? + + enum CodingKeys: String, CodingKey { + case videoCount = "video_count" + case photoCount = "photo_count" + case liveCount = "live_count" + case videoLength = "video_length" + case coverUrl = "cover_url" + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(videoCount, forKey: .videoCount) + try container.encode(photoCount, forKey: .photoCount) + try container.encode(liveCount, forKey: .liveCount) + try container.encode(videoLength, forKey: .videoLength) + try container.encodeIfPresent(coverUrl, forKey: .coverUrl) + } +} + +// MARK: - MedalInfo +struct MedalInfo: Codable, Identifiable { + let id: Int + let url: String + + enum CodingKeys: String, CodingKey { + case id, url + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(url, forKey: .url) + } +} + +// MARK: - API Response Wrapper +struct MemberAPIResponse: Codable { + let code: Int + let message: String + let data: T + + enum CodingKeys: String, CodingKey { + case code, message, data + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(code, forKey: .code) + try container.encode(message, forKey: .message) + try container.encode(data, forKey: .data) + } +} + +// MARK: - Date Formatter +class DateFormatterManager { + static let shared = DateFormatterManager() + + let iso8601Full: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + + private init() {} +} + +// MARK: - JSON Decoder Extension +extension JSONDecoder { + static let `default`: JSONDecoder = { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + decoder.dateDecodingStrategy = .custom { decoder -> Date in + let container = try decoder.singleValueContainer() + let dateString = try container.decode(String.self) + + if let date = DateFormatterManager.shared.iso8601Full.date(from: dateString) { + return date + } + + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Cannot decode date string \(dateString)") + } + return decoder + }() +} diff --git a/wake/Utils/SVGImage.swift b/wake/Utils/SVGImage.swift index a16299d..78ce50d 100644 --- a/wake/Utils/SVGImage.swift +++ b/wake/Utils/SVGImage.swift @@ -3,6 +3,7 @@ import WebKit struct SVGImage: UIViewRepresentable { let svgName: String + var shouldFill: Bool = false func makeUIView(context: Context) -> WKWebView { let webView = WKWebView() @@ -11,20 +12,23 @@ struct SVGImage: UIViewRepresentable { webView.scrollView.isScrollEnabled = false webView.scrollView.contentInsetAdjustmentBehavior = .never - // 1. 获取 SVG 文件路径(注意:移除了 inDirectory 参数) guard let path = Bundle.main.path(forResource: svgName, ofType: "svg") else { print("❌ 无法找到 SVG 文件: \(svgName).svg") - // 打印所有可用的资源文件,用于调试 - if let resourcePath = Bundle.main.resourcePath { - print("可用的资源文件: \(try? FileManager.default.contentsOfDirectory(atPath: resourcePath))") - } return webView } - // 2. 创建文件 URL let fileURL = URL(fileURLWithPath: path) - // 3. 创建 HTML 字符串 + let svgStyle = shouldFill ? """ + width: 100%; + height: 100%; + object-fit: cover; + """ : """ + max-width: 100%; + max-height: 100%; + object-fit: contain; + """ + let htmlString = """ @@ -39,30 +43,50 @@ struct SVGImage: UIViewRepresentable { overflow: hidden; background-color: transparent; } - svg { - width: 100%; - height: 100%; - display: block; + .container { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; } - img { - max-width: 100%; - max-height: 100%; - object-fit: contain; + svg { + \(svgStyle) + display: block; } -
- +
+
""" - // 4. 加载 HTML 字符串 webView.loadHTMLString(htmlString, baseURL: fileURL.deletingLastPathComponent()) return webView } func updateUIView(_ uiView: WKWebView, context: Context) {} + + func sizeThatFits(_ proposal: ProposedViewSize, uiView: WKWebView, context: Context) -> CGSize? { + return nil + } +} + +// MARK: - Preview +#Preview { + VStack { + Text("Filled SVG") + SVGImage(svgName: "YourSVGName", shouldFill: true) + .frame(width: 200, height: 100) + .background(Color.gray.opacity(0.2)) + + Text("Intrinsic Size SVG") + SVGImage(svgName: "YourSVGName", shouldFill: false) + .frame(width: 200, height: 100) + .background(Color.gray.opacity(0.2)) + } + .padding() } \ No newline at end of file diff --git a/wake/View/Subscribe/Components/SubscriptionStatusBar.swift b/wake/View/Subscribe/Components/SubscriptionStatusBar.swift index 94f8823..8040ee4 100644 --- a/wake/View/Subscribe/Components/SubscriptionStatusBar.swift +++ b/wake/View/Subscribe/Components/SubscriptionStatusBar.swift @@ -15,28 +15,19 @@ enum SubscriptionStatus { var title: String { switch self { case .free: - return "Free" + return "" case .pioneer: return "Pioneer" } } - var hasExpiry: Bool { - switch self { - case .free: - return false - case .pioneer: - return true - } - } - var backgroundColor: Color { switch self { case .free: - return Theme.Colors.freeBackground // 浅橙色背景 + return .clear case .pioneer: - return Theme.Colors.pioneerBackground // 橙色背景 - } + return .clear + } } var textColor: Color { @@ -44,7 +35,16 @@ enum SubscriptionStatus { case .free: return Theme.Colors.textPrimary case .pioneer: - return Theme.Colors.textPrimary + return .themeTextMessageMain + } + } + + var backgroundImageName: String { + switch self { + case .free: + return "Free" + case .pioneer: + return "Pioneer" } } } @@ -60,81 +60,73 @@ struct SubscriptionStatusBar: View { } var body: some View { - HStack(spacing: 16) { - VStack(alignment: .leading, spacing: 8) { - // 订阅类型标题 + ZStack(alignment: .topLeading) { + // Background SVG - First layer + SVGImage(svgName: status.backgroundImageName, shouldFill: true) + .frame(maxWidth: .infinity, minHeight: 120) + .clipped() + + // Content - Second layer + VStack(alignment: .leading, spacing: 0) { + Spacer() + + // Subscription title Text(status.title) .font(.system(size: 28, weight: .bold, design: .rounded)) .foregroundColor(status.textColor) + .padding(.leading, 24) - // 过期时间或订阅按钮 + // Expiry date or subscribe button if case .pioneer(let expiryDate) = status { VStack(alignment: .leading, spacing: 4) { - Text("Expires on :") + Text("Expires on:") .font(.system(size: 14, weight: .medium)) - .foregroundColor(status.textColor.opacity(0.8)) + .foregroundColor(status.textColor.opacity(0.9)) Text(formatDate(expiryDate)) .font(.system(size: 16, weight: .semibold)) .foregroundColor(status.textColor) } - } else { - Button(action: { - onSubscribeTap?() - }) { - Text("Subscribe") - .font(.system(size: 14, weight: .semibold)) - .foregroundColor(Theme.Colors.textPrimary) - .padding(.horizontal, 20) - .padding(.vertical, 8) - .background(Theme.Colors.subscribeButton) - .cornerRadius(Theme.CornerRadius.large) - } + .padding(.leading, 24) + .padding(.bottom, 20) } } - - Spacer() - - // 播放按钮图标 - Circle() - .fill(Color.black) - .frame(width: 60, height: 60) - .overlay( - Image(systemName: "play.fill") - .foregroundColor(.white) - .font(.title2) - .offset(x: 2) // 微调播放图标位置 - ) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading) } - .padding(20) - .background(status.backgroundColor) + .frame(height: 155) + .frame(maxWidth: .infinity) .cornerRadius(20) - // .shadow(color: Color.black.opacity(0.1), radius: 4, x: 0, y: 2) + .clipped() } // MARK: - 日期格式化 private func formatDate(_ date: Date) -> String { let formatter = DateFormatter() - formatter.dateFormat = "MMMM d, yyyy" + formatter.dateFormat = "MMM d, yyyy" return formatter.string(from: date) } } // MARK: - 预览 -#Preview("Free Status") { +#Preview { VStack(spacing: 20) { + // Free status preview SubscriptionStatusBar( status: .free, onSubscribeTap: { print("Subscribe tapped") } ) - .padding() + .padding(.horizontal) + // Pioneer status preview SubscriptionStatusBar( - status: .pioneer(expiryDate: Calendar.current.date(byAdding: .month, value: 6, to: Date()) ?? Date()) + status: .pioneer( + expiryDate: Calendar.current.date(byAdding: .month, value: 6, to: Date()) ?? Date() + ) ) - .padding() + .padding(.horizontal) } - .background(Color(.systemGroupedBackground)) + .padding() + .background(Theme.Colors.background) } diff --git a/wake/View/Subscribe/SubscribeView.swift b/wake/View/Subscribe/SubscribeView.swift index 26e8cb4..1c30a7a 100644 --- a/wake/View/Subscribe/SubscribeView.swift +++ b/wake/View/Subscribe/SubscribeView.swift @@ -45,7 +45,7 @@ struct SubscribeView: View { @State private var isLoading = false @State private var showErrorAlert = false @State private var errorText = "" - + @State private var memberProfile: MemberProfile? // 功能对比数据 private let features = [ @@ -100,6 +100,9 @@ struct SubscribeView: View { } .background(Color.themeTextWhiteSecondary) .navigationBarHidden(true) + .onAppear { + fetchMemberInfo() + } .task { // Load products and refresh current entitlements on appear await store.loadProducts() @@ -125,15 +128,18 @@ struct SubscribeView: View { // MARK: - 当前订阅状态卡片 private var currentSubscriptionCard: some View { let status: SubscriptionStatus = { - if store.isSubscribed { - return .pioneer(expiryDate: store.subscriptionExpiry ?? Date()) + if memberProfile?.membershipLevel == "pioneer" { + let dateFormatter = ISO8601DateFormatter() + dateFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let expiryDate = memberProfile.flatMap { dateFormatter.date(from: $0.membershipEndAt) } ?? Date() + return .pioneer(expiryDate: expiryDate) } else { return .free } }() return SubscriptionStatusBar( - status: status, + status: .pioneer(expiryDate: Date()), onSubscribeTap: { // 订阅操作 handleSubscribe() @@ -146,7 +152,7 @@ struct SubscribeView: View { private var creditsSection: some View { VStack(spacing: 16) { CreditsInfoCard( - totalCredits: 3290, + totalCredits: memberProfile?.remainPoints ?? 0, onInfoTap: { // 显示积分信息说明 }, @@ -260,6 +266,30 @@ struct SubscribeView: View { private func handleSubscribe() { Task { await store.purchasePioneer() } } + + // MARK: - Helper Methods + private func fetchMemberInfo() { + NetworkService.shared.get( + path: "/membership/personal-center-info", + parameters: nil + ) { (result: Result, NetworkError>) in + DispatchQueue.main.async { + switch result { + case .success(let response): + self.memberProfile = response.data + print("✅ 成功获取会员信息:", response.data) + print("✅ 用户ID:", response.data.userInfo.userId) + print("✅ 用户昵称:", response.data.userInfo.nickname) + print("✅ 用户邮箱:", response.data.userInfo.email) + case .failure(let error): + print("❌ 获取会员信息失败:", error) + // Optionally show an error message to the user + self.errorText = "Failed to load member info: \(error.localizedDescription)" + self.showErrorAlert = true + } + } + } + } } #Preview {