feat: 暂提

This commit is contained in:
jinyaqiu 2025-09-01 19:49:52 +08:00
parent d7911d0828
commit 54a451fe6e
6 changed files with 403 additions and 181 deletions

24
wake/Assets/Svg/Free.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -105,36 +105,6 @@ struct BlindBoxView: View {
case description 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 { struct BlindCount: Codable {
let availableQuantity: Int 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 // MARK: - BlindBox Response Model
struct BlindBoxData: Codable { struct BlindBoxData: Codable {
@ -353,15 +270,13 @@ struct BlindBoxView: View {
NetworkService.shared.get( NetworkService.shared.get(
path: "/membership/personal-center-info", path: "/membership/personal-center-info",
parameters: nil parameters: nil
) { (result: Result<APIResponse<MemberProfile>, NetworkError>) in ) { (result: Result<MemberProfileResponse, NetworkError>) in
DispatchQueue.main.async { DispatchQueue.main.async {
switch result { switch result {
case .success(let response): case .success(let response):
self.memberProfile = response.data self.memberProfile = response.data
print("✅ 成功获取会员信息:", response.data) print("✅ 成功获取会员信息:", response.data)
print("✅ 用户ID:", response.data.userInfo.userId) print("✅ 用户ID:", response.data.userInfo.userId)
print("✅ 用户昵称:", response.data.userInfo.nickname)
print("✅ 用户邮箱:", response.data.userInfo.email)
case .failure(let error): case .failure(let error):
print("❌ 获取会员信息失败:", 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<APIResponse<MemberProfile>, 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() { private func loadImage() {
guard !imageURL.isEmpty, let url = URL(string: imageURL) else { guard !imageURL.isEmpty, let url = URL(string: imageURL) else {
print("⚠️ 图片URL无效或为空") print("⚠️ 图片URL无效或为空")

View File

@ -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<T: Codable>: 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
}()
}

View File

@ -3,6 +3,7 @@ import WebKit
struct SVGImage: UIViewRepresentable { struct SVGImage: UIViewRepresentable {
let svgName: String let svgName: String
var shouldFill: Bool = false
func makeUIView(context: Context) -> WKWebView { func makeUIView(context: Context) -> WKWebView {
let webView = WKWebView() let webView = WKWebView()
@ -11,20 +12,23 @@ struct SVGImage: UIViewRepresentable {
webView.scrollView.isScrollEnabled = false webView.scrollView.isScrollEnabled = false
webView.scrollView.contentInsetAdjustmentBehavior = .never webView.scrollView.contentInsetAdjustmentBehavior = .never
// 1. SVG inDirectory
guard let path = Bundle.main.path(forResource: svgName, ofType: "svg") else { guard let path = Bundle.main.path(forResource: svgName, ofType: "svg") else {
print("❌ 无法找到 SVG 文件: \(svgName).svg") print("❌ 无法找到 SVG 文件: \(svgName).svg")
//
if let resourcePath = Bundle.main.resourcePath {
print("可用的资源文件: \(try? FileManager.default.contentsOfDirectory(atPath: resourcePath))")
}
return webView return webView
} }
// 2. URL
let fileURL = URL(fileURLWithPath: path) 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 = """ let htmlString = """
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
@ -39,30 +43,50 @@ struct SVGImage: UIViewRepresentable {
overflow: hidden; overflow: hidden;
background-color: transparent; background-color: transparent;
} }
svg { .container {
width: 100%; width: 100%;
height: 100%; height: 100%;
display: block; display: flex;
align-items: center;
justify-content: center;
} }
img { svg {
max-width: 100%; \(svgStyle)
max-height: 100%; display: block;
object-fit: contain;
} }
</style> </style>
</head> </head>
<body> <body>
<div style="width:100%; height:100%; display:flex; align-items:center; justify-content:center;"> <div class="container">
<img src="\(fileURL.lastPathComponent)" /> <object type="image/svg+xml" data="\(fileURL.lastPathComponent)"></object>
</div> </div>
</body> </body>
</html> </html>
""" """
// 4. HTML
webView.loadHTMLString(htmlString, baseURL: fileURL.deletingLastPathComponent()) webView.loadHTMLString(htmlString, baseURL: fileURL.deletingLastPathComponent())
return webView return webView
} }
func updateUIView(_ uiView: WKWebView, context: Context) {} 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()
} }

View File

@ -15,27 +15,18 @@ enum SubscriptionStatus {
var title: String { var title: String {
switch self { switch self {
case .free: case .free:
return "Free" return ""
case .pioneer: case .pioneer:
return "Pioneer" return "Pioneer"
} }
} }
var hasExpiry: Bool {
switch self {
case .free:
return false
case .pioneer:
return true
}
}
var backgroundColor: Color { var backgroundColor: Color {
switch self { switch self {
case .free: case .free:
return Theme.Colors.freeBackground // return .clear
case .pioneer: case .pioneer:
return Theme.Colors.pioneerBackground // return .clear
} }
} }
@ -44,7 +35,16 @@ enum SubscriptionStatus {
case .free: case .free:
return Theme.Colors.textPrimary return Theme.Colors.textPrimary
case .pioneer: 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 { var body: some View {
HStack(spacing: 16) { ZStack(alignment: .topLeading) {
VStack(alignment: .leading, spacing: 8) { // 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) Text(status.title)
.font(.system(size: 28, weight: .bold, design: .rounded)) .font(.system(size: 28, weight: .bold, design: .rounded))
.foregroundColor(status.textColor) .foregroundColor(status.textColor)
.padding(.leading, 24)
// // Expiry date or subscribe button
if case .pioneer(let expiryDate) = status { if case .pioneer(let expiryDate) = status {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text("Expires on :") Text("Expires on:")
.font(.system(size: 14, weight: .medium)) .font(.system(size: 14, weight: .medium))
.foregroundColor(status.textColor.opacity(0.8)) .foregroundColor(status.textColor.opacity(0.9))
Text(formatDate(expiryDate)) Text(formatDate(expiryDate))
.font(.system(size: 16, weight: .semibold)) .font(.system(size: 16, weight: .semibold))
.foregroundColor(status.textColor) .foregroundColor(status.textColor)
} }
} else { .padding(.leading, 24)
Button(action: { .padding(.bottom, 20)
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)
} }
} }
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading)
} }
.frame(height: 155)
Spacer() .frame(maxWidth: .infinity)
//
Circle()
.fill(Color.black)
.frame(width: 60, height: 60)
.overlay(
Image(systemName: "play.fill")
.foregroundColor(.white)
.font(.title2)
.offset(x: 2) //
)
}
.padding(20)
.background(status.backgroundColor)
.cornerRadius(20) .cornerRadius(20)
// .shadow(color: Color.black.opacity(0.1), radius: 4, x: 0, y: 2) .clipped()
} }
// MARK: - // MARK: -
private func formatDate(_ date: Date) -> String { private func formatDate(_ date: Date) -> String {
let formatter = DateFormatter() let formatter = DateFormatter()
formatter.dateFormat = "MMMM d, yyyy" formatter.dateFormat = "MMM d, yyyy"
return formatter.string(from: date) return formatter.string(from: date)
} }
} }
// MARK: - // MARK: -
#Preview("Free Status") { #Preview {
VStack(spacing: 20) { VStack(spacing: 20) {
// Free status preview
SubscriptionStatusBar( SubscriptionStatusBar(
status: .free, status: .free,
onSubscribeTap: { onSubscribeTap: {
print("Subscribe tapped") print("Subscribe tapped")
} }
) )
.padding() .padding(.horizontal)
// Pioneer status preview
SubscriptionStatusBar( 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)
} }

View File

@ -45,7 +45,7 @@ struct SubscribeView: View {
@State private var isLoading = false @State private var isLoading = false
@State private var showErrorAlert = false @State private var showErrorAlert = false
@State private var errorText = "" @State private var errorText = ""
@State private var memberProfile: MemberProfile?
// //
private let features = [ private let features = [
@ -100,6 +100,9 @@ struct SubscribeView: View {
} }
.background(Color.themeTextWhiteSecondary) .background(Color.themeTextWhiteSecondary)
.navigationBarHidden(true) .navigationBarHidden(true)
.onAppear {
fetchMemberInfo()
}
.task { .task {
// Load products and refresh current entitlements on appear // Load products and refresh current entitlements on appear
await store.loadProducts() await store.loadProducts()
@ -125,15 +128,18 @@ struct SubscribeView: View {
// MARK: - // MARK: -
private var currentSubscriptionCard: some View { private var currentSubscriptionCard: some View {
let status: SubscriptionStatus = { let status: SubscriptionStatus = {
if store.isSubscribed { if memberProfile?.membershipLevel == "pioneer" {
return .pioneer(expiryDate: store.subscriptionExpiry ?? Date()) let dateFormatter = ISO8601DateFormatter()
dateFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let expiryDate = memberProfile.flatMap { dateFormatter.date(from: $0.membershipEndAt) } ?? Date()
return .pioneer(expiryDate: expiryDate)
} else { } else {
return .free return .free
} }
}() }()
return SubscriptionStatusBar( return SubscriptionStatusBar(
status: status, status: .pioneer(expiryDate: Date()),
onSubscribeTap: { onSubscribeTap: {
// //
handleSubscribe() handleSubscribe()
@ -146,7 +152,7 @@ struct SubscribeView: View {
private var creditsSection: some View { private var creditsSection: some View {
VStack(spacing: 16) { VStack(spacing: 16) {
CreditsInfoCard( CreditsInfoCard(
totalCredits: 3290, totalCredits: memberProfile?.remainPoints ?? 0,
onInfoTap: { onInfoTap: {
// //
}, },
@ -260,6 +266,30 @@ struct SubscribeView: View {
private func handleSubscribe() { private func handleSubscribe() {
Task { await store.purchasePioneer() } Task { await store.purchasePioneer() }
} }
// MARK: - Helper Methods
private func fetchMemberInfo() {
NetworkService.shared.get(
path: "/membership/personal-center-info",
parameters: nil
) { (result: Result<APIResponse<MemberProfile>, 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 { #Preview {