fixed some bugs

This commit is contained in:
2026-09-07 11:42:53 +08:00
parent aca36c78c1
commit 9230db3b69
12 changed files with 883 additions and 14 deletions

View File

@@ -20,4 +20,7 @@ enum AppConfig {
/// 当前登录用户的认证 token,由 AuthManager 在登录/注销时设置。
static var authToken: String = ""
/// 当前登录用户 ID,用于隔离本机缓存的历史录音和评分。
static var currentUserId: String = ""
}

View File

@@ -0,0 +1,167 @@
import Foundation
struct PersistedRecording {
let audioURL: URL
let rawURL: URL
}
struct TrainingHistorySnapshot {
var audioSegments: [Int: URL] = [:]
var rawSegments: [Int: URL] = [:]
var scores: [Int: ImitationAssessmentResult] = [:]
}
@MainActor
final class TrainingHistoryManager: ObservableObject {
@Published private(set) var historyVersion = 0
let store = TrainingHistoryStore()
func notifyHistoryChanged() {
historyVersion += 1
}
}
final class TrainingHistoryStore {
private let root: URL
init() {
let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
root = documents.appendingPathComponent("TrainingHistory", isDirectory: true)
try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
}
func normalizedVideoHash(_ videoHash: String) -> String {
let allowed = CharacterSet(charactersIn: "0123456789abcdefABCDEF")
let filtered = videoHash.lowercased().unicodeScalars.filter { allowed.contains($0) }
return String(String.UnicodeScalarView(filtered))
}
private var userDirectory: URL {
let identifier = normalizedVideoHash(AppConfig.currentUserId)
return root.appendingPathComponent(identifier.isEmpty ? "anonymous" : identifier, isDirectory: true)
}
func load(videoHash: String) -> TrainingHistorySnapshot {
let directory = courseDirectory(videoHash)
var snapshot = TrainingHistorySnapshot()
for index in 0..<500 {
let audio = directory.appendingPathComponent(String(format: "segment-%04d.m4a", index))
let raw = directory.appendingPathComponent(String(format: "raw-%04d.caf", index))
if FileManager.default.fileExists(atPath: audio.path) {
snapshot.audioSegments[index] = audio
}
if FileManager.default.fileExists(atPath: raw.path) {
snapshot.rawSegments[index] = raw
}
}
let scoreFile = directory.appendingPathComponent("scores.json")
if let data = try? Data(contentsOf: scoreFile),
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
for (key, value) in object {
guard let index = Int(key), index >= 0 else { continue }
if let payload = value as? [String: Any],
let payloadData = try? JSONSerialization.data(withJSONObject: payload),
let score = try? JSONDecoder().decode(ImitationAssessmentResult.self, from: payloadData) {
snapshot.scores[index] = score
}
}
}
return snapshot
}
func saveSegment(
videoHash: String,
sentenceIndex: Int,
audioURL: URL,
rawURL: URL
) throws -> PersistedRecording {
let directory = courseDirectory(videoHash)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let savedAudio = directory.appendingPathComponent(
String(format: "segment-%04d.m4a", sentenceIndex)
)
let savedRaw = directory.appendingPathComponent(
String(format: "raw-%04d.caf", sentenceIndex)
)
try? FileManager.default.removeItem(at: savedAudio)
try? FileManager.default.removeItem(at: savedRaw)
try FileManager.default.moveItem(at: audioURL, to: savedAudio)
try FileManager.default.moveItem(at: rawURL, to: savedRaw)
return PersistedRecording(audioURL: savedAudio, rawURL: savedRaw)
}
func audioDestination(videoHash: String, sentenceIndex: Int) -> URL {
courseDirectory(videoHash).appendingPathComponent(
String(format: "segment-%04d.m4a", sentenceIndex)
)
}
func saveScores(
videoHash: String,
scores: [Int: ImitationAssessmentResult]
) {
let directory = courseDirectory(videoHash)
do {
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
var object: [String: Any] = [:]
for (index, score) in scores {
object[String(index)] = Self.payload(score)
}
let data = try JSONSerialization.data(withJSONObject: object, options: [.sortedKeys])
try data.write(
to: directory.appendingPathComponent("scores.json"),
options: [.atomic]
)
} catch {
// History persistence is best-effort; the server remains the source of record.
}
}
func removeScore(videoHash: String, sentenceIndex: Int) {
var snapshot = load(videoHash: videoHash)
snapshot.scores[sentenceIndex] = nil
saveScores(videoHash: videoHash, scores: snapshot.scores)
}
func removeAllAudio(videoHash: String) {
let snapshot = load(videoHash: videoHash)
for url in snapshot.audioSegments.values {
try? FileManager.default.removeItem(at: url)
}
for url in snapshot.rawSegments.values {
try? FileManager.default.removeItem(at: url)
}
}
private func courseDirectory(_ videoHash: String) -> URL {
userDirectory.appendingPathComponent(normalizedVideoHash(videoHash), isDirectory: true)
}
private static func payload(_ score: ImitationAssessmentResult) -> [String: Any] {
var value: [String: Any] = ["overall_score": score.overallScore]
if let item = score.passed { value["passed"] = item }
if let item = score.passScore { value["pass_score"] = item }
if let item = score.contentScore { value["content_score"] = item }
if let item = score.completenessScore { value["completeness_score"] = item }
if let item = score.fluencyScore { value["fluency_score"] = item }
if let item = score.pronunciationScore { value["pronunciation_score"] = item }
if let item = score.prosodyScore { value["prosody_score"] = item }
if let item = score.durationScore { value["duration_score"] = item }
if let item = score.pauseScore { value["pause_score"] = item }
if let item = score.speechRateScore { value["speech_rate_score"] = item }
if let item = score.referenceText { value["reference_text"] = item }
if let item = score.recognizedText { value["recognized_text"] = item }
if let item = score.durationRatio { value["duration_ratio"] = item }
if !score.missingTokens.isEmpty { value["missing_tokens"] = score.missingTokens }
if !score.extraTokens.isEmpty { value["extra_tokens"] = score.extraTokens }
if !score.substitutions.isEmpty {
value["substitutions"] = score.substitutions.map {
["expected": $0.expected, "actual": $0.actual]
}
}
if let item = score.feedback { value["feedback"] = item }
if !score.details.isEmpty { value["details"] = score.details }
return value
}
}

View File

@@ -0,0 +1,177 @@
import Foundation
struct UserResultSummary: Identifiable, Decodable {
let attemptId: String
let videoHash: String
let courseTitle: String
let sentenceIndex: Int
let sentenceText: String
let overallScore: Float
let passed: Bool
let createdAt: String
var id: String { attemptId }
private enum CodingKeys: String, CodingKey {
case overallScore = "overall_score"
case courseTitle = "course_title"
case sentenceIndex = "sentence_index"
case sentenceText = "sentence_text"
case createdAt = "created_at"
case attemptId = "attempt_id"
case videoHash = "video_hash"
case passed
}
}
struct UserDubShareSummary: Identifiable, Decodable {
let shareId: String
let videoHash: String
let courseTitle: String
let title: String
let segmentCount: Int
let averageScore: Float
let createdAt: String
var id: String { shareId }
private enum CodingKeys: String, CodingKey {
case averageScore = "average_score"
case courseTitle = "course_title"
case segmentCount = "segment_count"
case createdAt = "created_at"
case shareId = "share_id"
case videoHash = "video_hash"
case title
}
}
struct DubShareSegmentDetail: Decodable {
let index: Int
let overallScore: Float
let recognizedText: String
let scoreDetails: [String: Float]
let audioURL: String
private enum CodingKeys: String, CodingKey {
case overallScore = "overall_score"
case recognizedText = "recognized_text"
case scoreDetails = "score_details"
case audioURL = "audio_url"
case index
}
}
struct DubShareDetail: Decodable {
let shareId: String
let videoHash: String
let title: String
let segments: [DubShareSegmentDetail]
private enum CodingKeys: String, CodingKey {
case shareId = "share_id"
case videoHash = "video_hash"
case title
case segments
}
}
enum UserHistoryError: LocalizedError {
case badStatus(Int, String)
var errorDescription: String? {
switch self {
case .badStatus(let code, let body):
return "HTTP \(code): \(body.prefix(200))"
}
}
}
enum UserHistoryAPI {
static func results(token: String) async throws -> [UserResultSummary] {
try await getJSON([UserResultSummary].self, path: "api/v1/me/results", token: token)
}
static func dubShares(token: String) async throws -> [UserDubShareSummary] {
try await getJSON([UserDubShareSummary].self, path: "api/v1/me/dub-shares", token: token)
}
static func dubShareDetail(token: String, shareId: String) async throws -> DubShareDetail {
try await getJSON(
DubShareDetail.self,
path: "api/v1/dub-shares/\(shareId)",
token: token
)
}
static func deleteResult(token: String, attemptId: String) async throws {
try await delete(path: "api/v1/me/results/\(attemptId)", token: token)
}
static func deleteDubShare(token: String, shareId: String) async throws {
try await delete(path: "api/v1/me/dub-shares/\(shareId)", token: token)
}
static func downloadAudio(
token: String,
audioPath: String,
destination: URL
) async throws {
let result = try await HTTPClient.execute(
baseURL: AppConfig.serverBaseURL,
path: audioPath,
method: "GET",
timeout: AppConfig.readTimeout,
headers: authorizationHeaders(token: token)
)
guard (200...299).contains(result.statusCode) else {
throw UserHistoryError.badStatus(
result.statusCode,
String(data: result.body, encoding: .utf8) ?? ""
)
}
try? FileManager.default.removeItem(at: destination)
try result.body.write(to: destination, options: .atomic)
}
private static func getJSON<T: Decodable>(
_ type: T.Type,
path: String,
token: String
) async throws -> T {
let result = try await HTTPClient.execute(
baseURL: AppConfig.serverBaseURL,
path: path,
method: "GET",
timeout: AppConfig.readTimeout,
headers: authorizationHeaders(token: token)
)
guard (200...299).contains(result.statusCode) else {
throw UserHistoryError.badStatus(
result.statusCode,
String(data: result.body, encoding: .utf8) ?? ""
)
}
return try JSONDecoder().decode(T.self, from: result.body)
}
private static func delete(path: String, token: String) async throws {
let result = try await HTTPClient.execute(
baseURL: AppConfig.serverBaseURL,
path: path,
method: "DELETE",
timeout: AppConfig.readTimeout,
headers: authorizationHeaders(token: token)
)
guard (200...299).contains(result.statusCode) else {
throw UserHistoryError.badStatus(
result.statusCode,
String(data: result.body, encoding: .utf8) ?? ""
)
}
}
private static func authorizationHeaders(token: String) -> [String: String] {
token.isEmpty ? [:] : ["Authorization": "Bearer \(token)"]
}
}

View File

@@ -6,15 +6,20 @@ import SwiftUI
final class AuthManager: ObservableObject {
@Published var token: String
@Published var nickname: String
@Published var userID: String
@Published var showingRegister = false
@Published var showingSSO = false
@Published var showingPhoneBinding = false
@Published var ssoPendingToken: String?
@Published var ssoPendingUserId = ""
init() {
let defaults = UserDefaults.standard
token = defaults.string(forKey: "oral_trainer_token") ?? ""
nickname = defaults.string(forKey: "oral_trainer_nickname") ?? ""
let userID = defaults.string(forKey: "oral_trainer_user_id") ?? ""
AppConfig.currentUserId = userID
self.userID = userID
AppConfig.authToken = token
}
@@ -24,22 +29,29 @@ final class AuthManager: ObservableObject {
token = session.token
nickname = session.user.nickname
AppConfig.authToken = session.token
userID = session.user.id
AppConfig.currentUserId = session.user.id
let defaults = UserDefaults.standard
defaults.set(session.token, forKey: "oral_trainer_token")
defaults.set(session.user.nickname, forKey: "oral_trainer_nickname")
defaults.set(session.user.id, forKey: "oral_trainer_user_id")
}
func logout() {
token = ""
nickname = ""
AppConfig.authToken = ""
userID = ""
AppConfig.currentUserId = ""
showingRegister = false
showingSSO = false
showingPhoneBinding = false
ssoPendingToken = nil
ssoPendingUserId = ""
let defaults = UserDefaults.standard
defaults.removeObject(forKey: "oral_trainer_token")
defaults.removeObject(forKey: "oral_trainer_nickname")
defaults.removeObject(forKey: "oral_trainer_user_id")
}
}
@@ -357,6 +369,7 @@ struct SSOWebView: View {
dismiss()
if !result.phoneBound {
auth.ssoPendingToken = result.token
auth.ssoPendingUserId = result.user.id
auth.showingPhoneBinding = true
} else {
auth.saveSession(AuthSession(token: result.token, user: result.user))
@@ -499,7 +512,13 @@ struct PhoneBindingView: View {
try await AuthAPI.ssoBindPhone(token: token, phone: cleaned)
auth.showingPhoneBinding = false
auth.ssoPendingToken = nil
auth.saveSession(AuthSession(token: token, user: AuthUser(id: "", username: "", nickname: "")))
let userID = auth.ssoPendingUserId
auth.saveSession(
AuthSession(
token: token,
user: AuthUser(id: userID, username: "", nickname: "")
)
)
dismiss()
onLoginSuccess()
} catch {

View File

@@ -6,6 +6,7 @@ struct RootView: View {
@StateObject private var auth = AuthManager()
@StateObject private var controller = PlayerController()
@StateObject private var catalogVM = CatalogViewModel()
@StateObject private var historyManager = TrainingHistoryManager()
@State private var activeModule: Module = .course
@State private var continuousPlayback = false
@State private var showImporter = false
@@ -14,6 +15,11 @@ struct RootView: View {
@State private var currentCourse: Course?
@State private var courseStatusText = "正在同步"
@State private var showCourseGuardToast = false
@State private var managedResults: [UserResultSummary] = []
@State private var managedDubShares: [UserDubShareSummary] = []
@State private var managementStatus = ""
@State private var managementIsLoading = false
@State private var deletingHistoryID: String?
enum Module {
case course
@@ -49,7 +55,11 @@ struct RootView: View {
onFullscreen: enterFullscreen
)
case .test:
TestView(controller: controller, onFullscreen: enterFullscreen)
TestView(
controller: controller,
historyManager: historyManager,
onFullscreen: enterFullscreen
)
case .mine:
minePage
}
@@ -276,10 +286,195 @@ struct RootView: View {
.font(.system(size: 13))
.foregroundColor(Theme.textMuted)
.padding(.horizontal, 16)
HStack(spacing: 10) {
Button {
Task { await loadManagedHistory() }
} label: {
Text(managementIsLoading ? "刷新中..." : "刷新记录")
.font(.system(size: 13, weight: .bold))
.foregroundColor(.white)
.padding(.horizontal, 14)
.frame(height: 36)
.background(Theme.buttonBlue)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.plain)
.disabled(managementIsLoading)
if !managementStatus.isEmpty {
Text(managementStatus)
.font(.system(size: 12))
.foregroundColor(Theme.textMuted)
}
}
.padding(.horizontal, 16)
historyGroup(title: "我的测试结果", isEmpty: managedResults.isEmpty) {
ForEach(managedResults) { row in
resultRow(row)
}
}
historyGroup(title: "我的配音分享", isEmpty: managedDubShares.isEmpty) {
ForEach(managedDubShares) { row in
dubShareRow(row)
}
}
}
.padding(.bottom, 24)
}
.task(id: currentCourse?.videoHash) {
await loadManagedHistory()
}
}
private func historyGroup<Content: View>(
title: String,
isEmpty: Bool,
@ViewBuilder content: () -> Content
) -> some View {
VStack(alignment: .leading, spacing: 10) {
Text(title)
.font(.system(size: 18, weight: .bold))
.foregroundColor(.white)
.padding(.horizontal, 16)
if isEmpty {
Text("该课程暂无记录")
.font(.system(size: 13))
.foregroundColor(Theme.textMuted)
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Theme.surface)
.clipShape(RoundedRectangle(cornerRadius: 10))
.padding(.horizontal, 16)
} else {
LazyVStack(spacing: 8, content: content)
.padding(.horizontal, 16)
}
}
}
private func resultRow(_ row: UserResultSummary) -> some View {
historyRow(
title: "第 \(row.sentenceIndex + 1) 句 · \(String(format: "%.0f", row.overallScore)) 分",
subtitle: "\(row.passed ? "通过" : "未通过") · \(displayDate(row.createdAt))\n\(row.sentenceText)",
isDeleting: deletingHistoryID == row.id,
deleteLabel: "删除"
) {
await deleteResult(row)
}
}
private func dubShareRow(_ row: UserDubShareSummary) -> some View {
historyRow(
title: row.title,
subtitle: "\(row.segmentCount) 句 · 平均 \(String(format: "%.0f", row.averageScore)) 分 · \(displayDate(row.createdAt))",
isDeleting: deletingHistoryID == row.id,
deleteLabel: "删除"
) {
await deleteDubShare(row)
}
}
private func historyRow(
title: String,
subtitle: String,
isDeleting: Bool,
deleteLabel: String,
action: @escaping () async -> Void
) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text(title)
.font(.system(size: 15, weight: .bold))
.foregroundColor(Theme.textDark)
Text(subtitle)
.font(.system(size: 12))
.foregroundColor(Theme.textSubtle)
.fixedSize(horizontal: false, vertical: true)
HStack {
Spacer()
Button {
Task { await action() }
} label: {
Text(isDeleting ? "删除中..." : deleteLabel)
.font(.system(size: 12, weight: .bold))
.foregroundColor(.white)
.padding(.horizontal, 12)
.frame(height: 28)
.background(isDeleting ? Theme.border : Theme.danger)
.clipShape(RoundedRectangle(cornerRadius: 7))
}
.buttonStyle(.plain)
.disabled(isDeleting)
}
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Theme.surface)
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(Theme.lightBorder, lineWidth: 1)
)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
private func loadManagedHistory() async {
guard let course = currentCourse else {
managementStatus = "请先选择课程"
managedResults = []
managedDubShares = []
return
}
managementIsLoading = true
managementStatus = "正在加载..."
defer { managementIsLoading = false }
do {
async let resultsTask = UserHistoryAPI.results(token: auth.token)
async let sharesTask = UserHistoryAPI.dubShares(token: auth.token)
let (allResults, allShares) = try await (resultsTask, sharesTask)
managedResults = allResults.filter { $0.videoHash.caseInsensitiveCompare(course.videoHash) == .orderedSame }
managedDubShares = allShares.filter { $0.videoHash.caseInsensitiveCompare(course.videoHash) == .orderedSame }
managementStatus = "共 \(managedResults.count) 条测试、\(managedDubShares.count) 条配音"
} catch {
managementStatus = "记录加载失败:\(error.localizedDescription)"
}
}
private func deleteResult(_ row: UserResultSummary) async {
guard let course = currentCourse, deletingHistoryID == nil else { return }
deletingHistoryID = row.id
defer { deletingHistoryID = nil }
do {
try await UserHistoryAPI.deleteResult(token: auth.token, attemptId: row.attemptId)
managedResults.removeAll { $0.id == row.id }
historyManager.store.removeScore(videoHash: course.videoHash, sentenceIndex: row.sentenceIndex)
historyManager.notifyHistoryChanged()
managementStatus = "测试记录已删除"
} catch {
managementStatus = "删除失败:\(error.localizedDescription)"
}
}
private func deleteDubShare(_ row: UserDubShareSummary) async {
guard let course = currentCourse, deletingHistoryID == nil else { return }
deletingHistoryID = row.id
defer { deletingHistoryID = nil }
do {
try await UserHistoryAPI.deleteDubShare(token: auth.token, shareId: row.shareId)
managedDubShares.removeAll { $0.id == row.id }
historyManager.store.removeAllAudio(videoHash: course.videoHash)
historyManager.notifyHistoryChanged()
managementStatus = "配音分享已删除"
} catch {
managementStatus = "删除失败:\(error.localizedDescription)"
}
}
private func displayDate(_ value: String) -> String {
value.replacingOccurrences(of: "T", with: " ").prefix(16).description
}
// MARK: - Load Courses
private func loadCourses() async {

View File

@@ -3,6 +3,7 @@ import SwiftUI
struct TestView: View {
@ObservedObject var controller: PlayerController
@ObservedObject var historyManager: TrainingHistoryManager
let onFullscreen: () -> Void
@State private var recordingSession: AudioRecorderSession?
@@ -25,6 +26,8 @@ struct TestView: View {
@State private var showShareSheet = false
@State private var dubPlaybackObserver: NSObjectProtocol?
@State private var testSubtitleVisible = true
@State private var loadedCourseHash: String?
@State private var remoteHistoryGeneration = 0
var body: some View {
ScrollView(showsIndicators: false) {
@@ -121,7 +124,8 @@ struct TestView: View {
.padding(16)
}
}
.onReceive(controller.$currentItem) { _ in
.onReceive(controller.$currentItem) { item in
synchronizeHistory(for: item?.id)
refreshTestUI()
}
.onReceive(controller.$currentSentence) { _ in
@@ -130,6 +134,14 @@ struct TestView: View {
.onDisappear {
stopDubPlayback(message: nil)
}
.onAppear {
synchronizeHistory(for: controller.currentItem?.id)
refreshTestUI()
}
.onChange(of: historyManager.historyVersion) { _ in
synchronizeHistory(for: controller.currentItem?.id, forceLocalReload: true)
refreshTestUI()
}
.sheet(isPresented: $showShareSheet) {
if let url = shareURL {
ActivityShareSheet(items: [url])
@@ -290,14 +302,24 @@ struct TestView: View {
try? FileManager.default.removeItem(at: rawURL)
return
}
if let oldURL = dubSegments[sentence.index] {
try? FileManager.default.removeItem(at: oldURL)
var audioURL = url
var rawPersistedURL = rawURL
if let item = controller.currentItem {
do {
let persisted = try historyManager.store.saveSegment(
videoHash: item.id,
sentenceIndex: sentence.index,
audioURL: url,
rawURL: rawURL
)
audioURL = persisted.audioURL
rawPersistedURL = persisted.rawURL
} catch {
testStatus = "录音已评测,但本地历史保存失败:\(error.localizedDescription)"
}
}
if let oldRawURL = dubRawSegments[sentence.index] {
try? FileManager.default.removeItem(at: oldRawURL)
}
dubSegments[sentence.index] = url
dubRawSegments[sentence.index] = rawURL
dubSegments[sentence.index] = audioURL
dubRawSegments[sentence.index] = rawPersistedURL
mergedDubURL = nil
submitAssessment(recordingURL: url)
}
@@ -337,6 +359,8 @@ struct TestView: View {
Task {
do {
let result = try await ImitationAssessor.assess(request: request)
latestScores[sentence.index] = result
historyManager.store.saveScores(videoHash: item.id, scores: latestScores)
applyAssessmentResult(result)
} catch {
testStatus = "评测失败:\(error.localizedDescription)"
@@ -391,14 +415,159 @@ struct TestView: View {
testStatus = "请先在训练模块选择一个云端课程"
return
}
let currentRecording = dubSegments[sentence.index]
let segmentLabel = currentRecording == nil ? "" : " · 本句已录音,重录将覆盖"
testStatus = "评测对象:第 \(sentence.index + 1) 句(共 \(item.sentences.count) 句)\(segmentLabel)"
if mergedDubURL == nil {
if let score = latestScores[sentence.index] {
applyAssessmentResult(score)
} else {
let currentRecording = dubSegments[sentence.index]
let segmentLabel = currentRecording == nil ? "" : " · 本句已录音,重录将覆盖"
testStatus = "评测对象:第 \(sentence.index + 1) 句(共 \(item.sentences.count) 句)\(segmentLabel)"
scoreSummary = "尚未评测"
scoreDetail = ""
}
if mergedDubURL == nil && !dubbingPlayback && !isGeneratingDub {
dubbingStatus = dubSegments.isEmpty ? "配音:请先录音" : "配音:已保存 \(dubSegments.count) 句,可生成配音"
}
}
// MARK: - 历史记录
private func synchronizeHistory(for videoHash: String?, forceLocalReload: Bool = false) {
guard let videoHash else {
loadedCourseHash = nil
dubSegments = [:]
dubRawSegments = [:]
latestScores = [:]
return
}
let normalizedHash = historyManager.store.normalizedVideoHash(videoHash)
guard loadedCourseHash != normalizedHash || forceLocalReload else { return }
loadedCourseHash = normalizedHash
remoteHistoryGeneration += 1
let generation = remoteHistoryGeneration
let snapshot = historyManager.store.load(videoHash: videoHash)
dubSegments = snapshot.audioSegments
dubRawSegments = snapshot.rawSegments
for (index, audioURL) in snapshot.audioSegments where dubRawSegments[index] == nil {
dubRawSegments[index] = audioURL
}
latestScores = snapshot.scores
mergedDubURL = nil
if snapshot.audioSegments.isEmpty && snapshot.scores.isEmpty {
dubbingStatus = "正在同步历史配音…"
} else {
dubbingStatus = "已加载历史配音 \(snapshot.audioSegments.count) 句、评分 \(snapshot.scores.count) 句"
}
Task {
await restoreRemoteHistory(videoHash: videoHash, generation: generation)
}
}
private func restoreRemoteHistory(videoHash: String, generation: Int) async {
let token = AppConfig.authToken
guard !token.isEmpty else { return }
let normalizedHash = historyManager.store.normalizedVideoHash(videoHash)
do {
let rows = try await UserHistoryAPI.results(token: token)
guard remoteHistoryGeneration == generation,
loadedCourseHash == normalizedHash else { return }
var latest = [Int: UserResultSummary]()
rows.filter { historyManager.store.normalizedVideoHash($0.videoHash) == normalizedHash }
.sorted { $0.createdAt < $1.createdAt }
.forEach { latest[$0.sentenceIndex] = $0 }
var addedScores = 0
for (index, row) in latest where latestScores[index] == nil {
if let score = decodedScore(payload: [
"overall_score": row.overallScore,
"passed": row.passed,
"reference_text": row.sentenceText,
"feedback": "来自历史测试记录",
]) {
latestScores[index] = score
addedScores += 1
}
}
if addedScores > 0 {
historyManager.store.saveScores(videoHash: videoHash, scores: latestScores)
dubbingStatus = "已同步历史评分 \(latestScores.count) 句"
refreshTestUI()
}
} catch {
if remoteHistoryGeneration == generation, latestScores.isEmpty {
dubbingStatus = "历史评分同步失败:\(error.localizedDescription)"
}
}
guard dubSegments.isEmpty else { return }
do {
let shares = try await UserHistoryAPI.dubShares(token: token)
guard remoteHistoryGeneration == generation,
loadedCourseHash == normalizedHash else { return }
guard let share = shares
.filter({ historyManager.store.normalizedVideoHash($0.videoHash) == normalizedHash })
.max(by: { $0.createdAt < $1.createdAt }) else {
if dubSegments.isEmpty && latestScores.isEmpty {
dubbingStatus = "暂无可用的历史配音音频"
}
return
}
let detail = try await UserHistoryAPI.dubShareDetail(token: token, shareId: share.shareId)
var restored = [Int: URL]()
for segment in detail.segments {
guard remoteHistoryGeneration == generation else { return }
let filename = segment.audioURL.split(separator: "/").last.map(String.init) ?? ""
guard !filename.isEmpty else { continue }
let destination = historyManager.store.audioDestination(
videoHash: videoHash,
sentenceIndex: segment.index
)
try await UserHistoryAPI.downloadAudio(
token: token,
audioPath: "api/v1/dub-shares/\(share.shareId)/audio/\(filename)",
destination: destination
)
restored[segment.index] = destination
var payload: [String: Any] = [
"overall_score": segment.overallScore,
"feedback": "来自历史配音分享",
]
if !segment.recognizedText.isEmpty {
payload["recognized_text"] = segment.recognizedText
}
segment.scoreDetails.forEach { payload[$0.key] = $0.value }
if let score = decodedScore(payload: payload), latestScores[segment.index] == nil {
latestScores[segment.index] = score
}
}
guard remoteHistoryGeneration == generation,
loadedCourseHash == normalizedHash else { return }
for (index, audioURL) in restored where dubSegments[index] == nil {
dubSegments[index] = audioURL
dubRawSegments[index] = audioURL
}
historyManager.store.saveScores(videoHash: videoHash, scores: latestScores)
if !restored.isEmpty {
dubbingStatus = "已从云端恢复配音 \(restored.count) 句"
} else if dubSegments.isEmpty && latestScores.isEmpty {
dubbingStatus = "暂无可用的历史配音音频"
}
refreshTestUI()
} catch {
guard remoteHistoryGeneration == generation, dubSegments.isEmpty else { return }
dubbingStatus = "历史配音同步失败:\(error.localizedDescription)"
}
}
private func decodedScore(payload: [String: Any]) -> ImitationAssessmentResult? {
guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return nil }
return try? JSONDecoder().decode(ImitationAssessmentResult.self, from: data)
}
// MARK: - 配音
private func toggleDubPlayback() {