初始化傻瓜比价正式版 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:
2026-06-05 00:54:11 +08:00
commit 98b7600797
60 changed files with 3112 additions and 0 deletions
+20
View File
@@ -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/
+45
View File
@@ -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
}
}
}
+53
View File
@@ -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)
}
}
+56
View File
@@ -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
}
}
+29
View File
@@ -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
}
}
+16
View File
@@ -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)
}
}
+21
View File
@@ -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)
}
}
+73
View File
@@ -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)
}
}
}
+38
View File
@@ -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)
}
}
}
+84
View File
@@ -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 ?? "提交失败,请重试"
}
}
}
+59
View File
@@ -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)
}
}
+69
View File
@@ -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)
}
}
}
+70
View File
@@ -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)
}
}
}
+54
View File
@@ -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)
}
}
+110
View File
@@ -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)
}
}
+67
View File
@@ -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
}
}
}
}
+113
View File
@@ -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 ?? "上传失败"
}
}
}
+102
View File
@@ -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 {
//
}
}
}
+125
View File
@@ -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
}
}
}
+35
View File
@@ -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"
}
}
+52
View File
@@ -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
}
}
}
+30
View File
@@ -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
}
}
}
+167
View File
@@ -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 ?? []
}
}
+15
View File
@@ -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") }
}
}
}
+192
View File
@@ -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 Bearer401 ( 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
}
}
+36
View File
@@ -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)"
}
}
}
+27
View File
@@ -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)
}
}
+26
View File
@@ -0,0 +1,26 @@
import Foundation
/// , app/api/v1/auth.pyjverify ()
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")
}
}
+12
View File
@@ -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)
}
}
+66
View File
@@ -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?
}
+6
View File
@@ -0,0 +1,6 @@
import Foundation
/// {"ok": true}
struct OkResponse: Codable {
let ok: Bool
}
+91
View File
@@ -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?
}
+66
View File
@@ -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)) ?? [:]
}
}
+137
View File
@@ -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?
}
+17
View File
@@ -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
)
}
}
+29
View File
@@ -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
)
}
}
+20
View File
@@ -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)
}
}
+24
View File
@@ -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")
}
}
+17
View File
@@ -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)
}
}
+24
View File
@@ -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")
}
}
+49
View File
@@ -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() }
}
}
}
+45
View File
@@ -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)
}
}
}
+14
View File
@@ -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
}
}
+65
View File
@@ -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>
+68
View File
@@ -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>
+55
View File
@@ -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>
+20
View File
@@ -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
}
}
}
}
}
+15
View File
@@ -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)
}
}
}
+51
View File
@@ -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
View File
@@ -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