初始化傻瓜比价正式版 iOS 客户端
对标安卓正式版、对接 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 <noreply@anthropic.com>
This commit is contained in:
+20
@@ -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/
|
||||
@@ -0,0 +1,45 @@
|
||||
import Foundation
|
||||
|
||||
/// Token 中枢:读写 Keychain + 串行刷新(对应安卓 RefreshAuthenticator 的 @Synchronized 去重)。
|
||||
/// actor 保证并发安全;`inFlight` 让同时撞 401 的多个请求复用同一次刷新,只刷一次。
|
||||
actor AuthTokens {
|
||||
static let shared = AuthTokens()
|
||||
|
||||
private var inFlight: Task<Bool, Never>?
|
||||
|
||||
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<Bool, Never> { 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import CoreLocation
|
||||
|
||||
/// CoreLocation 的极简 async 封装:请求一次"使用期间"定位。
|
||||
/// 无权限/拒绝/失败都返 nil,调用方用默认坐标兜底(对齐安卓首页定位失败兜底天安门)。
|
||||
@MainActor
|
||||
final class LocationManager: NSObject, CLLocationManagerDelegate {
|
||||
private let manager = CLLocationManager()
|
||||
private var authContinuation: CheckedContinuation<Bool, Never>?
|
||||
private var locContinuation: CheckedContinuation<CLLocationCoordinate2D?, Never>?
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 ?? "提交失败,请重试"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<Void, Never>?
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 ?? "上传失败"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
// 加载更多失败静默
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 切回登录引导,导航栈自动重置
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 ?? []
|
||||
}
|
||||
}
|
||||
@@ -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") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<R: Decodable>(_ 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<B: Encodable, R: Decodable>(_ 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<R: Decodable>(_ path: String, auth: Bool = true) async throws -> R {
|
||||
try await send(path: path, method: "POST", query: nil, body: EmptyBody?.none, auth: auth)
|
||||
}
|
||||
|
||||
func patch<B: Encodable, R: Decodable>(_ 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<R: Decodable>(_ 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<R: Decodable>(
|
||||
_ 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<B: Encodable, R: Decodable>(
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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?
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
/// 通用响应。后端不少接口返 {"ok": true}。
|
||||
struct OkResponse: Codable {
|
||||
let ok: Bool
|
||||
}
|
||||
@@ -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?
|
||||
}
|
||||
@@ -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)) ?? [:]
|
||||
}
|
||||
}
|
||||
@@ -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?
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>zh_CN</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>傻瓜比价</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleLocalizations</key>
|
||||
<array>
|
||||
<string>zh-Hans</string>
|
||||
</array>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>用于获取你附近的优惠门店和优惠券,仅在你使用 App 期间读取大致位置,不会持续追踪。</string>
|
||||
|
||||
<!-- ⚠️ 仅 Debug 包:全放行明文 HTTP,方便真机/模拟器连任意本地后端(含 192.168.x.x 局域网 IP)。
|
||||
Release 包用 App/Resources/Info.plist(只放行本地网络、公网强制 HTTPS),审核不受影响。 -->
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
|
||||
<key>LSApplicationQueriesSchemes</key>
|
||||
<array>
|
||||
<string>imeituan</string>
|
||||
<string>meituanwaimai</string>
|
||||
</array>
|
||||
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<false/>
|
||||
</dict>
|
||||
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
</array>
|
||||
|
||||
<key>UILaunchScreen</key>
|
||||
<dict>
|
||||
<key>UIColorName</key>
|
||||
<string>LaunchBackground</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,68 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>zh_CN</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>傻瓜比价</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleLocalizations</key>
|
||||
<array>
|
||||
<string>zh-Hans</string>
|
||||
</array>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
|
||||
<!-- 加密合规:仅用 HTTPS/TLS(豁免类),无自研加密。声明后上传不再被询问。 -->
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
|
||||
<!-- 定位:首页拉附近优惠券/门店。仅使用期间。 -->
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>用于获取你附近的优惠门店和优惠券,仅在你使用 App 期间读取大致位置,不会持续追踪。</string>
|
||||
|
||||
<!-- ATS:只放行本地/局域网明文(debug 连本地后端),公网仍强制 HTTPS。
|
||||
审核友好——比 NSAllowsArbitraryLoads 安全得多。 -->
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsLocalNetworking</key>
|
||||
<true/>
|
||||
</dict>
|
||||
|
||||
<!-- canOpenURL 判断"是否装了美团",跳转下单用。未声明则 canOpenURL 恒 false。 -->
|
||||
<key>LSApplicationQueriesSchemes</key>
|
||||
<array>
|
||||
<string>imeituan</string>
|
||||
<string>meituanwaimai</string>
|
||||
</array>
|
||||
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<false/>
|
||||
</dict>
|
||||
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
</array>
|
||||
|
||||
<key>UILaunchScreen</key>
|
||||
<dict>
|
||||
<key>UIColorName</key>
|
||||
<string>LaunchBackground</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,55 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- 不做用户追踪 -->
|
||||
<key>NSPrivacyTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyTrackingDomains</key>
|
||||
<array/>
|
||||
|
||||
<!-- 收集的数据类型(M6 上架前据最终功能复核)。
|
||||
当前:账号(手机号)+ 粗略位置,均用于 App 功能,不用于追踪。 -->
|
||||
<key>NSPrivacyCollectedDataTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>NSPrivacyCollectedDataType</key>
|
||||
<string>NSPrivacyCollectedDataTypePhoneNumber</string>
|
||||
<key>NSPrivacyCollectedDataTypeLinked</key>
|
||||
<true/>
|
||||
<key>NSPrivacyCollectedDataTypeTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyCollectedDataTypePurposes</key>
|
||||
<array>
|
||||
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>NSPrivacyCollectedDataType</key>
|
||||
<string>NSPrivacyCollectedDataTypeCoarseLocation</string>
|
||||
<key>NSPrivacyCollectedDataTypeLinked</key>
|
||||
<false/>
|
||||
<key>NSPrivacyCollectedDataTypeTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyCollectedDataTypePurposes</key>
|
||||
<array>
|
||||
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
|
||||
<!-- 使用的 required reason API -->
|
||||
<key>NSPrivacyAccessedAPITypes</key>
|
||||
<array>
|
||||
<!-- UserDefaults:隐私同意状态、设置开关 -->
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>CA92.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+47
@@ -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
|
||||
Reference in New Issue
Block a user