From 8bda0d9d0be17d91ae3384e8a353dcb2c7f509de Mon Sep 17 00:00:00 2001 From: sallowayma-git Date: Tue, 30 Jun 2026 15:44:25 +0800 Subject: [PATCH 01/13] =?UTF-8?q?feat(writing):=20=E5=86=99=E4=BD=9C?= =?UTF-8?q?=E9=A2=98=E5=BA=93=E5=85=A8=E9=93=BE=E8=B7=AF=E5=88=9B=E4=BD=9C?= =?UTF-8?q?+=E5=AF=BC=E5=87=BA=EF=BC=8C=E5=AF=B9=E6=8E=A5=20NAS=20?= =?UTF-8?q?=E5=AD=A6=E7=94=9F=E7=AB=AF=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增写作题库创作能力,产出 NAS 端 NasJsDirectWritingAssetProvider 能识别的 writing-exams/manifest.js + task1.js + task2.js。 == Rust 后端(独立模型,不污染阅读 ImportJob)== - writing_store.rs: WritingJob 模型 + CRUD(writing-jobs//writing-job.json) WritingJobStatus(Draft/ExportReady/Exported)、writing_source() 转导出 payload - export_writing_library.rs: 导出管线 build_writing_wrapper(__WRITING_EXAM_DATA__.register IIFE,key=taskType) build_writing_manifest(window.__WRITING_EXAM_MANIFEST__ 赋值,key=taskType) 校验 task1+task2 齐全 + promptText 非空;输出到 {exportDir}/writing-exams/ - util.rs: writing_job_dir/safe_writing_job_dir + ensure_app_dirs 加 writing-jobs - lib.rs: 6 个 #[tauri::command] + generate_handler 注册 (create/list/get/update/delete_writing_job + export_writing_library) == React 前端 == - types/writing.ts: WritingJob/CreateWritingJobInput/WritingJobPatch/WritingJobFilter/ WritingExportResult/ExportWritingLibraryInput/WritingExamSourceV1(与 NAS 契约对齐) - tauriCommands.ts: 6 个 API 方法 - devFallbackBackend.ts: 6 个 case + Store.writingJobs 内存存储 - WritingStudio.tsx: 创作页(手输 promptText + taskType 切换 + 状态流转) - ExportPage.tsx: writing-library 导出模式(选 task1+task2 job → 导出) - router.ts/App.tsx/AppShell.tsx/Dashboard.tsx: 写作路由 + 侧栏入口 + 工作台按钮 - styles.css: 补 writing-create-form/writing-edit-form/writing-prompt-textarea 等 8 类 == 导出契约(与 NAS 端逐字段核对,并发子代理联调验证)== manifest: window.__WRITING_EXAM_MANIFEST__ = { task1:{taskType,examId,dataKey,script,title}, task2:{...} } task: __WRITING_EXAM_DATA__.register(taskType, {schemaVersion:"WritingExamSourceV1",examId,taskType,promptText,suggestedWordCount,meta{title,taskType}}) == 验证 == - cargo check: 通过(仅既有未使用函数警告) - tsc --noEmit: 通过 - vite build: 通过(49 模块) - 联调:NAS E2E 注入 PDF2Test 格式产物,console 不再 404,writingSubmissions:2 正常 - 阅读链路零改动:ImportJob/job_store/export_nas_library/reading_source 一行未动 --- src-tauri/src/export_writing_library.rs | 246 +++++++++++++++++++++++ src-tauri/src/lib.rs | 253 +++++++++++++++++++++++ src-tauri/src/util.rs | 15 ++ src-tauri/src/writing_store.rs | 191 ++++++++++++++++++ src/api/tauriCommands.ts | 33 ++- src/app/App.tsx | 2 + src/app/router.ts | 2 + src/components/AppShell.tsx | 1 + src/pages/Dashboard.tsx | 1 + src/pages/ExportPage.tsx | 116 ++++++++++- src/pages/WritingStudio.tsx | 257 ++++++++++++++++++++++++ src/services/devFallbackBackend.ts | 137 ++++++++++++- src/styles.css | 53 +++++ src/types/index.ts | 1 + src/types/writing.ts | 89 ++++++++ 15 files changed, 1391 insertions(+), 6 deletions(-) create mode 100644 src-tauri/src/export_writing_library.rs create mode 100644 src-tauri/src/writing_store.rs create mode 100644 src/pages/WritingStudio.tsx create mode 100644 src/types/writing.ts diff --git a/src-tauri/src/export_writing_library.rs b/src-tauri/src/export_writing_library.rs new file mode 100644 index 0000000..a731d55 --- /dev/null +++ b/src-tauri/src/export_writing_library.rs @@ -0,0 +1,246 @@ +//! 写作题库导出管线(镜像 export_nas_library.rs,但极简)。 +//! +//! 产出 NAS 端 NasJsDirectWritingAssetProvider 能识别的: +//! writing-exams/manifest.js (window.__WRITING_EXAM_MANIFEST__ = { task1:{...}, task2:{...} }) +//! writing-exams/task1.js (__WRITING_EXAM_DATA__.register("task1", {...})) +//! writing-exams/task2.js (__WRITING_EXAM_DATA__.register("task2", {...})) +//! +//! 关键差异 vs 阅读: +//! - 单包双题(task1+task2 固定两题),非多 job 组套 +//! - manifest key 是 taskType("task1"/"task2"),非 examId(NAS provider 按 taskType 查) +//! - wrapper register key 也是 taskType +//! - 无 runtime gate(写作无 passage/answerKey,仅校验 promptText 非空 + taskType 合法) + +use crate::util::{read_json, validate_path_segment, write_text, safe_writing_job_dir}; +use crate::writing_store::{writing_source, WritingJob, WritingJobStatus}; +use crate::CommandResult; +use chrono::Utc; +use serde_json::{json, Value}; +use std::{collections::HashSet, fs, path::Path}; + +const WRITING_TASK_TYPES: [&str; 2] = ["task1", "task2"]; + +/// 生成单题 wrapper JS(IIFE 调 __WRITING_EXAM_DATA__.register(taskType, payload))。 +fn build_writing_wrapper(source: &Value) -> CommandResult { + let task_type = source + .get("taskType") + .and_then(Value::as_str) + .unwrap_or("task1"); + let key_json = serde_json::to_string(task_type).map_err(|error| error.to_string())?; + let source_json = serde_json::to_string_pretty(source).map_err(|error| error.to_string())?; + Ok(format!( + "(function registerWritingExamData(global) {{\n 'use strict';\n if (!global.__WRITING_EXAM_DATA__ || typeof global.__WRITING_EXAM_DATA__.register !== \"function\") {{\n throw new Error(\"writing_exam_registry_missing\");\n }}\n global.__WRITING_EXAM_DATA__.register({}, {});\n}})(typeof window !== \"undefined\" ? window : globalThis);\n", + key_json, source_json + )) +} + +/// 生成 manifest JS(window.__WRITING_EXAM_MANIFEST__ = { task1:{...}, task2:{...} })。 +/// key 用 taskType(NAS provider 按 taskType 查 entries)。 +fn build_writing_manifest(sources: &[Value]) -> CommandResult { + let mut manifest = serde_json::Map::new(); + for source in sources { + let task_type = source + .get("taskType") + .and_then(Value::as_str) + .unwrap_or("task1"); + let exam_id = source + .get("examId") + .and_then(Value::as_str) + .unwrap_or(""); + manifest.insert( + task_type.to_string(), + json!({ + "taskType": task_type, + "examId": exam_id, + "dataKey": task_type, + "script": format!("./{}.js", task_type), + "title": source.pointer("/meta/title").and_then(Value::as_str).unwrap_or("Untitled Writing") + }), + ); + } + Ok(format!( + "window.__WRITING_EXAM_MANIFEST__ = {};\n", + serde_json::to_string_pretty(&Value::Object(manifest)) + .map_err(|error| error.to_string())? + )) +} + +/// 校验单个写作 job 可导出:taskType 合法 + promptText 非空。 +fn validate_writing_job_for_export(job: &WritingJob) -> CommandResult<()> { + if !WRITING_TASK_TYPES.contains(&job.task_type.as_str()) { + return Err(format!( + "writing_export_invalid_task_type:{}:{}", + job.job_id, job.task_type + )); + } + if job.prompt_text.trim().is_empty() { + return Err(format!( + "writing_export_prompt_empty:{}", + job.job_id + )); + } + Ok(()) +} + +#[derive(Debug, Clone)] +struct WrittenWritingSource { + job_id: String, + task_type: String, + wrapper_js: String, + source: Value, +} + +#[derive(Debug, Clone)] +struct WritingExportWriteResult { + files: Vec, + manifest_js: String, +} + +/// 读指定 writing jobs → 校验 → 生成 task1.js/task2.js + manifest.js。 +/// job_ids 应含两个(task1 + task2),按 taskType 去重。 +fn write_writing_library_files( + root: &Path, + job_ids: &[String], + writing_exams_dir: &Path, +) -> CommandResult { + let mut files = Vec::with_capacity(job_ids.len()); + let mut seen_task_types = HashSet::new(); + + for job_id in job_ids { + validate_path_segment("writing_job_id", job_id)?; + let job: WritingJob = read_json(&safe_writing_job_dir(root, job_id)?.join("writing-job.json"))?; + validate_writing_job_for_export(&job)?; + if !seen_task_types.insert(job.task_type.clone()) { + return Err(format!( + "writing_export_duplicate_task_type:{}:{}", + job.job_id, job.task_type + )); + } + let source = writing_source(&job); + let wrapper = build_writing_wrapper(&source)?; + files.push(WrittenWritingSource { + job_id: job.job_id.clone(), + task_type: job.task_type.clone(), + wrapper_js: wrapper, + source, + }); + } + + // 必须两题齐全(task1 + task2) + if !seen_task_types.contains("task1") || !seen_task_types.contains("task2") { + return Err(format!( + "writing_export_requires_both_tasks:missing={:?}", + WRITING_TASK_TYPES + .iter() + .filter(|t| !seen_task_types.contains(**t)) + .collect::>() + )); + } + + let sources: Vec = files.iter().map(|f| f.source.clone()).collect(); + let manifest_js = build_writing_manifest(&sources)?; + + fs::create_dir_all(writing_exams_dir).map_err(|error| error.to_string())?; + for file in &files { + write_text( + &writing_exams_dir.join(format!("{}.js", file.task_type)), + &file.wrapper_js, + )?; + } + write_text(&writing_exams_dir.join("manifest.js"), &manifest_js)?; + + Ok(WritingExportWriteResult { files, manifest_js }) +} + +/// 导出核心:input = { jobIds: [task1JobId, task2JobId], exportDir: string } +pub(crate) fn export_writing_library_core(root: &Path, input: &Value) -> CommandResult { + let job_ids: Vec = input + .get("jobIds") + .and_then(Value::as_array) + .ok_or_else(|| "writing_export_requires_job_ids".to_string())? + .iter() + .filter_map(Value::as_str) + .map(ToString::to_string) + .collect(); + if job_ids.len() != 2 { + return Err("writing_export_requires_two_jobs:task1+task2".to_string()); + } + + let export_dir = input + .get("exportDir") + .and_then(Value::as_str) + .ok_or_else(|| "writing_export_requires_export_dir".to_string())?; + let library_root = if export_dir.starts_with("local://") { + root.join("exports").join("nas-library") + } else { + std::path::PathBuf::from(export_dir) + }; + // 写作输出到 writing-exams 子目录,与 reading-exams 平级 + let writing_exams_dir = library_root.join("writing-exams"); + + let write_result = write_writing_library_files(root, &job_ids, &writing_exams_dir)?; + let asset_count = write_result.files.len(); + let version = Utc::now().format("%Y.%m.%d-%H%M%S").to_string(); + + // 标记 job 已导出 + let mut cleanup = Vec::with_capacity(write_result.files.len()); + for written in &write_result.files { + let mut job = crate::writing_store::load_writing_job(root, &written.job_id)?; + job.status = WritingJobStatus::Exported; + job.updated_at = Utc::now(); + crate::writing_store::save_writing_job(root, &job)?; + cleanup.push(json!({ + "jobId": written.job_id, + "taskType": written.task_type, + "status": "Exported" + })); + } + + let files_array = write_result + .files + .iter() + .map(|written| { + json!({ + "name": format!("{}.js", written.task_type), + "content": written.wrapper_js + }) + }) + .chain(std::iter::once(json!({ + "name": "manifest.js", + "content": write_result.manifest_js + }))) + .collect::>(); + + Ok(json!({ + "mode": "writing-library", + "jobIds": write_result.files.iter().map(|f| f.job_id.clone()).collect::>(), + "taskTypes": write_result.files.iter().map(|f| f.task_type.clone()).collect::>(), + "assetCount": asset_count, + "libraryRoot": library_root.to_string_lossy(), + "writingExamsDir": writing_exams_dir.to_string_lossy(), + "version": version, + "files": files_array, + "report": { + "status": "ok", + "version": version, + "generatedAt": Utc::now().to_rfc3339(), + "summary": { + "runtime": "nas-js-direct", + "writingTaskCount": asset_count, + "manifestFileCount": 1 + }, + "errors": [] + }, + "exportSummary": { + "type": "writing-library", + "runtime": "nas-js-direct", + "jobIds": job_ids, + "version": version, + "outputDir": library_root.to_string_lossy(), + "writingExamsDir": writing_exams_dir.to_string_lossy(), + "assetCount": asset_count, + "exportedAt": Utc::now().to_rfc3339() + }, + "cleanup": cleanup + })) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f098a9a..3c15a9d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -9,6 +9,7 @@ use diagnostics::DiagnosticsSettings; use export_artifacts::{build_manifest, build_wrapper, safe_exam_id}; use export_nas_library::export_nas_library_core; use export_pack::{build_pack_core, export_reading_assets_core, export_reading_js_core}; +use export_writing_library::export_writing_library_core; use llm_commands::{ apply_llm_suggestion_core, delete_llm_profile_core, llm_run_group_core, save_llm_profile_core, test_llm_profile_core, @@ -36,6 +37,7 @@ mod environment; mod export_artifacts; mod export_nas_library; mod export_pack; +mod export_writing_library; mod job_commands; mod job_store; mod llm_commands; @@ -43,6 +45,7 @@ mod llm_gateway; mod llm_profiles; mod llm_suggestions; mod parser; +mod pdf_geometry; mod preview_commands; mod reading_source; mod runtime_validation; @@ -50,6 +53,7 @@ mod source_review; mod util; mod validator; mod workflow_state; +mod writing_store; use tauri::{AppHandle, Manager}; pub type CommandResult = Result; @@ -955,6 +959,83 @@ async fn build_pack(input: Value, app: AppHandle) -> CommandResult { build_pack_core(&root, &input, true) } +// ---------- 写作题库命令(独立模型,不污染阅读 ImportJob)---------- +#[tauri::command] +async fn create_writing_job(input: Value, app: AppHandle) -> CommandResult { + let root = app_root(&app)?; + util::ensure_app_dirs(&root)?; + let parsed: writing_store::CreateWritingJobInput = serde_json::from_value(input) + .map_err(|error| format!("create_writing_job_invalid_input:{}", error))?; + let job = writing_store::make_writing_job(parsed); + writing_store::save_writing_job(&root, &job)?; + Ok(serde_json::to_value(&job).map_err(|error| error.to_string())?) +} + +#[tauri::command] +async fn list_writing_jobs(filter: Option, app: AppHandle) -> CommandResult { + let root = app_root(&app)?; + let parsed_filter = match filter { + Some(value) => Some( + serde_json::from_value::(value) + .map_err(|error| format!("list_writing_jobs_invalid_filter:{}", error))?, + ), + None => None, + }; + let jobs = writing_store::list_writing_jobs(&root, parsed_filter)?; + Ok(serde_json::to_value(&jobs).map_err(|error| error.to_string())?) +} + +#[tauri::command] +async fn get_writing_job(job_id: String, app: AppHandle) -> CommandResult { + let root = app_root(&app)?; + let job = writing_store::load_writing_job(&root, &job_id)?; + Ok(serde_json::to_value(&job).map_err(|error| error.to_string())?) +} + +#[tauri::command] +async fn update_writing_job(job_id: String, patch: Value, app: AppHandle) -> CommandResult { + let root = app_root(&app)?; + let parsed: writing_store::WritingJobPatch = serde_json::from_value(patch) + .map_err(|error| format!("update_writing_job_invalid_patch:{}", error))?; + let job = writing_store::update_writing_job(&root, &job_id, |job| { + if let Some(title) = parsed.title { + job.title = title; + } + if let Some(task_type) = parsed.task_type { + let normalized = task_type.trim().to_lowercase(); + if normalized == "task1" || normalized == "task2" { + job.task_type = normalized; + } + } + if let Some(exam_id) = parsed.exam_id { + job.exam_id = exam_id; + } + if let Some(prompt_text) = parsed.prompt_text { + job.prompt_text = prompt_text; + } + if let Some(suggested_word_count) = parsed.suggested_word_count { + job.suggested_word_count = suggested_word_count; + } + if let Some(status) = parsed.status { + job.status = status; + } + })?; + Ok(serde_json::to_value(&job).map_err(|error| error.to_string())?) +} + +#[tauri::command] +async fn delete_writing_job(job_id: String, app: AppHandle) -> CommandResult { + let root = app_root(&app)?; + writing_store::delete_writing_job(&root, &job_id)?; + Ok(json!({ "deleted": true, "jobId": job_id })) +} + +#[tauri::command] +async fn export_writing_library(input: Value, app: AppHandle) -> CommandResult { + let root = app_root(&app)?; + export_writing_library_core(&root, &input) +} + #[tauri::command] async fn run_auto_pipeline( job_id: String, @@ -1027,6 +1108,12 @@ pub fn run() { export_reading_assets, export_reading_js, export_nas_library, + export_writing_library, + create_writing_job, + list_writing_jobs, + get_writing_job, + update_writing_job, + delete_writing_job, build_pack ]) .run(tauri::generate_context!()) @@ -7222,6 +7309,172 @@ Answers assert_complex_fixture_pipeline("complex-reading.docx", "rust-parser:docx:ooxml"); } + /// Regression test for the demanding DOCX fixture that marks structure ONLY + /// via run-level formatting (bold/centered/font-size) — no Heading styles, + /// no numbering, no tables. Verifies the parser now captures `runFormat` + /// and infers synthetic heading levels so passage sub-headings and the + /// centered passage title are recognized. + #[test] + fn demanding_docx_run_format_detects_bold_subheadings_and_title() { + let mut job = test_job(); + job.source_files = vec![test_source("docx")]; + let output = env::temp_dir().join(format!( + "epic8-demanding-docx-{}.json", + Uuid::new_v4().simple() + )); + let doc = parse_source_document( + &job, + job.source_files.first().unwrap(), + &parser_fixture("demanding-reading-passage-1.docx"), + &output, + "auto", + ) + .unwrap(); + let _ = fs::remove_file(&output); + + // Collect every block's text + runFormat + headingLevel. + let mut blocks: Vec<(String, Option, Option)> = Vec::new(); + for page in doc.get("pages").and_then(Value::as_array).into_iter().flatten() { + for block in page.get("blocks").and_then(Value::as_array).into_iter().flatten() { + let text = block + .get("text") + .and_then(Value::as_str) + .unwrap_or("") + .trim() + .to_string(); + let run_format = block + .pointer("/layoutHints/runFormat") + .cloned(); + let heading_level = block + .pointer("/layoutHints/headingLevel") + .and_then(Value::as_u64); + blocks.push((text, run_format, heading_level)); + } + } + + // The centered, bold, largest-font passage title must be a heading. + let title = blocks + .iter() + .find(|(text, _, _)| text == "The history of lighting"); + assert!(title.is_some(), "passage title block should be present"); + let (_, title_fmt, title_hl) = title.unwrap(); + let title_fmt = title_fmt.as_ref().expect("title must have runFormat"); + assert_eq!( + title_fmt.get("bold").and_then(Value::as_bool), + Some(true), + "title must be bold" + ); + assert_eq!( + title_fmt.get("centered").and_then(Value::as_bool), + Some(true), + "title must be centered" + ); + assert_eq!(*title_hl, Some(2), "centered title should be heading level 2"); + + // Each bold sub-heading should be detected as a heading (level 3). + for subheading in ["Candlelight", "Oil lamps", "Gas lighting", "Electricity"] { + let found = blocks.iter().find(|(text, _, _)| text == subheading); + assert!(found.is_some(), "sub-heading {} should be present", subheading); + let (_, fmt, hl) = found.unwrap(); + let fmt = fmt.as_ref().expect("sub-heading must have runFormat"); + assert_eq!( + fmt.get("bold").and_then(Value::as_bool), + Some(true), + "sub-heading {} must be bold", + subheading + ); + assert_eq!( + *hl, Some(3), + "sub-heading {} should be heading level 3", + subheading + ); + } + + // Body paragraphs must NOT be flagged as headings. + let body = blocks + .iter() + .find(|(text, _, _)| text.starts_with("We forget how painfully dark")); + assert!(body.is_some(), "a body paragraph should be present"); + let (_, body_fmt, body_hl) = body.unwrap(); + if let Some(fmt) = body_fmt.as_ref() { + assert_ne!( + fmt.get("bold").and_then(Value::as_bool), + Some(true), + "body paragraph must not be bold" + ); + } + assert_eq!(*body_hl, None, "body paragraph must not be a heading"); + } + + /// Regression test for the demanding PDF fixture (2-column passage, A-H + /// option grid, mixed blank markers, no answer key). When the pdfium + /// library is available, verifies the parser yields REAL coordinates (not + /// the fabricated [72, *, 520, *] envelope) and that the 2-column passage + /// page is correctly split into left/right columns. Skips gracefully when + /// pdfium is not bundled (e.g. CI without the binary). + #[test] + fn demanding_pdf_pdfium_yields_real_coordinates_and_column_split() { + if crate::pdf_geometry::pdfium_library_path().is_none() { + // pdfium binary not available in this environment — skip. + return; + } + let mut job = test_job(); + job.source_files = vec![test_source("pdf")]; + let output = env::temp_dir().join(format!( + "epic8-demanding-pdf-{}.json", + Uuid::new_v4().simple() + )); + let doc = parse_source_document( + &job, + job.source_files.first().unwrap(), + &parser_fixture("demanding-reading-passage-3.pdf"), + &output, + "auto", + ) + .unwrap(); + let _ = fs::remove_file(&output); + + // Provider must be pdfium (real coordinates), not the text-layer fallback. + let provider = doc + .pointer("/parser/provider") + .and_then(Value::as_str) + .unwrap_or(""); + assert_eq!( + provider, "rust-parser:pdf:pdfium", + "pdfium backend should be used when the library is available" + ); + + // At least one block must escape the fabricated bbox envelope + // [72, *, 520, *] — i.e. have a real x1 > 520.5 or real x0 < 71.5. + let mut has_real_coord = false; + let mut page0_has_right_column = false; + for page in doc.get("pages").and_then(Value::as_array).into_iter().flatten() { + let page_index = page.get("pageIndex").and_then(Value::as_u64).unwrap_or(0); + for block in page.get("blocks").and_then(Value::as_array).into_iter().flatten() { + if let Some(bbox) = block.get("bbox").and_then(Value::as_array) { + let x0 = bbox.first().and_then(Value::as_f64).unwrap_or(0.0); + let x1 = bbox.get(2).and_then(Value::as_f64).unwrap_or(0.0); + if x1 > 520.5 || x0 < 71.5 { + has_real_coord = true; + } + // On the 2-column passage page, a right-column block has + // x0 clearly past the gutter (> 300 on a 612pt page). + if page_index == 1 && x0 > 300.0 { + page0_has_right_column = true; + } + } + } + } + assert!( + has_real_coord, + "pdfium must produce real coordinates, not the fabricated envelope" + ); + assert!( + page0_has_right_column, + "the 2-column passage page must contain right-column blocks (column split working)" + ); + } + #[test] fn docx_ooxml_parser_preserves_table_ir_for_split_evidence() { let mut job = test_job(); diff --git a/src-tauri/src/util.rs b/src-tauri/src/util.rs index 987c73e..99a8580 100644 --- a/src-tauri/src/util.rs +++ b/src-tauri/src/util.rs @@ -47,11 +47,26 @@ pub(crate) fn safe_job_dir(root: &Path, job_id: &str) -> CommandResult Ok(job_dir(root, job_id)) } +pub(crate) fn writing_job_dir(root: &Path, job_id: &str) -> PathBuf { + let segment = if is_safe_path_segment(job_id) { + job_id + } else { + "__invalid_writing_job_id__" + }; + root.join("writing-jobs").join(segment) +} + +pub(crate) fn safe_writing_job_dir(root: &Path, job_id: &str) -> CommandResult { + validate_path_segment("writing_job_id", job_id)?; + Ok(writing_job_dir(root, job_id)) +} + pub(crate) fn ensure_app_dirs(root: &Path) -> CommandResult<()> { for relative in [ "config", "config/secrets", "jobs", + "writing-jobs", "packs", "logs", "cache", diff --git a/src-tauri/src/writing_store.rs b/src-tauri/src/writing_store.rs new file mode 100644 index 0000000..cd05419 --- /dev/null +++ b/src-tauri/src/writing_store.rs @@ -0,0 +1,191 @@ +//! 写作题库创作任务存储(镜像 job_store.rs,但独立模型,不污染阅读 ImportJob)。 +//! +//! 存储:writing-jobs//writing-job.json +//! 复用 util::{safe_writing_job_dir, writing_job_dir, read_json, write_json, validate_path_segment}。 + +use crate::util::{safe_writing_job_dir, validate_path_segment, writing_job_dir, read_json, write_json}; +use crate::CommandResult; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::{cmp::Reverse, fs, path::Path}; +use uuid::Uuid; + +/// 写作任务状态。比阅读简单:无 PDF 解析中间态。 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "PascalCase")] +pub(crate) enum WritingJobStatus { + Draft, + ExportReady, + Exported, +} + +impl Default for WritingJobStatus { + fn default() -> Self { + WritingJobStatus::Draft + } +} + +/// 写作任务(手输 prompt,无 passage/questionGroups/answerKey)。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct WritingJob { + pub job_id: String, + pub title: String, + pub task_type: String, // "task1" | "task2" + pub exam_id: String, + pub prompt_text: String, + pub suggested_word_count: u32, + #[serde(default)] + pub status: WritingJobStatus, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CreateWritingJobInput { + pub title: Option, + pub task_type: Option, // 默认 task1 + pub prompt_text: Option, + pub suggested_word_count: Option, +} + +#[derive(Debug, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub(crate) struct WritingJobPatch { + pub title: Option, + pub task_type: Option, + pub exam_id: Option, + pub prompt_text: Option, + pub suggested_word_count: Option, + pub status: Option, +} + +#[derive(Debug, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub(crate) struct WritingJobFilter { + pub task_type: Option, + pub search: Option, +} + +fn normalize_task_type(value: Option<&str>) -> String { + match value.map(|v| v.trim().to_lowercase()).as_deref() { + Some("task2") => "task2".to_string(), + _ => "task1".to_string(), + } +} + +fn default_suggested_word_count(task_type: &str) -> u32 { + if task_type == "task2" { + 250 + } else { + 150 + } +} + +fn default_exam_id(task_type: &str, now: &DateTime) -> String { + format!("wt-{}-{}", task_type, now.format("%Y%m%d%H%M%S")) +} + +pub(crate) fn make_writing_job(input: CreateWritingJobInput) -> WritingJob { + let now = Utc::now(); + let task_type = normalize_task_type(input.task_type.as_deref()); + let suggested_word_count = input + .suggested_word_count + .filter(|value| *value > 0) + .unwrap_or_else(|| default_suggested_word_count(&task_type)); + let suffix = Uuid::new_v4().simple().to_string()[..8].to_string(); + WritingJob { + job_id: format!("writing-{}-{}", now.format("%Y%m%d%H%M%S"), suffix), + title: input + .title + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| format!("Untitled Writing {}", task_type)), + exam_id: default_exam_id(&task_type, &now), + task_type, + prompt_text: input.prompt_text.unwrap_or_default(), + suggested_word_count, + status: WritingJobStatus::Draft, + created_at: now, + updated_at: now, + } +} + +pub(crate) fn load_writing_job(root: &Path, job_id: &str) -> CommandResult { + let dir = safe_writing_job_dir(root, job_id)?; + read_json(&dir.join("writing-job.json")) +} + +pub(crate) fn save_writing_job(root: &Path, job: &WritingJob) -> CommandResult<()> { + validate_path_segment("writing_job_id", &job.job_id)?; + write_json(&writing_job_dir(root, &job.job_id).join("writing-job.json"), job) +} + +pub(crate) fn update_writing_job( + root: &Path, + job_id: &str, + mutator: impl FnOnce(&mut WritingJob), +) -> CommandResult { + let mut job = load_writing_job(root, job_id)?; + mutator(&mut job); + job.updated_at = Utc::now(); + save_writing_job(root, &job)?; + Ok(job) +} + +pub(crate) fn list_writing_jobs( + root: &Path, + filter: Option, +) -> CommandResult> { + let jobs_root = root.join("writing-jobs"); + let mut jobs = Vec::new(); + if let Ok(entries) = fs::read_dir(&jobs_root) { + for entry in entries.flatten() { + let path = entry.path().join("writing-job.json"); + if path.exists() { + if let Ok(job) = read_json::(&path) { + jobs.push(job); + } + } + } + } + let filter = filter.unwrap_or_default(); + if let Some(task_type) = filter.task_type { + let normalized = task_type.trim().to_lowercase(); + jobs.retain(|job| job.task_type == normalized); + } + if let Some(search) = filter.search.filter(|value| !value.trim().is_empty()) { + let search = search.to_lowercase(); + jobs.retain(|job| { + job.title.to_lowercase().contains(&search) + || job.job_id.to_lowercase().contains(&search) + || job.exam_id.to_lowercase().contains(&search) + }); + } + jobs.sort_by_key(|job| Reverse(job.updated_at)); + Ok(jobs) +} + +pub(crate) fn delete_writing_job(root: &Path, job_id: &str) -> CommandResult<()> { + let dir = safe_writing_job_dir(root, job_id)?; + if dir.exists() { + fs::remove_dir_all(&dir).map_err(|error| format!("remove_writing_job_dir:{}", error))?; + } + Ok(()) +} + +/// 将 WritingJob 转成导出用 source(WritingExamSourceV1 形状,作为 serde_json::Value)。 +pub(crate) fn writing_source(job: &WritingJob) -> Value { + serde_json::json!({ + "schemaVersion": "WritingExamSourceV1", + "examId": job.exam_id, + "taskType": job.task_type, + "promptText": job.prompt_text, + "suggestedWordCount": job.suggested_word_count, + "meta": { + "title": job.title, + "taskType": job.task_type + } + }) +} diff --git a/src/api/tauriCommands.ts b/src/api/tauriCommands.ts index 903e239..3700b43 100644 --- a/src/api/tauriCommands.ts +++ b/src/api/tauriCommands.ts @@ -28,7 +28,13 @@ import type { SourceFileRole, SourceReview, SplitCandidates, - ValidationReport + ValidationReport, + WritingJob, + CreateWritingJobInput, + WritingJobPatch, + WritingJobFilter, + ExportWritingLibraryInput, + WritingExportResult } from "../types"; import { devFallbackInvoke, type JobDetail } from "../services/devFallbackBackend"; @@ -188,3 +194,28 @@ export async function exportNasLibrary(input: ExportNasLibraryInput): Promise { return command("build_pack", { input }); } + +// ---------- 写作题库命令 ---------- +export async function createWritingJob(input: CreateWritingJobInput): Promise { + return command("create_writing_job", { input }); +} + +export async function listWritingJobs(filter: WritingJobFilter = {}): Promise { + return command("list_writing_jobs", { filter }); +} + +export async function getWritingJob(jobId: string): Promise { + return command("get_writing_job", { jobId }); +} + +export async function updateWritingJob(jobId: string, patch: WritingJobPatch): Promise { + return command("update_writing_job", { jobId, patch }); +} + +export async function deleteWritingJob(jobId: string): Promise<{ deleted: true; jobId: string }> { + return command("delete_writing_job", { jobId }); +} + +export async function exportWritingLibrary(input: ExportWritingLibraryInput): Promise { + return command("export_writing_library", { input }); +} diff --git a/src/app/App.tsx b/src/app/App.tsx index fd1ff74..c9fb552 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -7,6 +7,7 @@ import { JobList } from "../pages/JobList"; import { Settings } from "../pages/Settings"; import { UnifiedPreview } from "../pages/UnifiedPreview"; import { ExportPage } from "../pages/ExportPage"; +import { WritingStudio } from "../pages/WritingStudio"; import { getJob, listJobs } from "../api/tauriCommands"; import type { ImportJob } from "../types"; import { parseRoute, type RouteState } from "./router"; @@ -52,6 +53,7 @@ export function App() { if (route.jobId && route.name === "preview") return ; if (route.jobId && route.name === "export") return ; if (route.name === "packs") return ; + if (route.name === "writing") return ; if (route.name === "settings") return ; return ; }, [jobs, route, refreshToken]); diff --git a/src/app/router.ts b/src/app/router.ts index 70012eb..fbf84d8 100644 --- a/src/app/router.ts +++ b/src/app/router.ts @@ -9,6 +9,7 @@ export type RouteName = | "preview" | "export" | "packs" + | "writing" | "settings"; export interface RouteState { @@ -28,6 +29,7 @@ export function parseRoute(hash = window.location.hash): RouteState { return { name: "document", jobId }; } if (parts[0] === "packs") return { name: "packs" }; + if (parts[0] === "writing") return { name: "writing" }; if (parts[0] === "settings") return { name: "settings" }; if (parts[0] === "jobs") return { name: "jobs" }; return { name: "dashboard" }; diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index b384314..e286449 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -8,6 +8,7 @@ const nav = [ { label: "工作台", short: "台", path: "/dashboard", match: "dashboard" }, { label: "导题任务", short: "任务", path: "/jobs", match: "jobs" }, { label: "新建导题", short: "新建", path: "/jobs/new", match: "new" }, + { label: "写作题创作", short: "写作", path: "/writing", match: "writing" }, { label: "导出/组卷", short: "导出", path: "/packs", match: "packs" }, { label: "设置", short: "设", path: "/settings", match: "settings" } ]; diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index d94debe..8f43e45 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -18,6 +18,7 @@ export function Dashboard({ jobs, refresh }: { jobs: ImportJob[]; refresh: () =>
+
diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 9b7c5ea..7914859 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -1,11 +1,11 @@ import { useEffect, useMemo, useState } from "react"; import { chooseExportDirectory } from "../api/desktopDialogs"; -import { buildPack, exportNasLibrary, exportReadingAssets, exportReadingJs } from "../api/tauriCommands"; +import { buildPack, exportNasLibrary, exportReadingAssets, exportReadingJs, exportWritingLibrary, listWritingJobs } from "../api/tauriCommands"; import { StatusPill } from "../components/StatusPill"; -import type { ExportResult, ImportJob, JsExportResult, NasExportResult, PackBuildResult, ValidationIssue } from "../types"; +import type { ExportResult, ImportJob, JsExportResult, NasExportResult, PackBuildResult, ValidationIssue, WritingExportResult, WritingJob } from "../types"; import { validationIssueDisplay } from "../utils/displayLabels"; -type ExportMode = "single-js" | "batch-js" | "full-assets" | "nas-library"; +type ExportMode = "single-js" | "batch-js" | "full-assets" | "nas-library" | "writing-library"; const EXPORTABLE_STATUSES = new Set(["DraftSaved", "ExportReady", "Exported", "Cleaned"]); const PACKABLE_STATUSES = new Set(["ExportReady", "Exported", "Cleaned"]); @@ -96,6 +96,12 @@ export function ExportPage({ const [running, setRunning] = useState(false); const [packRunning, setPackRunning] = useState(false); + // ---------- 写作导出状态 ---------- + const [writingJobs, setWritingJobs] = useState([]); + const [writingSelected, setWritingSelected] = useState([]); + const [writingResult, setWritingResult] = useState(); + const [writingRunning, setWritingRunning] = useState(false); + const exportable = useMemo( () => jobs.filter((job) => EXPORTABLE_STATUSES.has(job.status)), [jobs] @@ -206,6 +212,46 @@ export function ExportPage({ void run("single-js", [jobId]); }, [jobId]); + // 加载写作任务列表(写作导出模式用) + const [writingRefreshTick, setWritingRefreshTick] = useState(0); + useEffect(() => { + void listWritingJobs().then((list) => { + setWritingJobs(list); + setWritingSelected((current) => { + if (current.length) return current; + // 默认预选一个 task1 + 一个 task2 + const task1 = list.find((j) => j.taskType === "task1"); + const task2 = list.find((j) => j.taskType === "task2"); + return [task1?.jobId, task2?.jobId].filter(Boolean) as string[]; + }); + }).catch(() => setWritingJobs([])); + }, [writingRefreshTick]); + + const writingExportable = useMemo( + () => writingJobs.filter((j) => j.status === "ExportReady" || j.status === "Exported"), + [writingJobs] + ); + const writingTask1 = writingExportable.find((j) => j.taskType === "task1"); + const writingTask2 = writingExportable.find((j) => j.taskType === "task2"); + const canExportWriting = Boolean(writingTask1) && Boolean(writingTask2) + && writingSelected.length === 2 + && new Set(writingSelected).size === 2; + + async function runWritingExport() { + setWritingRunning(true); + setError(undefined); + setWritingResult(undefined); + try { + const result = await exportWritingLibrary({ jobIds: writingSelected, exportDir }); + setWritingResult(result); + refresh(); + } catch (caught) { + setError(caught instanceof Error ? caught.message : String(caught)); + } finally { + setWritingRunning(false); + } + } + const previewText = mode === "full-assets" ? singleResult?.files.find((file) => file.name.endsWith(".js") && file.name !== "manifest.js") @@ -308,6 +354,13 @@ export function ExportPage({ > NAS 题库 +
@@ -341,6 +394,57 @@ export function ExportPage({ {!exportable.length ?

当前没有可直接导出的题目。

: null} ) : null} + {mode === "writing-library" ? ( + <> +

选择 Task 1 与 Task 2 写作任务

+

写作题库需选一个 Task 1 + 一个 Task 2,且二者都需先在写作创作页标记为「可导出」。

+ {writingExportable.map((job) => ( + + ))} + {!writingExportable.length ? ( +

暂无可导出的写作任务。请到「写作创作」页创建并标记为可导出。

+ ) : !canExportWriting ? ( +

需同时勾选一个 Task 1 和一个 Task 2。

+ ) : null} +

+ +

+ {writingResult ? ( +
+ 写作题库已导出 +

版本:{writingResult.version}

+

输出目录:{writingResult.writingExamsDir}

+

文件:{writingResult.files.map((f) => f.name).join("、")}

+ 把 {writingResult.writingExamsDir} 下的 manifest.js + task1.js + task2.js 放进 NAS 学生端 publish/assets/generated/writing-exams/ 即可被识别。 +
+ ) : null} + + ) : null} {error ? (
{error} @@ -450,3 +554,9 @@ export function ExportPage({ ); } + +function mapWritingStatusToJobStatus(status: WritingJob["status"]): "Working" | "DraftSaved" | "ExportReady" | "Exported" { + if (status === "Exported") return "Exported"; + if (status === "ExportReady") return "ExportReady"; + return "DraftSaved"; +} diff --git a/src/pages/WritingStudio.tsx b/src/pages/WritingStudio.tsx new file mode 100644 index 0000000..b9fd46c --- /dev/null +++ b/src/pages/WritingStudio.tsx @@ -0,0 +1,257 @@ +import { useEffect, useMemo, useState } from "react"; +import { + createWritingJob, + deleteWritingJob, + listWritingJobs, + updateWritingJob +} from "../api/tauriCommands"; +import { StatusPill } from "../components/StatusPill"; +import { go } from "../app/router"; +import type { WritingJob, WritingJobStatus, WritingTaskType } from "../types"; + +const TASK_DEFAULTS: Record = { + task1: { suggested: 150, label: "图表描述题 (Task 1)" }, + task2: { suggested: 250, label: "议论文 (Task 2)" } +}; + +export function WritingStudio({ refresh }: { refresh: () => void }) { + const [jobs, setJobs] = useState([]); + const [selectedJobId, setSelectedJobId] = useState(); + const [editing, setEditing] = useState(); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(); + const [creatingTaskType, setCreatingTaskType] = useState("task1"); + const [newTitle, setNewTitle] = useState(""); + + const draft = useMemo(() => jobs.find((j) => j.status === "Draft"), [jobs]); + const ready = useMemo(() => jobs.filter((j) => j.status === "ExportReady" || j.status === "Exported"), [jobs]); + + async function reload() { + try { + const list = await listWritingJobs(); + setJobs(list); + if (!selectedJobId && list.length) setSelectedJobId(list[0].jobId); + const current = list.find((j) => j.jobId === selectedJobId); + if (current) setEditing({ ...current }); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } + } + + useEffect(() => { + void reload(); + }, []); + + useEffect(() => { + const current = jobs.find((j) => j.jobId === selectedJobId); + setEditing(current ? { ...current } : undefined); + }, [selectedJobId, jobs]); + + async function handleCreate() { + setBusy(true); + setError(undefined); + try { + const job = await createWritingJob({ + title: newTitle.trim() || `写作题 ${creatingTaskType}`, + taskType: creatingTaskType, + suggestedWordCount: TASK_DEFAULTS[creatingTaskType].suggested + }); + setNewTitle(""); + await reload(); + setSelectedJobId(job.jobId); + refresh(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + } + + async function handleSave() { + if (!editing) return; + setBusy(true); + setError(undefined); + try { + await updateWritingJob(editing.jobId, { + title: editing.title, + taskType: editing.taskType, + examId: editing.examId, + promptText: editing.promptText, + suggestedWordCount: editing.suggestedWordCount, + status: editing.status + }); + await reload(); + refresh(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + } + + async function handleMarkReady() { + if (!editing) return; + if (!editing.promptText.trim()) { + setError("题目内容 (promptText) 不能为空。"); + return; + } + setBusy(true); + setError(undefined); + try { + const updated = await updateWritingJob(editing.jobId, { status: "ExportReady" as WritingJobStatus }); + setEditing({ ...updated }); + await reload(); + refresh(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + } + + async function handleDelete() { + if (!editing) return; + if (!window.confirm(`确认删除写作任务「${editing.title}」?此操作不可恢复。`)) return; + setBusy(true); + setError(undefined); + try { + await deleteWritingJob(editing.jobId); + setSelectedJobId(undefined); + setEditing(undefined); + await reload(); + refresh(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + } + + return ( +
+
+
+

写作题库创作

+

手输 Task 1 / Task 2 题目,导出为 NAS 端可识别的写作题库

+

创作完成后到导出页选择两个任务打包下发。

+
+
+ +
+
+ +
+
+
+

新建写作任务

+

创建

+
+
+ + + +
+ +
+

写作任务列表

+

已有任务

+
+
+ {jobs.map((job) => ( + + ))} + {!jobs.length ?

暂无写作任务。在上方创建一个。

: null} +
+
+ +