fixed some bugs
This commit is contained in:
@@ -8,6 +8,8 @@
|
|||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
AA0000000000000000000090 /* AuthAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000091 /* AuthAPI.swift */; };
|
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 */; };
|
AA0000000000000000000092 /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000093 /* AuthView.swift */; };
|
||||||
AA0000000000000000000040 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000020 /* Assets.xcassets */; };
|
AA0000000000000000000040 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000020 /* Assets.xcassets */; };
|
||||||
AA0000000000000000000042 /* OralTrainerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000022 /* OralTrainerApp.swift */; };
|
AA0000000000000000000042 /* OralTrainerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000022 /* OralTrainerApp.swift */; };
|
||||||
@@ -36,6 +38,8 @@
|
|||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
AA0000000000000000000091 /* AuthAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthAPI.swift; sourceTree = "<group>"; };
|
AA0000000000000000000091 /* AuthAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthAPI.swift; sourceTree = "<group>"; };
|
||||||
|
AA00000000000000000000A2 /* UserHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserHistory.swift; sourceTree = "<group>"; };
|
||||||
|
AA00000000000000000000A4 /* TrainingHistoryStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrainingHistoryStore.swift; sourceTree = "<group>"; };
|
||||||
AA0000000000000000000093 /* AuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthView.swift; sourceTree = "<group>"; };
|
AA0000000000000000000093 /* AuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthView.swift; sourceTree = "<group>"; };
|
||||||
AA000000000000000000000A /* OralTrainer.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OralTrainer.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
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 = "<group>"; };
|
AA000000000000000000001F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
@@ -124,6 +128,8 @@
|
|||||||
AA0000000000000000000091 /* AuthAPI.swift */,
|
AA0000000000000000000091 /* AuthAPI.swift */,
|
||||||
AA000000000000000000002A /* SentenceBoundaryAPI.swift */,
|
AA000000000000000000002A /* SentenceBoundaryAPI.swift */,
|
||||||
AA000000000000000000002B /* ImitationAssessor.swift */,
|
AA000000000000000000002B /* ImitationAssessor.swift */,
|
||||||
|
AA00000000000000000000A4 /* TrainingHistoryStore.swift */,
|
||||||
|
AA00000000000000000000A2 /* UserHistory.swift */,
|
||||||
AA000000000000000000007E /* DubbingCompositor.swift */,
|
AA000000000000000000007E /* DubbingCompositor.swift */,
|
||||||
AA0000000000000000000081 /* AudioRecorderSession.swift */,
|
AA0000000000000000000081 /* AudioRecorderSession.swift */,
|
||||||
);
|
);
|
||||||
@@ -255,6 +261,8 @@
|
|||||||
AA0000000000000000000051 /* PlayerSectionView.swift in Sources */,
|
AA0000000000000000000051 /* PlayerSectionView.swift in Sources */,
|
||||||
AA0000000000000000000052 /* CatalogView.swift in Sources */,
|
AA0000000000000000000052 /* CatalogView.swift in Sources */,
|
||||||
AA0000000000000000000090 /* AuthAPI.swift in Sources */,
|
AA0000000000000000000090 /* AuthAPI.swift in Sources */,
|
||||||
|
AA00000000000000000000A1 /* UserHistory.swift in Sources */,
|
||||||
|
AA00000000000000000000A3 /* TrainingHistoryStore.swift in Sources */,
|
||||||
AA0000000000000000000092 /* AuthView.swift in Sources */,
|
AA0000000000000000000092 /* AuthView.swift in Sources */,
|
||||||
AA0000000000000000000053 /* TestView.swift in Sources */,
|
AA0000000000000000000053 /* TestView.swift in Sources */,
|
||||||
AA0000000000000000000054 /* FullscreenPlayerView.swift in Sources */,
|
AA0000000000000000000054 /* FullscreenPlayerView.swift in Sources */,
|
||||||
|
|||||||
Binary file not shown.
@@ -20,4 +20,7 @@ enum AppConfig {
|
|||||||
|
|
||||||
/// 当前登录用户的认证 token,由 AuthManager 在登录/注销时设置。
|
/// 当前登录用户的认证 token,由 AuthManager 在登录/注销时设置。
|
||||||
static var authToken: String = ""
|
static var authToken: String = ""
|
||||||
|
|
||||||
|
/// 当前登录用户 ID,用于隔离本机缓存的历史录音和评分。
|
||||||
|
static var currentUserId: String = ""
|
||||||
}
|
}
|
||||||
|
|||||||
167
ios/OralTrainer/Services/TrainingHistoryStore.swift
Normal file
167
ios/OralTrainer/Services/TrainingHistoryStore.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
177
ios/OralTrainer/Services/UserHistory.swift
Normal file
177
ios/OralTrainer/Services/UserHistory.swift
Normal 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)"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,15 +6,20 @@ import SwiftUI
|
|||||||
final class AuthManager: ObservableObject {
|
final class AuthManager: ObservableObject {
|
||||||
@Published var token: String
|
@Published var token: String
|
||||||
@Published var nickname: String
|
@Published var nickname: String
|
||||||
|
@Published var userID: String
|
||||||
@Published var showingRegister = false
|
@Published var showingRegister = false
|
||||||
@Published var showingSSO = false
|
@Published var showingSSO = false
|
||||||
@Published var showingPhoneBinding = false
|
@Published var showingPhoneBinding = false
|
||||||
@Published var ssoPendingToken: String?
|
@Published var ssoPendingToken: String?
|
||||||
|
@Published var ssoPendingUserId = ""
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
let defaults = UserDefaults.standard
|
let defaults = UserDefaults.standard
|
||||||
token = defaults.string(forKey: "oral_trainer_token") ?? ""
|
token = defaults.string(forKey: "oral_trainer_token") ?? ""
|
||||||
nickname = defaults.string(forKey: "oral_trainer_nickname") ?? ""
|
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
|
AppConfig.authToken = token
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,22 +29,29 @@ final class AuthManager: ObservableObject {
|
|||||||
token = session.token
|
token = session.token
|
||||||
nickname = session.user.nickname
|
nickname = session.user.nickname
|
||||||
AppConfig.authToken = session.token
|
AppConfig.authToken = session.token
|
||||||
|
userID = session.user.id
|
||||||
|
AppConfig.currentUserId = session.user.id
|
||||||
let defaults = UserDefaults.standard
|
let defaults = UserDefaults.standard
|
||||||
defaults.set(session.token, forKey: "oral_trainer_token")
|
defaults.set(session.token, forKey: "oral_trainer_token")
|
||||||
defaults.set(session.user.nickname, forKey: "oral_trainer_nickname")
|
defaults.set(session.user.nickname, forKey: "oral_trainer_nickname")
|
||||||
|
defaults.set(session.user.id, forKey: "oral_trainer_user_id")
|
||||||
}
|
}
|
||||||
|
|
||||||
func logout() {
|
func logout() {
|
||||||
token = ""
|
token = ""
|
||||||
nickname = ""
|
nickname = ""
|
||||||
AppConfig.authToken = ""
|
AppConfig.authToken = ""
|
||||||
|
userID = ""
|
||||||
|
AppConfig.currentUserId = ""
|
||||||
showingRegister = false
|
showingRegister = false
|
||||||
showingSSO = false
|
showingSSO = false
|
||||||
showingPhoneBinding = false
|
showingPhoneBinding = false
|
||||||
ssoPendingToken = nil
|
ssoPendingToken = nil
|
||||||
|
ssoPendingUserId = ""
|
||||||
let defaults = UserDefaults.standard
|
let defaults = UserDefaults.standard
|
||||||
defaults.removeObject(forKey: "oral_trainer_token")
|
defaults.removeObject(forKey: "oral_trainer_token")
|
||||||
defaults.removeObject(forKey: "oral_trainer_nickname")
|
defaults.removeObject(forKey: "oral_trainer_nickname")
|
||||||
|
defaults.removeObject(forKey: "oral_trainer_user_id")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,6 +369,7 @@ struct SSOWebView: View {
|
|||||||
dismiss()
|
dismiss()
|
||||||
if !result.phoneBound {
|
if !result.phoneBound {
|
||||||
auth.ssoPendingToken = result.token
|
auth.ssoPendingToken = result.token
|
||||||
|
auth.ssoPendingUserId = result.user.id
|
||||||
auth.showingPhoneBinding = true
|
auth.showingPhoneBinding = true
|
||||||
} else {
|
} else {
|
||||||
auth.saveSession(AuthSession(token: result.token, user: result.user))
|
auth.saveSession(AuthSession(token: result.token, user: result.user))
|
||||||
@@ -499,7 +512,13 @@ struct PhoneBindingView: View {
|
|||||||
try await AuthAPI.ssoBindPhone(token: token, phone: cleaned)
|
try await AuthAPI.ssoBindPhone(token: token, phone: cleaned)
|
||||||
auth.showingPhoneBinding = false
|
auth.showingPhoneBinding = false
|
||||||
auth.ssoPendingToken = nil
|
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()
|
dismiss()
|
||||||
onLoginSuccess()
|
onLoginSuccess()
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ struct RootView: View {
|
|||||||
@StateObject private var auth = AuthManager()
|
@StateObject private var auth = AuthManager()
|
||||||
@StateObject private var controller = PlayerController()
|
@StateObject private var controller = PlayerController()
|
||||||
@StateObject private var catalogVM = CatalogViewModel()
|
@StateObject private var catalogVM = CatalogViewModel()
|
||||||
|
@StateObject private var historyManager = TrainingHistoryManager()
|
||||||
@State private var activeModule: Module = .course
|
@State private var activeModule: Module = .course
|
||||||
@State private var continuousPlayback = false
|
@State private var continuousPlayback = false
|
||||||
@State private var showImporter = false
|
@State private var showImporter = false
|
||||||
@@ -14,6 +15,11 @@ struct RootView: View {
|
|||||||
@State private var currentCourse: Course?
|
@State private var currentCourse: Course?
|
||||||
@State private var courseStatusText = "正在同步"
|
@State private var courseStatusText = "正在同步"
|
||||||
@State private var showCourseGuardToast = false
|
@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 {
|
enum Module {
|
||||||
case course
|
case course
|
||||||
@@ -49,7 +55,11 @@ struct RootView: View {
|
|||||||
onFullscreen: enterFullscreen
|
onFullscreen: enterFullscreen
|
||||||
)
|
)
|
||||||
case .test:
|
case .test:
|
||||||
TestView(controller: controller, onFullscreen: enterFullscreen)
|
TestView(
|
||||||
|
controller: controller,
|
||||||
|
historyManager: historyManager,
|
||||||
|
onFullscreen: enterFullscreen
|
||||||
|
)
|
||||||
case .mine:
|
case .mine:
|
||||||
minePage
|
minePage
|
||||||
}
|
}
|
||||||
@@ -276,8 +286,193 @@ struct RootView: View {
|
|||||||
.font(.system(size: 13))
|
.font(.system(size: 13))
|
||||||
.foregroundColor(Theme.textMuted)
|
.foregroundColor(Theme.textMuted)
|
||||||
.padding(.horizontal, 16)
|
.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
|
// MARK: - Load Courses
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import SwiftUI
|
|||||||
|
|
||||||
struct TestView: View {
|
struct TestView: View {
|
||||||
@ObservedObject var controller: PlayerController
|
@ObservedObject var controller: PlayerController
|
||||||
|
@ObservedObject var historyManager: TrainingHistoryManager
|
||||||
let onFullscreen: () -> Void
|
let onFullscreen: () -> Void
|
||||||
|
|
||||||
@State private var recordingSession: AudioRecorderSession?
|
@State private var recordingSession: AudioRecorderSession?
|
||||||
@@ -25,6 +26,8 @@ struct TestView: View {
|
|||||||
@State private var showShareSheet = false
|
@State private var showShareSheet = false
|
||||||
@State private var dubPlaybackObserver: NSObjectProtocol?
|
@State private var dubPlaybackObserver: NSObjectProtocol?
|
||||||
@State private var testSubtitleVisible = true
|
@State private var testSubtitleVisible = true
|
||||||
|
@State private var loadedCourseHash: String?
|
||||||
|
@State private var remoteHistoryGeneration = 0
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView(showsIndicators: false) {
|
ScrollView(showsIndicators: false) {
|
||||||
@@ -121,7 +124,8 @@ struct TestView: View {
|
|||||||
.padding(16)
|
.padding(16)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onReceive(controller.$currentItem) { _ in
|
.onReceive(controller.$currentItem) { item in
|
||||||
|
synchronizeHistory(for: item?.id)
|
||||||
refreshTestUI()
|
refreshTestUI()
|
||||||
}
|
}
|
||||||
.onReceive(controller.$currentSentence) { _ in
|
.onReceive(controller.$currentSentence) { _ in
|
||||||
@@ -130,6 +134,14 @@ struct TestView: View {
|
|||||||
.onDisappear {
|
.onDisappear {
|
||||||
stopDubPlayback(message: nil)
|
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) {
|
.sheet(isPresented: $showShareSheet) {
|
||||||
if let url = shareURL {
|
if let url = shareURL {
|
||||||
ActivityShareSheet(items: [url])
|
ActivityShareSheet(items: [url])
|
||||||
@@ -290,14 +302,24 @@ struct TestView: View {
|
|||||||
try? FileManager.default.removeItem(at: rawURL)
|
try? FileManager.default.removeItem(at: rawURL)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if let oldURL = dubSegments[sentence.index] {
|
var audioURL = url
|
||||||
try? FileManager.default.removeItem(at: oldURL)
|
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
|
dubSegments[sentence.index] = audioURL
|
||||||
dubRawSegments[sentence.index] = rawURL
|
dubRawSegments[sentence.index] = rawPersistedURL
|
||||||
mergedDubURL = nil
|
mergedDubURL = nil
|
||||||
submitAssessment(recordingURL: url)
|
submitAssessment(recordingURL: url)
|
||||||
}
|
}
|
||||||
@@ -337,6 +359,8 @@ struct TestView: View {
|
|||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
let result = try await ImitationAssessor.assess(request: request)
|
let result = try await ImitationAssessor.assess(request: request)
|
||||||
|
latestScores[sentence.index] = result
|
||||||
|
historyManager.store.saveScores(videoHash: item.id, scores: latestScores)
|
||||||
applyAssessmentResult(result)
|
applyAssessmentResult(result)
|
||||||
} catch {
|
} catch {
|
||||||
testStatus = "评测失败:\(error.localizedDescription)"
|
testStatus = "评测失败:\(error.localizedDescription)"
|
||||||
@@ -391,14 +415,159 @@ struct TestView: View {
|
|||||||
testStatus = "请先在训练模块选择一个云端课程"
|
testStatus = "请先在训练模块选择一个云端课程"
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if let score = latestScores[sentence.index] {
|
||||||
|
applyAssessmentResult(score)
|
||||||
|
} else {
|
||||||
let currentRecording = dubSegments[sentence.index]
|
let currentRecording = dubSegments[sentence.index]
|
||||||
let segmentLabel = currentRecording == nil ? "" : " · 本句已录音,重录将覆盖"
|
let segmentLabel = currentRecording == nil ? "" : " · 本句已录音,重录将覆盖"
|
||||||
testStatus = "评测对象:第 \(sentence.index + 1) 句(共 \(item.sentences.count) 句)\(segmentLabel)"
|
testStatus = "评测对象:第 \(sentence.index + 1) 句(共 \(item.sentences.count) 句)\(segmentLabel)"
|
||||||
if mergedDubURL == nil {
|
scoreSummary = "尚未评测"
|
||||||
|
scoreDetail = ""
|
||||||
|
}
|
||||||
|
if mergedDubURL == nil && !dubbingPlayback && !isGeneratingDub {
|
||||||
dubbingStatus = dubSegments.isEmpty ? "配音:请先录音" : "配音:已保存 \(dubSegments.count) 句,可生成配音"
|
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: - 配音
|
// MARK: - 配音
|
||||||
|
|
||||||
private func toggleDubPlayback() {
|
private func toggleDubPlayback() {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ FastAPI service for the Android oral-training SDK. It provides:
|
|||||||
- video catalog and HTTP Range playback;
|
- video catalog and HTTP Range playback;
|
||||||
- sentence-boundary lookup compatible with the existing Android SDK;
|
- sentence-boundary lookup compatible with the existing Android SDK;
|
||||||
- student recording upload and `asr-fluency-v1` assessment;
|
- 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;
|
- AI sentence explanation for signed-in mobile users;
|
||||||
- the legacy JSON boundary index as a read-only fallback.
|
- the legacy JSON boundary index as a read-only fallback.
|
||||||
|
|
||||||
|
|||||||
@@ -346,6 +346,30 @@ def create_app(
|
|||||||
def my_dub_shares(user: AuthenticatedUser = Depends(current_user)) -> List[UserDubShareSummary]:
|
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)]
|
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)
|
@application.get("/admin", include_in_schema=False)
|
||||||
def admin_page() -> FileResponse:
|
def admin_page() -> FileResponse:
|
||||||
page = static_dir / "admin.html"
|
page = static_dir / "admin.html"
|
||||||
|
|||||||
@@ -1118,6 +1118,47 @@ class VideoRepository:
|
|||||||
).fetchall()
|
).fetchall()
|
||||||
return [dict(row) for row in rows]
|
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]]:
|
def delete_video(self, video_hash: str) -> Optional[Dict[str, Any]]:
|
||||||
video = self.get_video(video_hash)
|
video = self.get_video(video_hash)
|
||||||
if video is None:
|
if video is None:
|
||||||
|
|||||||
@@ -239,3 +239,68 @@ def test_user_enrollment_results_and_dub_shares_are_scoped(tmp_path):
|
|||||||
assert len(shares) == 1
|
assert len(shares) == 1
|
||||||
assert shares[0]["segment_count"] == 1
|
assert shares[0]["segment_count"] == 1
|
||||||
assert shares[0]["average_score"] == 88
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user