diff --git a/ios/OralTrainer.xcodeproj/project.pbxproj b/ios/OralTrainer.xcodeproj/project.pbxproj index 80ecbd3..d07f68b 100644 --- a/ios/OralTrainer.xcodeproj/project.pbxproj +++ b/ios/OralTrainer.xcodeproj/project.pbxproj @@ -8,6 +8,8 @@ /* Begin PBXBuildFile section */ AA0000000000000000000090 /* AuthAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000091 /* AuthAPI.swift */; }; + AA00000000000000000000A1 /* UserHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA00000000000000000000A2 /* UserHistory.swift */; }; + AA00000000000000000000A3 /* TrainingHistoryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA00000000000000000000A4 /* TrainingHistoryStore.swift */; }; AA0000000000000000000092 /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000093 /* AuthView.swift */; }; AA0000000000000000000040 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000020 /* Assets.xcassets */; }; AA0000000000000000000042 /* OralTrainerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000022 /* OralTrainerApp.swift */; }; @@ -36,6 +38,8 @@ /* Begin PBXFileReference section */ AA0000000000000000000091 /* AuthAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthAPI.swift; sourceTree = ""; }; + AA00000000000000000000A2 /* UserHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserHistory.swift; sourceTree = ""; }; + AA00000000000000000000A4 /* TrainingHistoryStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrainingHistoryStore.swift; sourceTree = ""; }; AA0000000000000000000093 /* AuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthView.swift; sourceTree = ""; }; AA000000000000000000000A /* OralTrainer.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OralTrainer.app; sourceTree = BUILT_PRODUCTS_DIR; }; AA000000000000000000001F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -124,6 +128,8 @@ AA0000000000000000000091 /* AuthAPI.swift */, AA000000000000000000002A /* SentenceBoundaryAPI.swift */, AA000000000000000000002B /* ImitationAssessor.swift */, + AA00000000000000000000A4 /* TrainingHistoryStore.swift */, + AA00000000000000000000A2 /* UserHistory.swift */, AA000000000000000000007E /* DubbingCompositor.swift */, AA0000000000000000000081 /* AudioRecorderSession.swift */, ); @@ -255,6 +261,8 @@ AA0000000000000000000051 /* PlayerSectionView.swift in Sources */, AA0000000000000000000052 /* CatalogView.swift in Sources */, AA0000000000000000000090 /* AuthAPI.swift in Sources */, + AA00000000000000000000A1 /* UserHistory.swift in Sources */, + AA00000000000000000000A3 /* TrainingHistoryStore.swift in Sources */, AA0000000000000000000092 /* AuthView.swift in Sources */, AA0000000000000000000053 /* TestView.swift in Sources */, AA0000000000000000000054 /* FullscreenPlayerView.swift in Sources */, diff --git a/ios/OralTrainer.xcodeproj/project.xcworkspace/xcuserdata/liushuming.xcuserdatad/UserInterfaceState.xcuserstate b/ios/OralTrainer.xcodeproj/project.xcworkspace/xcuserdata/liushuming.xcuserdatad/UserInterfaceState.xcuserstate index 4ba8701..a8a8d22 100644 Binary files a/ios/OralTrainer.xcodeproj/project.xcworkspace/xcuserdata/liushuming.xcuserdatad/UserInterfaceState.xcuserstate and b/ios/OralTrainer.xcodeproj/project.xcworkspace/xcuserdata/liushuming.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/ios/OralTrainer/Config.swift b/ios/OralTrainer/Config.swift index ee12455..30ba6c6 100644 --- a/ios/OralTrainer/Config.swift +++ b/ios/OralTrainer/Config.swift @@ -20,4 +20,7 @@ enum AppConfig { /// 当前登录用户的认证 token,由 AuthManager 在登录/注销时设置。 static var authToken: String = "" + + /// 当前登录用户 ID,用于隔离本机缓存的历史录音和评分。 + static var currentUserId: String = "" } diff --git a/ios/OralTrainer/Services/TrainingHistoryStore.swift b/ios/OralTrainer/Services/TrainingHistoryStore.swift new file mode 100644 index 0000000..07cf67c --- /dev/null +++ b/ios/OralTrainer/Services/TrainingHistoryStore.swift @@ -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 + } +} diff --git a/ios/OralTrainer/Services/UserHistory.swift b/ios/OralTrainer/Services/UserHistory.swift new file mode 100644 index 0000000..39d1c0b --- /dev/null +++ b/ios/OralTrainer/Services/UserHistory.swift @@ -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( + _ 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)"] + } +} diff --git a/ios/OralTrainer/Views/AuthView.swift b/ios/OralTrainer/Views/AuthView.swift index 3860c3b..c6d04c1 100644 --- a/ios/OralTrainer/Views/AuthView.swift +++ b/ios/OralTrainer/Views/AuthView.swift @@ -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 { diff --git a/ios/OralTrainer/Views/RootView.swift b/ios/OralTrainer/Views/RootView.swift index 65665b3..69798b3 100644 --- a/ios/OralTrainer/Views/RootView.swift +++ b/ios/OralTrainer/Views/RootView.swift @@ -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( + 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 { diff --git a/ios/OralTrainer/Views/TestView.swift b/ios/OralTrainer/Views/TestView.swift index cdba569..e0ebc70 100644 --- a/ios/OralTrainer/Views/TestView.swift +++ b/ios/OralTrainer/Views/TestView.swift @@ -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() { diff --git a/sentence_api/README.md b/sentence_api/README.md index 8abc544..54260c3 100644 --- a/sentence_api/README.md +++ b/sentence_api/README.md @@ -12,6 +12,7 @@ FastAPI service for the Android oral-training SDK. It provides: - video catalog and HTTP Range playback; - sentence-boundary lookup compatible with the existing Android SDK; - student recording upload and `asr-fluency-v1` assessment; +- signed-in user history for test results and dubbing shares, including deletion; - AI sentence explanation for signed-in mobile users; - the legacy JSON boundary index as a read-only fallback. diff --git a/sentence_api/main.py b/sentence_api/main.py index c42a17c..d9d2a3d 100644 --- a/sentence_api/main.py +++ b/sentence_api/main.py @@ -346,6 +346,30 @@ def create_app( def my_dub_shares(user: AuthenticatedUser = Depends(current_user)) -> List[UserDubShareSummary]: return [UserDubShareSummary(**row) for row in video_repository.list_user_dub_shares(user.id)] + @application.delete("/api/v1/me/results/{attempt_id}", status_code=204) + def delete_my_result( + attempt_id: str, + user: AuthenticatedUser = Depends(current_user), + ) -> Response: + deleted_result = video_repository.delete_user_result(user.id, attempt_id) + if deleted_result is None: + raise HTTPException(status_code=404, detail="Test result was not found.") + if deleted_result["audio_filename"]: + (service_settings.attempts_dir / Path(deleted_result["audio_filename"]).name).unlink(missing_ok=True) + return Response(status_code=204) + + @application.delete("/api/v1/me/dub-shares/{share_id}", status_code=204) + def delete_my_dub_share( + share_id: str, + user: AuthenticatedUser = Depends(current_user), + ) -> Response: + audio_filenames = video_repository.delete_user_dub_share(user.id, share_id) + if audio_filenames is None: + raise HTTPException(status_code=404, detail="Dubbing share was not found.") + for filename in audio_filenames: + (service_settings.dub_shares_dir / Path(filename).name).unlink(missing_ok=True) + return Response(status_code=204) + @application.get("/admin", include_in_schema=False) def admin_page() -> FileResponse: page = static_dir / "admin.html" diff --git a/sentence_api/repository.py b/sentence_api/repository.py index 3797f3c..a49b656 100644 --- a/sentence_api/repository.py +++ b/sentence_api/repository.py @@ -1118,6 +1118,47 @@ class VideoRepository: ).fetchall() return [dict(row) for row in rows] + def delete_user_result(self, user_id: str, attempt_id: str) -> Optional[Dict[str, Any]]: + """Delete one attempt owned by the user and return its stored audio filename.""" + with self._connect() as connection: + row = connection.execute( + """ + SELECT audio_filename FROM attempts + WHERE attempt_id = ? AND user_id = ? + """, + (attempt_id, user_id), + ).fetchone() + if row is None: + return None + connection.execute( + "DELETE FROM attempts WHERE attempt_id = ? AND user_id = ?", + (attempt_id, user_id), + ) + return {"audio_filename": row["audio_filename"]} + + def delete_user_dub_share(self, user_id: str, share_id: str) -> Optional[List[str]]: + """Delete one dub share owned by the user and return stored audio filenames.""" + with self._connect() as connection: + share = connection.execute( + "SELECT share_id FROM dub_shares WHERE share_id = ? AND user_id = ?", + (share_id, user_id), + ).fetchone() + if share is None: + return None + filenames = [ + row["audio_filename"] + for row in connection.execute( + "SELECT audio_filename FROM dub_share_segments WHERE share_id = ?", + (share_id,), + ).fetchall() + ] + # Segments are removed by ON DELETE CASCADE. + connection.execute( + "DELETE FROM dub_shares WHERE share_id = ? AND user_id = ?", + (share_id, user_id), + ) + return filenames + def delete_video(self, video_hash: str) -> Optional[Dict[str, Any]]: video = self.get_video(video_hash) if video is None: diff --git a/sentence_api/tests/test_assessment_api.py b/sentence_api/tests/test_assessment_api.py index 014ebf5..727813f 100644 --- a/sentence_api/tests/test_assessment_api.py +++ b/sentence_api/tests/test_assessment_api.py @@ -239,3 +239,68 @@ def test_user_enrollment_results_and_dub_shares_are_scoped(tmp_path): assert len(shares) == 1 assert shares[0]["segment_count"] == 1 assert shares[0]["average_score"] == 88 + + +def test_user_can_delete_owned_result_and_dub_share(tmp_path): + client = make_client(tmp_path) + assessed = client.post( + f"/api/v1/videos/{VIDEO_HASH}/sentences/0/assessments", + files={"audio": ("reading.wav", make_wav(), "audio/wav")}, + ) + attempt_id = client.get("/api/v1/me/results").json()[0]["attempt_id"] + shared = client.post( + "/api/v1/dub-shares", + data={ + "video_hash": VIDEO_HASH, + "segments": json.dumps([{"sentence_index": 0}]), + }, + files={"files": ("dub.wav", make_wav(), "audio/wav")}, + ) + share_id = shared.json()["share_id"] + + deleted_result = client.delete(f"/api/v1/me/results/{attempt_id}") + deleted_share = client.delete(f"/api/v1/me/dub-shares/{share_id}") + + assert assessed.status_code == 200 + assert shared.status_code == 201 + assert deleted_result.status_code == 204 + assert deleted_share.status_code == 204 + assert client.get("/api/v1/me/results").json() == [] + assert client.get("/api/v1/me/dub-shares").json() == [] + assert client.get(f"/api/v1/dub-shares/{share_id}").status_code == 404 + + +def test_user_cannot_delete_another_users_history(tmp_path): + client = make_client(tmp_path) + client.post( + f"/api/v1/videos/{VIDEO_HASH}/sentences/0/assessments", + files={"audio": ("reading.wav", make_wav(), "audio/wav")}, + ) + attempt_id = client.get("/api/v1/me/results").json()[0]["attempt_id"] + shared = client.post( + "/api/v1/dub-shares", + data={ + "video_hash": VIDEO_HASH, + "segments": json.dumps([{"sentence_index": 0}]), + }, + files={"files": ("dub.wav", make_wav(), "audio/wav")}, + ) + share_id = shared.json()["share_id"] + + other = TestClient(create_app( + BoundaryStore(tmp_path / "legacy.json"), + settings=replace( + Settings.from_env(), + data_dir=tmp_path / "data", + legacy_boundaries_file=tmp_path / "legacy.json", + ), + )) + registered = other.post( + "/api/v1/auth/register", + json={"username": "other", "password": "password-123"}, + ) + other.headers.update({"Authorization": f"Bearer {registered.json()['token']}"}) + + assert registered.status_code == 201 + assert other.delete(f"/api/v1/me/results/{attempt_id}").status_code == 404 + assert other.delete(f"/api/v1/me/dub-shares/{share_id}").status_code == 404