- StoreKitManager: purchase, Transaction.updates, restore, finish - MembershipView: plan cards, purchase section, usage grid, compliance - MembershipService: API client for /membership/* endpoints - MembershipModels: Codable DTOs for plans/grants/transactions - Route.membership + ProfileView entry point - Products.storekit: Sandbox test products (monthly/yearly) - Remove hardcoded price fallback (displayPrice nil → 价格暂不可用) - Add MembershipModelsTests (12 tests) Co-Authored-By: Claude <noreply@anthropic.com>
324 lines
13 KiB
Swift
324 lines
13 KiB
Swift
import SwiftUI
|
|
import Combine
|
|
import StoreKit
|
|
|
|
// MARK: - MembershipView (M-MEMBER-01-10)
|
|
|
|
struct MembershipView: View {
|
|
@StateObject private var store = MembershipStore()
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
Color.zxBg0.ignoresSafeArea()
|
|
|
|
switch store.state {
|
|
case .loading:
|
|
VStack(spacing: 12) {
|
|
ProgressView().tint(Color.zxPurple)
|
|
Text("加载中…").font(.system(size: 14)).foregroundColor(Color.zxF04)
|
|
}
|
|
|
|
case .loaded(let membership, let plans):
|
|
loadedView(membership: membership, plans: plans)
|
|
|
|
case .error(let msg):
|
|
VStack(spacing: 16) {
|
|
Image(systemName: "wifi.slash").font(.system(size: 36)).foregroundColor(Color.zxF03)
|
|
Text(msg).font(.system(size: 14)).foregroundColor(Color.zxF04)
|
|
Button("重试") { Task { await store.load() } }
|
|
.font(.system(size: 14, weight: .medium)).foregroundColor(Color.zxPurple)
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle("会员中心").navigationBarTitleDisplayMode(.inline)
|
|
.toolbar(.hidden, for: .tabBar)
|
|
.task { await store.load() }
|
|
}
|
|
|
|
// MARK: - Loaded State
|
|
|
|
@ViewBuilder
|
|
private func loadedView(membership: MyMembershipResponse, plans: MembershipPlanResponse) -> some View {
|
|
ScrollView {
|
|
VStack(spacing: 16) {
|
|
// Current plan card
|
|
currentPlanCard(membership)
|
|
|
|
// Purchase section (only if not premium)
|
|
if membership.effectivePlan != "premium" {
|
|
purchaseSection(plans)
|
|
}
|
|
|
|
// Usage
|
|
if let entitlements = membership.entitlements {
|
|
usageSection(entitlements: entitlements)
|
|
}
|
|
|
|
// Actions
|
|
actionsSection(membership)
|
|
|
|
// Compliance
|
|
complianceSection
|
|
}
|
|
.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 100)
|
|
}
|
|
.scrollIndicators(.hidden)
|
|
.alert(store.alertTitle, isPresented: $store.showAlert) {
|
|
Button("确定", role: .cancel) {}
|
|
} message: {
|
|
Text(store.alertMessage)
|
|
}
|
|
}
|
|
|
|
// MARK: - Current Plan
|
|
|
|
private func currentPlanCard(_ m: MyMembershipResponse) -> some View {
|
|
VStack(spacing: 12) {
|
|
HStack {
|
|
Image(systemName: m.effectivePlan == "premium" ? "crown.fill" : "person.fill")
|
|
.font(.system(size: 24)).foregroundColor(m.effectivePlan == "premium" ? Color.zxAmber : Color.zxF03)
|
|
Text(m.effectivePlan == "premium" ? "Premium 会员" : "免费用户")
|
|
.font(.system(size: 18, weight: .bold)).foregroundColor(Color.zxF0)
|
|
Spacer()
|
|
}
|
|
|
|
if let expires = m.grants.first?.expiresAt, m.effectivePlan == "premium" {
|
|
HStack {
|
|
Text("到期时间:\(formatDate(expires))").font(.system(size: 12)).foregroundColor(Color.zxF04)
|
|
Spacer()
|
|
if m.grants.first?.autoRenewStatus == "off" {
|
|
Text("续订已关闭").font(.system(size: 11)).foregroundColor(Color.zxCoral)
|
|
.padding(.horizontal, 8).padding(.vertical, 3)
|
|
.background(Color.zxCoral.opacity(0.1)).clipShape(Capsule())
|
|
}
|
|
}
|
|
}
|
|
|
|
if let grant = m.grants.first {
|
|
HStack(spacing: 12) {
|
|
Label("来源:\(sourceLabel(grant.source))", systemImage: "checkmark.shield")
|
|
.font(.system(size: 11)).foregroundColor(Color.zxF04)
|
|
if let env = grant.environment, env == "Sandbox" {
|
|
Text("沙盒").font(.system(size: 10)).foregroundColor(Color.zxAmber)
|
|
.padding(.horizontal, 6).padding(.vertical, 2)
|
|
.background(Color.zxAmber.opacity(0.15)).clipShape(Capsule())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(20).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 16))
|
|
}
|
|
|
|
// MARK: - Purchase
|
|
|
|
@ViewBuilder
|
|
private func purchaseSection(_ plans: MembershipPlanResponse) -> some View {
|
|
VStack(spacing: 8) {
|
|
Text("升级 Premium").font(.system(size: 16, weight: .bold)).foregroundColor(Color.zxF0)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
ForEach(plans.plans) { plan in
|
|
Button {
|
|
Task { await store.purchase(plan: plan) }
|
|
} label: {
|
|
HStack {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(plan.name).font(.system(size: 15, weight: .semibold)).foregroundColor(Color.zxF0)
|
|
Text(plan.planCode.contains("yearly") ? "每年自动续订,更优惠" : "每月自动续订")
|
|
.font(.system(size: 11)).foregroundColor(Color.zxF04)
|
|
}
|
|
Spacer()
|
|
if store.purchasingPlanId == plan.planCode {
|
|
ProgressView().tint(Color.zxPurple)
|
|
} else if let price = store.displayPrice(for: plan.planCode) {
|
|
Text(price)
|
|
.font(.system(size: 14, weight: .bold)).foregroundColor(Color.zxPurple)
|
|
} else {
|
|
Text("价格暂不可用")
|
|
.font(.system(size: 12)).foregroundColor(Color.zxF04)
|
|
}
|
|
}
|
|
.padding(16).background(Color.zxFill004)
|
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
|
.overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxPurple.opacity(0.15), lineWidth: 1))
|
|
}
|
|
.disabled(store.purchasingPlanId != nil)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Usage
|
|
|
|
private func usageSection(entitlements: Entitlements) -> some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("额度").font(.system(size: 16, weight: .bold)).foregroundColor(Color.zxF0)
|
|
|
|
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) {
|
|
QuotaItem(label: "知识库", value: "\(entitlements.maxKnowledgeBases)")
|
|
QuotaItem(label: "AI 对话/月", value: entitlements.monthlyChatCount > 0 ? "\(entitlements.monthlyChatCount)" : "无限")
|
|
QuotaItem(label: "OCR/月", value: "\(entitlements.monthlyOcrPages) 页")
|
|
QuotaItem(label: "测验/天", value: "\(entitlements.monthlyQuizCount)")
|
|
QuotaItem(label: "存储", value: formatBytes(entitlements.maxStorageBytes))
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Actions
|
|
|
|
private func actionsSection(_ m: MyMembershipResponse) -> some View {
|
|
VStack(spacing: 8) {
|
|
Button { Task { await store.restore() } } label: {
|
|
HStack {
|
|
Text("恢复购买").font(.system(size: 14)).foregroundColor(Color.zxPurple)
|
|
Spacer()
|
|
if store.isRestoring { ProgressView().tint(Color.zxPurple) }
|
|
}.padding(16).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 14))
|
|
}
|
|
|
|
if m.effectivePlan == "premium" {
|
|
Button { store.openManageSubscription() } label: {
|
|
HStack {
|
|
Text("管理订阅").font(.system(size: 14)).foregroundColor(Color.zxF04)
|
|
Spacer()
|
|
Image(systemName: "arrow.up.forward").font(.system(size: 12)).foregroundColor(Color.zxF03)
|
|
}.padding(16).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 14))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Compliance
|
|
|
|
private var complianceSection: some View {
|
|
VStack(spacing: 8) {
|
|
Link("隐私政策", destination: URL(string: "https://longde.cloud/privacy")!)
|
|
.font(.system(size: 13)).foregroundColor(Color.zxF04)
|
|
Link("用户协议", destination: URL(string: "https://longde.cloud/terms")!)
|
|
.font(.system(size: 13)).foregroundColor(Color.zxF04)
|
|
Text("自动续订说明:订阅将在到期前 24 小时内自动续订,可随时在系统设置中取消。")
|
|
.font(.system(size: 11)).foregroundColor(Color.zxF04).multilineTextAlignment(.center)
|
|
Link("联系支持", destination: URL(string: "mailto:support@longde.cloud")!)
|
|
.font(.system(size: 13)).foregroundColor(Color.zxPurple)
|
|
}
|
|
.padding(.top, 8)
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private func formatDate(_ iso: String) -> String {
|
|
let f = ISO8601DateFormatter()
|
|
f.formatOptions = [.withFullDate]
|
|
return f.date(from: iso)?.formatted(date: .abbreviated, time: .omitted) ?? iso.prefix(10).description
|
|
}
|
|
|
|
private func formatBytes(_ b: Int) -> String {
|
|
if b >= 1_073_741_824 { return String(format: "%.0f GB", Double(b) / 1_073_741_824) }
|
|
if b >= 1_048_576 { return String(format: "%.0f MB", Double(b) / 1_048_576) }
|
|
return "\(b) B"
|
|
}
|
|
|
|
private func sourceLabel(_ s: String) -> String {
|
|
switch s { case "apple": return "Apple"; case "admin": return "管理员"; case "trial": return "试用"; default: return s }
|
|
}
|
|
}
|
|
|
|
// MARK: - Quota Item
|
|
|
|
struct QuotaItem: View {
|
|
let label: String
|
|
let value: String
|
|
var body: some View {
|
|
VStack(spacing: 4) {
|
|
Text(value).font(.system(size: 16, weight: .bold)).foregroundColor(Color.zxF0)
|
|
Text(label).font(.system(size: 11)).foregroundColor(Color.zxF04)
|
|
}
|
|
.frame(maxWidth: .infinity).padding(.vertical, 12)
|
|
.background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 10))
|
|
}
|
|
}
|
|
|
|
// MARK: - MembershipStore
|
|
|
|
@MainActor
|
|
final class MembershipStore: ObservableObject {
|
|
@Published var state: State = .loading
|
|
@Published var purchasingPlanId: String? = nil
|
|
@Published var isRestoring = false
|
|
@Published var showAlert = false
|
|
var alertTitle = ""; var alertMessage = ""
|
|
|
|
enum State {
|
|
case loading
|
|
case loaded(membership: MyMembershipResponse, plans: MembershipPlanResponse)
|
|
case error(String)
|
|
}
|
|
|
|
func load() async {
|
|
state = .loading
|
|
do {
|
|
async let membership = MembershipService.shared.myMembership()
|
|
async let plans = MembershipService.shared.getPlans()
|
|
state = try await .loaded(membership: membership, plans: plans)
|
|
} catch {
|
|
state = .error("加载失败:\(error.localizedDescription)")
|
|
}
|
|
}
|
|
|
|
func purchase(plan: PlanItem) async {
|
|
purchasingPlanId = plan.planCode
|
|
guard let tokenStr = try? await MembershipService.shared.myMembership().appAccountToken,
|
|
let uuid = UUID(uuidString: tokenStr) else {
|
|
show("无法获取账号令牌")
|
|
purchasingPlanId = nil; return
|
|
}
|
|
|
|
let pm = StoreKitManager.shared
|
|
await pm.loadProducts()
|
|
guard let product = pm.products.first(where: {
|
|
$0.id == (plan.planCode.contains("yearly")
|
|
? "cloud.longde.aistudyapp.premium.yearly"
|
|
: "cloud.longde.aistudyapp.premium.monthly")
|
|
}) else {
|
|
show("商品不可用"); purchasingPlanId = nil; return
|
|
}
|
|
|
|
await pm.purchase(product, appAccountToken: uuid)
|
|
|
|
switch pm.purchaseState {
|
|
case .success: await load()
|
|
case .userCancelled: break
|
|
case .pending: show("购买处理中,请稍候")
|
|
case .failed(let msg): show("购买失败:\(msg)")
|
|
default: break
|
|
}
|
|
purchasingPlanId = nil
|
|
}
|
|
|
|
func restore() async {
|
|
isRestoring = true
|
|
let restored = await StoreKitManager.shared.restorePurchases()
|
|
if restored { await load() } else { show("未找到可恢复的订阅") }
|
|
isRestoring = false
|
|
}
|
|
|
|
/// 从 StoreKit Product 获取本地化价格。未加载时返回 nil。
|
|
func displayPrice(for planCode: String) -> String? {
|
|
let productId = planCode.contains("yearly")
|
|
? "cloud.longde.aistudyapp.premium.yearly"
|
|
: "cloud.longde.aistudyapp.premium.monthly"
|
|
return StoreKitManager.shared.products.first(where: { $0.id == productId })?.displayPrice
|
|
}
|
|
|
|
/// N2: 使用系统 API 打开订阅管理
|
|
func openManageSubscription() {
|
|
if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene {
|
|
Task { try? await AppStore.showManageSubscriptions(in: scene) }
|
|
}
|
|
}
|
|
|
|
private func show(_ msg: String) {
|
|
alertTitle = "提示"; alertMessage = msg; showAlert = true
|
|
}
|
|
}
|