feat(bat-api): 实现内嵌 dashboard
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s

Closes #46
This commit is contained in:
2026-08-31 23:17:23 +08:00
parent ab21344773
commit 4ed81f0030
30 changed files with 3527 additions and 91 deletions
+19 -3
View File
@@ -4190,6 +4190,7 @@ fn dispatch_translation_tasks_filters_current_queue() {
let output_root = temp.path().join("output");
let current_dir = write_catalog_fixture(&state_dir, &output_root, "bundle-b2", None);
write_textunit_task_queue_fixture(&current_dir);
write_textunit_index_fixture(&current_dir);
write_translation_task_repository_fixture(&current_dir);
let envelope = dispatch_rpc_method(
@@ -4290,7 +4291,13 @@ fn dispatch_translation_tasks_filters_current_queue() {
Some(serde_json::json!({
"task_id": "textunit/v-current/Bundle/a.bundle",
"status": "completed",
"provider_run_id": "crowdin-run-2"
"provider": "manual",
"provider_run_id": "manual-run-1",
"translation_results": [{
"unit_id": "direct:a#unit:0",
"source_text": "こんにちは",
"translated_text": "你好"
}]
})),
),
&state_dir,
@@ -4305,7 +4312,16 @@ fn dispatch_translation_tasks_filters_current_queue() {
value["data"]["entry"]["failure_reason"],
serde_json::Value::Null
);
assert_eq!(value["data"]["entry"]["provider_run_id"], "crowdin-run-2");
assert_eq!(value["data"]["entry"]["provider_run_id"], "manual-run-1");
assert_eq!(value["data"]["entry"]["provider"], "manual");
assert_eq!(
value["data"]["entry"]["translation_results"][0]["unit_id"],
"direct:a#unit:0"
);
assert_eq!(
value["data"]["entry"]["translation_results"][0]["translated_text"],
"你好"
);
assert!(value["data"]["entry"]["completed_unix_seconds"].is_number());
let envelope = dispatch_rpc_method(
@@ -4329,7 +4345,7 @@ fn dispatch_translation_tasks_filters_current_queue() {
assert_eq!(value["data"]["handoff"]["units"][0]["status"], "translated");
assert_eq!(
value["data"]["handoff"]["provider_runs"][0]["provider_run_id"],
"crowdin-run-2"
"manual-run-1"
);
}
+129 -4
View File
@@ -1,5 +1,12 @@
use super::*;
#[derive(Debug, Deserialize)]
struct TranslationTaskResultUpdateParam {
unit_id: String,
source_text: String,
translated_text: String,
}
pub(super) fn build_translation_tasks_report(
state_dir: &Path,
query: OfficialTextUnitTaskQuery,
@@ -170,6 +177,13 @@ pub(super) fn update_translation_task_status_report(
.or_else(|| rpc_string_param(params, "reason"))
.map(str::to_string);
let provider_run_id = rpc_string_param(params, "provider_run_id").map(str::to_string);
let provider = rpc_string_param(params, "provider").map(str::to_string);
let result_params = translation_task_result_params(params)?;
if !result_params.is_empty() && status != TranslationTaskStatus::Completed {
return Err(anyhow::anyhow!(
"translation_results 只能随 completed 状态写入"
));
}
let (_, version_state) = read_daemon_resource_state(state_dir)?;
let current = version_state
.as_ref()
@@ -182,6 +196,22 @@ pub(super) fn update_translation_task_status_report(
repository_path.display()
));
}
let textunit_index = if result_params.is_empty() {
None
} else {
Some(
read_textunit_index_at(&current.resource_root)
.map_err(anyhow::Error::msg)?
.ok_or_else(|| {
anyhow::anyhow!("当前 release 缺少 TextUnit 明细索引,无法校验人工校对结果")
})?,
)
};
let result_provider = provider.clone().unwrap_or_else(|| "manual".to_string());
let result_timestamp = unix_seconds_now();
let result_provider_run_id = provider_run_id
.clone()
.unwrap_or_else(|| format!("manual-{result_timestamp}"));
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
@@ -190,10 +220,36 @@ pub(super) fn update_translation_task_status_report(
let repository = SqliteTranslationTaskRepository::open(&repository_path)
.await
.map_err(|error| anyhow::anyhow!("{error}"))?;
repository
.update_status(task_id, status, failure_reason, provider_run_id)
.await
.map_err(|error| anyhow::anyhow!("{error}"))
if let Some(index) = textunit_index.as_ref() {
let current_task = repository
.find(task_id)
.await
.map_err(|error| anyhow::anyhow!("{error}"))?;
let results = build_manual_translation_results(
&current_task,
index,
&result_params,
&result_provider,
&result_provider_run_id,
result_timestamp,
)?;
repository
.update_status_with_results(
task_id,
status,
failure_reason,
Some(result_provider_run_id),
Some(result_provider),
Some(&results),
)
.await
.map_err(|error| anyhow::anyhow!("{error}"))
} else {
repository
.update_status(task_id, status, failure_reason, provider_run_id)
.await
.map_err(|error| anyhow::anyhow!("{error}"))
}
})?;
Ok(serde_json::json!({
"available": true,
@@ -215,6 +271,75 @@ pub(super) fn textunit_query_json(query: &OfficialTextUnitQuery) -> serde_json::
})
}
fn translation_task_result_params(
params: Option<&serde_json::Value>,
) -> anyhow::Result<Vec<TranslationTaskResultUpdateParam>> {
let Some(value) = params
.and_then(|params| params.get("translation_results"))
.or_else(|| params.and_then(|params| params.get("results")))
else {
return Ok(Vec::new());
};
if value.is_null() {
return Ok(Vec::new());
}
serde_json::from_value(value.clone())
.map_err(|error| anyhow::anyhow!("translation_results 必须是结果数组:{error}"))
}
fn build_manual_translation_results(
task: &bat_infrastructure::PersistedTranslationTask,
index: &bat_infrastructure::OfficialTextUnitIndex,
params: &[TranslationTaskResultUpdateParam],
provider: &str,
provider_run_id: &str,
translated_unix_seconds: u64,
) -> anyhow::Result<Vec<bat_infrastructure::TranslationTaskUnitResult>> {
let index_by_id = index
.units
.iter()
.map(|unit| (unit.id.as_str(), unit))
.collect::<std::collections::BTreeMap<_, _>>();
let mut seen = std::collections::BTreeSet::new();
let mut results = Vec::with_capacity(params.len());
for param in params {
let unit_id = param.unit_id.trim();
if unit_id.is_empty() {
return Err(anyhow::anyhow!("translation_results[].unit_id 不能为空"));
}
if !seen.insert(unit_id.to_string()) {
return Err(anyhow::anyhow!(
"translation_results 包含重复 TextUnit{unit_id}"
));
}
let unit = index_by_id
.get(unit_id)
.ok_or_else(|| anyhow::anyhow!("translation_results 引用了未知 TextUnit{unit_id}"))?;
if unit.destination != task.task.destination
|| unit.archive_entry != task.task.archive_entry
{
return Err(anyhow::anyhow!(
"TextUnit {unit_id} 不属于翻译任务 {}",
task.task.task_id
));
}
if param.source_text != unit.source_text {
return Err(anyhow::anyhow!(
"TextUnit {unit_id} 的 source_text 与当前索引不一致"
));
}
results.push(bat_infrastructure::TranslationTaskUnitResult {
unit_id: unit_id.to_string(),
source_text: param.source_text.clone(),
translated_text: param.translated_text.clone(),
provider: provider.to_string(),
provider_run_id: provider_run_id.to_string(),
translated_unix_seconds,
});
}
Ok(results)
}
pub(super) fn translation_task_query_json(query: &OfficialTextUnitTaskQuery) -> serde_json::Value {
serde_json::json!({
"task_id": query.task_id.clone(),