From 98b7600797fe38ae6ddafe4ee697fca8df03922d Mon Sep 17 00:00:00 2001 From: marco Date: Fri, 5 Jun 2026 00:54:11 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E5=A7=8B=E5=8C=96=E5=82=BB=E7=93=9C?= =?UTF-8?q?=E6=AF=94=E4=BB=B7=E6=AD=A3=E5=BC=8F=E7=89=88=20iOS=20=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对标安卓正式版、对接 app-server(8770)的 SwiftUI 客户端。iOS 无障碍受限、无比价核心,做自成一体:美团 CPS 导购 + 福利(签到/任务/金币)+ 查看(比价记录/省钱战绩)。 功能完整 M0-M5:脚手架、短信登录(JWT+Keychain+401 自动刷新)、首页券 feed 导购、比价记录+省钱战绩、福利、我的(资料/反馈/设置/注销)。 单 target、0 三方依赖、XcodeGen 生成工程;Debug/Release 双 plist 分流 ATS。 Co-Authored-By: Claude Opus 4.8 --- .gitignore | 20 ++ App/Auth/AuthTokens.swift | 45 ++++ App/Auth/KeychainStore.swift | 53 +++++ App/Auth/SessionStore.swift | 56 +++++ App/Common/AppDate.swift | 29 +++ App/Common/Formatters.swift | 16 ++ App/Common/ImageDownsampler.swift | 21 ++ App/Common/LocationManager.swift | 73 +++++++ App/Components/LoginRequiredView.swift | 38 ++++ App/Features/Feedback/FeedbackView.swift | 84 ++++++++ App/Features/Home/CouponCardView.swift | 59 ++++++ App/Features/Home/HomeView.swift | 69 +++++++ App/Features/Home/HomeViewModel.swift | 70 +++++++ App/Features/Invite/InviteView.swift | 54 +++++ App/Features/Login/LoginView.swift | 110 ++++++++++ App/Features/Login/LoginViewModel.swift | 67 ++++++ App/Features/Profile/ProfileEditView.swift | 113 +++++++++++ App/Features/Profile/ProfileView.swift | 102 ++++++++++ App/Features/Records/CompareRecordsView.swift | 85 ++++++++ .../Records/CompareRecordsViewModel.swift | 45 ++++ App/Features/Records/SavingsView.swift | 125 ++++++++++++ App/Features/Records/SavingsViewModel.swift | 48 +++++ App/Features/Settings/AboutView.swift | 35 ++++ App/Features/Settings/SettingsView.swift | 52 +++++ App/Features/Welfare/CoinHistoryView.swift | 46 +++++ .../Welfare/CoinHistoryViewModel.swift | 39 ++++ App/Features/Welfare/WelfareCatalog.swift | 30 +++ App/Features/Welfare/WelfareView.swift | 167 +++++++++++++++ App/Features/Welfare/WelfareViewModel.swift | 68 +++++++ App/MainTabView.swift | 15 ++ App/Networking/APIClient.swift | 192 ++++++++++++++++++ App/Networking/APIError.swift | 36 ++++ App/Networking/AppConfig.swift | 27 +++ App/Networking/AuthService.swift | 26 +++ App/Networking/CompareService.swift | 12 ++ App/Networking/DTO/AuthDTO.swift | 66 ++++++ App/Networking/DTO/CommonDTO.swift | 6 + App/Networking/DTO/CompareRecordDTO.swift | 91 +++++++++ App/Networking/DTO/MeituanDTO.swift | 66 ++++++ App/Networking/DTO/WelfareDTO.swift | 137 +++++++++++++ App/Networking/FeedbackService.swift | 17 ++ App/Networking/MeituanService.swift | 29 +++ App/Networking/SavingsService.swift | 20 ++ App/Networking/UserService.swift | 24 +++ App/Networking/WalletService.swift | 17 ++ App/Networking/WelfareService.swift | 24 +++ App/Privacy/PrivacyConsentView.swift | 49 +++++ App/Privacy/PrivacyPolicyView.swift | 45 ++++ App/Privacy/PrivacyPrefs.swift | 14 ++ .../AccentColor.colorset/Contents.json | 20 ++ .../AppIcon.appiconset/Contents.json | 13 ++ App/Resources/Assets.xcassets/Contents.json | 6 + .../LaunchBackground.colorset/Contents.json | 20 ++ App/Resources/Info-Debug.plist | 65 ++++++ App/Resources/Info.plist | 68 +++++++ App/Resources/PrivacyInfo.xcprivacy | 55 +++++ App/RootView.swift | 20 ++ App/ShaguabijiaApp.swift | 15 ++ App/Theme/AppColors.swift | 51 +++++ project.yml | 47 +++++ 60 files changed, 3112 insertions(+) create mode 100644 .gitignore create mode 100644 App/Auth/AuthTokens.swift create mode 100644 App/Auth/KeychainStore.swift create mode 100644 App/Auth/SessionStore.swift create mode 100644 App/Common/AppDate.swift create mode 100644 App/Common/Formatters.swift create mode 100644 App/Common/ImageDownsampler.swift create mode 100644 App/Common/LocationManager.swift create mode 100644 App/Components/LoginRequiredView.swift create mode 100644 App/Features/Feedback/FeedbackView.swift create mode 100644 App/Features/Home/CouponCardView.swift create mode 100644 App/Features/Home/HomeView.swift create mode 100644 App/Features/Home/HomeViewModel.swift create mode 100644 App/Features/Invite/InviteView.swift create mode 100644 App/Features/Login/LoginView.swift create mode 100644 App/Features/Login/LoginViewModel.swift create mode 100644 App/Features/Profile/ProfileEditView.swift create mode 100644 App/Features/Profile/ProfileView.swift create mode 100644 App/Features/Records/CompareRecordsView.swift create mode 100644 App/Features/Records/CompareRecordsViewModel.swift create mode 100644 App/Features/Records/SavingsView.swift create mode 100644 App/Features/Records/SavingsViewModel.swift create mode 100644 App/Features/Settings/AboutView.swift create mode 100644 App/Features/Settings/SettingsView.swift create mode 100644 App/Features/Welfare/CoinHistoryView.swift create mode 100644 App/Features/Welfare/CoinHistoryViewModel.swift create mode 100644 App/Features/Welfare/WelfareCatalog.swift create mode 100644 App/Features/Welfare/WelfareView.swift create mode 100644 App/Features/Welfare/WelfareViewModel.swift create mode 100644 App/MainTabView.swift create mode 100644 App/Networking/APIClient.swift create mode 100644 App/Networking/APIError.swift create mode 100644 App/Networking/AppConfig.swift create mode 100644 App/Networking/AuthService.swift create mode 100644 App/Networking/CompareService.swift create mode 100644 App/Networking/DTO/AuthDTO.swift create mode 100644 App/Networking/DTO/CommonDTO.swift create mode 100644 App/Networking/DTO/CompareRecordDTO.swift create mode 100644 App/Networking/DTO/MeituanDTO.swift create mode 100644 App/Networking/DTO/WelfareDTO.swift create mode 100644 App/Networking/FeedbackService.swift create mode 100644 App/Networking/MeituanService.swift create mode 100644 App/Networking/SavingsService.swift create mode 100644 App/Networking/UserService.swift create mode 100644 App/Networking/WalletService.swift create mode 100644 App/Networking/WelfareService.swift create mode 100644 App/Privacy/PrivacyConsentView.swift create mode 100644 App/Privacy/PrivacyPolicyView.swift create mode 100644 App/Privacy/PrivacyPrefs.swift create mode 100644 App/Resources/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 App/Resources/Assets.xcassets/Contents.json create mode 100644 App/Resources/Assets.xcassets/LaunchBackground.colorset/Contents.json create mode 100644 App/Resources/Info-Debug.plist create mode 100644 App/Resources/Info.plist create mode 100644 App/Resources/PrivacyInfo.xcprivacy create mode 100644 App/RootView.swift create mode 100644 App/ShaguabijiaApp.swift create mode 100644 App/Theme/AppColors.swift create mode 100644 project.yml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2b8a6fa --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +# XcodeGen 生成物(从 project.yml 重新生成,不入 git) +*.xcodeproj +*.xcworkspace + +# Xcode +xcuserdata/ +DerivedData/ +build/ +*.xcuserstate + +# macOS +.DS_Store + +# Swift Package Manager +.swiftpm/ +.build/ + +# 本地配置 / 密钥 +*.local.xcconfig +secrets/ diff --git a/App/Auth/AuthTokens.swift b/App/Auth/AuthTokens.swift new file mode 100644 index 0000000..4b6cc0b --- /dev/null +++ b/App/Auth/AuthTokens.swift @@ -0,0 +1,45 @@ +import Foundation + +/// Token 中枢:读写 Keychain + 串行刷新(对应安卓 RefreshAuthenticator 的 @Synchronized 去重)。 +/// actor 保证并发安全;`inFlight` 让同时撞 401 的多个请求复用同一次刷新,只刷一次。 +actor AuthTokens { + static let shared = AuthTokens() + + private var inFlight: Task? + + func accessToken() -> String? { KeychainStore.get(.accessToken) } + func refreshToken() -> String? { KeychainStore.get(.refreshToken) } + + func setTokens(access: String, refresh: String) { + KeychainStore.set(access, .accessToken) + KeychainStore.set(refresh, .refreshToken) + } + + func clear() { + KeychainStore.delete(.accessToken) + KeychainStore.delete(.refreshToken) + } + + /// 用 refresh_token 换新一对。并发调用复用同一 Task。成功写回 Keychain 返 true。 + func refresh() async -> Bool { + if let inFlight { return await inFlight.value } + let task = Task { await Self.performRefresh() } + inFlight = task + let ok = await task.value + inFlight = nil + return ok + } + + /// 裸刷新:不带鉴权头、不会再触发刷新(避免递归),对应安卓"用裸 OkHttp 调 /refresh"。 + private static func performRefresh() async -> Bool { + guard let rt = KeychainStore.get(.refreshToken) else { return false } + do { + let pair = try await APIClient.shared.rawRefresh(refreshToken: rt) + KeychainStore.set(pair.accessToken, .accessToken) + KeychainStore.set(pair.refreshToken, .refreshToken) + return true + } catch { + return false + } + } +} diff --git a/App/Auth/KeychainStore.swift b/App/Auth/KeychainStore.swift new file mode 100644 index 0000000..b6ad964 --- /dev/null +++ b/App/Auth/KeychainStore.swift @@ -0,0 +1,53 @@ +import Foundation +import Security + +/// JWT 持久化(对应安卓的 EncryptedSharedPreferences)。Keychain 本身加密 + 系统托管密钥。 +enum KeychainStore { + enum Key: String { + case accessToken = "access_token" + case refreshToken = "refresh_token" + } + + private static let service = "com.jishisongfu.shaguabijia.auth" + + static func get(_ key: Key) -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key.rawValue, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var item: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, + let data = item as? Data, + let str = String(data: data, encoding: .utf8) else { + return nil + } + return str + } + + static func set(_ value: String, _ key: Key) { + let data = Data(value.utf8) + let base: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key.rawValue, + ] + // upsert:先删再加,避免 duplicate item + SecItemDelete(base as CFDictionary) + var attrs = base + attrs[kSecValueData as String] = data + attrs[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock + SecItemAdd(attrs as CFDictionary, nil) + } + + static func delete(_ key: Key) { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key.rawValue, + ] + SecItemDelete(query as CFDictionary) + } +} diff --git a/App/Auth/SessionStore.swift b/App/Auth/SessionStore.swift new file mode 100644 index 0000000..074b188 --- /dev/null +++ b/App/Auth/SessionStore.swift @@ -0,0 +1,56 @@ +import SwiftUI + +/// 登录态真相源(UI 订阅)。token 实际存 Keychain,这里持内存镜像 + user 资料。 +@MainActor +@Observable +final class SessionStore { + var user: UserDTO? + private(set) var isLoggedIn: Bool + @ObservationIgnored private var authExpireObserver: NSObjectProtocol? + + init() { + // 启动即判断:Keychain 有 access token 视为已登录(资料由 refreshProfile 拉) + isLoggedIn = KeychainStore.get(.accessToken) != nil + authExpireObserver = NotificationCenter.default.addObserver( + forName: .authDidExpire, object: nil, queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { self?.clearLocal() } + } + } + + deinit { + if let authExpireObserver { + NotificationCenter.default.removeObserver(authExpireObserver) + } + } + + /// 登录成功:存 token + user。 + func loginSucceeded(_ resp: TokenWithUserDTO) async { + await AuthTokens.shared.setTokens(access: resp.accessToken, refresh: resp.refreshToken) + user = resp.user + isLoggedIn = true + } + + /// 资料更新后(改昵称/头像)同步内存镜像。 + func updateUser(_ user: UserDTO) { + self.user = user + } + + /// 拉最新资料(进入「我的」时刷新)。 + func refreshProfile() async { + guard isLoggedIn else { return } + if let u = try? await AuthService().me() { user = u } + } + + /// 主动退出登录。 + func logout() async { + _ = try? await AuthService().logout() // 占位接口,失败也无所谓 + await AuthTokens.shared.clear() + clearLocal() + } + + private func clearLocal() { + user = nil + isLoggedIn = false + } +} diff --git a/App/Common/AppDate.swift b/App/Common/AppDate.swift new file mode 100644 index 0000000..746ba61 --- /dev/null +++ b/App/Common/AppDate.swift @@ -0,0 +1,29 @@ +import Foundation + +/// 后端 ISO 8601 时间字符串 → 友好展示。后端 datetime 可能带/不带时区与微秒,逐档尝试解析。 +enum AppDate { + static func friendly(_ iso: String) -> String { + guard let date = parse(iso) else { return String(iso.prefix(10)) } + let f = DateFormatter() + f.locale = Locale(identifier: "zh_CN") + f.dateFormat = "M月d日" + return f.string(from: date) + } + + private static func parse(_ iso: String) -> Date? { + let isoFmt = ISO8601DateFormatter() + isoFmt.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let d = isoFmt.date(from: iso) { return d } + isoFmt.formatOptions = [.withInternetDateTime] + if let d = isoFmt.date(from: iso) { return d } + + let df = DateFormatter() + df.locale = Locale(identifier: "en_US_POSIX") + df.timeZone = TimeZone(identifier: "Asia/Shanghai") + for fmt in ["yyyy-MM-dd'T'HH:mm:ss.SSSSSS", "yyyy-MM-dd'T'HH:mm:ss.SSS", "yyyy-MM-dd'T'HH:mm:ss"] { + df.dateFormat = fmt + if let d = df.date(from: iso) { return d } + } + return nil + } +} diff --git a/App/Common/Formatters.swift b/App/Common/Formatters.swift new file mode 100644 index 0000000..e0181d7 --- /dev/null +++ b/App/Common/Formatters.swift @@ -0,0 +1,16 @@ +import Foundation + +extension String { + /// 138****5678 + var maskedPhone: String { + guard count == 11 else { return self } + return "\(prefix(3))****\(suffix(4))" + } +} + +extension Int { + /// 分 → "¥12.34" + var centsToYuanText: String { + String(format: "¥%.2f", Double(self) / 100.0) + } +} diff --git a/App/Common/ImageDownsampler.swift b/App/Common/ImageDownsampler.swift new file mode 100644 index 0000000..9da4818 --- /dev/null +++ b/App/Common/ImageDownsampler.swift @@ -0,0 +1,21 @@ +import UIKit +import ImageIO + +/// 图片降采样 + JPEG 压缩(头像/反馈截图上传前)。用 ImageIO 流式缩略,避免大图整张解进内存。 +enum ImageDownsampler { + static func jpeg(_ data: Data, maxDimension: CGFloat = 1024, quality: CGFloat = 0.8) -> Data? { + let srcOpts: [CFString: Any] = [kCGImageSourceShouldCache: false] + guard let src = CGImageSourceCreateWithData(data as CFData, srcOpts as CFDictionary) else { + return UIImage(data: data)?.jpegData(compressionQuality: quality) + } + let thumbOpts: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, // 按 EXIF 自动旋转 + kCGImageSourceThumbnailMaxPixelSize: maxDimension, + ] + guard let cg = CGImageSourceCreateThumbnailAtIndex(src, 0, thumbOpts as CFDictionary) else { + return UIImage(data: data)?.jpegData(compressionQuality: quality) + } + return UIImage(cgImage: cg).jpegData(compressionQuality: quality) + } +} diff --git a/App/Common/LocationManager.swift b/App/Common/LocationManager.swift new file mode 100644 index 0000000..a0f8bb2 --- /dev/null +++ b/App/Common/LocationManager.swift @@ -0,0 +1,73 @@ +import CoreLocation + +/// CoreLocation 的极简 async 封装:请求一次"使用期间"定位。 +/// 无权限/拒绝/失败都返 nil,调用方用默认坐标兜底(对齐安卓首页定位失败兜底天安门)。 +@MainActor +final class LocationManager: NSObject, CLLocationManagerDelegate { + private let manager = CLLocationManager() + private var authContinuation: CheckedContinuation? + private var locContinuation: CheckedContinuation? + + override init() { + super.init() + manager.delegate = self + manager.desiredAccuracy = kCLLocationAccuracyHundredMeters + } + + /// 请求一次定位。notDetermined 先弹权限,授权后取一次坐标;拒绝/失败返 nil。 + func requestOnce() async -> CLLocationCoordinate2D? { + // 防重入:Tab 切换会让 HomeView 的 .task 重启二次调用,避免覆盖 continuation 导致前一次永久挂起 + guard authContinuation == nil, locContinuation == nil else { return nil } + switch manager.authorizationStatus { + case .denied, .restricted: + return nil + case .notDetermined: + guard await requestAuthorization() else { return nil } + default: + break + } + return await fetchLocation() + } + + private func requestAuthorization() async -> Bool { + await withCheckedContinuation { cont in + authContinuation = cont + manager.requestWhenInUseAuthorization() + } + } + + private func fetchLocation() async -> CLLocationCoordinate2D? { + await withCheckedContinuation { cont in + locContinuation = cont + manager.requestLocation() + } + } + + // MARK: - CLLocationManagerDelegate(回调可能非主线程,跳回 MainActor;continuation 置 nil 防重复 resume) + + nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + let status = manager.authorizationStatus + Task { @MainActor in + guard let cont = authContinuation else { return } // init 时的初始回调:cont 还没设,忽略 + authContinuation = nil + cont.resume(returning: status == .authorizedWhenInUse || status == .authorizedAlways) + } + } + + nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + let coord = locations.first?.coordinate + Task { @MainActor in + guard let cont = locContinuation else { return } + locContinuation = nil + cont.resume(returning: coord) + } + } + + nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { + Task { @MainActor in + guard let cont = locContinuation else { return } + locContinuation = nil + cont.resume(returning: nil) + } + } +} diff --git a/App/Components/LoginRequiredView.swift b/App/Components/LoginRequiredView.swift new file mode 100644 index 0000000..d4d1684 --- /dev/null +++ b/App/Components/LoginRequiredView.swift @@ -0,0 +1,38 @@ +import SwiftUI + +/// 登录守卫占位:未登录时显示,点按弹出登录页。登录成功后 session 变化,父视图自动切走。 +struct LoginRequiredView: View { + var message: String = "登录后查看" + @Environment(SessionStore.self) private var session + @State private var showLogin = false + + var body: some View { + VStack(spacing: 20) { + Image(systemName: "person.crop.circle.badge.questionmark") + .font(.system(size: 60)) + .foregroundStyle(AppColors.gray400) + + Text(message) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + + Button { + showLogin = true + } label: { + Text("登录 / 注册") + .font(.headline) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(AppColors.primary) + .foregroundStyle(.black) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + .padding(.horizontal, 40) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .sheet(isPresented: $showLogin) { + LoginView().environment(session) + } + } +} diff --git a/App/Features/Feedback/FeedbackView.swift b/App/Features/Feedback/FeedbackView.swift new file mode 100644 index 0000000..ac8b941 --- /dev/null +++ b/App/Features/Feedback/FeedbackView.swift @@ -0,0 +1,84 @@ +import SwiftUI +import PhotosUI + +/// 帮助与反馈:内容 + 联系方式 + 可选截图(≤3 张,压缩后上传)。 +struct FeedbackView: View { + @Environment(\.dismiss) private var dismiss + @State private var content = "" + @State private var contact = "" + @State private var pickedItems: [PhotosPickerItem] = [] + @State private var imageDatas: [Data] = [] + @State private var submitting = false + @State private var toast: String? + private let service = FeedbackService() + + var body: some View { + Form { + Section("反馈内容") { + TextField("说说你遇到的问题或建议…", text: $content, axis: .vertical) + .lineLimit(4...8) + } + Section("联系方式(选填)") { + TextField("手机号 / 微信,方便我们联系你", text: $contact) + } + Section("截图(选填)") { + PhotosPicker(selection: $pickedItems, maxSelectionCount: 3, matching: .images) { + Label("添加截图", systemImage: "photo.on.rectangle") + } + if !imageDatas.isEmpty { + Text("已选 \(imageDatas.count) 张").font(.caption).foregroundStyle(.secondary) + } + } + Section { + Button { + Task { await submit() } + } label: { + Text(submitting ? "提交中…" : "提交反馈").frame(maxWidth: .infinity) + } + .disabled(submitting || content.isEmpty) + } + } + .navigationTitle("帮助与反馈") + .navigationBarTitleDisplayMode(.inline) + .onChange(of: pickedItems) { _, items in + Task { await loadImages(items) } + } + .overlay(alignment: .bottom) { toastView } + } + + @ViewBuilder private var toastView: some View { + if let toast { + Text(toast) + .font(.subheadline) + .padding(.horizontal, 16).padding(.vertical, 10) + .background(.black.opacity(0.8)).foregroundStyle(.white) + .clipShape(Capsule()).padding(.bottom, 30) + .task(id: toast) { + try? await Task.sleep(for: .milliseconds(1500)) + if toast.contains("成功") { dismiss() } else { self.toast = nil } + } + } + } + + private func loadImages(_ items: [PhotosPickerItem]) async { + var datas: [Data] = [] + for item in items { + if let raw = try? await item.loadTransferable(type: Data.self), + let jpeg = ImageDownsampler.jpeg(raw) { + datas.append(jpeg) + } + } + imageDatas = datas + } + + private func submit() async { + submitting = true + defer { submitting = false } + do { + _ = try await service.submit(content: content, contact: contact, images: imageDatas) + toast = "提交成功,感谢反馈" + } catch { + toast = (error as? LocalizedError)?.errorDescription ?? "提交失败,请重试" + } + } +} diff --git a/App/Features/Home/CouponCardView.swift b/App/Features/Home/CouponCardView.swift new file mode 100644 index 0000000..2c96052 --- /dev/null +++ b/App/Features/Home/CouponCardView.swift @@ -0,0 +1,59 @@ +import SwiftUI + +/// 单张优惠券卡片,对齐安卓 FeedCard:图 + 券名 + 门店 + 到手价/原价 + 「抢」。 +struct CouponCardView: View { + let card: CouponCard + let onTap: () -> Void + + var body: some View { + HStack(spacing: 12) { + AsyncImage(url: URL(string: card.headImageUrl)) { phase in + switch phase { + case .success(let image): + image.resizable().aspectRatio(contentMode: .fill) + default: + AppColors.gray100 + } + } + .frame(width: 84, height: 84) + .clipShape(RoundedRectangle(cornerRadius: 10)) + + VStack(alignment: .leading, spacing: 4) { + Text(card.name) + .font(.subheadline) + .lineLimit(2) + if let poi = card.poiName, !poi.isEmpty { + Text(poi) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text("¥\(card.sellPrice)") + .font(.headline) + .foregroundStyle(AppColors.hot) + Text("¥\(card.originalPrice)") + .font(.caption) + .strikethrough() + .foregroundStyle(.secondary) + if let dist = card.distanceText, !dist.isEmpty { + Spacer(minLength: 0) + Text(dist).font(.caption2).foregroundStyle(.secondary) + } + } + } + + Button(action: onTap) { + Text("抢") + .font(.subheadline.bold()) + .padding(.horizontal, 18) + .padding(.vertical, 8) + .background(AppColors.primary) + .foregroundStyle(.black) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + } + .padding(.vertical, 6) + } +} diff --git a/App/Features/Home/HomeView.swift b/App/Features/Home/HomeView.swift new file mode 100644 index 0000000..4db8709 --- /dev/null +++ b/App/Features/Home/HomeView.swift @@ -0,0 +1,69 @@ +import SwiftUI + +/// 首页(导购)。不设防(未登录可浏览)。进入时先用默认坐标出券,定位回来再刷新成附近的。 +struct HomeView: View { + @State private var vm = HomeViewModel() + @State private var locationManager = LocationManager() + @Environment(\.openURL) private var openURL + + var body: some View { + NavigationStack { + content + .navigationTitle("傻瓜比价") + } + .task { + await vm.loadFirstPage() + // 首屏已出券;定位回来若成功,刷新成附近的(失败则保持默认坐标结果) + if let coord = await locationManager.requestOnce() { + await vm.updateLocation(longitude: coord.longitude, latitude: coord.latitude) + } + } + } + + @ViewBuilder + private var content: some View { + if vm.isLoading && vm.coupons.isEmpty { + ProgressView("加载中…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if vm.coupons.isEmpty { + ContentUnavailableView { + Label("暂时没有优惠", systemImage: "ticket") + } description: { + Text(vm.errorMessage ?? "下拉刷新试试") + } + } else { + List { + ForEach(vm.coupons) { card in + CouponCardView(card: card) { + Task { await openCard(card) } + } + .listRowSeparator(.hidden) + } + + if vm.hasNext { + HStack { + Spacer() + ProgressView() + Spacer() + } + .listRowSeparator(.hidden) + .onAppear { Task { await vm.loadNextPage() } } + } + } + .listStyle(.plain) + .refreshable { await vm.loadFirstPage() } + } + } + + /// 点券:换推广链接 → deeplink 优先唤起美团 App,唤不起用 H5 兜底。 + private func openCard(_ card: CouponCard) async { + let links = await vm.referralLinks(for: card) + if let deeplink = links.deeplink { + openURL(deeplink) { accepted in + if !accepted, let h5 = links.h5 { openURL(h5) } + } + } else if let h5 = links.h5 { + openURL(h5) + } + } +} diff --git a/App/Features/Home/HomeViewModel.swift b/App/Features/Home/HomeViewModel.swift new file mode 100644 index 0000000..296b429 --- /dev/null +++ b/App/Features/Home/HomeViewModel.swift @@ -0,0 +1,70 @@ +import SwiftUI + +/// 首页(导购)逻辑,对齐安卓 HomeViewModel:feed 分页 + 定位更新 + 换推广链接。 +@MainActor +@Observable +final class HomeViewModel { + private(set) var coupons: [CouponCard] = [] + private(set) var isLoading = false + private(set) var isLoadingMore = false + private(set) var hasNext = true + private(set) var errorMessage: String? + + // 默认天安门,定位失败兜底(对齐安卓) + private var longitude = 116.404 + private var latitude = 39.928 + private var page = 1 + private let service = MeituanService() + + func loadFirstPage() async { + guard !isLoading else { return } + isLoading = true + errorMessage = nil + defer { isLoading = false } + do { + let resp = try await service.feed(longitude: longitude, latitude: latitude, page: 1) + coupons = resp.items + page = resp.page + hasNext = resp.hasNext + } catch { + errorMessage = (error as? LocalizedError)?.errorDescription ?? "加载失败,请下拉重试" + } + } + + func loadNextPage() async { + guard hasNext, !isLoading, !isLoadingMore else { return } + isLoadingMore = true + defer { isLoadingMore = false } + do { + let resp = try await service.feed(longitude: longitude, latitude: latitude, page: page + 1) + coupons += resp.items + page = resp.page + hasNext = resp.hasNext + } catch { + // 加载更多失败静默,不打断已有列表 + } + } + + /// 拿到真实定位后刷新成附近的券。 + func updateLocation(longitude: Double, latitude: Double) async { + self.longitude = longitude + self.latitude = latitude + await loadFirstPage() + } + + /// 点券 → 换推广链接 → 返回(deeplink 优先唤起美团 App,H5 兜底)。 + func referralLinks(for card: CouponCard) async -> (deeplink: URL?, h5: URL?) { + do { + let resp = try await service.referralLink( + productViewSign: card.productViewSign, + platform: card.platform, + bizLine: card.bizLine + ) + let deeplink = resp.linkMap["3"].flatMap { URL(string: $0) } + let h5raw = resp.linkMap["1"] ?? resp.link + return (deeplink, URL(string: h5raw)) + } catch { + return (nil, nil) + } + } +} diff --git a/App/Features/Invite/InviteView.swift b/App/Features/Invite/InviteView.swift new file mode 100644 index 0000000..e11abe0 --- /dev/null +++ b/App/Features/Invite/InviteView.swift @@ -0,0 +1,54 @@ +import SwiftUI +import UIKit + +/// 邀请好友(mock:后端暂无邀请接口,用 user.id 当邀请码占位,对齐安卓 InviteScreen 的 mock 状态)。 +struct InviteView: View { + @Environment(SessionStore.self) private var session + @State private var copied = false + + private var inviteCode: String { + if let id = session.user?.id { return String(format: "%06d", id) } + return "------" + } + + var body: some View { + VStack(spacing: 20) { + Image(systemName: "gift.fill") + .font(.system(size: 56)) + .foregroundStyle(AppColors.brand) + Text("邀请好友一起省钱").font(.title3.bold()) + Text("把傻瓜比价推荐给好友,一起比价领券") + .font(.subheadline).foregroundStyle(.secondary) + .multilineTextAlignment(.center) + + VStack(spacing: 6) { + Text("我的邀请码").font(.caption).foregroundStyle(.secondary) + Text(inviteCode).font(.system(.largeTitle, design: .monospaced).bold()) + } + .padding(24) + .frame(maxWidth: .infinity) + .background(AppColors.primaryBg) + .clipShape(RoundedRectangle(cornerRadius: 14)) + .padding(.horizontal, 32) + + Button { + UIPasteboard.general.string = inviteCode + copied = true + } label: { + Text(copied ? "已复制" : "复制邀请码") + .font(.headline) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(AppColors.primary) + .foregroundStyle(.black) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + .padding(.horizontal, 32) + + Spacer() + } + .padding(.top, 40) + .navigationTitle("邀请好友") + .navigationBarTitleDisplayMode(.inline) + } +} diff --git a/App/Features/Login/LoginView.swift b/App/Features/Login/LoginView.swift new file mode 100644 index 0000000..8bc072a --- /dev/null +++ b/App/Features/Login/LoginView.swift @@ -0,0 +1,110 @@ +import SwiftUI + +/// 短信登录页(sheet 形式)。登录成功后 dismiss,守卫页因 session 变化自动切到已登录内容。 +struct LoginView: View { + @Environment(SessionStore.self) private var session + @Environment(\.dismiss) private var dismiss + @State private var vm = LoginViewModel() + + var body: some View { + NavigationStack { + VStack(spacing: 24) { + header + + VStack(spacing: 14) { + phoneField + codeRow + } + + loginButton + + if vm.isMock { + Text("体验模式:任意 6 位验证码即可登录") + .font(.caption) + .foregroundStyle(AppColors.warning) + } + if let err = vm.errorMessage { + Text(err).font(.footnote).foregroundStyle(AppColors.error) + } + + Spacer() + } + .padding(24) + .navigationTitle("手机号登录") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("关闭") { dismiss() } + } + } + } + } + + private var header: some View { + VStack(spacing: 10) { + Image(systemName: "tag.fill") + .font(.system(size: 48)) + .foregroundStyle(AppColors.brand) + Text("登录后查看你的省钱数据") + .font(.subheadline) + .foregroundStyle(.secondary) + } + .padding(.top, 12) + } + + private var phoneField: some View { + TextField("请输入手机号", text: $vm.phone) + .keyboardType(.numberPad) + .textContentType(.telephoneNumber) + .padding() + .background(AppColors.gray100) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .onChange(of: vm.phone) { _, new in + vm.phone = String(new.filter(\.isNumber).prefix(11)) + } + } + + private var codeRow: some View { + HStack(spacing: 12) { + TextField("验证码", text: $vm.code) + .keyboardType(.numberPad) + .textContentType(.oneTimeCode) + .padding() + .background(AppColors.gray100) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .onChange(of: vm.code) { _, new in + vm.code = String(new.filter(\.isNumber).prefix(6)) + } + + Button { + Task { await vm.sendCode() } + } label: { + Text(vm.countdown > 0 ? "\(vm.countdown)s" : "发送验证码") + .font(.subheadline) + .frame(width: 100) + .padding(.vertical, 14) + .background(vm.canSendCode ? AppColors.primary : AppColors.gray200) + .foregroundStyle(vm.canSendCode ? .black : AppColors.gray500) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + .disabled(!vm.canSendCode) + } + } + + private var loginButton: some View { + Button { + Task { + if await vm.login(into: session) { dismiss() } + } + } label: { + Text(vm.isLoggingIn ? "登录中…" : "登录") + .font(.headline) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background(vm.canLogin ? AppColors.primary : AppColors.gray200) + .foregroundStyle(vm.canLogin ? .black : AppColors.gray500) + .clipShape(RoundedRectangle(cornerRadius: 14)) + } + .disabled(!vm.canLogin) + } +} diff --git a/App/Features/Login/LoginViewModel.swift b/App/Features/Login/LoginViewModel.swift new file mode 100644 index 0000000..9329aec --- /dev/null +++ b/App/Features/Login/LoginViewModel.swift @@ -0,0 +1,67 @@ +import SwiftUI + +/// 短信登录状态机(对齐安卓 SmsLoginViewModel)。一键登录首版不做。 +@MainActor +@Observable +final class LoginViewModel { + var phone: String = "" + var code: String = "" + var countdown: Int = 0 + var isSending = false + var isLoggingIn = false + var isMock = false + var errorMessage: String? + + private let auth = AuthService() + private var timerTask: Task? + + var isValidPhone: Bool { phone.count == 11 && phone.hasPrefix("1") } + var canSendCode: Bool { countdown == 0 && !isSending && isValidPhone } + var canLogin: Bool { isValidPhone && code.count >= 4 && !isLoggingIn } + + func sendCode() async { + guard canSendCode else { return } + errorMessage = nil + isSending = true + defer { isSending = false } + do { + let resp = try await auth.smsSend(phone: phone) + isMock = resp.mock + startCountdown(resp.cooldownSec > 0 ? resp.cooldownSec : 60) + } catch { + errorMessage = (error as? LocalizedError)?.errorDescription ?? "验证码发送失败,请重试" + } + } + + /// 登录成功则已写入 session,返回 true(供 View 关闭页面)。 + func login(into session: SessionStore) async -> Bool { + guard canLogin else { return false } + errorMessage = nil + isLoggingIn = true + defer { isLoggingIn = false } + do { + let resp = try await auth.smsLogin(phone: phone, code: code) + await session.loginSucceeded(resp) + return true + } catch APIError.http(let status, _) where status == 400 { + errorMessage = "验证码错误或已失效,请重新获取" + return false + } catch { + errorMessage = (error as? LocalizedError)?.errorDescription ?? "登录失败,请重试" + return false + } + } + + private func startCountdown(_ seconds: Int) { + countdown = seconds + timerTask?.cancel() + timerTask = Task { @MainActor [weak self] in + while true { + do { try await Task.sleep(for: .seconds(1)) } + catch { return } // 被 cancel(重发时)→ 立即退出,不再误减新一轮计时 + guard let self, self.countdown > 0 else { return } + self.countdown -= 1 + } + } + } +} diff --git a/App/Features/Profile/ProfileEditView.swift b/App/Features/Profile/ProfileEditView.swift new file mode 100644 index 0000000..6577f20 --- /dev/null +++ b/App/Features/Profile/ProfileEditView.swift @@ -0,0 +1,113 @@ +import SwiftUI +import PhotosUI + +/// 编辑资料:头像(PHPicker 选图上传)+ 昵称(改后 PATCH)。 +struct ProfileEditView: View { + @Environment(SessionStore.self) private var session + @State private var nickname = "" + @State private var avatarItem: PhotosPickerItem? + @State private var saving = false + @State private var uploading = false + @State private var toast: String? + private let service = UserService() + + var body: some View { + Form { + Section { + HStack { + Spacer() + PhotosPicker(selection: $avatarItem, matching: .images) { + avatarThumb + } + Spacer() + } + } + Section("昵称") { + TextField("起个昵称", text: $nickname) + .onChange(of: nickname) { _, new in + nickname = String(new.prefix(16)) + } + } + Section { + Button { + Task { await saveNickname() } + } label: { + Text(saving ? "保存中…" : "保存").frame(maxWidth: .infinity) + } + .disabled(saving || nickname.isEmpty) + } + } + .navigationTitle("编辑资料") + .navigationBarTitleDisplayMode(.inline) + .onAppear { if nickname.isEmpty { nickname = session.user?.nickname ?? "" } } + .onChange(of: avatarItem) { _, item in + if let item { Task { await uploadAvatar(item) } } + } + .overlay(alignment: .bottom) { toastView } + } + + private var avatarThumb: some View { + AsyncImage(url: AppConfig.mediaURL(session.user?.avatarUrl)) { phase in + if case .success(let image) = phase { + image.resizable().aspectRatio(contentMode: .fill) + } else { + Image(systemName: "person.crop.circle.fill") + .resizable() + .foregroundStyle(AppColors.gray300) + } + } + .frame(width: 88, height: 88) + .clipShape(Circle()) + .overlay(alignment: .bottomTrailing) { + Image(systemName: "camera.circle.fill") + .font(.title3) + .foregroundStyle(AppColors.brand) + .background(Circle().fill(.white)) + } + .opacity(uploading ? 0.5 : 1) + } + + @ViewBuilder private var toastView: some View { + if let toast { + Text(toast) + .font(.subheadline) + .padding(.horizontal, 16).padding(.vertical, 10) + .background(.black.opacity(0.8)).foregroundStyle(.white) + .clipShape(Capsule()).padding(.bottom, 30) + .task(id: toast) { + try? await Task.sleep(for: .seconds(2)) + self.toast = nil + } + } + } + + private func saveNickname() async { + saving = true + defer { saving = false } + do { + let updated = try await service.updateNickname(nickname) + session.updateUser(updated) + toast = "昵称已保存" + } catch { + toast = (error as? LocalizedError)?.errorDescription ?? "保存失败" + } + } + + private func uploadAvatar(_ item: PhotosPickerItem) async { + uploading = true + // 重置 avatarItem,使重选同一张图仍能再次触发(PhotosPickerItem 同图会生成新实例,但置 nil 更稳) + defer { uploading = false; avatarItem = nil } + guard let raw = try? await item.loadTransferable(type: Data.self), + let jpeg = ImageDownsampler.jpeg(raw) else { + toast = "图片读取失败" + return + } + do { + let updated = try await service.uploadAvatar(jpeg: jpeg) + session.updateUser(updated) + toast = "头像已更新" + } catch { + toast = (error as? LocalizedError)?.errorDescription ?? "上传失败" + } + } +} diff --git a/App/Features/Profile/ProfileView.swift b/App/Features/Profile/ProfileView.swift new file mode 100644 index 0000000..7875804 --- /dev/null +++ b/App/Features/Profile/ProfileView.swift @@ -0,0 +1,102 @@ +import SwiftUI + +/// 我的。未登录显示登录引导;已登录显示账户信息 + 菜单(子页 M3/M5 接) + 退出登录。 +struct ProfileView: View { + @Environment(SessionStore.self) private var session + @State private var showLogoutConfirm = false + + var body: some View { + NavigationStack { + Group { + if session.isLoggedIn { + loggedInContent + } else { + LoginRequiredView(message: "登录后查看你的账户、比价记录与省钱战绩") + } + } + .navigationTitle("我的") + .task { await session.refreshProfile() } + } + } + + private var loggedInContent: some View { + List { + Section { + NavigationLink { + ProfileEditView() + } label: { + HStack(spacing: 14) { + avatarView + VStack(alignment: .leading, spacing: 4) { + Text(session.user?.nickname ?? "傻瓜比价用户") + .font(.headline) + if let phone = session.user?.phone { + Text(phone.maskedPhone) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + } + .padding(.vertical, 6) + } + } + + Section { + NavigationLink { + CompareRecordsView() + } label: { + Label("我的比价记录", systemImage: "list.bullet.rectangle") + } + NavigationLink { + SavingsView() + } label: { + Label("省钱战绩", systemImage: "chart.line.uptrend.xyaxis") + } + } + + Section { + NavigationLink { + FeedbackView() + } label: { + Label("帮助与反馈", systemImage: "questionmark.circle") + } + NavigationLink { + InviteView() + } label: { + Label("邀请好友", systemImage: "person.2") + } + NavigationLink { + SettingsView() + } label: { + Label("设置", systemImage: "gearshape") + } + } + + Section { + Button(role: .destructive) { + showLogoutConfirm = true + } label: { + Text("退出登录").frame(maxWidth: .infinity) + } + } + } + .alert("确认退出登录?", isPresented: $showLogoutConfirm) { + Button("退出", role: .destructive) { Task { await session.logout() } } + Button("取消", role: .cancel) {} + } + } + + private var avatarView: some View { + AsyncImage(url: AppConfig.mediaURL(session.user?.avatarUrl)) { phase in + if case .success(let image) = phase { + image.resizable().aspectRatio(contentMode: .fill) + } else { + Image(systemName: "person.crop.circle.fill") + .resizable() + .foregroundStyle(AppColors.gray300) + } + } + .frame(width: 52, height: 52) + .clipShape(Circle()) + } +} diff --git a/App/Features/Records/CompareRecordsView.swift b/App/Features/Records/CompareRecordsView.swift new file mode 100644 index 0000000..dbd04f8 --- /dev/null +++ b/App/Features/Records/CompareRecordsView.swift @@ -0,0 +1,85 @@ +import SwiftUI + +/// 我的比价记录(每次比价的概要,含成功/失败)。数据由安卓端比价后上报,iOS 只读。 +struct CompareRecordsView: View { + @State private var vm = CompareRecordsViewModel() + + var body: some View { + Group { + if vm.isLoading && vm.records.isEmpty { + ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) + } else if vm.loaded && vm.records.isEmpty { + ContentUnavailableView { + Label("还没有比价记录", systemImage: "list.bullet.rectangle") + } description: { + Text(vm.errorMessage ?? "在安卓 App 上完成比价后,记录会自动同步到这里") + } + } else { + List { + ForEach(vm.records) { record in + CompareRecordRow(record: record) + .listRowSeparator(.hidden) + } + if vm.hasMore { + HStack { Spacer(); ProgressView(); Spacer() } + .listRowSeparator(.hidden) + .onAppear { Task { await vm.loadMore() } } + } + } + .listStyle(.plain) + .refreshable { await vm.loadFirst() } + } + } + .navigationTitle("我的比价记录") + .navigationBarTitleDisplayMode(.inline) + .task { if !vm.loaded { await vm.loadFirst() } } + } +} + +private struct CompareRecordRow: View { + let record: ComparisonRecordDTO + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(record.storeName ?? "比价记录") + .font(.headline) + .lineLimit(1) + Spacer() + Text(AppDate.friendly(record.createdAt)) + .font(.caption) + .foregroundStyle(.secondary) + } + + if record.status == "success" { + HStack(spacing: 6) { + if let sp = record.sourcePlatformName, let spc = record.sourcePriceCents { + Text("\(sp) \(spc.centsToYuanText)") + .strikethrough(record.isSourceBest != true) + .foregroundStyle(.secondary) + } + Image(systemName: "arrow.right").font(.caption2).foregroundStyle(.secondary) + if let bp = record.bestPlatformName, let bpc = record.bestPriceCents { + Text("\(bp) \(bpc.centsToYuanText)").bold() + } + } + .font(.subheadline) + + if let saved = record.savedAmountCents, saved > 0 { + Text("省 \(saved.centsToYuanText)") + .font(.subheadline.bold()) + .foregroundStyle(AppColors.success) + } else if record.isSourceBest == true { + Text("源平台已是最低价") + .font(.caption) + .foregroundStyle(.secondary) + } + } else { + Text(record.information ?? "本次比价未完成") + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 6) + } +} diff --git a/App/Features/Records/CompareRecordsViewModel.swift b/App/Features/Records/CompareRecordsViewModel.swift new file mode 100644 index 0000000..05c3bbd --- /dev/null +++ b/App/Features/Records/CompareRecordsViewModel.swift @@ -0,0 +1,45 @@ +import SwiftUI + +/// 比价记录列表(游标分页),对齐安卓 CompareRecordsViewModel。 +@MainActor +@Observable +final class CompareRecordsViewModel { + private(set) var records: [ComparisonRecordDTO] = [] + private(set) var isLoading = false + private(set) var isLoadingMore = false + private(set) var hasMore = false + private(set) var loaded = false + private(set) var errorMessage: String? + + private var cursor: Int? + private let service = CompareService() + + func loadFirst() async { + guard !isLoading else { return } + isLoading = true + errorMessage = nil + defer { isLoading = false; loaded = true } + do { + let page = try await service.records(cursor: nil) + records = page.items + cursor = page.nextCursor + hasMore = page.nextCursor != nil + } catch { + errorMessage = (error as? LocalizedError)?.errorDescription ?? "加载失败,请下拉重试" + } + } + + func loadMore() async { + guard hasMore, !isLoading, !isLoadingMore, let cursor else { return } + isLoadingMore = true + defer { isLoadingMore = false } + do { + let page = try await service.records(cursor: cursor) + records += page.items + self.cursor = page.nextCursor + hasMore = page.nextCursor != nil + } catch { + // 加载更多失败静默 + } + } +} diff --git a/App/Features/Records/SavingsView.swift b/App/Features/Records/SavingsView.swift new file mode 100644 index 0000000..afba659 --- /dev/null +++ b/App/Features/Records/SavingsView.swift @@ -0,0 +1,125 @@ +import SwiftUI + +/// 省钱战绩:顶部累计/战绩卡 + 省钱订单明细(实际下单省了多少)。 +struct SavingsView: View { + @State private var vm = SavingsViewModel() + + var body: some View { + Group { + if vm.isLoading && vm.summary == nil && vm.records.isEmpty { + ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + List { + Section { + SavingsHeaderCard(summary: vm.summary, battle: vm.battle) + } + + if vm.loaded && vm.records.isEmpty { + Section { + Text("还没有省钱订单。比价后下单,省了多少会记在这里。") + .font(.subheadline) + .foregroundStyle(.secondary) + } + } else { + Section("省钱明细") { + ForEach(vm.records) { SavingsRecordRow(record: $0) } + if vm.hasMore { + HStack { Spacer(); ProgressView(); Spacer() } + .onAppear { Task { await vm.loadMore() } } + } + } + } + } + .listStyle(.insetGrouped) + .refreshable { await vm.loadFirst() } + } + } + .navigationTitle("省钱战绩") + .navigationBarTitleDisplayMode(.inline) + .task { if !vm.loaded { await vm.loadFirst() } } + } +} + +private struct SavingsHeaderCard: View { + let summary: SavingsSummaryDTO? + let battle: SavingsBattleDTO? + + var body: some View { + VStack(spacing: 12) { + Text("累计帮你省了") + .font(.subheadline) + .foregroundStyle(.secondary) + Text((summary?.totalSavedCents ?? 0).centsToYuanText) + .font(.system(size: 36, weight: .bold)) + .foregroundStyle(AppColors.success) + Text("共 \(summary?.orderCount ?? 0) 单 · 平均每单省 \((summary?.avgSavedCents ?? 0).centsToYuanText)") + .font(.caption) + .foregroundStyle(.secondary) + + Divider() + + HStack { + stat("本周省", (battle?.weekSavedCents ?? 0).centsToYuanText) + Divider() + stat("击败用户", "\(battle?.beatPercent ?? 0)%") + Divider() + stat("连续打卡", "\(battle?.streakDays ?? 0)天") + } + .frame(height: 44) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + } + + private func stat(_ title: String, _ value: String) -> some View { + VStack(spacing: 4) { + Text(value).font(.headline) + Text(title).font(.caption2).foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + } +} + +private struct SavingsRecordRow: View { + let record: SavingsRecordDTO + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(record.platform ?? record.shopName ?? "订单") + .font(.subheadline.bold()) + .lineLimit(1) + Spacer() + Text(AppDate.friendly(record.createdAt)) + .font(.caption2) + .foregroundStyle(.secondary) + } + + if !record.dishes.isEmpty { + Text(record.dishes.joined(separator: "、")) + .font(.caption).foregroundStyle(.secondary).lineLimit(1) + } else if let title = record.title { + Text(title).font(.caption).foregroundStyle(.secondary).lineLimit(1) + } + + HStack(spacing: 8) { + Text("实付 \(record.orderAmountCents.centsToYuanText)") + .font(.subheadline) + if let orig = record.originalPriceCents, orig > record.orderAmountCents { + Text(orig.centsToYuanText) + .font(.caption).strikethrough().foregroundStyle(.secondary) + } + Spacer() + if record.savedAmountCents > 0 { + Text("省\(record.savedAmountCents.centsToYuanText)") + .font(.caption.bold()) + .padding(.horizontal, 8).padding(.vertical, 3) + .background(AppColors.successBg) + .foregroundStyle(AppColors.success) + .clipShape(Capsule()) + } + } + } + .padding(.vertical, 4) + } +} diff --git a/App/Features/Records/SavingsViewModel.swift b/App/Features/Records/SavingsViewModel.swift new file mode 100644 index 0000000..c100bf4 --- /dev/null +++ b/App/Features/Records/SavingsViewModel.swift @@ -0,0 +1,48 @@ +import SwiftUI + +/// 省钱战绩 = 累计汇总(summary)+ 战绩(battle)+ 省钱订单明细(records 分页)。 +@MainActor +@Observable +final class SavingsViewModel { + private(set) var summary: SavingsSummaryDTO? + private(set) var battle: SavingsBattleDTO? + private(set) var records: [SavingsRecordDTO] = [] + private(set) var isLoading = false + private(set) var isLoadingMore = false + private(set) var hasMore = false + private(set) var loaded = false + + private var cursor: Int? + private let service = SavingsService() + + func loadFirst() async { + guard !isLoading else { return } + isLoading = true + defer { isLoading = false; loaded = true } + // 三个接口并发拉,各自容错(某个失败不影响其它) + async let summaryTask = service.summary() + async let battleTask = service.battle() + async let recordsTask = service.records(cursor: nil) + + summary = try? await summaryTask + battle = try? await battleTask + if let page = try? await recordsTask { + records = page.items + cursor = page.nextCursor + hasMore = page.nextCursor != nil + } else { + hasMore = false + } + } + + func loadMore() async { + guard hasMore, !isLoading, !isLoadingMore, let cursor else { return } + isLoadingMore = true + defer { isLoadingMore = false } + if let page = try? await service.records(cursor: cursor) { + records += page.items + self.cursor = page.nextCursor + hasMore = page.nextCursor != nil + } + } +} diff --git a/App/Features/Settings/AboutView.swift b/App/Features/Settings/AboutView.swift new file mode 100644 index 0000000..eb1d35c --- /dev/null +++ b/App/Features/Settings/AboutView.swift @@ -0,0 +1,35 @@ +import SwiftUI + +/// 关于。M6 上架前用户协议替换为正式文本(暂复用隐私政策页占位)。 +struct AboutView: View { + var body: some View { + List { + Section { + VStack(spacing: 10) { + Image(systemName: "tag.fill") + .font(.system(size: 52)) + .foregroundStyle(AppColors.brand) + Text("傻瓜比价").font(.title2.bold()) + Text("版本 \(appVersion)") + .font(.caption).foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } + Section { + NavigationLink { PrivacyPolicyView() } label: { Text("隐私政策") } + NavigationLink { PrivacyPolicyView() } label: { Text("用户协议") } + } + Section { + Text("买东西前先比一比,自动帮你找全网最低价、领券省钱。") + .font(.footnote).foregroundStyle(.secondary) + } + } + .navigationTitle("关于") + .navigationBarTitleDisplayMode(.inline) + } + + private var appVersion: String { + (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) ?? "1.0.0" + } +} diff --git a/App/Features/Settings/SettingsView.swift b/App/Features/Settings/SettingsView.swift new file mode 100644 index 0000000..aeed43d --- /dev/null +++ b/App/Features/Settings/SettingsView.swift @@ -0,0 +1,52 @@ +import SwiftUI +import UIKit + +/// 设置。iOS 无无障碍/悬浮窗那套权限卡(安卓特有),只保留账户/通用。 +struct SettingsView: View { + @Environment(SessionStore.self) private var session + @State private var showDeleteConfirm = false + @State private var deleting = false + private let service = UserService() + + var body: some View { + List { + Section("通用") { + NavigationLink { AboutView() } label: { Label("关于傻瓜比价", systemImage: "info.circle") } + NavigationLink { PrivacyPolicyView() } label: { Label("隐私政策", systemImage: "lock.shield") } + Button { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + } label: { + Label("通知设置", systemImage: "bell") + } + } + + Section { + Button(role: .destructive) { + showDeleteConfirm = true + } label: { + Text(deleting ? "注销中…" : "注销账号") + } + .disabled(deleting) + } footer: { + Text("注销后,你的资料、金币、记录将被删除或匿名化,不可恢复。") + } + } + .navigationTitle("设置") + .navigationBarTitleDisplayMode(.inline) + .alert("确认注销账号?", isPresented: $showDeleteConfirm) { + Button("注销", role: .destructive) { Task { await deleteAccount() } } + Button("取消", role: .cancel) {} + } message: { + Text("此操作不可恢复。") + } + } + + private func deleteAccount() async { + deleting = true + defer { deleting = false } + _ = try? await service.deleteAccount() + await session.logout() // 注销后登出;ProfileView 切回登录引导,导航栈自动重置 + } +} diff --git a/App/Features/Welfare/CoinHistoryView.swift b/App/Features/Welfare/CoinHistoryView.swift new file mode 100644 index 0000000..4e4b9f4 --- /dev/null +++ b/App/Features/Welfare/CoinHistoryView.swift @@ -0,0 +1,46 @@ +import SwiftUI + +/// 金币明细(流水)。从福利页金币卡进入。 +struct CoinHistoryView: View { + @State private var vm = CoinHistoryViewModel() + + var body: some View { + Group { + if vm.isLoading && vm.items.isEmpty { + ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) + } else if vm.loaded && vm.items.isEmpty { + ContentUnavailableView("还没有金币记录", systemImage: "circle.hexagongrid") + } else { + List { + ForEach(vm.items) { tx in + coinRow(tx) + } + if vm.hasMore { + HStack { Spacer(); ProgressView(); Spacer() } + .onAppear { Task { await vm.loadMore() } } + } + } + .listStyle(.plain) + .refreshable { await vm.loadFirst() } + } + } + .navigationTitle("金币明细") + .navigationBarTitleDisplayMode(.inline) + .task { if !vm.loaded { await vm.loadFirst() } } + } + + private func coinRow(_ tx: CoinTransactionDTO) -> some View { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(WelfareCatalog.coinBizText(tx)).font(.subheadline) + Text(AppDate.friendly(tx.createdAt)) + .font(.caption2).foregroundStyle(.secondary) + } + Spacer() + Text("\(tx.amount > 0 ? "+" : "")\(tx.amount)") + .font(.subheadline.bold()) + .foregroundStyle(tx.amount > 0 ? AppColors.coin : AppColors.gray600) + } + .padding(.vertical, 2) + } +} diff --git a/App/Features/Welfare/CoinHistoryViewModel.swift b/App/Features/Welfare/CoinHistoryViewModel.swift new file mode 100644 index 0000000..5bbdb6d --- /dev/null +++ b/App/Features/Welfare/CoinHistoryViewModel.swift @@ -0,0 +1,39 @@ +import SwiftUI + +/// 金币流水(游标分页)。 +@MainActor +@Observable +final class CoinHistoryViewModel { + private(set) var items: [CoinTransactionDTO] = [] + private(set) var isLoading = false + private(set) var isLoadingMore = false + private(set) var hasMore = false + private(set) var loaded = false + + private var cursor: Int? + private let wallet = WalletService() + + func loadFirst() async { + guard !isLoading else { return } + isLoading = true + defer { isLoading = false; loaded = true } + if let page = try? await wallet.coinTransactions(cursor: nil) { + items = page.items + cursor = page.nextCursor + hasMore = page.nextCursor != nil + } else { + hasMore = false + } + } + + func loadMore() async { + guard hasMore, !isLoading, !isLoadingMore, let cursor else { return } + isLoadingMore = true + defer { isLoadingMore = false } + if let page = try? await wallet.coinTransactions(cursor: cursor) { + items += page.items + self.cursor = page.nextCursor + hasMore = page.nextCursor != nil + } + } +} diff --git a/App/Features/Welfare/WelfareCatalog.swift b/App/Features/Welfare/WelfareCatalog.swift new file mode 100644 index 0000000..48e4c25 --- /dev/null +++ b/App/Features/Welfare/WelfareCatalog.swift @@ -0,0 +1,30 @@ +import Foundation + +/// 后端只给 task_key / biz_type,前端做中文文案映射。任务现仅 enable_notification。 +enum WelfareCatalog { + static func taskTitle(_ key: String) -> String { + switch key { + case "enable_notification": return "开启推送通知" + default: return key + } + } + + static func taskDesc(_ key: String) -> String { + switch key { + case "enable_notification": return "接收降价提醒与省钱攻略" + default: return "" + } + } + + /// 金币流水的业务文案:remark 优先,否则按 biz_type 映射。 + static func coinBizText(_ tx: CoinTransactionDTO) -> String { + if let remark = tx.remark, !remark.isEmpty { return remark } + switch tx.bizType { + case "signin": return "每日签到" + case "ad_reward": return "看广告奖励" + case "exchange": return "兑换现金" + default: + return tx.bizType.hasPrefix("task_") ? "任务奖励" : tx.bizType + } + } +} diff --git a/App/Features/Welfare/WelfareView.swift b/App/Features/Welfare/WelfareView.swift new file mode 100644 index 0000000..b0c6c62 --- /dev/null +++ b/App/Features/Welfare/WelfareView.swift @@ -0,0 +1,167 @@ +import SwiftUI + +/// 福利:金币余额(只进不出,无提现出口)+ 每日签到 + 任务。未登录拦截。 +struct WelfareView: View { + @Environment(SessionStore.self) private var session + @State private var vm = WelfareViewModel() + + var body: some View { + NavigationStack { + Group { + if session.isLoggedIn { + content + } else { + LoginRequiredView(message: "登录后领取签到与任务奖励,查看省钱战绩") + } + } + .navigationTitle("福利") + } + .task(id: session.isLoggedIn) { + // 登录态变化(含换用户)/ 每次进入都重载;登出清空,防残留上一用户数据 + if session.isLoggedIn { + await vm.loadAll() + } else { + vm.reset() + } + } + } + + @ViewBuilder + private var content: some View { + List { + Section { coinCard } + Section("每日签到") { signinSection } + Section("任务") { + if vm.tasks.isEmpty { + Text("暂无任务").font(.subheadline).foregroundStyle(.secondary) + } else { + ForEach(vm.tasks) { taskRow($0) } + } + } + } + .listStyle(.insetGrouped) + .refreshable { await vm.loadAll() } + .overlay(alignment: .bottom) { toastView } + } + + private var coinCard: some View { + NavigationLink { CoinHistoryView() } label: { + HStack(spacing: 12) { + Image(systemName: "circle.hexagongrid.fill") + .font(.title2) + .foregroundStyle(AppColors.coin) + VStack(alignment: .leading, spacing: 2) { + Text("我的金币").font(.caption).foregroundStyle(.secondary) + Text("\(vm.account?.coinBalance ?? 0)").font(.title3.bold()) + } + Spacer() + Text("累计赚 \(vm.account?.totalCoinEarned ?? 0)") + .font(.caption).foregroundStyle(.secondary) + } + } + } + + @ViewBuilder + private var signinSection: some View { + VStack(spacing: 14) { + if let steps = vm.signin?.steps, !steps.isEmpty { + HStack(alignment: .top) { + ForEach(steps, id: \.day) { step in + VStack(spacing: 4) { + ZStack { + Circle().fill(circleColor(step.status)).frame(width: 34, height: 34) + if step.status == "claimed" { + Image(systemName: "checkmark").font(.caption).foregroundStyle(.white) + } else { + Text("\(step.coin)") + .font(.caption2) + .foregroundStyle(step.status == "today" ? .black : AppColors.gray500) + } + } + Text("第\(step.day)天").font(.caption2).foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + } + } + } + Button { + Task { await vm.doSignin() } + } label: { + Text(signinButtonText) + .font(.subheadline.bold()) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(signinEnabled ? AppColors.primary : AppColors.gray200) + .foregroundStyle(signinEnabled ? .black : AppColors.gray500) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + .disabled(!signinEnabled) + } + .padding(.vertical, 4) + } + + private func taskRow(_ task: TaskDTO) -> some View { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(WelfareCatalog.taskTitle(task.taskKey)).font(.subheadline) + let desc = WelfareCatalog.taskDesc(task.taskKey) + if !desc.isEmpty { + Text(desc).font(.caption).foregroundStyle(.secondary) + } + } + Spacer() + if task.claimed { + Text("已领取").font(.caption).foregroundStyle(.secondary) + } else { + Button { + Task { await vm.claimTask(task.taskKey) } + } label: { + Text("领\(task.coin)金币") + .font(.caption.bold()) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(AppColors.primary) + .foregroundStyle(.black) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + } + } + } + + @ViewBuilder + private var toastView: some View { + if let toast = vm.toast { + Text(toast) + .font(.subheadline) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(.black.opacity(0.8)) + .foregroundStyle(.white) + .clipShape(Capsule()) + .padding(.bottom, 30) + .task(id: toast) { + try? await Task.sleep(for: .seconds(2)) + vm.clearToast() + } + } + } + + private var signinEnabled: Bool { + vm.signin?.canClaim == true && !vm.signinInProgress + } + + private var signinButtonText: String { + if vm.signin?.todaySigned == true { return "今日已签到" } + if let coin = vm.signin?.todayCoin { return "签到领 \(coin) 金币" } + return "签到" + } + + private func circleColor(_ status: String) -> Color { + switch status { + case "claimed": return AppColors.success + case "today": return AppColors.primary + default: return AppColors.gray200 + } + } +} diff --git a/App/Features/Welfare/WelfareViewModel.swift b/App/Features/Welfare/WelfareViewModel.swift new file mode 100644 index 0000000..00a1e6e --- /dev/null +++ b/App/Features/Welfare/WelfareViewModel.swift @@ -0,0 +1,68 @@ +import SwiftUI + +/// 福利页主数据:金币余额 + 签到状态 + 任务列表,以及签到/领取操作。 +@MainActor +@Observable +final class WelfareViewModel { + private(set) var account: CoinAccountDTO? + private(set) var signin: SigninStatusDTO? + private(set) var tasks: [TaskDTO] = [] + private(set) var isLoading = false + private(set) var loaded = false + private(set) var signinInProgress = false + private(set) var toast: String? + + private let welfare = WelfareService() + private let wallet = WalletService() + + func loadAll() async { + guard !isLoading else { return } + isLoading = true + defer { isLoading = false; loaded = true } + await refresh() + } + + func doSignin() async { + guard signin?.canClaim == true, !signinInProgress else { return } + signinInProgress = true + defer { signinInProgress = false } + do { + let result = try await welfare.doSignin() + toast = "签到成功,+\(result.coinAwarded) 金币" + await refresh() + } catch { + toast = (error as? LocalizedError)?.errorDescription ?? "签到失败" + } + } + + func claimTask(_ key: String) async { + do { + let result = try await welfare.claimTask(taskKey: key) + toast = "领取成功,+\(result.coinAwarded) 金币" + await refresh() + } catch { + toast = (error as? LocalizedError)?.errorDescription ?? "领取失败" + } + } + + func clearToast() { toast = nil } + + /// 登出时清空,避免换用户后残留上一用户的金币/签到/任务数据。 + func reset() { + account = nil + signin = nil + tasks = [] + loaded = false + toast = nil + } + + /// 三接口并发拉,各自容错。 + private func refresh() async { + async let accountTask = wallet.account() + async let signinTask = welfare.signinStatus() + async let tasksTask = welfare.tasks() + account = try? await accountTask + signin = try? await signinTask + tasks = (try? await tasksTask)?.items ?? [] + } +} diff --git a/App/MainTabView.swift b/App/MainTabView.swift new file mode 100644 index 0000000..2b43798 --- /dev/null +++ b/App/MainTabView.swift @@ -0,0 +1,15 @@ +import SwiftUI + +/// 底部三 Tab,对齐安卓 AppNavHost 的 TAB_HOME / TAB_WELFARE / TAB_PROFILE。 +struct MainTabView: View { + var body: some View { + TabView { + HomeView() + .tabItem { Label("首页", systemImage: "house.fill") } + WelfareView() + .tabItem { Label("福利", systemImage: "gift.fill") } + ProfileView() + .tabItem { Label("我的", systemImage: "person.crop.circle.fill") } + } + } +} diff --git a/App/Networking/APIClient.swift b/App/Networking/APIClient.swift new file mode 100644 index 0000000..8e49617 --- /dev/null +++ b/App/Networking/APIClient.swift @@ -0,0 +1,192 @@ +import Foundation + +extension Notification.Name { + /// 刷新失败/登录彻底过期。SessionStore 监听后清登录态、UI 跳登录。 + static let authDidExpire = Notification.Name("authDidExpire") +} + +/// multipart 上传的单个文件部件。 +struct MultipartFile { + let name: String // 表单字段名(头像="file"、反馈="images") + let filename: String + let mimeType: String + let data: Data +} + +/// REST 客户端。自动注入 Bearer、401 自动刷新重放一次(对应安卓 AuthInterceptor + RefreshAuthenticator)。 +/// 全局 snake_case ↔ camelCase 编解码,所以 DTO 用 camelCase 属性、无需逐个 CodingKeys。 +final class APIClient: @unchecked Sendable { + static let shared = APIClient() + + private let baseURL: URL + private let session: URLSession + let decoder: JSONDecoder + let encoder: JSONEncoder + + init(baseURL: URL = AppConfig.apiBaseURL, session: URLSession = .shared) { + self.baseURL = baseURL + self.session = session + let d = JSONDecoder() + d.keyDecodingStrategy = .convertFromSnakeCase + self.decoder = d + let e = JSONEncoder() + e.keyEncodingStrategy = .convertToSnakeCase + self.encoder = e + } + + // MARK: 便捷方法 + + func get(_ path: String, query: [URLQueryItem]? = nil, auth: Bool = true) async throws -> R { + try await send(path: path, method: "GET", query: query, body: EmptyBody?.none, auth: auth) + } + + func post(_ path: String, body: B, auth: Bool = true) async throws -> R { + try await send(path: path, method: "POST", query: nil, body: body, auth: auth) + } + + func post(_ path: String, auth: Bool = true) async throws -> R { + try await send(path: path, method: "POST", query: nil, body: EmptyBody?.none, auth: auth) + } + + func patch(_ path: String, body: B, auth: Bool = true) async throws -> R { + try await send(path: path, method: "PATCH", query: nil, body: body, auth: auth) + } + + func delete(_ path: String, auth: Bool = true) async throws -> R { + try await send(path: path, method: "DELETE", query: nil, body: EmptyBody?.none, auth: auth) + } + + /// multipart/form-data 上传(头像、反馈截图)。401 同样自动刷新重放一次。 + func postMultipart( + _ path: String, + fields: [String: String] = [:], + files: [MultipartFile] = [], + auth: Bool = true, + isRetry: Bool = false + ) async throws -> R { + let cleanPath = path.hasPrefix("/") ? String(path.dropFirst()) : path + var req = URLRequest(url: baseURL.appending(path: cleanPath)) + req.httpMethod = "POST" + req.timeoutInterval = 30 + let boundary = "----shagua-\(UUID().uuidString)" + req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + req.httpBody = Self.buildMultipart(boundary: boundary, fields: fields, files: files) + if auth, let token = await AuthTokens.shared.accessToken() { + req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + + let (data, resp) = try await performData(req) + guard let http = resp as? HTTPURLResponse else { throw APIError.invalidResponse } + if http.statusCode == 401, auth { + if !isRetry, await AuthTokens.shared.refresh() { + return try await postMultipart(path, fields: fields, files: files, auth: auth, isRetry: true) + } + NotificationCenter.default.post(name: .authDidExpire, object: nil) + throw APIError.unauthorized + } + guard (200..<300).contains(http.statusCode) else { + throw APIError.http(status: http.statusCode, detail: Self.parseDetail(data)) + } + do { return try decoder.decode(R.self, from: data) } + catch { throw APIError.decoding(error) } + } + + // MARK: 核心 + + private struct EmptyBody: Encodable {} + + private func send( + path: String, + method: String, + query: [URLQueryItem]?, + body: B?, + auth: Bool, + isRetry: Bool = false + ) async throws -> R { + let cleanPath = path.hasPrefix("/") ? String(path.dropFirst()) : path + guard var comps = URLComponents( + url: baseURL.appending(path: cleanPath), + resolvingAgainstBaseURL: false + ) else { throw APIError.invalidResponse } + if let query, !query.isEmpty { comps.queryItems = query } + guard let url = comps.url else { throw APIError.invalidResponse } + + var req = URLRequest(url: url) + req.httpMethod = method + req.timeoutInterval = 30 + if let body { + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + do { req.httpBody = try encoder.encode(body) } + catch { throw APIError.decoding(error) } + } + if auth, let token = await AuthTokens.shared.accessToken() { + req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + + let (data, resp) = try await performData(req) + guard let http = resp as? HTTPURLResponse else { throw APIError.invalidResponse } + + if http.statusCode == 401, auth { + if !isRetry, await AuthTokens.shared.refresh() { + return try await send( + path: path, method: method, query: query, + body: body, auth: auth, isRetry: true + ) + } + NotificationCenter.default.post(name: .authDidExpire, object: nil) + throw APIError.unauthorized + } + + guard (200..<300).contains(http.statusCode) else { + throw APIError.http(status: http.statusCode, detail: Self.parseDetail(data)) + } + do { return try decoder.decode(R.self, from: data) } + catch { throw APIError.decoding(error) } + } + + /// 裸刷新,供 AuthTokens 调用(不走 send 的鉴权/重试,避免递归)。 + func rawRefresh(refreshToken: String) async throws -> TokenPairDTO { + var req = URLRequest(url: baseURL.appending(path: "api/v1/auth/refresh")) + req.httpMethod = "POST" + req.timeoutInterval = 20 + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.httpBody = try encoder.encode(RefreshRequest(refreshToken: refreshToken)) + let (data, resp) = try await performData(req) + guard let http = resp as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + throw APIError.http(status: (resp as? HTTPURLResponse)?.statusCode ?? -1, detail: nil) + } + return try decoder.decode(TokenPairDTO.self, from: data) + } + + // MARK: helpers + + private func performData(_ req: URLRequest) async throws -> (Data, URLResponse) { + do { return try await session.data(for: req) } + catch { throw APIError.network(error) } + } + + private static func buildMultipart(boundary: String, fields: [String: String], files: [MultipartFile]) -> Data { + var body = Data() + let crlf = "\r\n" + for (key, value) in fields { + body.append("--\(boundary)\(crlf)".data(using: .utf8)!) + body.append("Content-Disposition: form-data; name=\"\(key)\"\(crlf)\(crlf)".data(using: .utf8)!) + body.append("\(value)\(crlf)".data(using: .utf8)!) + } + for file in files { + body.append("--\(boundary)\(crlf)".data(using: .utf8)!) + body.append("Content-Disposition: form-data; name=\"\(file.name)\"; filename=\"\(file.filename)\"\(crlf)".data(using: .utf8)!) + body.append("Content-Type: \(file.mimeType)\(crlf)\(crlf)".data(using: .utf8)!) + body.append(file.data) + body.append(crlf.data(using: .utf8)!) + } + body.append("--\(boundary)--\(crlf)".data(using: .utf8)!) + return body + } + + /// FastAPI 错误体 {"detail": "..."};422 的 detail 是数组,解析失败返 nil(不崩)。 + private static func parseDetail(_ data: Data) -> String? { + struct E: Decodable { let detail: String? } + return (try? JSONDecoder().decode(E.self, from: data))?.detail + } +} diff --git a/App/Networking/APIError.swift b/App/Networking/APIError.swift new file mode 100644 index 0000000..f6fc890 --- /dev/null +++ b/App/Networking/APIError.swift @@ -0,0 +1,36 @@ +import Foundation + +enum APIError: Error, LocalizedError { + case invalidResponse // 不是 HTTPURLResponse / URL 构造失败 + case http(status: Int, detail: String?) + case unauthorized // 401 且刷新失败 → 需重新登录 + case decoding(Error) + case network(Error) + + var errorDescription: String? { + switch self { + case .invalidResponse: + return "服务器响应异常" + case .http(let status, let detail): + // 后端返回中文 detail(友好提示)直接透出;英文/技术性 detail 按状态码给中文兜底 + if let detail, detail.contains(where: { !$0.isASCII }) { + return detail + } + switch status { + case 400: return "请求有误,请重试" + case 401: return "登录已过期,请重新登录" + case 403: return "账号状态异常,请联系客服" + case 404: return "内容不存在" + case 429: return "操作太频繁,请稍后再试" + case 500...: return "服务器繁忙,请稍后重试" + default: return "请求失败(\(status))" + } + case .unauthorized: + return "登录已过期,请重新登录" + case .decoding: + return "数据解析失败" + case .network(let e): + return "网络异常:\(e.localizedDescription)" + } + } +} diff --git a/App/Networking/AppConfig.swift b/App/Networking/AppConfig.swift new file mode 100644 index 0000000..a74e682 --- /dev/null +++ b/App/Networking/AppConfig.swift @@ -0,0 +1,27 @@ +import Foundation + +/// 后端地址。debug 连本地/局域网(可被环境变量 SHAGUA_API_BASE_URL 覆盖,真机联调用), +/// release 连线上。对齐安卓 BuildConfig.BASE_URL 的 debug/release 切换。 +enum AppConfig { + static let apiBaseURL: URL = { + #if DEBUG + if let raw = ProcessInfo.processInfo.environment["SHAGUA_API_BASE_URL"], + let url = URL(string: raw) { + return url + } + // 真机/模拟器都连开发机的局域网 IP(真机用 127.0.0.1 会连到手机自己,连不上后端)。 + // 换了 WiFi 网段后 IP 会变,用 `ipconfig getifaddr en0` 重查并改这里(或用上面的环境变量覆盖)。 + return URL(string: "http://192.168.0.119:8770")! + #else + return URL(string: "https://app-api.shaguabijia.com")! + #endif + }() + + /// 后端相对媒体路径(/media/...)拼成完整 URL,用于展示头像/反馈截图。 + static func mediaURL(_ path: String?) -> URL? { + guard let path, !path.isEmpty else { return nil } + if path.hasPrefix("http") { return URL(string: path) } + let sep = path.hasPrefix("/") ? "" : "/" + return URL(string: apiBaseURL.absoluteString + sep + path) + } +} diff --git a/App/Networking/AuthService.swift b/App/Networking/AuthService.swift new file mode 100644 index 0000000..c41c5bf --- /dev/null +++ b/App/Networking/AuthService.swift @@ -0,0 +1,26 @@ +import Foundation + +/// 登录相关接口,对齐后端 app/api/v1/auth.py。jverify 一键登录首版不做(只短信)。 +struct AuthService { + private var api: APIClient { .shared } + + /// 发短信验证码(SMS_MOCK 下不真发,60s 冷却)。 + func smsSend(phone: String) async throws -> SmsSendResponse { + try await api.post("api/v1/auth/sms/send", body: SmsSendRequest(phone: phone), auth: false) + } + + /// 短信登录(mock 下任意 6 位过)。返回 token + user。 + func smsLogin(phone: String, code: String) async throws -> TokenWithUserDTO { + try await api.post("api/v1/auth/sms/login", body: SmsLoginRequest(phone: phone, code: code), auth: false) + } + + /// 当前用户资料。 + func me() async throws -> UserDTO { + try await api.get("api/v1/auth/me") + } + + /// 退出(后端占位,不真正吊销 token)。 + func logout() async throws -> LogoutResponse { + try await api.post("api/v1/auth/logout") + } +} diff --git a/App/Networking/CompareService.swift b/App/Networking/CompareService.swift new file mode 100644 index 0000000..3fab7fa --- /dev/null +++ b/App/Networking/CompareService.swift @@ -0,0 +1,12 @@ +import Foundation + +/// 比价记录读取,对齐后端 app/api/v1/compare_record.py(需登录)。iOS 只读,不上报(上报在安卓)。 +struct CompareService { + private var api: APIClient { .shared } + + func records(limit: Int = 20, cursor: Int? = nil) async throws -> ComparisonRecordPageDTO { + var query = [URLQueryItem(name: "limit", value: String(limit))] + if let cursor { query.append(URLQueryItem(name: "cursor", value: String(cursor))) } + return try await api.get("api/v1/compare/records", query: query) + } +} diff --git a/App/Networking/DTO/AuthDTO.swift b/App/Networking/DTO/AuthDTO.swift new file mode 100644 index 0000000..2bcfcbe --- /dev/null +++ b/App/Networking/DTO/AuthDTO.swift @@ -0,0 +1,66 @@ +import Foundation + +// ===== 请求 ===== + +struct SmsSendRequest: Codable { + let phone: String +} + +struct SmsLoginRequest: Codable { + let phone: String + let code: String +} + +struct RefreshRequest: Codable { + let refreshToken: String // → refresh_token +} + +struct ProfileUpdateRequest: Codable { + let nickname: String +} + +// ===== 响应 ===== + +struct UserDTO: Codable, Identifiable, Hashable { + let id: Int + let phone: String + var nickname: String? + var avatarUrl: String? + let registerChannel: String + let status: String + let createdAt: String + let lastLoginAt: String +} + +struct TokenPairDTO: Codable { + let accessToken: String + let refreshToken: String + let tokenType: String + let expiresIn: Int + let refreshExpiresIn: Int +} + +struct TokenWithUserDTO: Codable { + let accessToken: String + let refreshToken: String + let tokenType: String + let expiresIn: Int + let refreshExpiresIn: Int + let user: UserDTO +} + +struct SmsSendResponse: Codable { + let sent: Bool + let mock: Bool + let cooldownSec: Int +} + +struct LogoutResponse: Codable { + let ok: Bool +} + +struct FeedbackResultDTO: Codable { + let id: Int + let status: String + let createdAt: String? +} diff --git a/App/Networking/DTO/CommonDTO.swift b/App/Networking/DTO/CommonDTO.swift new file mode 100644 index 0000000..d292a72 --- /dev/null +++ b/App/Networking/DTO/CommonDTO.swift @@ -0,0 +1,6 @@ +import Foundation + +/// 通用响应。后端不少接口返 {"ok": true}。 +struct OkResponse: Codable { + let ok: Bool +} diff --git a/App/Networking/DTO/CompareRecordDTO.swift b/App/Networking/DTO/CompareRecordDTO.swift new file mode 100644 index 0000000..9c2250d --- /dev/null +++ b/App/Networking/DTO/CompareRecordDTO.swift @@ -0,0 +1,91 @@ +import Foundation + +// 比价记录(「我的比价记录」)。对齐后端 schemas/compare_record.py。 +// 金额两种口径:记录级 *_cents 是分(Int);逐平台 price / couponSaved 是元(Double)。 +// 来自 raw JSON 列,字段可能不全 → 容错性字段一律 optional。 + +struct ComparisonResultItemDTO: Codable, Hashable { + let platformId: String? + let platformName: String? + let `package`: String? // 安卓包名,跳转用;Swift 关键字,反引号转义 + let price: Double? // 元;失败/未知为 null + let isSource: Bool? + let rank: Int? + let couponSaved: Double? // 纯红包优惠额(元);没用/没抠到为 null +} + +struct ComparisonItemDTO: Codable, Hashable { + let name: String + var qty: Int = 1 + let specs: [String]? + + enum CodingKeys: String, CodingKey { case name, qty, specs } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + name = try c.decode(String.self, forKey: .name) + qty = (try? c.decode(Int.self, forKey: .qty)) ?? 1 + specs = try? c.decode([String].self, forKey: .specs) + } +} + +struct ComparisonRecordDTO: Codable, Identifiable, Hashable { + let id: Int + let businessType: String + let traceId: String + let sourcePlatformId: String? + let sourcePlatformName: String? + let sourcePackage: String? + let sourcePriceCents: Int? + let bestPlatformId: String? + let bestPlatformName: String? + let bestPriceCents: Int? + let savedAmountCents: Int? + let isSourceBest: Bool? + let storeName: String? + let totalDishCount: Int? + let skippedDishCount: Int? + let status: String + let information: String? + var items: [ComparisonItemDTO] = [] + var comparisonResults: [ComparisonResultItemDTO] = [] + var skippedDishNames: [String] = [] + let createdAt: String + + enum CodingKeys: String, CodingKey { + case id, businessType, traceId, sourcePlatformId, sourcePlatformName, sourcePackage + case sourcePriceCents, bestPlatformId, bestPlatformName, bestPriceCents, savedAmountCents + case isSourceBest, storeName, totalDishCount, skippedDishCount, status, information + case items, comparisonResults, skippedDishNames, createdAt + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(Int.self, forKey: .id) + businessType = (try? c.decode(String.self, forKey: .businessType)) ?? "food" + traceId = (try? c.decode(String.self, forKey: .traceId)) ?? "" + sourcePlatformId = try? c.decode(String.self, forKey: .sourcePlatformId) + sourcePlatformName = try? c.decode(String.self, forKey: .sourcePlatformName) + sourcePackage = try? c.decode(String.self, forKey: .sourcePackage) + sourcePriceCents = try? c.decode(Int.self, forKey: .sourcePriceCents) + bestPlatformId = try? c.decode(String.self, forKey: .bestPlatformId) + bestPlatformName = try? c.decode(String.self, forKey: .bestPlatformName) + bestPriceCents = try? c.decode(Int.self, forKey: .bestPriceCents) + savedAmountCents = try? c.decode(Int.self, forKey: .savedAmountCents) + isSourceBest = try? c.decode(Bool.self, forKey: .isSourceBest) + storeName = try? c.decode(String.self, forKey: .storeName) + totalDishCount = try? c.decode(Int.self, forKey: .totalDishCount) + skippedDishCount = try? c.decode(Int.self, forKey: .skippedDishCount) + status = (try? c.decode(String.self, forKey: .status)) ?? "unknown" + information = try? c.decode(String.self, forKey: .information) + items = (try? c.decode([ComparisonItemDTO].self, forKey: .items)) ?? [] + comparisonResults = (try? c.decode([ComparisonResultItemDTO].self, forKey: .comparisonResults)) ?? [] + skippedDishNames = (try? c.decode([String].self, forKey: .skippedDishNames)) ?? [] + createdAt = (try? c.decode(String.self, forKey: .createdAt)) ?? "" + } +} + +struct ComparisonRecordPageDTO: Codable { + let items: [ComparisonRecordDTO] + let nextCursor: Int? +} diff --git a/App/Networking/DTO/MeituanDTO.swift b/App/Networking/DTO/MeituanDTO.swift new file mode 100644 index 0000000..60b1196 --- /dev/null +++ b/App/Networking/DTO/MeituanDTO.swift @@ -0,0 +1,66 @@ +import Foundation + +// 美团 CPS 导购。价格字段是字符串(后端原样透传),展示直接用。 + +struct FeedRequest: Codable { + let longitude: Double + let latitude: Double + var page: Int = 1 + var pageSize: Int = 20 +} + +struct FeedResponse: Codable { + let items: [CouponCard] + let hasNext: Bool + let page: Int +} + +struct CouponCard: Codable, Identifiable, Hashable { + var id: String { productViewSign } + + let productViewSign: String + let platform: Int + let bizLine: Int? + let name: String + let headImageUrl: String + let brandName: String? + let brandLogoUrl: String? + let sellPrice: String + let originalPrice: String + let discountAmount: String + let commissionRate: String + let commissionAmount: String? + let saleVolume: String? + let poiName: String? + let distanceText: String? + let distanceMeters: Double? + let availablePoiNum: Int? + let couponNum: Int? + let validDays: Int? + let priceLabel: String? + let rankLabel: String? + let ratingLabel: String? +} + +struct ReferralLinkRequest: Codable { + let productViewSign: String + var platform: Int = 1 + var bizLine: Int? + var linkTypeList: [Int]? +} + +struct ReferralLinkResponse: Codable { + let link: String + var linkMap: [String: String] = [:] + + enum CodingKeys: String, CodingKey { + case link + case linkMap // 全局 convertFromSnakeCase 已把 link_map → linkMap + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + link = try c.decode(String.self, forKey: .link) + linkMap = (try? c.decode([String: String].self, forKey: .linkMap)) ?? [:] + } +} diff --git a/App/Networking/DTO/WelfareDTO.swift b/App/Networking/DTO/WelfareDTO.swift new file mode 100644 index 0000000..bbf4dae --- /dev/null +++ b/App/Networking/DTO/WelfareDTO.swift @@ -0,0 +1,137 @@ +import Foundation + +// 福利模块(钱包/签到/任务/省钱)。金额:金币=个数,现金=分。客户端把"分"换算成"元"展示。 +// 提现 / 看广告 / 金币兑现金 首版不做,故不含 Withdraw / Ad / Exchange / Cash 相关 DTO。 + +// ===== 钱包(金币只进不出:展示余额 + 流水)===== + +struct CoinAccountDTO: Codable { + let coinBalance: Int + let cashBalanceCents: Int + let totalCoinEarned: Int +} + +struct CoinTransactionDTO: Codable, Identifiable, Hashable { + let id: Int + let amount: Int + let balanceAfter: Int + let bizType: String + let refId: String? + let remark: String? + let createdAt: String +} + +struct CoinTransactionPageDTO: Codable { + let items: [CoinTransactionDTO] + let nextCursor: Int? +} + +// ===== 签到 ===== + +struct SigninStepDTO: Codable, Hashable { + let day: Int + let coin: Int + let status: String // claimed / today / locked +} + +struct SigninStatusDTO: Codable { + let todaySigned: Bool + let consecutiveDays: Int + let todayCycleDay: Int + let todayCoin: Int + let canClaim: Bool + let steps: [SigninStepDTO] +} + +struct SigninResultDTO: Codable { + let coinAwarded: Int + let cycleDay: Int + let streak: Int + let coinBalance: Int +} + +// ===== 任务 ===== + +struct TaskDTO: Codable, Identifiable, Hashable { + var id: String { taskKey } + let taskKey: String + let coin: Int + let claimed: Bool +} + +struct TaskListDTO: Codable { + let items: [TaskDTO] +} + +struct TaskClaimResultDTO: Codable { + let taskKey: String + let coinAwarded: Int + let coinBalance: Int +} + +// ===== 比价战绩里程碑(福利页「记录比价战绩」)===== + +struct MilestoneStateDTO: Codable, Hashable { + let milestone: Int + let coin: Int + let state: String // claimed / active / locked +} + +struct MilestoneStatusDTO: Codable { + let successCount: Int + let claimableCount: Int + let milestones: [MilestoneStateDTO] +} + +// ===== 省钱(累计/战绩/明细)===== + +struct SavingsSummaryDTO: Codable { + let totalSavedCents: Int + let orderCount: Int + let avgSavedCents: Int +} + +struct SavingsBattleDTO: Codable { + let weekSavedCents: Int + let beatPercent: Int + let streakDays: Int +} + +struct SavingsRecordDTO: Codable, Identifiable, Hashable { + let id: Int + let orderAmountCents: Int + let savedAmountCents: Int + let platform: String? + let title: String? + let shopName: String? + var dishes: [String] = [] + let payChannel: String? + let sourcePlatformName: String? + let originalPriceCents: Int? + let createdAt: String + + enum CodingKeys: String, CodingKey { + case id, orderAmountCents, savedAmountCents, platform, title + case shopName, dishes, payChannel, sourcePlatformName, originalPriceCents, createdAt + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(Int.self, forKey: .id) + orderAmountCents = try c.decode(Int.self, forKey: .orderAmountCents) + savedAmountCents = try c.decode(Int.self, forKey: .savedAmountCents) + platform = try? c.decode(String.self, forKey: .platform) + title = try? c.decode(String.self, forKey: .title) + shopName = try? c.decode(String.self, forKey: .shopName) + dishes = (try? c.decode([String].self, forKey: .dishes)) ?? [] + payChannel = try? c.decode(String.self, forKey: .payChannel) + sourcePlatformName = try? c.decode(String.self, forKey: .sourcePlatformName) + originalPriceCents = try? c.decode(Int.self, forKey: .originalPriceCents) + createdAt = try c.decode(String.self, forKey: .createdAt) + } +} + +struct SavingsRecordPageDTO: Codable { + let items: [SavingsRecordDTO] + let nextCursor: Int? +} diff --git a/App/Networking/FeedbackService.swift b/App/Networking/FeedbackService.swift new file mode 100644 index 0000000..61f7399 --- /dev/null +++ b/App/Networking/FeedbackService.swift @@ -0,0 +1,17 @@ +import Foundation + +/// 帮助与反馈,对齐后端 app/api/v1/feedback.py(multipart:content/contact 必填 + images 可选)。 +struct FeedbackService { + private var api: APIClient { .shared } + + func submit(content: String, contact: String, images: [Data]) async throws -> FeedbackResultDTO { + let files = images.enumerated().map { index, data in + MultipartFile(name: "images", filename: "img\(index).jpg", mimeType: "image/jpeg", data: data) + } + return try await api.postMultipart( + "api/v1/feedback", + fields: ["content": content, "contact": contact], + files: files + ) + } +} diff --git a/App/Networking/MeituanService.swift b/App/Networking/MeituanService.swift new file mode 100644 index 0000000..97f90ed --- /dev/null +++ b/App/Networking/MeituanService.swift @@ -0,0 +1,29 @@ +import Foundation + +/// 美团 CPS 导购接口,对齐后端 app/api/v1/meituan.py。未登录可用(后端不鉴权)。 +struct MeituanService { + private var api: APIClient { .shared } + + /// 首页推荐流(2 外卖 + 1 到店交叉,后端写死 3 页)。 + func feed(longitude: Double, latitude: Double, page: Int) async throws -> FeedResponse { + try await api.post( + "api/v1/meituan/feed", + body: FeedRequest(longitude: longitude, latitude: latitude, page: page), + auth: false + ) + } + + /// 换推广链接(带 CPS sid 分佣)。linkTypeList=[1,3]:1=H5 长链、3=deeplink。 + func referralLink(productViewSign: String, platform: Int, bizLine: Int?) async throws -> ReferralLinkResponse { + try await api.post( + "api/v1/meituan/referral-link", + body: ReferralLinkRequest( + productViewSign: productViewSign, + platform: platform, + bizLine: bizLine, + linkTypeList: [1, 3] + ), + auth: false + ) + } +} diff --git a/App/Networking/SavingsService.swift b/App/Networking/SavingsService.swift new file mode 100644 index 0000000..d8df4e3 --- /dev/null +++ b/App/Networking/SavingsService.swift @@ -0,0 +1,20 @@ +import Foundation + +/// 省钱(累计/战绩/明细)读取,对齐后端 app/api/v1/savings.py(需登录)。 +struct SavingsService { + private var api: APIClient { .shared } + + func summary() async throws -> SavingsSummaryDTO { + try await api.get("api/v1/savings/summary") + } + + func battle() async throws -> SavingsBattleDTO { + try await api.get("api/v1/savings/battle") + } + + func records(limit: Int = 20, cursor: Int? = nil) async throws -> SavingsRecordPageDTO { + var query = [URLQueryItem(name: "limit", value: String(limit))] + if let cursor { query.append(URLQueryItem(name: "cursor", value: String(cursor))) } + return try await api.get("api/v1/savings/records", query: query) + } +} diff --git a/App/Networking/UserService.swift b/App/Networking/UserService.swift new file mode 100644 index 0000000..c5db638 --- /dev/null +++ b/App/Networking/UserService.swift @@ -0,0 +1,24 @@ +import Foundation + +/// 用户资料,对齐后端 app/api/v1/user.py(需登录)。 +struct UserService { + private var api: APIClient { .shared } + + /// 改昵称,返回更新后的 user。 + func updateNickname(_ nickname: String) async throws -> UserDTO { + try await api.patch("api/v1/user/profile", body: ProfileUpdateRequest(nickname: nickname)) + } + + /// 上传头像(字段名 file),返回更新后的 user(含新 avatar_url)。 + func uploadAvatar(jpeg: Data) async throws -> UserDTO { + try await api.postMultipart( + "api/v1/user/avatar", + files: [MultipartFile(name: "file", filename: "avatar.jpg", mimeType: "image/jpeg", data: jpeg)] + ) + } + + /// 注销账号(软删除 + 匿名化)。 + func deleteAccount() async throws -> OkResponse { + try await api.delete("api/v1/user") + } +} diff --git a/App/Networking/WalletService.swift b/App/Networking/WalletService.swift new file mode 100644 index 0000000..9dc8d2a --- /dev/null +++ b/App/Networking/WalletService.swift @@ -0,0 +1,17 @@ +import Foundation + +/// 钱包(只读:余额 + 金币流水)。对齐后端 wallet.py。 +/// 首版"金币只进不出":不接 exchange/withdraw/bind-wechat 等出口端点。 +struct WalletService { + private var api: APIClient { .shared } + + func account() async throws -> CoinAccountDTO { + try await api.get("api/v1/wallet/account") + } + + func coinTransactions(limit: Int = 20, cursor: Int? = nil) async throws -> CoinTransactionPageDTO { + var query = [URLQueryItem(name: "limit", value: String(limit))] + if let cursor { query.append(URLQueryItem(name: "cursor", value: String(cursor))) } + return try await api.get("api/v1/wallet/coin-transactions", query: query) + } +} diff --git a/App/Networking/WelfareService.swift b/App/Networking/WelfareService.swift new file mode 100644 index 0000000..58c3378 --- /dev/null +++ b/App/Networking/WelfareService.swift @@ -0,0 +1,24 @@ +import Foundation + +/// 签到 + 任务,对齐后端 signin.py / tasks.py(需登录)。 +struct WelfareService { + private var api: APIClient { .shared } + + func signinStatus() async throws -> SigninStatusDTO { + try await api.get("api/v1/signin/status") + } + + func doSignin() async throws -> SigninResultDTO { + try await api.post("api/v1/signin") + } + + func tasks() async throws -> TaskListDTO { + try await api.get("api/v1/tasks") + } + + /// task_key 在 URL 路径上,不是 body。 + func claimTask(taskKey: String) async throws -> TaskClaimResultDTO { + let key = taskKey.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? taskKey + return try await api.post("api/v1/tasks/\(key)/claim") + } +} diff --git a/App/Privacy/PrivacyConsentView.swift b/App/Privacy/PrivacyConsentView.swift new file mode 100644 index 0000000..dfdbce2 --- /dev/null +++ b/App/Privacy/PrivacyConsentView.swift @@ -0,0 +1,49 @@ +import SwiftUI + +/// 首启隐私同意闸。不同意无法进入主功能(留在本页)。 +struct PrivacyConsentView: View { + let onAgree: () -> Void + @State private var showPolicy = false + + var body: some View { + VStack(spacing: 20) { + Spacer() + + Image(systemName: "tag.fill") + .font(.system(size: 56)) + .foregroundStyle(AppColors.brand) + + Text("欢迎使用傻瓜比价") + .font(.title.bold()) + + Text("买东西前先比一比,自动帮你找全网最低价、领券省钱。\n我们仅在功能必要范围内使用你的信息,绝不追踪。") + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + .padding(.horizontal, 32) + + Button("查看《隐私政策》") { showPolicy = true } + .font(.footnote) + + Spacer() + + Button(action: onAgree) { + Text("同意并继续") + .font(.headline) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background(AppColors.primary) + .foregroundStyle(.black) + .clipShape(RoundedRectangle(cornerRadius: 14)) + } + .padding(.horizontal, 24) + + Text("继续即表示你已阅读并同意《隐私政策》") + .font(.caption2) + .foregroundStyle(.secondary) + .padding(.bottom, 24) + } + .sheet(isPresented: $showPolicy) { + NavigationStack { PrivacyPolicyView() } + } + } +} diff --git a/App/Privacy/PrivacyPolicyView.swift b/App/Privacy/PrivacyPolicyView.swift new file mode 100644 index 0000000..7a82734 --- /dev/null +++ b/App/Privacy/PrivacyPolicyView.swift @@ -0,0 +1,45 @@ +import SwiftUI + +/// 隐私政策正文。M6 上架前替换为法务确认的最终文本。 +struct PrivacyPolicyView: View { + @Environment(\.dismiss) private var dismiss + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + Text("隐私政策").font(.title.bold()) + Text("最后更新:2026-06") + .font(.footnote).foregroundStyle(.secondary) + + Group { + section("我们收集什么", + "手机号(用于登录与账户)、大致位置(用于展示你附近的优惠门店与优惠券)、你主动提交的反馈内容。") + section("如何使用", + "仅用于实现 App 功能:登录、展示优惠、记录你的省钱数据。我们不进行用户追踪,不向第三方出售你的个人信息。") + section("位置信息", + "仅在你使用 App 期间、为获取附近优惠时读取大致位置,不会在后台持续追踪。你可随时在系统设置中关闭定位权限。") + section("数据存储与注销", + "你可在「我的 → 账户」中注销账户。注销后我们将删除或匿名化你的个人数据。") + section("联系我们", + "如对隐私有任何疑问,可在「我的 → 帮助与反馈」中联系我们。") + } + } + .padding() + } + .navigationTitle("隐私政策") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("关闭") { dismiss() } + } + } + } + + @ViewBuilder + private func section(_ title: String, _ body: String) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(title).font(.headline) + Text(body).foregroundStyle(.secondary) + } + } +} diff --git a/App/Privacy/PrivacyPrefs.swift b/App/Privacy/PrivacyPrefs.swift new file mode 100644 index 0000000..06f3fad --- /dev/null +++ b/App/Privacy/PrivacyPrefs.swift @@ -0,0 +1,14 @@ +import Foundation + +/// 隐私同意状态(本地)。App Store 审核要求:首启隐私同意 + 政策可访问 + Privacy Manifest。 +enum PrivacyPrefs { + private static let key = "privacy_agreed_v1" + + static func agreed() -> Bool { + UserDefaults.standard.bool(forKey: key) + } + + static func setAgreed(_ value: Bool) { + UserDefaults.standard.set(value, forKey: key) + } +} diff --git a/App/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/App/Resources/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..63e61bf --- /dev/null +++ b/App/Resources/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "idiom" : "universal", + "color" : { + "color-space" : "srgb", + "components" : { + "red" : "1.000", + "green" : "0.520", + "blue" : "0.200", + "alpha" : "1.000" + } + } + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..13613e3 --- /dev/null +++ b/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/App/Resources/Assets.xcassets/Contents.json b/App/Resources/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/App/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/App/Resources/Assets.xcassets/LaunchBackground.colorset/Contents.json b/App/Resources/Assets.xcassets/LaunchBackground.colorset/Contents.json new file mode 100644 index 0000000..68fb1ca --- /dev/null +++ b/App/Resources/Assets.xcassets/LaunchBackground.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "idiom" : "universal", + "color" : { + "color-space" : "srgb", + "components" : { + "red" : "1.000", + "green" : "1.000", + "blue" : "1.000", + "alpha" : "1.000" + } + } + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/App/Resources/Info-Debug.plist b/App/Resources/Info-Debug.plist new file mode 100644 index 0000000..e6c5623 --- /dev/null +++ b/App/Resources/Info-Debug.plist @@ -0,0 +1,65 @@ + + + + + CFBundleDevelopmentRegion + zh_CN + CFBundleDisplayName + 傻瓜比价 + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleLocalizations + + zh-Hans + + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + + ITSAppUsesNonExemptEncryption + + + NSLocationWhenInUseUsageDescription + 用于获取你附近的优惠门店和优惠券,仅在你使用 App 期间读取大致位置,不会持续追踪。 + + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + + LSApplicationQueriesSchemes + + imeituan + meituanwaimai + + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + + UILaunchScreen + + UIColorName + LaunchBackground + + + diff --git a/App/Resources/Info.plist b/App/Resources/Info.plist new file mode 100644 index 0000000..0622894 --- /dev/null +++ b/App/Resources/Info.plist @@ -0,0 +1,68 @@ + + + + + CFBundleDevelopmentRegion + zh_CN + CFBundleDisplayName + 傻瓜比价 + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleLocalizations + + zh-Hans + + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + + + ITSAppUsesNonExemptEncryption + + + + NSLocationWhenInUseUsageDescription + 用于获取你附近的优惠门店和优惠券,仅在你使用 App 期间读取大致位置,不会持续追踪。 + + + NSAppTransportSecurity + + NSAllowsLocalNetworking + + + + + LSApplicationQueriesSchemes + + imeituan + meituanwaimai + + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + + UILaunchScreen + + UIColorName + LaunchBackground + + + diff --git a/App/Resources/PrivacyInfo.xcprivacy b/App/Resources/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..617e7d4 --- /dev/null +++ b/App/Resources/PrivacyInfo.xcprivacy @@ -0,0 +1,55 @@ + + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + + + NSPrivacyCollectedDataTypes + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypePhoneNumber + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAppFunctionality + + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeCoarseLocation + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAppFunctionality + + + + + + NSPrivacyAccessedAPITypes + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + + + diff --git a/App/RootView.swift b/App/RootView.swift new file mode 100644 index 0000000..07d759b --- /dev/null +++ b/App/RootView.swift @@ -0,0 +1,20 @@ +import SwiftUI + +/// 顶层 View:隐私同意未通过 → PrivacyConsentView;通过后 → 主 Tab。 +/// 登录守卫不在这里(首页允许未登录浏览),而在「福利 / 我的」Tab 内部按需拦截。 +struct RootView: View { + @State private var privacyAgreed: Bool = PrivacyPrefs.agreed() + + var body: some View { + Group { + if privacyAgreed { + MainTabView() + } else { + PrivacyConsentView { + PrivacyPrefs.setAgreed(true) + privacyAgreed = true + } + } + } + } +} diff --git a/App/ShaguabijiaApp.swift b/App/ShaguabijiaApp.swift new file mode 100644 index 0000000..1973d3b --- /dev/null +++ b/App/ShaguabijiaApp.swift @@ -0,0 +1,15 @@ +import SwiftUI + +@main +struct ShaguabijiaApp: App { + /// 登录态真相源,注入环境供全 App 订阅。 + @State private var session = SessionStore() + + var body: some Scene { + WindowGroup { + RootView() + .environment(session) + .tint(AppColors.brand) + } + } +} diff --git a/App/Theme/AppColors.swift b/App/Theme/AppColors.swift new file mode 100644 index 0000000..f0a3aad --- /dev/null +++ b/App/Theme/AppColors.swift @@ -0,0 +1,51 @@ +import SwiftUI + +/// 全局配色 — 对齐安卓 `ui/theme/Colors.kt` 的 ProtoColors(品牌主色=黄)。 +/// 不接系统 tint,保持品牌一致(与占坑版同思路,但占坑版那套橙是截图工具的语义色,口径不同)。 +enum AppColors { + // 品牌主色 — 黄 + static let primary = Color(hex: 0xFFD600) // --primary + static let primaryBg = Color(hex: 0xFFF8E1) // --primary-bg + static let primaryDark = Color(hex: 0xFFB300) + static let brand = Color(hex: 0xFF8533) // 强调橙(AccentColor 同色) + + // 金币 + static let coin = Color(hex: 0xFF8F00) + static let coinBg = Color(hex: 0xFFF3E0) + static let coinHot = Color(hex: 0xFF3D00) + + // 业务语义 + static let success = Color(hex: 0x4CAF50) + static let successBg = Color(hex: 0xE8F5E9) + static let info = Color(hex: 0x2196F3) + static let error = Color(hex: 0xF44336) + static let warning = Color(hex: 0xFF9800) + static let hot = Color(hex: 0xFF5722) // "抢"/"省"字红 + static let wechat = Color(hex: 0x07C160) + + // 中性灰(对齐原型自定义灰阶) + static let bg = Color(hex: 0xF5F5F5) + static let gray50 = Color(hex: 0xFAFAFA) + static let gray100 = Color(hex: 0xF5F5F5) + static let gray200 = Color(hex: 0xE5E5E5) + static let gray300 = Color(hex: 0xCCCCCC) + static let gray400 = Color(hex: 0xB3B3B3) + static let gray500 = Color(hex: 0x999999) + static let gray600 = Color(hex: 0x666666) + static let gray700 = Color(hex: 0x4D4D4D) + static let gray800 = Color(hex: 0x333333) + static let gray900 = Color(hex: 0x1A1A1A) // 标题色 +} + +extension Color { + /// 0xRRGGBB → Color(sRGB)。 + init(hex: UInt, alpha: Double = 1.0) { + self.init( + .sRGB, + red: Double((hex >> 16) & 0xFF) / 255.0, + green: Double((hex >> 8) & 0xFF) / 255.0, + blue: Double(hex & 0xFF) / 255.0, + opacity: alpha + ) + } +} diff --git a/project.yml b/project.yml new file mode 100644 index 0000000..1da9942 --- /dev/null +++ b/project.yml @@ -0,0 +1,47 @@ +# XcodeGen 配置:在本目录执行 `xcodegen generate` 生成 Shaguabijia.xcodeproj。 +# 安装 xcodegen: brew install xcodegen +# +# 正式版 iOS(对标 shaguabijia-app-android + 对接 shaguabijia-app-server 8770)。 +# 与占坑版 shaguabijia-ios(截图记价工具)不同:单 target、无 ShareExtension、 +# 无 App Group、无 SwiftData——纯 REST 消费端(导购 + 福利 + 查看)。 +name: Shaguabijia +options: + bundleIdPrefix: com.jishisongfu + deploymentTarget: + iOS: "17.0" + developmentLanguage: zh-Hans + createIntermediateGroups: true + groupSortPosition: top + +settings: + base: + SWIFT_VERSION: "5.0" + MARKETING_VERSION: "1.0.0" + CURRENT_PROJECT_VERSION: "1" + DEVELOPMENT_TEAM: "U5J54UM56P" # 同主体 Apple Distribution 证书 + SWIFT_STRICT_CONCURRENCY: minimal + ENABLE_USER_SCRIPT_SANDBOXING: NO + +targets: + Shaguabijia: + type: application + platform: iOS + deploymentTarget: "17.0" + sources: + - path: App + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.jishisongfu.shaguabijia + PRODUCT_NAME: 傻瓜比价 + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor + TARGETED_DEVICE_FAMILY: "1" # 仅 iPhone + GENERATE_INFOPLIST_FILE: NO + SUPPORTS_MACCATALYST: NO + configs: + # Debug 用全放行 ATS 的 plist(真机/模拟器连任意本地 HTTP 后端,含局域网 IP); + # Release 用强制 HTTPS 的 plist,审核不受影响。两份仅 ATS 段不同。 + Debug: + INFOPLIST_FILE: App/Resources/Info-Debug.plist + Release: + INFOPLIST_FILE: App/Resources/Info.plist