fix bug
This commit is contained in:
@@ -3,6 +3,13 @@ import SwiftData
|
||||
|
||||
/// SwiftData 持久化模型 - 对应 Android 版 Room 数据库
|
||||
|
||||
/// 收藏/历史的业务唯一键(source + vodId)。
|
||||
private func makeVodBusinessKey(vodId: String, sourceKey: String) -> String {
|
||||
let normalizedVodId = vodId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let normalizedSourceKey = sourceKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return "\(normalizedSourceKey)::\(normalizedVodId)"
|
||||
}
|
||||
|
||||
/// 单部剧的续播状态
|
||||
struct VodPlaybackState: Codable {
|
||||
/// 当前播放线路标识。
|
||||
@@ -16,6 +23,8 @@ struct VodPlaybackState: Codable {
|
||||
/// 视频收藏
|
||||
@Model
|
||||
final class VodCollect {
|
||||
/// 业务唯一键(sourceKey + vodId)。
|
||||
var bizKey: String = ""
|
||||
/// 视频 ID(与 sourceKey 组成唯一语义键)。
|
||||
var vodId: String = ""
|
||||
/// 片名。
|
||||
@@ -28,6 +37,7 @@ final class VodCollect {
|
||||
var updateTime: Date = Date()
|
||||
|
||||
init(vodId: String, vodName: String, vodPic: String, sourceKey: String) {
|
||||
self.bizKey = makeVodBusinessKey(vodId: vodId, sourceKey: sourceKey)
|
||||
self.vodId = vodId
|
||||
self.vodName = vodName
|
||||
self.vodPic = vodPic
|
||||
@@ -39,6 +49,8 @@ final class VodCollect {
|
||||
/// 播放历史记录
|
||||
@Model
|
||||
final class VodRecord {
|
||||
/// 业务唯一键(sourceKey + vodId)。
|
||||
var bizKey: String = ""
|
||||
/// 视频 ID。
|
||||
var vodId: String = ""
|
||||
/// 片名。
|
||||
@@ -55,6 +67,7 @@ final class VodRecord {
|
||||
var updateTime: Date = Date()
|
||||
|
||||
init(vodId: String, vodName: String, vodPic: String, sourceKey: String, playNote: String = "") {
|
||||
self.bizKey = makeVodBusinessKey(vodId: vodId, sourceKey: sourceKey)
|
||||
self.vodId = vodId
|
||||
self.vodName = vodName
|
||||
self.vodPic = vodPic
|
||||
@@ -89,50 +102,64 @@ actor CacheStore {
|
||||
|
||||
@MainActor
|
||||
func addCollect(_ video: Movie.Video, context: ModelContext) {
|
||||
// 先检查是否已存在
|
||||
let vodId = video.id
|
||||
let sourceKey = video.sourceKey
|
||||
let predicate = #Predicate<VodCollect> { item in
|
||||
item.vodId == vodId && item.sourceKey == sourceKey
|
||||
}
|
||||
let descriptor = FetchDescriptor<VodCollect>(predicate: predicate)
|
||||
let bizKey = makeVodBusinessKey(vodId: vodId, sourceKey: sourceKey)
|
||||
|
||||
if let existing = try? context.fetch(descriptor), !existing.isEmpty {
|
||||
return // 已收藏
|
||||
do {
|
||||
let matched = try fetchCollects(vodId: vodId, sourceKey: sourceKey, context: context)
|
||||
if let first = matched.first {
|
||||
first.bizKey = bizKey
|
||||
first.vodName = video.name
|
||||
first.vodPic = video.pic
|
||||
first.updateTime = Date()
|
||||
for duplicate in matched.dropFirst() {
|
||||
context.delete(duplicate)
|
||||
}
|
||||
} else {
|
||||
let collect = VodCollect(
|
||||
vodId: vodId,
|
||||
vodName: video.name,
|
||||
vodPic: video.pic,
|
||||
sourceKey: sourceKey
|
||||
)
|
||||
context.insert(collect)
|
||||
}
|
||||
try context.save()
|
||||
} catch {
|
||||
print("写入收藏失败: \(error)")
|
||||
}
|
||||
|
||||
let collect = VodCollect(
|
||||
vodId: video.id,
|
||||
vodName: video.name,
|
||||
vodPic: video.pic,
|
||||
sourceKey: video.sourceKey
|
||||
)
|
||||
context.insert(collect)
|
||||
try? context.save()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func removeCollect(vodId: String, sourceKey: String, context: ModelContext) {
|
||||
// 收藏以 (vodId, sourceKey) 为业务唯一键,删除时也按该组合匹配。
|
||||
let predicate = #Predicate<VodCollect> { item in
|
||||
item.vodId == vodId && item.sourceKey == sourceKey
|
||||
}
|
||||
let descriptor = FetchDescriptor<VodCollect>(predicate: predicate)
|
||||
if let items = try? context.fetch(descriptor) {
|
||||
do {
|
||||
let items = try fetchCollects(vodId: vodId, sourceKey: sourceKey, context: context)
|
||||
guard !items.isEmpty else { return }
|
||||
for item in items {
|
||||
context.delete(item)
|
||||
}
|
||||
try? context.save()
|
||||
try context.save()
|
||||
} catch {
|
||||
print("删除收藏失败: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func isCollected(vodId: String, sourceKey: String, context: ModelContext) -> Bool {
|
||||
let bizKey = makeVodBusinessKey(vodId: vodId, sourceKey: sourceKey)
|
||||
let predicate = #Predicate<VodCollect> { item in
|
||||
item.vodId == vodId && item.sourceKey == sourceKey
|
||||
item.bizKey == bizKey || (item.bizKey == "" && item.vodId == vodId && item.sourceKey == sourceKey)
|
||||
}
|
||||
let descriptor = FetchDescriptor<VodCollect>(predicate: predicate)
|
||||
return (try? context.fetchCount(descriptor)) ?? 0 > 0
|
||||
|
||||
do {
|
||||
let count = try context.fetchCount(descriptor)
|
||||
return count > 0
|
||||
} catch {
|
||||
print("查询收藏状态失败: \(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -144,39 +171,55 @@ actor CacheStore {
|
||||
) {
|
||||
let vodId = video.id
|
||||
let sourceKey = video.sourceKey
|
||||
let record = fetchRecord(vodId: vodId, sourceKey: sourceKey, context: context)
|
||||
let encodedState = Self.encodePlaybackState(playbackState)
|
||||
let bizKey = makeVodBusinessKey(vodId: vodId, sourceKey: sourceKey)
|
||||
|
||||
// 更新或插入
|
||||
if let record {
|
||||
record.playNote = playNote
|
||||
if let encodedState {
|
||||
record.dataJson = encodedState
|
||||
do {
|
||||
let matched = try fetchRecords(vodId: vodId, sourceKey: sourceKey, context: context)
|
||||
|
||||
// 更新或插入
|
||||
if let record = matched.first {
|
||||
record.bizKey = bizKey
|
||||
record.playNote = playNote
|
||||
if let encodedState {
|
||||
record.dataJson = encodedState
|
||||
}
|
||||
record.updateTime = Date()
|
||||
for duplicate in matched.dropFirst() {
|
||||
context.delete(duplicate)
|
||||
}
|
||||
} else {
|
||||
let record = VodRecord(
|
||||
vodId: vodId,
|
||||
vodName: video.name,
|
||||
vodPic: video.pic,
|
||||
sourceKey: sourceKey,
|
||||
playNote: playNote
|
||||
)
|
||||
if let encodedState {
|
||||
record.dataJson = encodedState
|
||||
}
|
||||
context.insert(record)
|
||||
}
|
||||
record.updateTime = Date()
|
||||
} else {
|
||||
let record = VodRecord(
|
||||
vodId: video.id,
|
||||
vodName: video.name,
|
||||
vodPic: video.pic,
|
||||
sourceKey: video.sourceKey,
|
||||
playNote: playNote
|
||||
)
|
||||
if let encodedState {
|
||||
record.dataJson = encodedState
|
||||
}
|
||||
context.insert(record)
|
||||
|
||||
try context.save()
|
||||
} catch {
|
||||
print("写入播放记录失败: \(error)")
|
||||
}
|
||||
try? context.save()
|
||||
}
|
||||
|
||||
/// 读取续播状态(若无记录或 JSON 无法解码则返回 `nil`)。
|
||||
@MainActor
|
||||
func getPlaybackState(vodId: String, sourceKey: String, context: ModelContext) -> VodPlaybackState? {
|
||||
guard let record = fetchRecord(vodId: vodId, sourceKey: sourceKey, context: context) else {
|
||||
do {
|
||||
guard let record = try fetchRecords(vodId: vodId, sourceKey: sourceKey, context: context).first else {
|
||||
return nil
|
||||
}
|
||||
return Self.decodePlaybackState(record.dataJson)
|
||||
} catch {
|
||||
print("读取续播状态失败: \(error)")
|
||||
return nil
|
||||
}
|
||||
return Self.decodePlaybackState(record.dataJson)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -190,14 +233,47 @@ actor CacheStore {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func fetchRecord(vodId: String, sourceKey: String, context: ModelContext) -> VodRecord? {
|
||||
// 历史记录同样以 (vodId, sourceKey) 作为业务键。
|
||||
private func fetchRecords(vodId: String, sourceKey: String, context: ModelContext) throws -> [VodRecord] {
|
||||
let bizKey = makeVodBusinessKey(vodId: vodId, sourceKey: sourceKey)
|
||||
let predicate = #Predicate<VodRecord> { item in
|
||||
item.vodId == vodId && item.sourceKey == sourceKey
|
||||
item.bizKey == bizKey || (item.bizKey == "" && item.vodId == vodId && item.sourceKey == sourceKey)
|
||||
}
|
||||
let descriptor = FetchDescriptor<VodRecord>(predicate: predicate)
|
||||
guard let records = try? context.fetch(descriptor) else { return nil }
|
||||
return records.first
|
||||
let records = try context.fetch(descriptor)
|
||||
|
||||
// 兼容旧数据:命中 legacy 记录时补写业务键。
|
||||
var needsSave = false
|
||||
for record in records where record.bizKey.isEmpty {
|
||||
record.bizKey = bizKey
|
||||
needsSave = true
|
||||
}
|
||||
if needsSave {
|
||||
try context.save()
|
||||
}
|
||||
|
||||
return records.sorted(by: { $0.updateTime > $1.updateTime })
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func fetchCollects(vodId: String, sourceKey: String, context: ModelContext) throws -> [VodCollect] {
|
||||
let bizKey = makeVodBusinessKey(vodId: vodId, sourceKey: sourceKey)
|
||||
let predicate = #Predicate<VodCollect> { item in
|
||||
item.bizKey == bizKey || (item.bizKey == "" && item.vodId == vodId && item.sourceKey == sourceKey)
|
||||
}
|
||||
let descriptor = FetchDescriptor<VodCollect>(predicate: predicate)
|
||||
let collects = try context.fetch(descriptor)
|
||||
|
||||
// 兼容旧数据:命中 legacy 记录时补写业务键。
|
||||
var needsSave = false
|
||||
for collect in collects where collect.bizKey.isEmpty {
|
||||
collect.bizKey = bizKey
|
||||
needsSave = true
|
||||
}
|
||||
if needsSave {
|
||||
try context.save()
|
||||
}
|
||||
|
||||
return collects.sorted(by: { $0.updateTime > $1.updateTime })
|
||||
}
|
||||
|
||||
private nonisolated static func encodePlaybackState(_ state: VodPlaybackState?) -> String? {
|
||||
|
||||
+140
-34
@@ -7,6 +7,8 @@ class ApiConfig: ObservableObject {
|
||||
static let shared = ApiConfig()
|
||||
private static let maxConfigResolveDepth = 6
|
||||
private static let maxRedirectCandidates = 20
|
||||
private static let rawConfigCacheTTL: TimeInterval = 20
|
||||
private static let maxRawConfigCacheEntries = 24
|
||||
|
||||
struct MultiRepoOption: Identifiable, Equatable {
|
||||
let name: String
|
||||
@@ -26,6 +28,13 @@ class ApiConfig: ObservableObject {
|
||||
@Published var wallpaper: String = ""
|
||||
|
||||
private let network = NetworkManager.shared
|
||||
private var activeLoadToken = UUID()
|
||||
private var liveParseTask: Task<Void, Never>?
|
||||
private struct RawConfigCacheEntry {
|
||||
let content: String
|
||||
let fetchedAt: Date
|
||||
}
|
||||
private var rawConfigCache: [String: RawConfigCacheEntry] = [:]
|
||||
|
||||
private init() {}
|
||||
|
||||
@@ -41,6 +50,10 @@ class ApiConfig: ObservableObject {
|
||||
guard !trimmedVod.isEmpty else {
|
||||
throw ConfigError.parseError("点播接口地址不能为空")
|
||||
}
|
||||
let loadToken = UUID()
|
||||
activeLoadToken = loadToken
|
||||
liveParseTask?.cancel()
|
||||
liveParseTask = nil
|
||||
|
||||
let resolvedLive = trimmedLive.isEmpty ? trimmedVod : trimmedLive
|
||||
self.configUrl = trimmedVod
|
||||
@@ -48,32 +61,55 @@ class ApiConfig: ObservableObject {
|
||||
|
||||
if trimmedVod == resolvedLive {
|
||||
let configResult = try await fetchConfig(from: trimmedVod)
|
||||
parseConfig(
|
||||
guard activeLoadToken == loadToken else { return }
|
||||
await parseConfig(
|
||||
configResult.config,
|
||||
apiUrl: configResult.loadedFrom,
|
||||
includeSources: true,
|
||||
includeLive: true
|
||||
includeLive: false,
|
||||
loadToken: loadToken
|
||||
)
|
||||
scheduleLiveParsing(
|
||||
config: configResult.config,
|
||||
apiUrl: configResult.loadedFrom,
|
||||
loadToken: loadToken
|
||||
)
|
||||
} else {
|
||||
let vodConfig = try await fetchConfig(from: trimmedVod)
|
||||
parseConfig(
|
||||
async let vodConfigTask = fetchConfig(from: trimmedVod)
|
||||
async let liveConfigTask = fetchConfig(from: resolvedLive)
|
||||
let (vodConfig, liveConfig) = try await (vodConfigTask, liveConfigTask)
|
||||
guard activeLoadToken == loadToken else { return }
|
||||
await parseConfig(
|
||||
vodConfig.config,
|
||||
apiUrl: vodConfig.loadedFrom,
|
||||
includeSources: true,
|
||||
includeLive: false
|
||||
includeLive: false,
|
||||
loadToken: loadToken
|
||||
)
|
||||
|
||||
let liveConfig = try await fetchConfig(from: resolvedLive)
|
||||
parseConfig(
|
||||
liveConfig.config,
|
||||
scheduleLiveParsing(
|
||||
config: liveConfig.config,
|
||||
apiUrl: liveConfig.loadedFrom,
|
||||
includeSources: false,
|
||||
includeLive: true
|
||||
loadToken: loadToken
|
||||
)
|
||||
}
|
||||
guard activeLoadToken == loadToken else { return }
|
||||
|
||||
self.isLoaded = true
|
||||
}
|
||||
|
||||
/// 直播分组改为后台解析,避免阻塞首页首屏进入。
|
||||
private func scheduleLiveParsing(config: AppConfigData, apiUrl: String, loadToken: UUID) {
|
||||
liveParseTask?.cancel()
|
||||
liveParseTask = Task { [config, apiUrl] in
|
||||
await parseConfig(
|
||||
config,
|
||||
apiUrl: apiUrl,
|
||||
includeSources: false,
|
||||
includeLive: true,
|
||||
loadToken: loadToken
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchConfig(from apiUrl: String) async throws -> (config: AppConfigData, loadedFrom: String) {
|
||||
try await fetchConfig(
|
||||
@@ -105,7 +141,7 @@ class ApiConfig: ObservableObject {
|
||||
var nextVisited = visitedUrls
|
||||
nextVisited.insert(visitKey)
|
||||
|
||||
let jsonStr = try await network.getString(from: normalizedUrl)
|
||||
let jsonStr = try await fetchConfigText(from: normalizedUrl)
|
||||
|
||||
// 清理非标准 JSON(Android 端 Gson 默认支持注释,Swift 需要手动处理)
|
||||
let cleanedJson = Self.stripJsonComments(jsonStr)
|
||||
@@ -173,6 +209,34 @@ class ApiConfig: ObservableObject {
|
||||
throw ConfigError.parseError("配置格式不受支持")
|
||||
}
|
||||
|
||||
/// 读取配置文本并做短时缓存,避免“多仓探测 + 正式加载”重复请求同一地址。
|
||||
private func fetchConfigText(from normalizedUrl: String) async throws -> String {
|
||||
let key = normalizedUrl.lowercased()
|
||||
let now = Date()
|
||||
|
||||
if let entry = rawConfigCache[key] {
|
||||
if now.timeIntervalSince(entry.fetchedAt) <= Self.rawConfigCacheTTL {
|
||||
return entry.content
|
||||
}
|
||||
rawConfigCache.removeValue(forKey: key)
|
||||
}
|
||||
|
||||
let content = try await network.getString(from: normalizedUrl)
|
||||
rawConfigCache[key] = RawConfigCacheEntry(content: content, fetchedAt: now)
|
||||
trimRawConfigCacheIfNeeded()
|
||||
return content
|
||||
}
|
||||
|
||||
private func trimRawConfigCacheIfNeeded() {
|
||||
guard rawConfigCache.count > Self.maxRawConfigCacheEntries else { return }
|
||||
let overflow = rawConfigCache.count - Self.maxRawConfigCacheEntries
|
||||
let staleKeys = rawConfigCache
|
||||
.sorted { $0.value.fetchedAt < $1.value.fetchedAt }
|
||||
.prefix(overflow)
|
||||
.map(\.key)
|
||||
staleKeys.forEach { rawConfigCache.removeValue(forKey: $0) }
|
||||
}
|
||||
|
||||
private static func uniqueUrlsInOrder(_ urls: [String]) -> [String] {
|
||||
var seen: Set<String> = []
|
||||
var result: [String] = []
|
||||
@@ -457,7 +521,7 @@ class ApiConfig: ObservableObject {
|
||||
/// 返回 nil 表示不是多仓库入口;返回数组表示是多仓库入口(数组可能为空)。
|
||||
func fetchMultiRepoOptions(from apiUrl: String) async throws -> [MultiRepoOption]? {
|
||||
let normalizedUrl = Self.normalizeConfigUrl(apiUrl)
|
||||
let jsonStr = try await network.getString(from: normalizedUrl)
|
||||
let jsonStr = try await fetchConfigText(from: normalizedUrl)
|
||||
let cleanedJson = Self.stripJsonComments(jsonStr)
|
||||
|
||||
guard let data = cleanedJson.data(using: .utf8) else {
|
||||
@@ -506,8 +570,11 @@ class ApiConfig: ObservableObject {
|
||||
_ config: AppConfigData,
|
||||
apiUrl: String,
|
||||
includeSources: Bool,
|
||||
includeLive: Bool
|
||||
) {
|
||||
includeLive: Bool,
|
||||
loadToken: UUID
|
||||
) async {
|
||||
guard activeLoadToken == loadToken else { return }
|
||||
|
||||
if includeSources {
|
||||
// 解析站点列表
|
||||
var sources: [SourceBean] = []
|
||||
@@ -543,6 +610,8 @@ class ApiConfig: ObservableObject {
|
||||
self.parseBeanList = parses.map { p in
|
||||
ParseBean(name: p.name ?? "", url: p.url ?? "", type: p.type?.value ?? 0)
|
||||
}
|
||||
} else {
|
||||
self.parseBeanList = []
|
||||
}
|
||||
|
||||
// 解析 DoH 列表
|
||||
@@ -551,6 +620,8 @@ class ApiConfig: ObservableObject {
|
||||
guard let name = d.name, let url = d.url else { return nil }
|
||||
return (name: name, url: url)
|
||||
}
|
||||
} else {
|
||||
self.dohList = []
|
||||
}
|
||||
|
||||
// 壁纸
|
||||
@@ -559,7 +630,9 @@ class ApiConfig: ObservableObject {
|
||||
|
||||
if includeLive {
|
||||
if let lives = config.lives {
|
||||
parseLives(lives, apiUrl: apiUrl)
|
||||
let parsedGroups = await parseLives(lives, apiUrl: apiUrl, loadToken: loadToken)
|
||||
guard activeLoadToken == loadToken else { return }
|
||||
liveChannelGroupList = parsedGroups
|
||||
} else {
|
||||
liveChannelGroupList = []
|
||||
}
|
||||
@@ -567,32 +640,65 @@ class ApiConfig: ObservableObject {
|
||||
}
|
||||
|
||||
/// 解析直播列表
|
||||
private func parseLives(_ lives: [AppConfigData.LiveConfig], apiUrl: String) {
|
||||
Task {
|
||||
var mergedGroups: [String: LiveChannelGroup] = [:]
|
||||
private func parseLives(
|
||||
_ lives: [AppConfigData.LiveConfig],
|
||||
apiUrl: String,
|
||||
loadToken: UUID
|
||||
) async -> [LiveChannelGroup] {
|
||||
var mergedGroups: [String: LiveChannelGroup] = [:]
|
||||
var remoteLiveTargets: [(order: Int, url: String)] = []
|
||||
|
||||
for (index, live) in lives.enumerated() {
|
||||
guard activeLoadToken == loadToken else { return [] }
|
||||
|
||||
for live in lives {
|
||||
// 如果有 url,从远程加载
|
||||
if let liveUrl = live.url, !liveUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
let resolvedUrl = resolveLiveUrl(liveUrl, baseConfigUrl: apiUrl)
|
||||
do {
|
||||
let content = try await network.getString(from: resolvedUrl)
|
||||
let groups = parseLiveContent(content)
|
||||
mergeLiveGroups(groups, into: &mergedGroups)
|
||||
} catch {
|
||||
print("加载直播源失败: \(resolvedUrl), error: \(error)")
|
||||
// 如果有 url,从远程加载
|
||||
if let liveUrl = live.url, !liveUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
let resolvedUrl = resolveLiveUrl(liveUrl, baseConfigUrl: apiUrl)
|
||||
remoteLiveTargets.append((order: index, url: resolvedUrl))
|
||||
}
|
||||
|
||||
// 如果有内嵌频道
|
||||
if let channels = live.channels {
|
||||
let inlineGroups = parseInlineLiveChannels(channels)
|
||||
mergeLiveGroups(inlineGroups, into: &mergedGroups)
|
||||
}
|
||||
}
|
||||
|
||||
if !remoteLiveTargets.isEmpty {
|
||||
let fetchedContents = await withTaskGroup(
|
||||
of: (Int, String?).self,
|
||||
returning: [(Int, String)].self
|
||||
) { group in
|
||||
for target in remoteLiveTargets {
|
||||
group.addTask {
|
||||
do {
|
||||
let content = try await NetworkManager.shared.getString(from: target.url)
|
||||
return (target.order, content)
|
||||
} catch {
|
||||
print("加载直播源失败: \(target.url), error: \(error)")
|
||||
return (target.order, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果有内嵌频道
|
||||
if let channels = live.channels {
|
||||
let inlineGroups = parseInlineLiveChannels(channels)
|
||||
mergeLiveGroups(inlineGroups, into: &mergedGroups)
|
||||
var results: [(Int, String)] = []
|
||||
for await (order, content) in group {
|
||||
if let content {
|
||||
results.append((order, content))
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
self.liveChannelGroupList = sortedGroups(from: mergedGroups)
|
||||
for (_, content) in fetchedContents.sorted(by: { $0.0 < $1.0 }) {
|
||||
guard activeLoadToken == loadToken else { return [] }
|
||||
let groups = parseLiveContent(content)
|
||||
mergeLiveGroups(groups, into: &mergedGroups)
|
||||
liveChannelGroupList = sortedGroups(from: mergedGroups)
|
||||
}
|
||||
}
|
||||
|
||||
return sortedGroups(from: mergedGroups)
|
||||
}
|
||||
|
||||
/// 解析 m3u / txt 格式的直播内容
|
||||
|
||||
@@ -34,19 +34,24 @@ class SourceService {
|
||||
jsonStr = try await network.getString(from: api)
|
||||
} else if sourceBean.type == 4 {
|
||||
// Type 4: 远程接口,需要 extend 和 filter 参数
|
||||
var url = api.contains("?") ? "\(api)&filter=true" : "\(api)?filter=true"
|
||||
var queryItems: [URLQueryItem] = [
|
||||
URLQueryItem(name: "filter", value: "true")
|
||||
]
|
||||
// 加载 extend
|
||||
if let ext = sourceBean.ext, !ext.isEmpty {
|
||||
let extend = await resolveExtend(ext)
|
||||
if !extend.isEmpty {
|
||||
let encoded = extend.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? extend
|
||||
url += "&extend=\(encoded)"
|
||||
queryItems.append(URLQueryItem(name: "extend", value: extend))
|
||||
}
|
||||
}
|
||||
let url = try buildURL(base: api, queryItems: queryItems)
|
||||
jsonStr = try await network.getString(from: url)
|
||||
} else {
|
||||
// JSON 接口 (type=1)
|
||||
let url = api.contains("?") ? "\(api)&ac=class" : "\(api)?ac=class"
|
||||
let url = try buildURL(
|
||||
base: api,
|
||||
queryItems: [URLQueryItem(name: "ac", value: "class")]
|
||||
)
|
||||
jsonStr = try await network.getString(from: url)
|
||||
}
|
||||
|
||||
@@ -62,12 +67,24 @@ class SourceService {
|
||||
if sourceBean.type == 4 {
|
||||
// type=4 用 ac=detail 格式,与 getList 保持一致
|
||||
let ext = Data("{}".utf8).base64EncodedString()
|
||||
listUrl = api.contains("?")
|
||||
? "\(api)&ac=detail&filter=true&pg=1&ext=\(ext)"
|
||||
: "\(api)?ac=detail&filter=true&pg=1&ext=\(ext)"
|
||||
listUrl = try buildURL(
|
||||
base: api,
|
||||
queryItems: [
|
||||
URLQueryItem(name: "ac", value: "detail"),
|
||||
URLQueryItem(name: "filter", value: "true"),
|
||||
URLQueryItem(name: "pg", value: "1"),
|
||||
URLQueryItem(name: "ext", value: ext)
|
||||
]
|
||||
)
|
||||
} else {
|
||||
// type=1 用 ac=videolist 格式
|
||||
listUrl = api.contains("?") ? "\(api)&ac=videolist&pg=1" : "\(api)?ac=videolist&pg=1"
|
||||
listUrl = try buildURL(
|
||||
base: api,
|
||||
queryItems: [
|
||||
URLQueryItem(name: "ac", value: "videolist"),
|
||||
URLQueryItem(name: "pg", value: "1")
|
||||
]
|
||||
)
|
||||
}
|
||||
if let listStr = try? await network.getString(from: listUrl) {
|
||||
let fallback = (try? parseVideoList(listStr, sourceKey: sourceBean.key, type: sourceBean.type)) ?? []
|
||||
@@ -152,48 +169,61 @@ class SourceService {
|
||||
guard sourceBean.isSupportedInSwift else { throw SourceError.unsupportedType(sourceBean.typeDescription) }
|
||||
guard sourceBean.isHttpApi else { throw SourceError.invalidApiUrl(api) }
|
||||
|
||||
var url: String
|
||||
let url: String
|
||||
if sourceBean.type == 0 {
|
||||
// XML 接口
|
||||
url = "\(api)?ac=videolist&t=\(sortData.id)&pg=\(page)"
|
||||
url = try buildURL(
|
||||
base: api,
|
||||
queryItems: [
|
||||
URLQueryItem(name: "ac", value: "videolist"),
|
||||
URLQueryItem(name: "t", value: sortData.id),
|
||||
URLQueryItem(name: "pg", value: String(page))
|
||||
]
|
||||
)
|
||||
} else if sourceBean.type == 4 {
|
||||
// Type 4: 远程接口
|
||||
url = api.contains("?")
|
||||
? "\(api)&ac=detail&filter=true&t=\(sortData.id)&pg=\(page)"
|
||||
: "\(api)?ac=detail&filter=true&t=\(sortData.id)&pg=\(page)"
|
||||
var queryItems: [URLQueryItem] = [
|
||||
URLQueryItem(name: "ac", value: "detail"),
|
||||
URLQueryItem(name: "filter", value: "true"),
|
||||
URLQueryItem(name: "t", value: sortData.id),
|
||||
URLQueryItem(name: "pg", value: String(page))
|
||||
]
|
||||
|
||||
// 附加筛选参数(base64 编码)
|
||||
if let filters = filters, !filters.isEmpty {
|
||||
if let filterData = try? JSONSerialization.data(withJSONObject: filters),
|
||||
let filterStr = String(data: filterData, encoding: .utf8) {
|
||||
let ext = Data(filterStr.utf8).base64EncodedString()
|
||||
url += "&ext=\(ext)"
|
||||
queryItems.append(URLQueryItem(name: "ext", value: ext))
|
||||
}
|
||||
} else {
|
||||
let ext = Data("{}".utf8).base64EncodedString()
|
||||
url += "&ext=\(ext)"
|
||||
queryItems.append(URLQueryItem(name: "ext", value: ext))
|
||||
}
|
||||
|
||||
// 加载 extend
|
||||
if let ext = sourceBean.ext, !ext.isEmpty {
|
||||
let extend = await resolveExtend(ext)
|
||||
if !extend.isEmpty {
|
||||
let encoded = extend.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? extend
|
||||
url += "&extend=\(encoded)"
|
||||
queryItems.append(URLQueryItem(name: "extend", value: extend))
|
||||
}
|
||||
}
|
||||
url = try buildURL(base: api, queryItems: queryItems)
|
||||
} else {
|
||||
// JSON 接口 (type=1)
|
||||
url = api.contains("?")
|
||||
? "\(api)&ac=videolist&t=\(sortData.id)&pg=\(page)"
|
||||
: "\(api)?ac=videolist&t=\(sortData.id)&pg=\(page)"
|
||||
var queryItems: [URLQueryItem] = [
|
||||
URLQueryItem(name: "ac", value: "videolist"),
|
||||
URLQueryItem(name: "t", value: sortData.id),
|
||||
URLQueryItem(name: "pg", value: String(page))
|
||||
]
|
||||
|
||||
// 附加筛选参数
|
||||
if let filters = filters {
|
||||
for (key, value) in filters {
|
||||
url += "&\(key)=\(value)"
|
||||
queryItems.append(URLQueryItem(name: key, value: value))
|
||||
}
|
||||
}
|
||||
url = try buildURL(base: api, queryItems: queryItems)
|
||||
}
|
||||
|
||||
let jsonStr = try await network.getString(from: url)
|
||||
@@ -255,28 +285,39 @@ class SourceService {
|
||||
guard sourceBean.isSupportedInSwift else { throw SourceError.unsupportedType(sourceBean.typeDescription) }
|
||||
guard sourceBean.isHttpApi else { throw SourceError.invalidApiUrl(api) }
|
||||
|
||||
var url: String
|
||||
let url: String
|
||||
if sourceBean.type == 0 {
|
||||
url = "\(api)?ac=videolist&ids=\(vodId)"
|
||||
url = try buildURL(
|
||||
base: api,
|
||||
queryItems: [
|
||||
URLQueryItem(name: "ac", value: "videolist"),
|
||||
URLQueryItem(name: "ids", value: vodId)
|
||||
]
|
||||
)
|
||||
} else if sourceBean.type == 4 {
|
||||
// Type 4: 远程接口
|
||||
url = api.contains("?")
|
||||
? "\(api)&ac=detail&ids=\(vodId)"
|
||||
: "\(api)?ac=detail&ids=\(vodId)"
|
||||
var queryItems: [URLQueryItem] = [
|
||||
URLQueryItem(name: "ac", value: "detail"),
|
||||
URLQueryItem(name: "ids", value: vodId)
|
||||
]
|
||||
|
||||
// 加载 extend
|
||||
if let ext = sourceBean.ext, !ext.isEmpty {
|
||||
let extend = await resolveExtend(ext)
|
||||
if !extend.isEmpty {
|
||||
let encoded = extend.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? extend
|
||||
url += "&extend=\(encoded)"
|
||||
queryItems.append(URLQueryItem(name: "extend", value: extend))
|
||||
}
|
||||
}
|
||||
url = try buildURL(base: api, queryItems: queryItems)
|
||||
} else {
|
||||
// JSON 接口 (type=1)
|
||||
url = api.contains("?")
|
||||
? "\(api)&ac=detail&ids=\(vodId)"
|
||||
: "\(api)?ac=detail&ids=\(vodId)"
|
||||
url = try buildURL(
|
||||
base: api,
|
||||
queryItems: [
|
||||
URLQueryItem(name: "ac", value: "detail"),
|
||||
URLQueryItem(name: "ids", value: vodId)
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
let jsonStr = try await network.getString(from: url)
|
||||
@@ -284,25 +325,27 @@ class SourceService {
|
||||
}
|
||||
|
||||
private func parseDetail(_ jsonStr: String, sourceKey: String, type: Int) throws -> VodInfo? {
|
||||
if type == 0 {
|
||||
return parseXMLDetail(jsonStr, sourceKey: sourceKey)
|
||||
}
|
||||
|
||||
guard let data = jsonStr.data(using: .utf8) else {
|
||||
throw SourceError.parseError("无法解析数据")
|
||||
}
|
||||
|
||||
if type == 1 || type == 4 {
|
||||
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let list = json["list"] as? [[String: Any]],
|
||||
let first = list.first {
|
||||
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let list = json["list"] as? [[String: Any]],
|
||||
let first = list.first {
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
if let itemData = try? JSONSerialization.data(withJSONObject: first),
|
||||
var video = try? decoder.decode(Movie.Video.self, from: itemData) {
|
||||
video.sourceKey = sourceKey
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
if let itemData = try? JSONSerialization.data(withJSONObject: first),
|
||||
var video = try? decoder.decode(Movie.Video.self, from: itemData) {
|
||||
video.sourceKey = sourceKey
|
||||
|
||||
let playFrom = first["vod_play_from"] as? String ?? ""
|
||||
let playUrl = first["vod_play_url"] as? String ?? ""
|
||||
|
||||
return VodInfo.from(video: video, playFrom: playFrom, playUrl: playUrl)
|
||||
}
|
||||
let playFrom = first["vod_play_from"] as? String ?? ""
|
||||
let playUrl = first["vod_play_url"] as? String ?? ""
|
||||
|
||||
return VodInfo.from(video: video, playFrom: playFrom, playUrl: playUrl)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,31 +361,35 @@ class SourceService {
|
||||
guard sourceBean.isSupportedInSwift else { throw SourceError.unsupportedType(sourceBean.typeDescription) }
|
||||
guard sourceBean.isHttpApi else { throw SourceError.invalidApiUrl(api) }
|
||||
|
||||
let encodedKeyword = keyword.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? keyword
|
||||
|
||||
var url: String
|
||||
let url: String
|
||||
if sourceBean.type == 0 {
|
||||
url = "\(api)?wd=\(encodedKeyword)"
|
||||
url = try buildURL(
|
||||
base: api,
|
||||
queryItems: [URLQueryItem(name: "wd", value: keyword)]
|
||||
)
|
||||
} else if sourceBean.type == 4 {
|
||||
// Type 4: 远程接口
|
||||
let quickValue = sourceBean.isQuickSearchEnabled ? "true" : "false"
|
||||
url = api.contains("?")
|
||||
? "\(api)&wd=\(encodedKeyword)&ac=detail&quick=\(quickValue)"
|
||||
: "\(api)?wd=\(encodedKeyword)&ac=detail&quick=\(quickValue)"
|
||||
var queryItems: [URLQueryItem] = [
|
||||
URLQueryItem(name: "wd", value: keyword),
|
||||
URLQueryItem(name: "ac", value: "detail"),
|
||||
URLQueryItem(name: "quick", value: quickValue)
|
||||
]
|
||||
|
||||
// 加载 extend
|
||||
if let ext = sourceBean.ext, !ext.isEmpty {
|
||||
let extend = await resolveExtend(ext)
|
||||
if !extend.isEmpty {
|
||||
let encoded = extend.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? extend
|
||||
url += "&extend=\(encoded)"
|
||||
queryItems.append(URLQueryItem(name: "extend", value: extend))
|
||||
}
|
||||
}
|
||||
url = try buildURL(base: api, queryItems: queryItems)
|
||||
} else {
|
||||
// JSON 接口 (type=1)
|
||||
url = api.contains("?")
|
||||
? "\(api)&wd=\(encodedKeyword)"
|
||||
: "\(api)?wd=\(encodedKeyword)"
|
||||
url = try buildURL(
|
||||
base: api,
|
||||
queryItems: [URLQueryItem(name: "wd", value: keyword)]
|
||||
)
|
||||
}
|
||||
|
||||
let jsonStr = try await network.getString(from: url)
|
||||
@@ -437,6 +484,128 @@ class SourceService {
|
||||
return extend
|
||||
}
|
||||
}
|
||||
|
||||
private func parseXMLDetail(_ xml: String, sourceKey: String) -> VodInfo? {
|
||||
guard let videoBlock = firstMatch(
|
||||
pattern: #"<video[\s\S]*?</video>"#,
|
||||
in: xml
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let vodId = extractXMLTag("id", in: videoBlock)
|
||||
guard !vodId.isEmpty else { return nil }
|
||||
|
||||
var video = Movie.Video(id: vodId)
|
||||
video.name = extractXMLTag("name", in: videoBlock)
|
||||
video.pic = extractXMLTag("pic", in: videoBlock)
|
||||
video.note = extractXMLTag("note", in: videoBlock)
|
||||
video.year = extractXMLTag("year", in: videoBlock)
|
||||
video.area = extractXMLTag("area", in: videoBlock)
|
||||
video.type = extractXMLTag("type", in: videoBlock)
|
||||
video.director = extractXMLTag("director", in: videoBlock)
|
||||
video.actor = extractXMLTag("actor", in: videoBlock)
|
||||
video.des = extractXMLTag("des", in: videoBlock)
|
||||
video.sourceKey = sourceKey
|
||||
|
||||
let ddNodes = extractXMLDDNodes(from: videoBlock)
|
||||
let playFrom: String
|
||||
let playUrl: String
|
||||
|
||||
if ddNodes.isEmpty {
|
||||
playFrom = "默认"
|
||||
playUrl = ""
|
||||
} else {
|
||||
playFrom = ddNodes.map { $0.flag }.joined(separator: "$$$")
|
||||
playUrl = ddNodes.map { $0.url }.joined(separator: "$$$")
|
||||
}
|
||||
|
||||
return VodInfo.from(video: video, playFrom: playFrom, playUrl: playUrl)
|
||||
}
|
||||
|
||||
private func extractXMLDDNodes(from block: String) -> [(flag: String, url: String)] {
|
||||
guard let regex = try? NSRegularExpression(
|
||||
pattern: #"<dd([^>]*)>([\s\S]*?)</dd>"#,
|
||||
options: [.caseInsensitive]
|
||||
) else {
|
||||
return []
|
||||
}
|
||||
|
||||
let nsRange = NSRange(block.startIndex..<block.endIndex, in: block)
|
||||
let matches = regex.matches(in: block, range: nsRange)
|
||||
var result: [(flag: String, url: String)] = []
|
||||
|
||||
for (index, match) in matches.enumerated() {
|
||||
guard match.numberOfRanges >= 3 else { continue }
|
||||
guard let attrRange = Range(match.range(at: 1), in: block),
|
||||
let valueRange = Range(match.range(at: 2), in: block) else {
|
||||
continue
|
||||
}
|
||||
|
||||
let attrs = String(block[attrRange])
|
||||
let rawUrl = decodeXMLText(String(block[valueRange]))
|
||||
guard !rawUrl.isEmpty else { continue }
|
||||
|
||||
let flag = firstMatch(
|
||||
pattern: #"flag\s*=\s*["']([^"']+)["']"#,
|
||||
in: attrs,
|
||||
captureGroup: 1
|
||||
) ?? "线路\(index + 1)"
|
||||
result.append((flag: decodeXMLText(flag), url: rawUrl))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private func extractXMLTag(_ tag: String, in content: String) -> String {
|
||||
let escapedTag = NSRegularExpression.escapedPattern(for: tag)
|
||||
let pattern = "<\(escapedTag)>\\s*([\\s\\S]*?)\\s*</\(escapedTag)>"
|
||||
let value = firstMatch(pattern: pattern, in: content, captureGroup: 1) ?? ""
|
||||
return decodeXMLText(value)
|
||||
}
|
||||
|
||||
private func firstMatch(pattern: String, in content: String, captureGroup: Int = 0) -> String? {
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else {
|
||||
return nil
|
||||
}
|
||||
let range = NSRange(content.startIndex..<content.endIndex, in: content)
|
||||
guard let match = regex.firstMatch(in: content, options: [], range: range),
|
||||
match.numberOfRanges > captureGroup,
|
||||
let subRange = Range(match.range(at: captureGroup), in: content) else {
|
||||
return nil
|
||||
}
|
||||
return String(content[subRange])
|
||||
}
|
||||
|
||||
private func decodeXMLText(_ raw: String) -> String {
|
||||
var value = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if value.hasPrefix("<![CDATA["), value.hasSuffix("]]>"), value.count >= 12 {
|
||||
value.removeFirst(9)
|
||||
value.removeLast(3)
|
||||
}
|
||||
value = value.replacingOccurrences(of: "&", with: "&")
|
||||
value = value.replacingOccurrences(of: "<", with: "<")
|
||||
value = value.replacingOccurrences(of: ">", with: ">")
|
||||
value = value.replacingOccurrences(of: """, with: "\"")
|
||||
value = value.replacingOccurrences(of: "'", with: "'")
|
||||
return value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private func buildURL(base: String, queryItems: [URLQueryItem]) throws -> String {
|
||||
let trimmedBase = base.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard var components = URLComponents(string: trimmedBase) else {
|
||||
throw SourceError.invalidApiUrl(base)
|
||||
}
|
||||
|
||||
var mergedQueryItems = components.queryItems ?? []
|
||||
mergedQueryItems.append(contentsOf: queryItems)
|
||||
components.queryItems = mergedQueryItems
|
||||
|
||||
guard let url = components.url else {
|
||||
throw SourceError.invalidApiUrl(base)
|
||||
}
|
||||
return url.absoluteString
|
||||
}
|
||||
}
|
||||
|
||||
enum SourceError: LocalizedError {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
/// 直播 ViewModel
|
||||
@MainActor
|
||||
@@ -19,12 +20,16 @@ class LiveViewModel: ObservableObject {
|
||||
/// 是否显示频道列表(预留给 TV 遥控交互)。
|
||||
@Published var showChannelList = false
|
||||
|
||||
/// 订阅配置更新,支持直播频道列表实时刷新。
|
||||
private var cancellables: Set<AnyCancellable> = []
|
||||
|
||||
init() {
|
||||
bindLiveChannelGroups()
|
||||
}
|
||||
|
||||
/// 加载直播频道
|
||||
func loadChannels() {
|
||||
self.channelGroups = ApiConfig.shared.liveChannelGroupList
|
||||
if let firstGroup = channelGroups.first, let firstChannel = firstGroup.channels.first {
|
||||
currentChannel = firstChannel
|
||||
}
|
||||
applyChannelGroups(ApiConfig.shared.liveChannelGroupList)
|
||||
}
|
||||
|
||||
/// 选择频道分组
|
||||
@@ -90,6 +95,60 @@ class LiveViewModel: ObservableObject {
|
||||
// 当前版本先清空,避免展示过期节目单。
|
||||
epgList = []
|
||||
}
|
||||
|
||||
private func bindLiveChannelGroups() {
|
||||
ApiConfig.shared.$liveChannelGroupList
|
||||
.sink { [weak self] groups in
|
||||
self?.applyChannelGroups(groups)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func applyChannelGroups(_ groups: [LiveChannelGroup]) {
|
||||
let previousChannelId = currentChannel?.id
|
||||
channelGroups = groups
|
||||
|
||||
guard !groups.isEmpty else {
|
||||
selectedGroupIndex = 0
|
||||
selectedChannelIndex = 0
|
||||
currentChannel = nil
|
||||
return
|
||||
}
|
||||
|
||||
if let previousChannelId,
|
||||
let located = locateChannel(withId: previousChannelId, in: groups) {
|
||||
selectedGroupIndex = located.groupIndex
|
||||
selectedChannelIndex = located.channelIndex
|
||||
currentChannel = groups[located.groupIndex].channels[located.channelIndex]
|
||||
return
|
||||
}
|
||||
|
||||
let clampedGroupIndex = min(max(0, selectedGroupIndex), groups.count - 1)
|
||||
selectedGroupIndex = clampedGroupIndex
|
||||
|
||||
let channels = groups[clampedGroupIndex].channels
|
||||
guard !channels.isEmpty else {
|
||||
selectedChannelIndex = 0
|
||||
currentChannel = nil
|
||||
return
|
||||
}
|
||||
|
||||
let clampedChannelIndex = min(max(0, selectedChannelIndex), channels.count - 1)
|
||||
selectedChannelIndex = clampedChannelIndex
|
||||
currentChannel = channels[clampedChannelIndex]
|
||||
}
|
||||
|
||||
private func locateChannel(
|
||||
withId channelId: String,
|
||||
in groups: [LiveChannelGroup]
|
||||
) -> (groupIndex: Int, channelIndex: Int)? {
|
||||
for (groupIndex, group) in groups.enumerated() {
|
||||
if let channelIndex = group.channels.firstIndex(where: { $0.id == channelId }) {
|
||||
return (groupIndex, channelIndex)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// 安全数组下标访问
|
||||
|
||||
@@ -289,6 +289,7 @@ struct ContentView: View {
|
||||
.clipShape(Capsule())
|
||||
.shadow(color: .red.opacity(0.4), radius: 12, x: 0, y: 6)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(
|
||||
settingsVM.isLoadingConfig
|
||||
|| settingsVM.vodApiUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
|
||||
@@ -38,7 +38,11 @@ struct FavoritesView: View {
|
||||
.contextMenu {
|
||||
Button(role: .destructive) {
|
||||
modelContext.delete(item)
|
||||
try? modelContext.save()
|
||||
do {
|
||||
try modelContext.save()
|
||||
} catch {
|
||||
print("删除收藏失败: \(error)")
|
||||
}
|
||||
} label: {
|
||||
Label("取消收藏", systemImage: "heart.slash")
|
||||
}
|
||||
|
||||
@@ -38,7 +38,11 @@ struct HistoryView: View {
|
||||
.contextMenu {
|
||||
Button(role: .destructive) {
|
||||
modelContext.delete(item)
|
||||
try? modelContext.save()
|
||||
do {
|
||||
try modelContext.save()
|
||||
} catch {
|
||||
print("删除历史记录失败: \(error)")
|
||||
}
|
||||
} label: {
|
||||
Label("删除记录", systemImage: "trash")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user