feat: 更新素材管理和举报风险地图

This commit is contained in:
2026-07-09 12:20:02 +08:00
parent 9b92d81902
commit 42aca73588
42 changed files with 6164 additions and 1369 deletions

View File

@ -0,0 +1,23 @@
//
// SettingAPI.swift
// suixinkan
//
import Foundation
@MainActor
/// API
final class SettingAPI {
private let client: APIClient
init(client: APIClient) {
self.client = client
}
/// App
func checkLatestVersion() async throws -> VersionResponse {
try await client.send(
APIRequest(method: .get, path: "/api/app/latest-version")
)
}
}

View File

@ -0,0 +1,136 @@
//
// SettingModels.swift
// suixinkan
//
import Foundation
/// Android `VersionResponse`
struct VersionResponse: Decodable, Equatable {
let id: Int
let type: Int
let version: String
let operatorName: String
let description: String
let status: Int
let createdAt: String
let updatedAt: String
let deletedAt: String?
let versionCode: Int
let apkURL: String
let isForceUpdate: Int
let approveNotes: String
enum CodingKeys: String, CodingKey {
case id
case type
case version
case operatorName = "operator"
case description
case status
case createdAt = "created_at"
case updatedAt = "updated_at"
case deletedAt = "deleted_at"
case versionCode = "version_code"
case apkURL = "apk_url"
case isForceUpdate = "is_force_update"
case approveNotes = "approve_notes"
}
init(
id: Int = 0,
type: Int = 0,
version: String = "",
operatorName: String = "",
description: String = "",
status: Int = 0,
createdAt: String = "",
updatedAt: String = "",
deletedAt: String? = nil,
versionCode: Int = 0,
apkURL: String = "",
isForceUpdate: Int = 0,
approveNotes: String = ""
) {
self.id = id
self.type = type
self.version = version
self.operatorName = operatorName
self.description = description
self.status = status
self.createdAt = createdAt
self.updatedAt = updatedAt
self.deletedAt = deletedAt
self.versionCode = versionCode
self.apkURL = apkURL
self.isForceUpdate = isForceUpdate
self.approveNotes = approveNotes
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeLossyInt(forKey: .id) ?? 0
type = try container.decodeLossyInt(forKey: .type) ?? 0
version = try container.decodeLossyString(forKey: .version)
operatorName = try container.decodeLossyString(forKey: .operatorName)
description = try container.decodeLossyString(forKey: .description)
status = try container.decodeLossyInt(forKey: .status) ?? 0
createdAt = try container.decodeLossyString(forKey: .createdAt)
updatedAt = try container.decodeLossyString(forKey: .updatedAt)
deletedAt = try container.decodeIfPresent(String.self, forKey: .deletedAt)
versionCode = try container.decodeLossyInt(forKey: .versionCode) ?? 0
apkURL = try container.decodeLossyString(forKey: .apkURL)
isForceUpdate = try container.decodeLossyInt(forKey: .isForceUpdate) ?? 0
approveNotes = try container.decodeLossyString(forKey: .approveNotes)
}
}
/// H5
struct SettingAgreementDestination: Equatable {
let title: String
let url: URL
}
///
enum SettingAgreementKind: CaseIterable {
case aboutUs
case userAgreement
case privacyPolicy
}
private extension KeyedDecodingContainer {
func decodeLossyString(forKey key: Key) throws -> String {
if let value = try? decodeIfPresent(String.self, forKey: key) {
return value
}
if let value = try? decodeIfPresent(Int.self, forKey: key) {
return String(value)
}
if let value = try? decodeIfPresent(Double.self, forKey: key) {
return String(value)
}
if let value = try? decodeIfPresent(Bool.self, forKey: key) {
return value ? "true" : "false"
}
return ""
}
func decodeLossyInt(forKey key: Key) throws -> Int? {
if let value = try? decodeIfPresent(Int.self, forKey: key) {
return value
}
if let value = try? decodeIfPresent(String.self, forKey: key) {
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
if let intValue = Int(text) {
return intValue
}
if let doubleValue = Double(text) {
return Int(doubleValue)
}
}
if let value = try? decodeIfPresent(Double.self, forKey: key) {
return Int(value)
}
return nil
}
}

View File

@ -0,0 +1,87 @@
//
// SettingViewModel.swift
// suixinkan
//
import Foundation
/// ViewModel
final class SettingViewModel {
private let infoDictionary: [String: Any]?
private let environment: APIEnvironment
private(set) var latestVersion: VersionResponse?
private(set) var hasNewVersion = false
var onStateChange: (() -> Void)?
init(
infoDictionary: [String: Any]? = Bundle.main.infoDictionary,
environment: APIEnvironment = .current
) {
self.infoDictionary = infoDictionary
self.environment = environment
}
var appVersion: String {
AppClientInfo.appVersion(infoDictionary: infoDictionary)
}
var appDownloadURL: URL {
environment.baseURL.appending(path: "h5/app/download")
}
///
func checkLatestVersion(api: SettingAPI) async {
do {
let response = try await api.checkLatestVersion()
latestVersion = response
hasNewVersion = Self.isNewerVersionCode(response.versionCode, than: currentVersionCode(infoDictionary: infoDictionary))
notifyStateChange()
} catch is CancellationError {
return
} catch {
latestVersion = nil
hasNewVersion = false
notifyStateChange()
}
}
/// URL
func agreementDestination(for kind: SettingAgreementKind) -> SettingAgreementDestination {
switch kind {
case .aboutUs:
SettingAgreementDestination(
title: "关于我们",
url: environment.baseURL.appending(path: "h5/app/about-us")
)
case .userAgreement:
SettingAgreementDestination(
title: "用户协议",
url: environment.baseURL.appending(path: "h5/app/user-agreement")
)
case .privacyPolicy:
SettingAgreementDestination(
title: "隐私政策",
url: environment.baseURL.appending(path: "h5/app/privacy-policy")
)
}
}
///
nonisolated static func isNewerVersionCode(_ latestVersionCode: Int, than currentVersionCode: Int?) -> Bool {
guard let currentVersionCode else { return false }
return latestVersionCode > currentVersionCode
}
private func currentVersionCode(infoDictionary: [String: Any]?) -> Int? {
let build = (infoDictionary?["CFBundleVersion"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
guard let build, !build.isEmpty else { return nil }
return Int(build)
}
private func notifyStateChange() {
onStateChange?()
}
}