diff --git a/backend/app/public_api.py b/backend/app/public_api.py index d8eed3c..d7f7e7c 100644 --- a/backend/app/public_api.py +++ b/backend/app/public_api.py @@ -3,12 +3,13 @@ import json import logging import tempfile +import threading import time import uuid from collections import defaultdict from datetime import UTC, datetime, timedelta from pathlib import Path -from typing import Annotated +from typing import Annotated, Literal from urllib.request import Request as UrlRequest from urllib.request import urlopen @@ -58,6 +59,21 @@ class PublicExportResponse(BaseModel): expires_at: datetime +PublicExportJobStatus = Literal["running", "done", "failed", "canceled"] + + +class PublicExportJobResponse(BaseModel): + job_id: str + export_id: str + status: PublicExportJobStatus + progress: int = 0 + message: str = "" + download_url: str | None = None + report_url: str | None = None + expires_at: datetime + error_code: str | None = None + + def client_ip(request: Request) -> str: forwarded = request.headers.get("x-forwarded-for", "") if forwarded: @@ -195,6 +211,85 @@ def public_export_docx(request: PublicExportDocxRequest) -> PublicExportResponse ) +@router.post("/export-jobs/docx", response_model=PublicExportJobResponse) +def create_public_export_job(request: PublicExportDocxRequest) -> PublicExportJobResponse: + digest = thesis_digest(request.thesis.model_dump_json()) + if not verify_export_token(request.export_token, digest): + raise AppError("EXPORT_TOKEN_INVALID", "导出凭证已失效,请重新预检后再导出。", status_code=403) + + expires_at = datetime.now(UTC).replace(tzinfo=None) + timedelta(seconds=PUBLIC_EXPORT_RETENTION_SECONDS) + job_id = f"job_{uuid.uuid4().hex[:16]}" + export_id = f"pub_{uuid.uuid4().hex[:16]}" + request_payload = { + "thesis": request.thesis.model_dump(mode="json"), + "export_token": request.export_token, + } + storage.put_bytes(_job_request_key(job_id), json.dumps(request_payload, ensure_ascii=False).encode("utf-8")) + _write_job_meta( + job_id, + { + "job_id": job_id, + "export_id": export_id, + "status": "running", + "progress": 5, + "message": "导出任务已创建。", + "download_url": None, + "report_url": None, + "expires_at": expires_at.isoformat(), + "error_code": None, + "cancel_requested": False, + }, + ) + thread = threading.Thread(target=_run_public_export_job, args=(job_id,), daemon=True) + thread.start() + return _public_job_response(_read_job_meta(job_id)) + + +@router.get("/export-jobs/{job_id}", response_model=PublicExportJobResponse) +def get_public_export_job(job_id: str) -> PublicExportJobResponse: + return _public_job_response(_read_job_meta(job_id)) + + +@router.post("/export-jobs/{job_id}/cancel", response_model=PublicExportJobResponse) +def cancel_public_export_job(job_id: str) -> PublicExportJobResponse: + meta = _read_job_meta(job_id) + if meta["status"] in {"done", "failed", "canceled"}: + return _public_job_response(meta) + meta["cancel_requested"] = True + meta["status"] = "canceled" + meta["message"] = "导出已取消,可重新导出。" + meta["error_code"] = "EXPORT_CANCELED" + _write_job_meta(job_id, meta) + return _public_job_response(meta) + + +@router.post("/export-jobs/{job_id}/retry", response_model=PublicExportJobResponse) +def retry_public_export_job(job_id: str) -> PublicExportJobResponse: + meta = _read_job_meta(job_id) + if meta["status"] not in {"failed", "canceled"}: + raise AppError("EXPORT_JOB_NOT_RETRYABLE", "当前导出任务尚未进入可重试状态。", status_code=409) + payload = json.loads(storage.get_bytes(_job_request_key(job_id)).decode("utf-8")) + thesis = NormalizedThesis.model_validate(payload["thesis"]) + digest = thesis_digest(thesis.model_dump_json()) + if not verify_export_token(payload["export_token"], digest): + raise AppError("EXPORT_TOKEN_INVALID", "导出凭证已失效,请重新预检后再导出。", status_code=403) + meta.update( + { + "status": "running", + "progress": 5, + "message": "正在重新导出。", + "download_url": None, + "report_url": None, + "error_code": None, + "cancel_requested": False, + } + ) + _write_job_meta(job_id, meta) + thread = threading.Thread(target=_run_public_export_job, args=(job_id,), daemon=True) + thread.start() + return _public_job_response(meta) + + @router.get("/exports/{export_id}/download") def download_public_export(export_id: str) -> Response: meta = _read_valid_meta(export_id) @@ -240,3 +335,119 @@ def _read_valid_meta(export_id: str) -> dict: storage.delete_prefix(f"public/exports/{export_id}") raise AppError("EXPORT_EXPIRED", "导出文件已超过 30 分钟保留期,请重新生成。", status_code=410) return meta + + +def _job_meta_key(job_id: str) -> str: + return f"public/export-jobs/{job_id}/meta.json" + + +def _job_request_key(job_id: str) -> str: + return f"public/export-jobs/{job_id}/request.json" + + +def _write_job_meta(job_id: str, meta: dict) -> None: + storage.put_bytes(_job_meta_key(job_id), json.dumps(meta, ensure_ascii=False).encode("utf-8")) + + +def _read_job_meta(job_id: str) -> dict: + try: + meta = json.loads(storage.get_bytes(_job_meta_key(job_id)).decode("utf-8")) + except FileNotFoundError as exc: + raise AppError("EXPORT_JOB_NOT_FOUND", "导出任务不存在或已删除。", status_code=404) from exc + expires_at = datetime.fromisoformat(meta["expires_at"]) + if expires_at < datetime.now(UTC).replace(tzinfo=None): + storage.delete_prefix(f"public/export-jobs/{job_id}") + if meta.get("export_id"): + storage.delete_prefix(f"public/exports/{meta['export_id']}") + raise AppError("EXPORT_EXPIRED", "导出任务已超过 30 分钟保留期,请重新生成。", status_code=410) + return meta + + +def _public_job_response(meta: dict) -> PublicExportJobResponse: + return PublicExportJobResponse( + job_id=meta["job_id"], + export_id=meta["export_id"], + status=meta["status"], + progress=max(0, min(100, int(meta.get("progress", 0)))), + message=meta.get("message", ""), + download_url=meta.get("download_url"), + report_url=meta.get("report_url"), + expires_at=datetime.fromisoformat(meta["expires_at"]), + error_code=meta.get("error_code"), + ) + + +def _job_cancel_requested(job_id: str) -> bool: + try: + meta = _read_job_meta(job_id) + except AppError: + return True + return bool(meta.get("cancel_requested")) or meta.get("status") == "canceled" + + +def _update_job(job_id: str, **patch: object) -> dict: + meta = _read_job_meta(job_id) + if meta.get("status") in {"done", "failed", "canceled"} and patch.get("status") not in {meta.get("status"), None}: + return meta + meta.update(patch) + _write_job_meta(job_id, meta) + return meta + + +def _run_public_export_job(job_id: str) -> None: + try: + if _job_cancel_requested(job_id): + return + _update_job(job_id, progress=18, message="正在准备导出参数。") + payload = json.loads(storage.get_bytes(_job_request_key(job_id)).decode("utf-8")) + thesis = NormalizedThesis.model_validate(payload["thesis"]) + digest = thesis_digest(thesis.model_dump_json()) + if not verify_export_token(payload["export_token"], digest): + _update_job( + job_id, + status="failed", + progress=100, + message="导出凭证已失效,请重新预检后再导出。", + error_code="EXPORT_TOKEN_INVALID", + ) + return + if _job_cancel_requested(job_id): + return + _update_job(job_id, progress=46, message="正在生成 Word 文件。") + payload_bytes = export_docx(thesis) + if _job_cancel_requested(job_id): + return + _update_job(job_id, progress=82, message="正在保存导出文件。") + meta = _read_job_meta(job_id) + expires_at = datetime.fromisoformat(meta["expires_at"]) + export_id = meta["export_id"] + safe_title = (thesis.cover.title.strip() or "SC-TH-export").replace("/", "-")[:40] + filename = f"{safe_title}.docx" + docx_key = f"public/exports/{export_id}/{filename}" + report_key = f"public/exports/{export_id}/self-check-report.json" + storage.put_bytes(docx_key, payload_bytes) + storage.put_bytes(report_key, _public_report_payload(export_id, thesis, expires_at)) + _write_meta(export_id, {"expires_at": expires_at.isoformat(), "docx_key": docx_key, "report_key": report_key, "filename": filename}) + if _job_cancel_requested(job_id): + storage.delete_prefix(f"public/exports/{export_id}") + return + _update_job( + job_id, + status="done", + progress=100, + message="导出完成。", + download_url=f"/api/public/exports/{export_id}/download", + report_url=f"/api/public/exports/{export_id}/report", + error_code=None, + ) + except AppError as exc: + try: + _update_job(job_id, status="failed", progress=100, message=exc.message, error_code=exc.code) + except AppError: + return + except Exception: + logger.exception("public export job failed job_id=%s", job_id) + try: + _update_job(job_id, status="failed", progress=100, message="导出失败,请稍后重试。", error_code="EXPORT_FAILED") + except AppError: + return diff --git a/backend/app/worker.py b/backend/app/worker.py index b727bc2..81a1dee 100644 --- a/backend/app/worker.py +++ b/backend/app/worker.py @@ -28,6 +28,7 @@ def cleanup_expired_exports() -> int: db.commit() storage.delete_prefix("public/uploads") cleanup_public_exports() + cleanup_public_export_jobs() return deleted @@ -50,11 +51,33 @@ def cleanup_public_exports() -> int: return deleted +def cleanup_public_export_jobs() -> int: + storage_root = Path(os.getenv("SCNU_STORAGE_DIR", str(OUTPUTS_DIR / "storage"))) + root = storage_root / "public" / "export-jobs" + if not root.exists(): + return 0 + now = datetime.now(UTC).replace(tzinfo=None) + deleted = 0 + for meta_path in root.glob("*/meta.json"): + try: + meta = json.loads(meta_path.read_text(encoding="utf-8")) + expires_at = datetime.fromisoformat(meta["expires_at"]) + except Exception: + continue + if expires_at < now: + storage.delete_prefix(f"public/export-jobs/{meta_path.parent.name}") + if meta.get("export_id"): + storage.delete_prefix(f"public/exports/{meta['export_id']}") + deleted += 1 + return deleted + + def main() -> None: init_db() print("SCNU Workbench worker ready. Queue integration is reserved for Celery/Redis jobs.") while True: cleanup_expired_exports() + cleanup_public_export_jobs() time.sleep(60) diff --git a/docs/api.md b/docs/api.md index 2f697e0..19a32b3 100644 --- a/docs/api.md +++ b/docs/api.md @@ -10,6 +10,10 @@ When `SCNU_ACCESS_CODE` is set, all `/api/*` routes require the access cookie ex - `POST /api/public/precheck/docx` - `POST /api/public/precheck/text` - `POST /api/public/exports/docx` +- `POST /api/public/export-jobs/docx` +- `GET /api/public/export-jobs/{id}` +- `POST /api/public/export-jobs/{id}/cancel` +- `POST /api/public/export-jobs/{id}/retry` - `GET /api/public/exports/{id}/download` - `GET /api/public/exports/{id}/report` - `GET /api/access-code/status` @@ -54,6 +58,54 @@ JSON request: Returns a retained export with `download_url`, `report_url`, and `expires_at`. Public exports are kept for 30 minutes. +This synchronous endpoint is retained for compatibility. The public UI uses the Job endpoints below. + +### `POST /api/public/export-jobs/docx` + +JSON request: + +```json +{ + "thesis": {}, + "export_token": "..." +} +``` + +Creates a background export job and returns: + +```json +{ + "job_id": "job_...", + "export_id": "pub_...", + "status": "running", + "progress": 5, + "message": "导出任务已创建。", + "download_url": null, + "report_url": null, + "expires_at": "2026-04-21T12:30:00", + "error_code": null +} +``` + +Job status values: + +- `running` +- `done` +- `failed` +- `canceled` + +### `GET /api/public/export-jobs/{id}` + +Returns the latest persisted job status. When `status=done`, `download_url` and `report_url` are available. + +### `POST /api/public/export-jobs/{id}/cancel` + +Requests cancellation and returns the persisted job status. Cancellation is cooperative: if the export has already completed, the completed state is returned. + +### `POST /api/public/export-jobs/{id}/retry` + +Retries a failed or canceled job with the original request payload while the original `export_token` remains valid. + ### `GET /api/access-code/status` Returns: diff --git a/tests/test_api.py b/tests/test_api.py index 9183ae7..ccac124 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,4 +1,6 @@ from pathlib import Path +import time +from threading import Event from zipfile import ZipFile import io import json @@ -8,7 +10,7 @@ from backend.app.main import app from backend.app.storage import storage -from backend.app.worker import cleanup_public_exports +from backend.app.worker import cleanup_public_export_jobs, cleanup_public_exports FIXTURE = Path(__file__).resolve().parent / "fixtures" / "sample-thesis.docx" @@ -201,6 +203,62 @@ def test_public_docx_export_uses_token_and_retained_download(): assert report.json()["export_id"] == payload["export_id"] +def test_public_export_job_completes_and_serves_download(): + with TestClient(app) as client: + precheck = client.post( + "/api/public/precheck/text", + json={"text": "# 引言\n\n这是已有论文正文。" * 40, "privacy_accepted": True}, + ).json() + job = client.post( + "/api/public/export-jobs/docx", + json={"thesis": precheck["thesis"], "export_token": precheck["export_token"]}, + ) + assert job.status_code == 200 + job_payload = job.json() + assert job_payload["status"] in {"running", "done"} + + deadline = time.time() + 3 + while job_payload["status"] == "running" and time.time() < deadline: + time.sleep(0.05) + job_payload = client.get(f"/api/public/export-jobs/{job_payload['job_id']}").json() + + assert job_payload["status"] == "done" + assert job_payload["progress"] == 100 + download = client.get(job_payload["download_url"]) + assert download.status_code == 200 + assert download.headers["content-type"] == "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + + +def test_public_export_job_can_be_canceled(monkeypatch): + started = Event() + release = Event() + + def slow_export(_thesis): + started.set() + release.wait(2) + return b"PK-slow-docx" + + monkeypatch.setattr("backend.app.public_api.export_docx", slow_export) + + with TestClient(app) as client: + precheck = client.post( + "/api/public/precheck/text", + json={"text": "# 引言\n\n这是已有论文正文。" * 40, "privacy_accepted": True}, + ).json() + job = client.post( + "/api/public/export-jobs/docx", + json={"thesis": precheck["thesis"], "export_token": precheck["export_token"]}, + ).json() + assert started.wait(1) + canceled = client.post(f"/api/public/export-jobs/{job['job_id']}/cancel").json() + release.set() + + assert canceled["status"] == "canceled" + assert canceled["error_code"] == "EXPORT_CANCELED" + final = client.get(f"/api/public/export-jobs/{job['job_id']}").json() + assert final["status"] == "canceled" + + def test_public_export_expiry_and_janitor_cleanup(): with TestClient(app) as client: precheck = client.post( @@ -221,6 +279,29 @@ def test_public_export_expiry_and_janitor_cleanup(): assert not meta_path.exists() +def test_public_export_job_expiry_cleanup(): + job_id = "job_expired_cleanup" + export_id = "pub_expired_cleanup" + meta = { + "job_id": job_id, + "export_id": export_id, + "status": "done", + "progress": 100, + "message": "导出完成。", + "download_url": f"/api/public/exports/{export_id}/download", + "report_url": f"/api/public/exports/{export_id}/report", + "expires_at": (datetime.now(UTC).replace(tzinfo=None) - timedelta(minutes=1)).isoformat(), + "error_code": None, + "cancel_requested": False, + } + storage.put_bytes(f"public/export-jobs/{job_id}/meta.json", json.dumps(meta).encode("utf-8")) + storage.put_bytes(f"public/exports/{export_id}/demo.docx", b"PK-expired") + meta_path = storage.root / "public" / "export-jobs" / job_id / "meta.json" + + assert cleanup_public_export_jobs() == 1 + assert not meta_path.exists() + + def test_export_docx_rejects_blocking_payload(): payload = sample_payload() payload["body_sections"] = [] diff --git a/web/src/App.flow.test.tsx b/web/src/App.flow.test.tsx index de79600..f4bd2eb 100644 --- a/web/src/App.flow.test.tsx +++ b/web/src/App.flow.test.tsx @@ -93,13 +93,18 @@ describe("App business flow", () => { }); it("exports docx and resets after success", async () => { + let jobPollCount = 0; const fetchMock = mockFetch((input, init) => { const url = String(input); if (url.includes("/api/public/precheck/text")) { return jsonResponse(samplePrecheck({ thesis: sampleThesis() })); } - if (url.includes("/api/public/exports/docx")) { - return jsonResponse({ export_id: "pub_1", download_url: "/api/public/exports/pub_1/download", report_url: "/api/public/exports/pub_1/report", expires_at: "2099-01-01T00:00:00" }); + if (url.endsWith("/api/public/export-jobs/docx")) { + return jsonResponse({ job_id: "job_1", export_id: "pub_1", status: "running", progress: 46, message: "正在生成 Word 文件。", download_url: null, report_url: null, expires_at: "2099-01-01T00:00:00", error_code: null }); + } + if (url.endsWith("/api/public/export-jobs/job_1")) { + jobPollCount += 1; + return jsonResponse({ job_id: "job_1", export_id: "pub_1", status: "done", progress: 100, message: "导出完成。", download_url: "/api/public/exports/pub_1/download", report_url: "/api/public/exports/pub_1/report", expires_at: "2099-01-01T00:00:00", error_code: null }); } if (url.includes("/api/public/exports/pub_1/download")) { return jsonResponse({}); @@ -122,11 +127,12 @@ describe("App business flow", () => { fireEvent.click(screen.getByRole("button", { name: "确认并导出" })); - expect(await screen.findByText("正在生成 Word 文件")).toBeInTheDocument(); + expect(await screen.findByText(/正在生成 Word 文件/)).toBeInTheDocument(); await waitFor(() => expect(screen.queryByRole("dialog", { name: "导出前结构预检" })).not.toBeInTheDocument()); await waitFor(() => expect(screen.getByLabelText("论文正文输入框")).toHaveValue("")); - const exportCall = fetchMock.mock.calls.find(([input]) => String(input).includes("/api/public/exports/docx")); + expect(jobPollCount).toBeGreaterThan(0); + const exportCall = fetchMock.mock.calls.find(([input]) => String(input).includes("/api/public/export-jobs/docx")); expect(exportCall).toBeTruthy(); const [, request] = exportCall as [RequestInfo | URL, RequestInit]; const payload = JSON.parse(String(request.body)); @@ -171,9 +177,12 @@ describe("App business flow", () => { if (url.includes("/api/public/precheck/text")) { return jsonResponse(samplePrecheck({ thesis: sampleThesis() })); } - if (url.includes("/api/public/exports/docx")) { + if (url.endsWith("/api/public/export-jobs/docx")) { return exportDeferred.promise; } + if (url.endsWith("/api/public/export-jobs/job_1")) { + return jsonResponse({ job_id: "job_1", export_id: "pub_1", status: "done", progress: 100, message: "导出完成。", download_url: "/api/public/exports/pub_1/download", report_url: "/api/public/exports/pub_1/report", expires_at: "2099-01-01T00:00:00", error_code: null }); + } if (url.includes("/api/public/exports/pub_1/download")) { return jsonResponse({}); } @@ -198,11 +207,55 @@ describe("App business flow", () => { const clickSpy = vi.spyOn(input, "click"); await waitFor(() => expect(uploadButton).toBeDisabled()); - expect(await screen.findByText("正在生成 Word 文件")).toBeInTheDocument(); + expect(await screen.findByText("正在创建导出任务。")).toBeInTheDocument(); fireEvent.click(uploadButton); expect(clickSpy).not.toHaveBeenCalled(); - exportDeferred.resolve(await jsonResponse({ export_id: "pub_1", download_url: "/api/public/exports/pub_1/download", report_url: "/api/public/exports/pub_1/report", expires_at: "2099-01-01T00:00:00" })); + exportDeferred.resolve(await jsonResponse({ job_id: "job_1", export_id: "pub_1", status: "done", progress: 100, message: "导出完成。", download_url: "/api/public/exports/pub_1/download", report_url: "/api/public/exports/pub_1/report", expires_at: "2099-01-01T00:00:00", error_code: null })); + await waitFor(() => expect(screen.getByLabelText("论文正文输入框")).toHaveValue("")); + }); + + it("can cancel and retry an export job", async () => { + let createCount = 0; + mockFetch((input) => { + const url = String(input); + if (url.includes("/api/public/precheck/text")) { + return jsonResponse(samplePrecheck({ thesis: sampleThesis() })); + } + if (url.endsWith("/api/public/export-jobs/docx")) { + createCount += 1; + return jsonResponse({ job_id: `job_${createCount}`, export_id: `pub_${createCount}`, status: "running", progress: 32, message: "正在生成 Word 文件。", download_url: null, report_url: null, expires_at: "2099-01-01T00:00:00", error_code: null }); + } + if (url.endsWith("/api/public/export-jobs/job_1/cancel")) { + return jsonResponse({ job_id: "job_1", export_id: "pub_1", status: "canceled", progress: 32, message: "导出已取消,可重新导出。", download_url: null, report_url: null, expires_at: "2099-01-01T00:00:00", error_code: "EXPORT_CANCELED" }); + } + if (url.endsWith("/api/public/export-jobs/job_2")) { + return jsonResponse({ job_id: "job_2", export_id: "pub_2", status: "done", progress: 100, message: "导出完成。", download_url: "/api/public/exports/pub_2/download", report_url: "/api/public/exports/pub_2/report", expires_at: "2099-01-01T00:00:00", error_code: null }); + } + if (url.includes("/api/public/exports/pub_2/download")) { + return jsonResponse({}); + } + return jsonResponse(healthPayload); + }); + + Object.defineProperty(URL, "createObjectURL", { value: vi.fn(() => "blob:docx"), configurable: true }); + Object.defineProperty(URL, "revokeObjectURL", { value: vi.fn(), configurable: true }); + vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined); + + render(); + fireEvent.change(screen.getByLabelText("论文正文输入框"), { + target: { value: "结构化映射示例论文\n\n摘要\n这是满足长度要求的摘要内容。".repeat(8) }, + }); + acceptPrivacy(); + fireEvent.click(screen.getByRole("button", { name: "开始预检" })); + await screen.findByRole("dialog", { name: "导出前结构预检" }); + fireEvent.click(screen.getByRole("button", { name: "确认并导出" })); + + fireEvent.click(await screen.findByRole("button", { name: "取消导出" })); + expect(await screen.findByText("导出已取消,可重新导出。")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "重新导出" })); await waitFor(() => expect(screen.getByLabelText("论文正文输入框")).toHaveValue("")); + expect(createCount).toBe(2); }); }); diff --git a/web/src/app/AppShell.tsx b/web/src/app/AppShell.tsx index 22ba15c..f18dd22 100644 --- a/web/src/app/AppShell.tsx +++ b/web/src/app/AppShell.tsx @@ -39,13 +39,17 @@ function HomeRoute() { selectedFile={flow.selectedFile} phase={flow.phase} exportProgress={flow.exportProgress} + exportMessage={flow.exportMessage} error={flow.inlineError} + canRetryExport={flow.canRetryExport} privacyAccepted={flow.privacyAccepted} turnstileToken={flow.turnstileToken} onTextChange={flow.handleTextChange} onUploadTrigger={flow.handleUploadTrigger} onFileSelect={flow.handleFileSelect} onSubmit={flow.handlePrecheck} + onCancelExport={flow.handleCancelExport} + onRetryExport={flow.handleRetryExport} onClear={flow.clearAll} onPrivacyAcceptedChange={flow.setPrivacyAccepted} onTurnstileTokenChange={flow.setTurnstileToken} diff --git a/web/src/app/api.ts b/web/src/app/api.ts index 7e05934..b4d2617 100644 --- a/web/src/app/api.ts +++ b/web/src/app/api.ts @@ -112,6 +112,20 @@ export interface PublicExportResponse { expires_at: string; } +export type PublicExportJobStatus = "running" | "done" | "failed" | "canceled"; + +export interface PublicExportJobResponse { + job_id: string; + export_id: string; + status: PublicExportJobStatus; + progress: number; + message: string; + download_url: string | null; + report_url: string | null; + expires_at: string; + error_code: string | null; +} + export function publicExportDocx(thesis: NormalizedThesis, exportToken: string) { return jsonRequest("/api/public/exports/docx", { method: "POST", @@ -120,6 +134,24 @@ export function publicExportDocx(thesis: NormalizedThesis, exportToken: string) }); } +export function createPublicExportJob(thesis: NormalizedThesis, exportToken: string) { + return jsonRequest("/api/public/export-jobs/docx", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ thesis, export_token: exportToken }), + }); +} + +export function getPublicExportJob(jobId: string) { + return jsonRequest(`/api/public/export-jobs/${jobId}`); +} + +export function cancelPublicExportJob(jobId: string) { + return jsonRequest(`/api/public/export-jobs/${jobId}/cancel`, { + method: "POST", + }); +} + export function downloadUrlAsBlob(url: string) { return blobRequest(url); } diff --git a/web/src/app/domain.ts b/web/src/app/domain.ts index 57d8caf..593ba1f 100644 --- a/web/src/app/domain.ts +++ b/web/src/app/domain.ts @@ -51,6 +51,9 @@ export function mapApiError(error: ApiError | null): InlineErrorState | null { TURNSTILE_INVALID: "人机验证未通过,请刷新后重试。", RATE_LIMITED: "当前 IP 的公开导出请求过于频繁,请稍后再试。", EXPORT_TOKEN_INVALID: "导出凭证已失效,请重新预检后再导出。", + EXPORT_CANCELED: "导出已取消,可重新导出。", + EXPORT_JOB_NOT_FOUND: "导出任务不存在,请重新开始。", + EXPORT_JOB_NOT_RETRYABLE: "当前导出任务暂时不能重试。", PARSE_FAILED: "无法完成结构识别,请调整输入内容后重试。", FIELD_MISSING: "预检仍有阻塞项,暂时无法导出。", TEMPLATE_DEPENDENCY_MISSING: "导出模板当前不可用,请稍后重试。", diff --git a/web/src/app/useMinimalExportFlow.ts b/web/src/app/useMinimalExportFlow.ts index b695dd3..39a024b 100644 --- a/web/src/app/useMinimalExportFlow.ts +++ b/web/src/app/useMinimalExportFlow.ts @@ -1,17 +1,20 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import type { HealthResponse, PrecheckResponse } from "../generated/contracts"; import { ApiError, + cancelPublicExportJob, + createPublicExportJob, downloadUrlAsBlob, downloadBlob, + getPublicExportJob, getHealth, - publicExportDocx, publicPrecheckDocx, publicPrecheckText, + type PublicExportJobResponse, } from "./api"; import { exportFilename, inferPhase, mapApiError, validateDocxFile, validateTextInput, type FlowPhase, type InlineErrorState } from "./domain"; -const EXPORT_MIN_DURATION_MS = typeof navigator !== "undefined" && /jsdom/i.test(navigator.userAgent) ? 20 : 4200; +const EXPORT_JOB_POLL_INTERVAL_MS = typeof navigator !== "undefined" && /jsdom/i.test(navigator.userAgent) ? 20 : 700; export function useMinimalExportFlow() { const [health, setHealth] = useState(null); @@ -22,9 +25,12 @@ export function useMinimalExportFlow() { const [precheck, setPrecheck] = useState(null); const [exporting, setExporting] = useState(false); const [exportProgress, setExportProgress] = useState(0); + const [exportMessage, setExportMessage] = useState(""); const [inlineError, setInlineError] = useState(null); const [privacyAccepted, setPrivacyAccepted] = useState(false); const [turnstileToken, setTurnstileToken] = useState(""); + const exportJobIdRef = useRef(null); + const cancelRequestedRef = useRef(false); useEffect(() => { getHealth() @@ -34,24 +40,6 @@ export function useMinimalExportFlow() { .catch((error) => setInlineError(mapApiError(error instanceof ApiError ? error : new ApiError("健康检查失败", "NETWORK_ERROR")))); }, []); - useEffect(() => { - if (!exporting) { - setExportProgress(0); - return; - } - - setExportProgress(0); - const timer = window.setInterval(() => { - setExportProgress((current) => { - if (current >= 92) return current; - const next = current + Math.max(1.5, (92 - current) * 0.11); - return Math.min(next, 92); - }); - }, 120); - - return () => window.clearInterval(timer); - }, [exporting]); - const phase: FlowPhase = useMemo( () => inferPhase(rawText, selectedFile, busy, previewModalOpen, exporting), [busy, exporting, previewModalOpen, rawText, selectedFile], @@ -65,9 +53,12 @@ export function useMinimalExportFlow() { setPrecheck(null); setExporting(false); setExportProgress(0); + setExportMessage(""); setInlineError(null); setPrivacyAccepted(false); setTurnstileToken(""); + exportJobIdRef.current = null; + cancelRequestedRef.current = false; } function resetResult() { @@ -144,6 +135,15 @@ export function useMinimalExportFlow() { setPreviewModalOpen(false); } + function syncExportJob(job: PublicExportJobResponse) { + setExportProgress(job.progress); + setExportMessage(job.message || "正在生成 Word 文件"); + } + + function sleep(ms: number) { + return new Promise((resolve) => window.setTimeout(resolve, ms)); + } + async function handleConfirmExport() { if (!precheck?.summary.can_confirm) { setInlineError({ message: precheck?.summary.blocking_message || "预检仍存在阻塞项,暂时无法导出。", code: "FIELD_MISSING" }); @@ -153,24 +153,76 @@ export function useMinimalExportFlow() { setInlineError(null); setPreviewModalOpen(false); setExporting(true); + setExportProgress(0); + setExportMessage("正在创建导出任务。"); + cancelRequestedRef.current = false; try { if (!precheck.export_token) { throw new ApiError("导出凭证已失效,请重新预检后再导出。", "EXPORT_TOKEN_INVALID"); } - const minDelay = new Promise((resolve) => window.setTimeout(resolve, EXPORT_MIN_DURATION_MS)); - const [exported] = await Promise.all([publicExportDocx(precheck.thesis, precheck.export_token), minDelay]); - const blob = await downloadUrlAsBlob(exported.download_url); + let job = await createPublicExportJob(precheck.thesis, precheck.export_token); + exportJobIdRef.current = job.job_id; + syncExportJob(job); + + while (job.status === "running") { + await sleep(EXPORT_JOB_POLL_INTERVAL_MS); + if (cancelRequestedRef.current) { + return; + } + job = await getPublicExportJob(job.job_id); + syncExportJob(job); + } + + if (job.status === "canceled") { + throw new ApiError("导出已取消,可重新导出。", "EXPORT_CANCELED"); + } + if (job.status === "failed") { + throw new ApiError(job.message || "导出失败,请稍后重试。", job.error_code || "EXPORT_FAILED"); + } + if (!job.download_url) { + throw new ApiError("导出文件尚未生成,请重新导出。", "EXPORT_FAILED"); + } + + const blob = await downloadUrlAsBlob(job.download_url); setExportProgress(100); downloadBlob(blob, exportFilename(precheck.thesis)); await new Promise((resolve) => window.setTimeout(resolve, 220)); clearAll(); } catch (error) { setExporting(false); + setExportMessage(""); setInlineError(mapApiError(error instanceof ApiError ? error : new ApiError("导出失败", "EXPORT_FAILED"))); } } + async function handleCancelExport() { + const jobId = exportJobIdRef.current; + cancelRequestedRef.current = true; + if (!jobId) { + setExporting(false); + setInlineError({ message: "导出已取消,可重新导出。", code: "EXPORT_CANCELED" }); + return; + } + try { + const job = await cancelPublicExportJob(jobId); + syncExportJob(job); + setInlineError({ message: "导出已取消,可重新导出。", code: "EXPORT_CANCELED" }); + } catch (error) { + setInlineError(mapApiError(error instanceof ApiError ? error : new ApiError("取消失败,请稍后重试。", "EXPORT_FAILED"))); + } finally { + setExporting(false); + setExportMessage(""); + } + } + + const canRetryExport = + Boolean(precheck?.summary.can_confirm) && + !busy && + !exporting && + !previewModalOpen && + (inlineError?.code === "EXPORT_FAILED" || inlineError?.code === "EXPORT_CANCELED"); + return { health, selectedFile, @@ -181,7 +233,9 @@ export function useMinimalExportFlow() { precheck, exporting, exportProgress, + exportMessage, inlineError, + canRetryExport, privacyAccepted, setPrivacyAccepted, turnstileToken, @@ -193,5 +247,7 @@ export function useMinimalExportFlow() { handlePrecheck, handleCancelPreview, handleConfirmExport, + handleCancelExport, + handleRetryExport: handleConfirmExport, }; } diff --git a/web/src/components/minimal/HomeComposer.tsx b/web/src/components/minimal/HomeComposer.tsx index 87c1eaa..a4708e2 100644 --- a/web/src/components/minimal/HomeComposer.tsx +++ b/web/src/components/minimal/HomeComposer.tsx @@ -8,12 +8,14 @@ type HomeComposerProps = { selectedFile: File | null; phase: FlowPhase; exportProgress: number; + exportMessage?: string; privacyAccepted: boolean; turnstileToken: string; onTextChange: (value: string) => void; onUploadTrigger: () => boolean; onFileSelect: (file: File | null) => void; onSubmit: () => void; + onCancelExport: () => void; onClear: () => void; onDragActiveChange?: (active: boolean) => void; onPrivacyAcceptedChange: (value: boolean) => void; @@ -44,12 +46,14 @@ export function HomeComposer({ selectedFile, phase, exportProgress, + exportMessage, privacyAccepted, turnstileToken, onTextChange, onUploadTrigger, onFileSelect, onSubmit, + onCancelExport, onClear, onDragActiveChange, onPrivacyAcceptedChange, @@ -220,7 +224,7 @@ export function HomeComposer({ {phase === "exporting" ? (
- +
) : selectedFile ? (
diff --git a/web/src/components/minimal/InlineError.tsx b/web/src/components/minimal/InlineError.tsx index 9d2d9bc..6c16217 100644 --- a/web/src/components/minimal/InlineError.tsx +++ b/web/src/components/minimal/InlineError.tsx @@ -1,11 +1,24 @@ type InlineErrorProps = { message?: string | null; + actionLabel?: string; + onAction?: () => void; }; -export function InlineError({ message }: InlineErrorProps) { +export function InlineError({ message, actionLabel, onAction }: InlineErrorProps) { return (
- {message ?

{message}

:  } + {message ? ( +

+ {message} + {actionLabel && onAction ? ( + + ) : null} +

+ ) : ( +   + )}
); } diff --git a/web/src/components/minimal/MinimalHome.tsx b/web/src/components/minimal/MinimalHome.tsx index e9ade1b..f0560d7 100644 --- a/web/src/components/minimal/MinimalHome.tsx +++ b/web/src/components/minimal/MinimalHome.tsx @@ -9,13 +9,17 @@ type MinimalHomeProps = { selectedFile: File | null; phase: FlowPhase; exportProgress: number; + exportMessage?: string; error: InlineErrorState | null; + canRetryExport: boolean; privacyAccepted: boolean; turnstileToken: string; onTextChange: (value: string) => void; onUploadTrigger: () => boolean; onFileSelect: (file: File | null) => void; onSubmit: () => void; + onCancelExport: () => void; + onRetryExport: () => void; onClear: () => void; onPrivacyAcceptedChange: (value: boolean) => void; onTurnstileTokenChange: (value: string) => void; @@ -65,18 +69,24 @@ export function MinimalHome(props: MinimalHomeProps) { selectedFile={props.selectedFile} phase={props.phase} exportProgress={props.exportProgress} + exportMessage={props.exportMessage} privacyAccepted={props.privacyAccepted} turnstileToken={props.turnstileToken} onTextChange={props.onTextChange} onUploadTrigger={props.onUploadTrigger} onFileSelect={props.onFileSelect} onSubmit={props.onSubmit} + onCancelExport={props.onCancelExport} onClear={props.onClear} onDragActiveChange={setIsDragActive} onPrivacyAcceptedChange={props.onPrivacyAcceptedChange} onTurnstileTokenChange={props.onTurnstileTokenChange} /> - + diff --git a/web/src/components/minimal/WaveExportProgress.tsx b/web/src/components/minimal/WaveExportProgress.tsx index 54f2500..c0938b5 100644 --- a/web/src/components/minimal/WaveExportProgress.tsx +++ b/web/src/components/minimal/WaveExportProgress.tsx @@ -1,8 +1,10 @@ type WaveExportProgressProps = { progress: number; + message?: string; + onCancel?: () => void; }; -export function WaveExportProgress({ progress }: WaveExportProgressProps) { +export function WaveExportProgress({ progress, message = "正在生成 Word 文件", onCancel }: WaveExportProgressProps) { return (
@@ -11,9 +13,14 @@ export function WaveExportProgress({ progress }: WaveExportProgressProps) {
- 正在生成 Word 文件 + {message} {Math.round(progress)}%
+ {onCancel ? ( + + ) : null}
); } diff --git a/web/src/styles/components.css b/web/src/styles/components.css index 71da175..2ce6b0e 100644 --- a/web/src/styles/components.css +++ b/web/src/styles/components.css @@ -204,6 +204,7 @@ .inline-error span { display: inline-flex; align-items: center; + gap: var(--space-2); min-height: 2rem; } @@ -217,6 +218,21 @@ font-size: var(--text-sm); } +.inline-error button, +.wave-progress-cancel { + border: 1px solid rgba(154, 95, 100, 0.18); + border-radius: 999px; + background: rgba(255, 255, 255, 0.72); + color: inherit; + cursor: pointer; + font: inherit; + font-weight: 700; +} + +.inline-error button { + padding: 0.18rem 0.55rem; +} + .wave-progress { display: grid; gap: var(--space-3); @@ -260,6 +276,12 @@ font-size: var(--text-sm); } +.wave-progress-cancel { + justify-self: end; + padding: 0.32rem 0.72rem; + color: var(--text-secondary); +} + .precheck-modal-top, .precheck-footer, .preview-block-header {