feat(platform): connect owned submissions and real browser flows

This commit is contained in:
2026-10-09 09:30:12 +08:00
parent 19a071573b
commit fb1b161c0d
31 changed files with 2669 additions and 388 deletions
+1 -1
View File
@@ -18,6 +18,7 @@ dependencies = [
"alembic>=1.15,<2",
"aio-pika>=9.5,<10",
"argon2-cffi>=25.1,<26",
"httpx>=0.28,<1",
]
[project.scripts]
@@ -31,7 +32,6 @@ kf-outbox-publisher = "kf_oj.workers.outbox:main"
dev = [
"ruff>=0.11,<1",
"pytest>=8.3,<10",
"httpx>=0.28,<1",
"jsonschema>=4.23,<5",
]
+20
View File
@@ -16,9 +16,11 @@ from kf_oj.core.logging import configure_logging
from kf_oj.core.settings import Settings
from kf_oj.infra.artifacts.repository import ArtifactRepository
from kf_oj.infra.artifacts.store import ArtifactStore
from kf_oj.infra.coordinator import CoordinatorControl
from kf_oj.infra.db.session import build_engine, build_session_factory
from kf_oj.modules.identity.service import IdentityService
from kf_oj.modules.problems.service import ProblemService
from kf_oj.modules.submissions.queries import SubmissionQueries
from kf_oj.modules.submissions.service import SubmissionService
from kf_oj.modules.submissions.worker_access import WorkerAccess
@@ -58,12 +60,23 @@ def create_app(settings: Settings | None = None) -> FastAPI:
app.state.worker_access = WorkerAccess(
app.state.sessions, app.state.problems, artifacts
)
cancellation = (
CoordinatorControl(
settings.coordinator_url, settings.coordinator_token.get_secret_value()
)
if settings.coordinator_url and settings.coordinator_token
else None
)
app.state.submission_queries = SubmissionQueries(
app.state.sessions, artifacts, cancellation
)
yield
finally:
app.state.identity = None
app.state.problems = None
app.state.worker_access = None
app.state.submissions = None
app.state.submission_queries = None
if engine is not None:
await engine.dispose()
if store is not None:
@@ -82,6 +95,13 @@ def create_app(settings: Settings | None = None) -> FastAPI:
app.state.settings = settings
app.add_middleware(BodyLimitMiddleware)
@app.middleware("http")
async def private_responses(request, call_next):
response = await call_next(request)
if request.url.path.startswith(("/api/v1/auth", "/api/v1/submissions", "/api/v1/internal")):
response.headers["Cache-Control"] = "no-store"
return response
@app.exception_handler(RequestValidationError)
async def invalid_request(_, __):
return JSONResponse({"detail": "invalid request"}, status_code=422)
+79 -3
View File
@@ -1,12 +1,21 @@
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from kf_oj.api.dependencies import unsafe_principal
from kf_oj.contracts.submissions import CreateSubmission, SubmissionCreated
from kf_oj.api.dependencies import current_principal, unsafe_principal
from kf_oj.contracts.submissions import (
CancellationAccepted,
CreateSubmission,
SubmissionCreated,
SubmissionDetail,
SubmissionPage,
SubmissionSource,
)
from kf_oj.infra.artifacts.store import ArtifactError
from kf_oj.modules.identity.public import Principal
from kf_oj.modules.submissions.public import (
CancelUnavailable,
IdempotencyConflict,
SubmissionBusy,
SubmissionUnavailable,
@@ -41,3 +50,70 @@ async def create(
raise HTTPException(422, "submission outside admission policy") from None
response.headers["Cache-Control"] = "no-store"
return result
def queries(request: Request):
service = getattr(request.app.state, "submission_queries", None)
if service is None:
raise HTTPException(503, "submission service unavailable")
return service
@router.get("", response_model=SubmissionPage)
async def history(
request: Request,
principal: Annotated[Principal, Depends(current_principal)],
after: Annotated[str | None, Query(max_length=128)] = None,
limit: Annotated[int, Query(ge=1, le=100)] = 20,
):
try:
return await queries(request).page(principal, after, limit)
except ValueError:
raise HTTPException(422, "invalid history request") from None
@router.get("/{submission_id}", response_model=SubmissionDetail)
async def detail(
submission_id: UUID,
request: Request,
principal: Annotated[Principal, Depends(current_principal)],
):
try:
return await queries(request).detail(principal, submission_id)
except SubmissionUnavailable:
raise HTTPException(404, "submission unavailable") from None
except ValueError:
raise HTTPException(503, "stored result unavailable") from None
@router.get("/{submission_id}/source", response_model=SubmissionSource)
async def source(
submission_id: UUID,
request: Request,
principal: Annotated[Principal, Depends(current_principal)],
):
service = queries(request)
try:
result = await service.detail(principal, submission_id)
data = await service.owned_source(principal, submission_id)
return SubmissionSource(
submission_id=submission_id, language=result.language, source=data.decode("utf-8")
)
except SubmissionUnavailable:
raise HTTPException(404, "submission unavailable") from None
except ValueError:
raise HTTPException(503, "stored source unavailable") from None
@router.post("/{submission_id}/cancel", status_code=202, response_model=CancellationAccepted)
async def cancel(
submission_id: UUID,
request: Request,
principal: Annotated[Principal, Depends(unsafe_principal)],
):
try:
return await queries(request).cancel(principal, submission_id)
except SubmissionUnavailable:
raise HTTPException(404, "submission unavailable") from None
except CancelUnavailable:
raise HTTPException(503, "cancellation service unavailable") from None
+56 -1
View File
@@ -3,7 +3,16 @@ from typing import Annotated, Literal
from pydantic import Field, model_validator
from kf_oj.contracts.judging import MAX_SOURCE_BYTES, Identifier, Language, WireModel
from kf_oj.contracts.judging import (
MAX_SOURCE_BYTES,
CompilationStatus,
FailureCode,
Identifier,
Language,
Measurement,
Verdict,
WireModel,
)
EvaluationState = Literal[
"queued", "claimed", "compiling", "running", "cancelling", "finished", "cancelled", "failed"
@@ -35,3 +44,49 @@ class SubmissionCreated(WireModel):
language: Language
state: EvaluationState
created_at: datetime
class SubmissionSummary(SubmissionCreated):
updated_at: datetime
attempts: int
verdict: Verdict | None
failure_code: FailureCode | None
class PublicCaseResult(WireModel):
position: int
verdict: Verdict
cpu_time_us: Measurement | None
memory_peak_bytes: Measurement | None
class PublicJudgement(WireModel):
compilation_status: CompilationStatus
diagnostics: Annotated[str, Field(max_length=65536)]
diagnostics_truncated: bool
cpu_time_us: Measurement | None
memory_peak_bytes: Measurement | None
cleanup_complete: bool
cases: tuple[PublicCaseResult, ...]
class SubmissionDetail(SubmissionSummary):
result: PublicJudgement | None
class SubmissionPage(WireModel):
items: tuple[SubmissionSummary, ...]
next_after: str | None
class SubmissionSource(WireModel):
submission_id: Identifier
language: Language
source: Annotated[str, Field(max_length=MAX_SOURCE_BYTES, repr=False)]
class CancellationAccepted(WireModel):
submission_id: Identifier
evaluation_id: Identifier
# Acceptance means Coordinator durably accepted intent, not that processes have stopped.
accepted: Literal[True] = True
+34 -1
View File
@@ -14,17 +14,50 @@ class Settings(BaseSettings):
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO"
database_url: SecretStr | None = None
amqp_url: SecretStr | None = None
browser_origins: tuple[str, ...] = ("http://localhost:5173", "http://localhost:8000")
browser_origins: tuple[str, ...] = (
"http://localhost:5173",
"http://localhost:8000",
"http://127.0.0.1:5173",
"http://127.0.0.1:8000",
)
session_lifetime_seconds: int = Field(default=43200, ge=300, le=86400)
artifact_root: Path | None = None
artifact_max_bytes: int = Field(default=536870912, ge=1, le=16 * 1024**3)
artifact_max_files: int = Field(default=4096, ge=1, le=65536)
judge_admission: AdmissionPolicy | None = None
coordinator_url: str | None = None
coordinator_token: SecretStr | None = None
@model_validator(mode="after")
def secure_origins(self):
from ipaddress import ip_address
from urllib.parse import urlsplit
if (self.coordinator_url is None) != (self.coordinator_token is None):
raise ValueError("both coordinator URL and token required")
if self.coordinator_url is not None:
parts = urlsplit(self.coordinator_url)
try:
valid = parts.hostname is not None and ip_address(parts.hostname).is_loopback
port = parts.port
except ValueError:
valid, port = False, None
if (
not valid
or parts.scheme != "http"
or port is None
or parts.path not in {"", "/"}
or parts.username
or parts.password
or parts.query
or parts.fragment
):
raise ValueError("coordinator must be an explicit numeric loopback HTTP origin")
import re
if not re.fullmatch(r"[0-9a-f]{64}", self.coordinator_token.get_secret_value()):
raise ValueError("invalid coordinator service credential")
if not self.browser_origins or len(self.browser_origins) > 8:
raise ValueError("one to eight browser origins required")
for origin in self.browser_origins:
+30
View File
@@ -0,0 +1,30 @@
"""Bounded loopback control adapter; never owns evaluation database writes."""
from uuid import UUID
import httpx
class CoordinatorControl:
def __init__(self, url: str, token: str):
self.url, self.token = url.rstrip("/"), token
async def cancel(self, evaluation_id: UUID) -> bool:
try:
async with httpx.AsyncClient(
timeout=3, trust_env=False, follow_redirects=False
) as client:
# Response has no business payload. Do not buffer or log arbitrary remote bodies.
async with client.stream(
"POST",
self.url + "/internal/cancel",
headers={"Authorization": "Bearer " + self.token},
json={
"schema_version": 1,
"kind": "judge.evaluation",
"evaluation_id": str(evaluation_id),
},
) as response:
return response.status_code == 204
except httpx.HTTPError:
return False
@@ -1,3 +1,15 @@
from typing import Protocol
from uuid import UUID
class CancelUnavailable(ValueError):
pass
class CancellationPort(Protocol):
async def cancel(self, evaluation_id: UUID) -> bool: ...
class SubmissionUnavailable(ValueError):
pass
@@ -1,20 +1,148 @@
import base64
from datetime import datetime
from uuid import UUID
from sqlalchemy import select
from sqlalchemy import select, tuple_
from sqlalchemy.ext.asyncio import async_sessionmaker
from kf_oj.contracts.judging import MAX_SOURCE_BYTES, ArtifactReference
from kf_oj.contracts.judging import MAX_SOURCE_BYTES, ArtifactReference, JudgementReport
from kf_oj.contracts.submissions import (
CancellationAccepted,
PublicCaseResult,
PublicJudgement,
SubmissionDetail,
SubmissionPage,
SubmissionSummary,
)
from kf_oj.infra.artifacts.models import Artifact
from kf_oj.infra.artifacts.repository import ArtifactRepository
from kf_oj.infra.artifacts.store import ArtifactError
from kf_oj.modules.identity.public import Principal
from kf_oj.modules.submissions.models import Submission
from kf_oj.modules.submissions.public import SubmissionUnavailable
from kf_oj.modules.submissions.models import Evaluation, Submission
from kf_oj.modules.submissions.public import (
CancellationPort,
CancelUnavailable,
SubmissionUnavailable,
)
def cursor_encode(submission: Submission) -> str:
return (
base64.urlsafe_b64encode(
f"{submission.created_at.isoformat()}|{submission.id}".encode("ascii")
)
.decode("ascii")
.rstrip("=")
)
def cursor_decode(value: str) -> tuple[datetime, UUID]:
try:
if not 1 <= len(value) <= 128:
raise ValueError
date, identifier = (
base64.b64decode(value + "=" * (-len(value) % 4), altchars=b"-_", validate=True)
.decode("ascii")
.split("|")
)
timestamp = datetime.fromisoformat(date)
if timestamp.utcoffset() is None:
raise ValueError
return timestamp, UUID(identifier)
except (ValueError, UnicodeError):
raise ValueError("invalid history cursor") from None
def summary(submission: Submission, evaluation: Evaluation) -> SubmissionSummary:
terminal = evaluation.state in {"finished", "failed", "cancelled"}
return SubmissionSummary(
submission_id=submission.id,
evaluation_id=evaluation.id,
problem_version_id=submission.problem_version_id,
language=submission.language,
state=evaluation.state,
created_at=submission.created_at,
updated_at=evaluation.updated_at,
attempts=evaluation.attempts_count,
verdict=evaluation.verdict if terminal else None,
failure_code=evaluation.failure_code,
)
class SubmissionQueries:
def __init__(self, sessions: async_sessionmaker, artifacts: ArtifactRepository):
def __init__(
self,
sessions: async_sessionmaker,
artifacts: ArtifactRepository,
cancellation: CancellationPort | None = None,
):
self.sessions, self.artifacts = sessions, artifacts
self.cancellation = cancellation
def owned(self, principal: Principal, submission_id: UUID):
return (
select(Submission, Evaluation)
.join(Evaluation, Evaluation.submission_id == Submission.id)
.where(Submission.id == submission_id, Submission.account_id == principal.account_id)
)
async def page(self, principal: Principal, after: str | None, limit: int) -> SubmissionPage:
if not 1 <= limit <= 100:
raise ValueError("invalid history limit")
query = (
select(Submission, Evaluation)
.join(Evaluation, Evaluation.submission_id == Submission.id)
.where(Submission.account_id == principal.account_id)
.order_by(Submission.created_at.desc(), Submission.id.desc())
.limit(limit + 1)
)
if after is not None:
query = query.where(tuple_(Submission.created_at, Submission.id) < cursor_decode(after))
async with self.sessions() as session:
rows = (await session.execute(query)).all()
return SubmissionPage(
items=tuple(summary(s, e) for s, e in rows[:limit]),
next_after=cursor_encode(rows[limit - 1][0]) if len(rows) > limit else None,
)
async def detail(self, principal: Principal, submission_id: UUID) -> SubmissionDetail:
async with self.sessions() as session:
row = (await session.execute(self.owned(principal, submission_id))).one_or_none()
if row is None:
raise SubmissionUnavailable("submission unavailable")
submission, evaluation = row
result = None
if evaluation.state in {"finished", "failed", "cancelled"} and evaluation.final_report:
# A single joined snapshot, strict validation and explicit projection prevent leaking
# internal bindings, artifacts, hidden case identifiers or partial attempt reports.
report = JudgementReport.model_validate(evaluation.final_report)
if str(report.binding.evaluation_id) != str(evaluation.id):
raise ValueError("stored result binding invalid")
if report.verdict.value != evaluation.verdict:
raise ValueError("stored result verdict invalid")
result = PublicJudgement(
compilation_status=report.compilation.status,
diagnostics=report.compilation.diagnostics,
diagnostics_truncated=report.compilation.diagnostics_truncated,
cpu_time_us=report.run_cpu_time_us,
memory_peak_bytes=report.run_memory_peak_bytes,
cleanup_complete=report.cleanup_complete,
cases=tuple(
PublicCaseResult(position=i, **c.model_dump(exclude={"case_id"}))
for i, c in enumerate(report.cases)
),
)
return SubmissionDetail(**summary(submission, evaluation).model_dump(), result=result)
async def cancel(self, principal: Principal, submission_id: UUID) -> CancellationAccepted:
async with self.sessions() as session:
row = (await session.execute(self.owned(principal, submission_id))).one_or_none()
if row is None:
raise SubmissionUnavailable("submission unavailable")
submission, evaluation = row
if self.cancellation is None or not await self.cancellation.cancel(evaluation.id):
raise CancelUnavailable("cancellation service unavailable")
return CancellationAccepted(submission_id=submission.id, evaluation_id=evaluation.id)
async def owned_source(self, principal: Principal, submission_id: UUID) -> bytes:
async with self.sessions() as session:
+47
View File
@@ -0,0 +1,47 @@
import asyncio
import json
import secrets
from http.server import BaseHTTPRequestHandler, HTTPServer
from threading import Thread
from uuid import uuid4
from kf_oj.infra.coordinator import CoordinatorControl
def test_real_loopback_cancellation_notification_and_failure():
token, evaluation = secrets.token_hex(32), uuid4()
class Handler(BaseHTTPRequestHandler):
code = 204
seen = []
def do_POST(self):
assert self.path == "/internal/cancel"
assert self.headers["Authorization"] == "Bearer " + token
body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
assert body == {
"schema_version": 1,
"kind": "judge.evaluation",
"evaluation_id": str(evaluation),
}
self.seen.append(body)
self.send_response(self.code)
self.end_headers()
def log_message(self, *_):
pass
server = HTTPServer(("127.0.0.1", 0), Handler)
thread = Thread(target=server.serve_forever, daemon=True)
thread.start()
control = CoordinatorControl(f"http://127.0.0.1:{server.server_port}/", token)
try:
assert asyncio.run(control.cancel(evaluation)) is True
Handler.code = 503
assert asyncio.run(control.cancel(evaluation)) is False
assert len(Handler.seen) == 2
finally:
server.shutdown()
server.server_close()
thread.join(timeout=2)
assert asyncio.run(control.cancel(evaluation)) is False
+152
View File
@@ -0,0 +1,152 @@
"""Real PostgreSQL owner projection/pagination acceptance; no program execution."""
import asyncio
import json
import secrets
from pathlib import Path
from uuid import uuid4
import httpx
import pytest
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
from test_submission_create import setup_app
from kf_oj.contracts.submissions import CreateSubmission
from kf_oj.core.settings import Settings
from kf_oj.modules.submissions.queries import cursor_decode
def test_cursor_and_control_configuration_fail_closed():
for value in ["garbage", "_" * 129, "", "eHx4"]:
with pytest.raises(ValueError):
cursor_decode(value)
for url in ["http://localhost:8100", "https://127.0.0.1:8100", "http://127.0.0.1:8100/path"]:
with pytest.raises(ValueError):
Settings(_env_file=None, coordinator_url=url, coordinator_token="a" * 64)
Settings(_env_file=None, coordinator_url="http://127.0.0.1:8100", coordinator_token="a" * 64)
def test_actual_owned_history_source_result_and_cancel(postgres_url, tmp_path, monkeypatch):
async def run():
app, engine, mod = await setup_app(postgres_url, tmp_path)
monkeypatch.setattr(mod, "build_engine", lambda _: engine)
async with app.router.lifespan_context(app):
passwords = [secrets.token_urlsafe(24), secrets.token_urlsafe(24)]
owners = [
await app.state.identity.create_account(name, password)
for name, password in zip(["reader_one", "reader_two"], passwords, strict=True)
]
version = await app.state.problems.import_directory(tmp_path / "import")
records = []
for i in range(4):
records.append(
await app.state.submissions.create(
owners[0],
CreateSubmission(
problem_version_id=version,
language="c17",
idempotency_key=uuid4(),
source="synthetic private source " + str(i),
),
)
)
# Synthetic contract projection only, never claimed as a real judgement.
fixture = Path(__file__).parents[2] / "tests/fixtures/contracts/judge-result.valid.json"
report = json.loads(fixture.read_text())
report["binding"]["evaluation_id"] = str(records[0].evaluation_id)
admin = create_async_engine(postgres_url)
async with admin.begin() as conn:
await conn.execute(text("SET LOCAL ROLE kf_oj_coordinator"))
await conn.execute(
text(
"UPDATE evaluations SET state='finished',verdict='accepted',"
"final_report=CAST(:report AS jsonb) WHERE id=:id"
),
{"id": records[0].evaluation_id, "report": json.dumps(report)},
)
await admin.dispose()
class CancelProbe:
accepted = True
seen = []
async def cancel(self, evaluation_id):
self.seen.append(evaluation_id)
return self.accepted
probe = CancelProbe()
app.state.submission_queries.cancellation = probe
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://localhost:8000"
) as client:
first = await client.post(
"/api/v1/auth/login",
json={"username": "reader_one", "password": passwords[0]},
headers={"Origin": "http://localhost:8000"},
)
assert first.status_code == 200
csrf = {
"Origin": "http://localhost:8000",
"X-CSRF-Token": first.json()["csrf_token"],
}
page = await client.get("/api/v1/submissions?limit=2")
assert page.status_code == 200 and page.headers["Cache-Control"] == "no-store"
after = page.json()["next_after"]
# Concurrent insertion must not shift the cursor into repeats or omit old rows.
await app.state.submissions.create(
owners[0],
CreateSubmission(
problem_version_id=version,
language="cpp20",
idempotency_key=uuid4(),
source="new synthetic private source",
),
)
tail = await client.get("/api/v1/submissions", params={"limit": 2, "after": after})
seen = [i["submission_id"] for i in page.json()["items"] + tail.json()["items"]]
assert len(set(seen)) == 4 and set(seen) == {str(r.submission_id) for r in records}
assert tail.json()["next_after"] is None
base = f"/api/v1/submissions/{records[0].submission_id}"
public = await client.get(base)
assert public.status_code == 200 and public.json()["verdict"] == "accepted"
projection = public.json()["result"]
assert projection["cpu_time_us"] == report["run_cpu_time_us"]
assert projection["memory_peak_bytes"] == report["run_memory_peak_bytes"]
assert all(
key not in public.text
for key in ["case_id", "digest", "lease", "expected", "manifest", "program"]
)
source = await client.get(base + "/source")
assert source.json()["source"] == "synthetic private source 0"
waiting = await client.get(f"/api/v1/submissions/{records[1].submission_id}")
assert waiting.json()["result"] is None and waiting.json()["verdict"] is None
cancel = f"/api/v1/submissions/{records[1].submission_id}/cancel"
assert (await client.post(cancel)).status_code == 403
assert (await client.post(cancel, headers=csrf)).status_code == 202
assert probe.seen == [records[1].evaluation_id]
assert (await client.get(cancel.removesuffix("/cancel"))).json()[
"state"
] == "queued"
probe.accepted = False
assert (await client.post(cancel, headers=csrf)).status_code == 503
assert (await client.get("/api/v1/submissions?after=invalid")).status_code == 422
second = await client.post(
"/api/v1/auth/login",
json={"username": "reader_two", "password": passwords[1]},
headers={"Origin": "http://localhost:8000"},
)
csrf["X-CSRF-Token"] = second.json()["csrf_token"]
assert (await client.get("/api/v1/submissions")).json()["items"] == []
for suffix in ["", "/source"]:
response = await client.get(base + suffix)
assert (
response.status_code == 404
and response.headers["Cache-Control"] == "no-store"
)
assert (await client.post(cancel, headers=csrf)).status_code == 404
assert len(probe.seen) == 2
assert (await client.post("/api/v1/auth/logout", headers=csrf)).status_code == 204
assert (await client.get(base)).status_code == 401
asyncio.run(run())
+2 -2
View File
@@ -311,6 +311,7 @@ dependencies = [
{ name = "argon2-cffi" },
{ name = "asyncpg" },
{ name = "fastapi" },
{ name = "httpx" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "sqlalchemy", extra = ["asyncio"] },
@@ -319,7 +320,6 @@ dependencies = [
[package.dev-dependencies]
dev = [
{ name = "httpx" },
{ name = "jsonschema" },
{ name = "pytest" },
{ name = "ruff" },
@@ -332,6 +332,7 @@ requires-dist = [
{ name = "argon2-cffi", specifier = ">=25.1,<26" },
{ name = "asyncpg", specifier = ">=0.30,<1" },
{ name = "fastapi", specifier = ">=0.115,<1" },
{ name = "httpx", specifier = ">=0.28,<1" },
{ name = "pydantic", specifier = ">=2.10,<3" },
{ name = "pydantic-settings", specifier = ">=2.7,<3" },
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0,<3" },
@@ -340,7 +341,6 @@ requires-dist = [
[package.metadata.requires-dev]
dev = [
{ name = "httpx", specifier = ">=0.28,<1" },
{ name = "jsonschema", specifier = ">=4.23,<5" },
{ name = "pytest", specifier = ">=8.3,<10" },
{ name = "ruff", specifier = ">=0.11,<1" },
+599
View File
@@ -25,6 +25,44 @@
"title": "ArtifactReference",
"type": "object"
},
"CancellationAccepted": {
"additionalProperties": false,
"properties": {
"accepted": {
"const": true,
"default": true,
"title": "Accepted",
"type": "boolean"
},
"evaluation_id": {
"format": "uuid",
"title": "Evaluation Id",
"type": "string"
},
"submission_id": {
"format": "uuid",
"title": "Submission Id",
"type": "string"
}
},
"required": [
"submission_id",
"evaluation_id"
],
"title": "CancellationAccepted",
"type": "object"
},
"CompilationStatus": {
"enum": [
"succeeded",
"compilation_error",
"compilation_limit",
"cancelled",
"system_error"
],
"title": "CompilationStatus",
"type": "string"
},
"CreateSubmission": {
"additionalProperties": false,
"properties": {
@@ -77,6 +115,17 @@
"title": "CurrentUser",
"type": "object"
},
"FailureCode": {
"enum": [
"artifact_unavailable",
"sandbox_unavailable",
"cleanup_failed",
"lease_lost",
"job_deadline"
],
"title": "FailureCode",
"type": "string"
},
"HTTPValidationError": {
"properties": {
"detail": {
@@ -394,6 +443,117 @@
"title": "ProblemSummary",
"type": "object"
},
"PublicCaseResult": {
"additionalProperties": false,
"properties": {
"cpu_time_us": {
"anyOf": [
{
"maximum": 9.223372036854776e+18,
"minimum": 0.0,
"type": "integer"
},
{
"type": "null"
}
],
"title": "Cpu Time Us"
},
"memory_peak_bytes": {
"anyOf": [
{
"maximum": 9.223372036854776e+18,
"minimum": 0.0,
"type": "integer"
},
{
"type": "null"
}
],
"title": "Memory Peak Bytes"
},
"position": {
"title": "Position",
"type": "integer"
},
"verdict": {
"$ref": "#/components/schemas/Verdict"
}
},
"required": [
"position",
"verdict",
"cpu_time_us",
"memory_peak_bytes"
],
"title": "PublicCaseResult",
"type": "object"
},
"PublicJudgement": {
"additionalProperties": false,
"properties": {
"cases": {
"items": {
"$ref": "#/components/schemas/PublicCaseResult"
},
"title": "Cases",
"type": "array"
},
"cleanup_complete": {
"title": "Cleanup Complete",
"type": "boolean"
},
"compilation_status": {
"$ref": "#/components/schemas/CompilationStatus"
},
"cpu_time_us": {
"anyOf": [
{
"maximum": 9.223372036854776e+18,
"minimum": 0.0,
"type": "integer"
},
{
"type": "null"
}
],
"title": "Cpu Time Us"
},
"diagnostics": {
"maxLength": 65536,
"title": "Diagnostics",
"type": "string"
},
"diagnostics_truncated": {
"title": "Diagnostics Truncated",
"type": "boolean"
},
"memory_peak_bytes": {
"anyOf": [
{
"maximum": 9.223372036854776e+18,
"minimum": 0.0,
"type": "integer"
},
{
"type": "null"
}
],
"title": "Memory Peak Bytes"
}
},
"required": [
"compilation_status",
"diagnostics",
"diagnostics_truncated",
"cpu_time_us",
"memory_peak_bytes",
"cleanup_complete",
"cases"
],
"title": "PublicJudgement",
"type": "object"
},
"PublicSample": {
"additionalProperties": false,
"properties": {
@@ -552,6 +712,241 @@
"title": "SubmissionCreated",
"type": "object"
},
"SubmissionDetail": {
"additionalProperties": false,
"properties": {
"attempts": {
"title": "Attempts",
"type": "integer"
},
"created_at": {
"format": "date-time",
"title": "Created At",
"type": "string"
},
"evaluation_id": {
"format": "uuid",
"title": "Evaluation Id",
"type": "string"
},
"failure_code": {
"anyOf": [
{
"$ref": "#/components/schemas/FailureCode"
},
{
"type": "null"
}
]
},
"language": {
"$ref": "#/components/schemas/Language"
},
"problem_version_id": {
"format": "uuid",
"title": "Problem Version Id",
"type": "string"
},
"result": {
"anyOf": [
{
"$ref": "#/components/schemas/PublicJudgement"
},
{
"type": "null"
}
]
},
"state": {
"enum": [
"queued",
"claimed",
"compiling",
"running",
"cancelling",
"finished",
"cancelled",
"failed"
],
"title": "State",
"type": "string"
},
"submission_id": {
"format": "uuid",
"title": "Submission Id",
"type": "string"
},
"updated_at": {
"format": "date-time",
"title": "Updated At",
"type": "string"
},
"verdict": {
"anyOf": [
{
"$ref": "#/components/schemas/Verdict"
},
{
"type": "null"
}
]
}
},
"required": [
"submission_id",
"evaluation_id",
"problem_version_id",
"language",
"state",
"created_at",
"updated_at",
"attempts",
"verdict",
"failure_code",
"result"
],
"title": "SubmissionDetail",
"type": "object"
},
"SubmissionPage": {
"additionalProperties": false,
"properties": {
"items": {
"items": {
"$ref": "#/components/schemas/SubmissionSummary"
},
"title": "Items",
"type": "array"
},
"next_after": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Next After"
}
},
"required": [
"items",
"next_after"
],
"title": "SubmissionPage",
"type": "object"
},
"SubmissionSource": {
"additionalProperties": false,
"properties": {
"language": {
"$ref": "#/components/schemas/Language"
},
"source": {
"maxLength": 262144,
"title": "Source",
"type": "string"
},
"submission_id": {
"format": "uuid",
"title": "Submission Id",
"type": "string"
}
},
"required": [
"submission_id",
"language",
"source"
],
"title": "SubmissionSource",
"type": "object"
},
"SubmissionSummary": {
"additionalProperties": false,
"properties": {
"attempts": {
"title": "Attempts",
"type": "integer"
},
"created_at": {
"format": "date-time",
"title": "Created At",
"type": "string"
},
"evaluation_id": {
"format": "uuid",
"title": "Evaluation Id",
"type": "string"
},
"failure_code": {
"anyOf": [
{
"$ref": "#/components/schemas/FailureCode"
},
{
"type": "null"
}
]
},
"language": {
"$ref": "#/components/schemas/Language"
},
"problem_version_id": {
"format": "uuid",
"title": "Problem Version Id",
"type": "string"
},
"state": {
"enum": [
"queued",
"claimed",
"compiling",
"running",
"cancelling",
"finished",
"cancelled",
"failed"
],
"title": "State",
"type": "string"
},
"submission_id": {
"format": "uuid",
"title": "Submission Id",
"type": "string"
},
"updated_at": {
"format": "date-time",
"title": "Updated At",
"type": "string"
},
"verdict": {
"anyOf": [
{
"$ref": "#/components/schemas/Verdict"
},
{
"type": "null"
}
]
}
},
"required": [
"submission_id",
"evaluation_id",
"problem_version_id",
"language",
"state",
"created_at",
"updated_at",
"attempts",
"verdict",
"failure_code"
],
"title": "SubmissionSummary",
"type": "object"
},
"TestCase": {
"additionalProperties": false,
"properties": {
@@ -642,6 +1037,21 @@
],
"title": "ValidationError",
"type": "object"
},
"Verdict": {
"enum": [
"accepted",
"wrong_answer",
"compilation_error",
"runtime_error",
"time_limit_exceeded",
"memory_limit_exceeded",
"output_limit_exceeded",
"cancelled",
"system_error"
],
"title": "Verdict",
"type": "string"
}
},
"securitySchemes": {
@@ -1028,6 +1438,66 @@
}
},
"/api/v1/submissions": {
"get": {
"operationId": "history_api_v1_submissions_get",
"parameters": [
{
"in": "query",
"name": "after",
"required": false,
"schema": {
"anyOf": [
{
"maxLength": 128,
"type": "string"
},
{
"type": "null"
}
],
"title": "After"
}
},
{
"in": "query",
"name": "limit",
"required": false,
"schema": {
"default": 20,
"maximum": 100,
"minimum": 1,
"title": "Limit",
"type": "integer"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SubmissionPage"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "History",
"tags": [
"submissions"
]
},
"post": {
"operationId": "create_api_v1_submissions_post",
"requestBody": {
@@ -1067,6 +1537,135 @@
"submissions"
]
}
},
"/api/v1/submissions/{submission_id}": {
"get": {
"operationId": "detail_api_v1_submissions__submission_id__get",
"parameters": [
{
"in": "path",
"name": "submission_id",
"required": true,
"schema": {
"format": "uuid",
"title": "Submission Id",
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SubmissionDetail"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Detail",
"tags": [
"submissions"
]
}
},
"/api/v1/submissions/{submission_id}/cancel": {
"post": {
"operationId": "cancel_api_v1_submissions__submission_id__cancel_post",
"parameters": [
{
"in": "path",
"name": "submission_id",
"required": true,
"schema": {
"format": "uuid",
"title": "Submission Id",
"type": "string"
}
}
],
"responses": {
"202": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CancellationAccepted"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Cancel",
"tags": [
"submissions"
]
}
},
"/api/v1/submissions/{submission_id}/source": {
"get": {
"operationId": "source_api_v1_submissions__submission_id__source_get",
"parameters": [
{
"in": "path",
"name": "submission_id",
"required": true,
"schema": {
"format": "uuid",
"title": "Submission Id",
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SubmissionSource"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Source",
"tags": [
"submissions"
]
}
}
}
}
+7
View File
@@ -1,6 +1,13 @@
import createClient from 'openapi-fetch'
import type { paths } from './generated/schema'
import { boundedFetch } from './transport'
let expired: () => void = () => {}
export function onUnauthorized(handler: () => void) {
expired = handler
}
export const api = createClient<paths>({
baseUrl: import.meta.env.VITE_API_BASE_URL || '',
fetch: boundedFetch(globalThis.fetch.bind(globalThis), () => expired()),
})
+333 -1
View File
@@ -150,7 +150,8 @@ export interface paths {
path?: never;
cookie?: never;
};
get?: never;
/** History */
get: operations["history_api_v1_submissions_get"];
put?: never;
/** Create */
post: operations["create_api_v1_submissions_post"];
@@ -160,6 +161,57 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/submissions/{submission_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Detail */
get: operations["detail_api_v1_submissions__submission_id__get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/submissions/{submission_id}/cancel": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Cancel */
post: operations["cancel_api_v1_submissions__submission_id__cancel_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/submissions/{submission_id}/source": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Source */
get: operations["source_api_v1_submissions__submission_id__source_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
@@ -171,6 +223,30 @@ export interface components {
/** Digest */
digest: string;
};
/** CancellationAccepted */
CancellationAccepted: {
/**
* Accepted
* @default true
* @constant
*/
accepted: true;
/**
* Evaluation Id
* Format: uuid
*/
evaluation_id: string;
/**
* Submission Id
* Format: uuid
*/
submission_id: string;
};
/**
* CompilationStatus
* @enum {string}
*/
CompilationStatus: "succeeded" | "compilation_error" | "compilation_limit" | "cancelled" | "system_error";
/** CreateSubmission */
CreateSubmission: {
/**
@@ -197,6 +273,11 @@ export interface components {
/** Username */
username: string;
};
/**
* FailureCode
* @enum {string}
*/
FailureCode: "artifact_unavailable" | "sandbox_unavailable" | "cleanup_failed" | "lease_lost" | "job_deadline";
/** HTTPValidationError */
HTTPValidationError: {
/** Detail */
@@ -339,6 +420,32 @@ export interface components {
*/
version_id: string;
};
/** PublicCaseResult */
PublicCaseResult: {
/** Cpu Time Us */
cpu_time_us: number | null;
/** Memory Peak Bytes */
memory_peak_bytes: number | null;
/** Position */
position: number;
verdict: components["schemas"]["Verdict"];
};
/** PublicJudgement */
PublicJudgement: {
/** Cases */
cases: components["schemas"]["PublicCaseResult"][];
/** Cleanup Complete */
cleanup_complete: boolean;
compilation_status: components["schemas"]["CompilationStatus"];
/** Cpu Time Us */
cpu_time_us: number | null;
/** Diagnostics */
diagnostics: string;
/** Diagnostics Truncated */
diagnostics_truncated: boolean;
/** Memory Peak Bytes */
memory_peak_bytes: number | null;
};
/** PublicSample */
PublicSample: {
/**
@@ -404,6 +511,101 @@ export interface components {
*/
submission_id: string;
};
/** SubmissionDetail */
SubmissionDetail: {
/** Attempts */
attempts: number;
/**
* Created At
* Format: date-time
*/
created_at: string;
/**
* Evaluation Id
* Format: uuid
*/
evaluation_id: string;
failure_code: components["schemas"]["FailureCode"] | null;
language: components["schemas"]["Language"];
/**
* Problem Version Id
* Format: uuid
*/
problem_version_id: string;
result: components["schemas"]["PublicJudgement"] | null;
/**
* State
* @enum {string}
*/
state: "queued" | "claimed" | "compiling" | "running" | "cancelling" | "finished" | "cancelled" | "failed";
/**
* Submission Id
* Format: uuid
*/
submission_id: string;
/**
* Updated At
* Format: date-time
*/
updated_at: string;
verdict: components["schemas"]["Verdict"] | null;
};
/** SubmissionPage */
SubmissionPage: {
/** Items */
items: components["schemas"]["SubmissionSummary"][];
/** Next After */
next_after: string | null;
};
/** SubmissionSource */
SubmissionSource: {
language: components["schemas"]["Language"];
/** Source */
source: string;
/**
* Submission Id
* Format: uuid
*/
submission_id: string;
};
/** SubmissionSummary */
SubmissionSummary: {
/** Attempts */
attempts: number;
/**
* Created At
* Format: date-time
*/
created_at: string;
/**
* Evaluation Id
* Format: uuid
*/
evaluation_id: string;
failure_code: components["schemas"]["FailureCode"] | null;
language: components["schemas"]["Language"];
/**
* Problem Version Id
* Format: uuid
*/
problem_version_id: string;
/**
* State
* @enum {string}
*/
state: "queued" | "claimed" | "compiling" | "running" | "cancelling" | "finished" | "cancelled" | "failed";
/**
* Submission Id
* Format: uuid
*/
submission_id: string;
/**
* Updated At
* Format: date-time
*/
updated_at: string;
verdict: components["schemas"]["Verdict"] | null;
};
/** TestCase */
TestCase: {
/**
@@ -439,6 +641,11 @@ export interface components {
/** Error Type */
type: string;
};
/**
* Verdict
* @enum {string}
*/
Verdict: "accepted" | "wrong_answer" | "compilation_error" | "runtime_error" | "time_limit_exceeded" | "memory_limit_exceeded" | "output_limit_exceeded" | "cancelled" | "system_error";
};
responses: never;
parameters: never;
@@ -673,6 +880,38 @@ export interface operations {
};
};
};
history_api_v1_submissions_get: {
parameters: {
query?: {
after?: string | null;
limit?: number;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SubmissionPage"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
create_api_v1_submissions_post: {
parameters: {
query?: never;
@@ -706,4 +945,97 @@ export interface operations {
};
};
};
detail_api_v1_submissions__submission_id__get: {
parameters: {
query?: never;
header?: never;
path: {
submission_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SubmissionDetail"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
cancel_api_v1_submissions__submission_id__cancel_post: {
parameters: {
query?: never;
header?: never;
path: {
submission_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
202: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["CancellationAccepted"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
source_api_v1_submissions__submission_id__source_get: {
parameters: {
query?: never;
header?: never;
path: {
submission_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SubmissionSource"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
}
+71
View File
@@ -0,0 +1,71 @@
export function boundedPoll<T>(options: {
load: (signal: AbortSignal) => Promise<T>
terminal: (value: T) => boolean
result: (value: T) => void
error: (error: unknown) => void
exhausted: () => void
interval?: number
maxRequests?: number
maxTime?: number
}) {
let timer: ReturnType<typeof setTimeout> | undefined
let request: AbortController | undefined
let generation = 0
let disposed = false
let visible = true
let active = false
let count = 0
let deadline = 0
function stop() {
generation++
clearTimeout(timer)
request?.abort()
request = undefined
}
async function tick() {
if (!active || !visible || disposed) return
if (count >= (options.maxRequests ?? 240) || Date.now() >= deadline) {
active = false
options.exhausted()
return
}
const current = generation
const controller = new AbortController()
request = controller
count++
try {
const value = await options.load(controller.signal)
if (current !== generation || disposed) return
options.result(value)
if (options.terminal(value)) active = false
if (active) timer = setTimeout(tick, options.interval ?? 2000)
} catch (error) {
if (current !== generation || disposed) return
active = false
options.error(error)
} finally {
if (request === controller) request = undefined
}
}
return {
start() {
if (disposed) return
stop()
active = true
count = 0
deadline = Date.now() + (options.maxTime ?? 600000)
void tick()
},
visible(value: boolean) {
if (disposed || visible === value) return
visible = value
stop()
if (visible) void tick()
},
dispose() {
disposed = true
active = false
stop()
},
}
}
+59
View File
@@ -0,0 +1,59 @@
// No source is stored here. Fingerprints bind an immutable request and owner to a UUID.
export type SubmissionInput = {
problem_version_id: string
language: 'c17' | 'cpp20'
source: string
}
type Intent = { fingerprint: string; key: string; submissionId?: string }
export function submissionIntent(storage: Pick<Storage, 'getItem' | 'setItem'>, owner: string) {
const prefix = 'kf-oj:submission-intent:' + owner + ':'
let pending: Promise<string> | undefined
return {
submit(input: SubmissionInput, send: (key: string) => Promise<string>): Promise<string> {
if (pending) return pending
const snapshot = { ...input }
pending = (async () => {
const bytes = new TextEncoder().encode(snapshot.source)
if (!bytes.length || bytes.length > 262144) throw new Error('源码必须为 1 至 262144 字节。')
const hash = await crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(JSON.stringify(snapshot)),
)
const fingerprint = Array.from(new Uint8Array(hash), (v) =>
v.toString(16).padStart(2, '0'),
).join('')
const location = prefix + snapshot.problem_version_id
let intent: Intent
try {
const raw: unknown = JSON.parse(storage.getItem(location) ?? 'null')
intent =
raw &&
typeof raw === 'object' &&
'fingerprint' in raw &&
raw.fingerprint === fingerprint &&
'key' in raw &&
typeof raw.key === 'string' &&
/^[0-9a-f-]{36}$/.test(raw.key)
? (raw as Intent)
: { fingerprint, key: crypto.randomUUID() }
storage.setItem(location, JSON.stringify(intent))
} catch {
throw new Error('浏览器无法保存提交凭据。请恢复本地存储后重试,草稿仍在编辑器中。')
}
if (intent.submissionId) return intent.submissionId
const id = await send(intent.key)
// The key was saved BEFORE the network call. If this write fails a retry still uses it.
try {
storage.setItem(location, JSON.stringify({ ...intent, submissionId: id }))
} catch {
/* stable key already persisted */
}
return id
})().finally(() => {
pending = undefined
})
return pending
},
}
}
+55
View File
@@ -0,0 +1,55 @@
export class ApiError extends Error {
status: number
constructor(status: number) {
const messages: Record<number, string> = {
401: '登录已过期,请重新登录。草稿已保留。',
403: '请求未通过会话验证,请刷新后重试。',
404: '记录不存在或无权访问。',
409: '提交凭据与内容不一致,请检查草稿后重试。',
422: '请求不符合题目或提交要求。',
429: '当前容量已满,请稍后重试。',
503: '服务暂时不可用,请稍后重试。',
}
super(messages[status] ?? `请求失败(HTTP ${status})`)
this.status = status
}
}
export function boundedFetch(fetcher: typeof fetch, unauthorized: () => void): typeof fetch {
return async (input, init) => {
const request = new Request(input, init)
try {
const response = await fetcher(
new Request(request, {
credentials: 'same-origin',
cache: 'no-store',
signal: AbortSignal.any([request.signal, AbortSignal.timeout(10000)]),
}),
)
if (response.status === 401) unauthorized()
return response
} catch (error) {
if (request.signal.aborted) throw error
throw new Error('网络中断或请求超时。草稿与提交凭据已保留,可手动重试。', { cause: error })
}
}
}
export function unwrap<T>(result: { data?: T; response: Response }): T {
if (!result.response.ok) throw new ApiError(result.response.status)
if (result.data === undefined) throw new Error('服务返回了不完整的数据,请重试。')
return result.data
}
export function csrfHeaders(): Record<string, string> {
const token = document.cookie
.split('; ')
.find((v) => v.startsWith('kf_csrf='))
?.slice(8)
if (!token || !/^[0-9a-f]{64}$/.test(token)) throw new ApiError(401)
return { 'X-CSRF-Token': token }
}
export function errorText(error: unknown): string {
return error instanceof Error ? error.message : '请求失败,请重试。'
}
+5 -2
View File
@@ -28,7 +28,10 @@ let subscription: monaco.IDisposable | undefined
).MonacoEnvironment = { getWorker: () => new EditorWorker() }
onMounted(() => {
if (!container.value) return
model = monaco.editor.createModel(props.modelValue, props.language)
model = monaco.editor.createModel(
props.modelValue,
props.language === 'c' ? 'cpp' : props.language,
)
editor = monaco.editor.create(container.value, {
model,
theme: props.theme,
@@ -57,7 +60,7 @@ watch(
watch(
() => props.language,
(value) => {
if (model) monaco.editor.setModelLanguage(model, value)
if (model) monaco.editor.setModelLanguage(model, value === 'c' ? 'cpp' : value)
},
)
watch(
@@ -0,0 +1,35 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { components } from '../api/generated/schema'
const props = defineProps<{
state: components['schemas']['SubmissionSummary']['state']
verdict: components['schemas']['SubmissionSummary']['verdict']
}>()
const grades: Record<string, string> = {
accepted: 'AC',
wrong_answer: 'WA',
compilation_error: 'CE',
runtime_error: 'RE',
time_limit_exceeded: 'TLE',
memory_limit_exceeded: 'MLE',
output_limit_exceeded: 'OLE',
cancelled: '已取消',
system_error: '系统错误',
}
const states: Record<string, string> = {
queued: '等待评测',
claimed: '已领取',
compiling: '正在编译',
running: '正在运行',
cancelling: '正在停止并清理',
cancelled: '已取消',
failed: '评测失败',
finished: '评测完成',
}
const label = computed(() =>
props.verdict ? (grades[props.verdict] ?? props.verdict) : states[props.state],
)
</script>
<template>
<span class="verdict" data-testid="judgement-status">{{ label }}</span>
</template>
+6 -4
View File
@@ -1,11 +1,13 @@
import { computed, onScopeDispose, ref, watch } from 'vue'
export type Language = 'cpp' | 'python' | 'rust'
export type Language = 'c' | 'cpp' | 'python' | 'rust'
export const languageLabels: Record<Language, string> = {
cpp: 'C++',
c: 'C17',
cpp: 'C++20',
python: 'Python',
rust: 'Rust',
}
export const codeTemplates: Record<Language, string> = {
c: '#include <stdio.h>\n\nint main(void) {\n return 0;\n}\n',
cpp: '#include <iostream>\nusing namespace std;\n\nint main() {\n // 在这里编写你的解法\n return 0;\n}\n',
python: '# 在这里编写你的解法\n\n',
rust: 'use std::io::{self, Read};\n\nfn main() {\n let mut input = String::new();\n io::stdin().read_to_string(&mut input).unwrap();\n // 在这里编写你的解法\n}\n',
@@ -30,11 +32,11 @@ export function useCodeDraft(problemId: string) {
typeof saved.drafts === 'object'
) {
const storedDrafts = saved.drafts as Record<string, unknown>
for (const lang of ['cpp', 'python', 'rust'] as const) {
for (const lang of ['c', 'cpp', 'python', 'rust'] as const) {
const value = storedDrafts[lang]
if (typeof value === 'string') drafts.value[lang] = value
}
if ('language' in saved && ['cpp', 'python', 'rust'].includes(String(saved.language)))
if ('language' in saved && ['c', 'cpp', 'python', 'rust'].includes(String(saved.language)))
language.value = saved.language as Language
state.value = 'saved'
}
+49
View File
@@ -0,0 +1,49 @@
import { readonly, ref } from 'vue'
import { api, onUnauthorized } from '../api/client'
import { ApiError, csrfHeaders, unwrap } from '../api/transport'
import type { components } from '../api/generated/schema'
const user = ref<components['schemas']['CurrentUser'] | null>(null)
let generation = 0
let pending: Promise<boolean> | undefined
onUnauthorized(() => {
generation++
user.value = null
})
async function refresh() {
if (pending) return pending
const current = generation
pending = (async () => {
try {
const data = unwrap(await api.GET('/api/v1/auth/me'))
if (current === generation) user.value = data
return current === generation && user.value !== null
} catch (error) {
if (error instanceof ApiError && error.status === 401) return false
throw error
} finally {
pending = undefined
}
})()
return pending
}
async function login(username: string, password: string) {
const current = ++generation
user.value = null
const data = unwrap(await api.POST('/api/v1/auth/login', { body: { username, password } }))
if (current === generation) user.value = data.user
}
async function logout() {
const response = await api.POST('/api/v1/auth/logout', { headers: csrfHeaders() })
if (!response.response.ok && response.response.status !== 401)
throw new ApiError(response.response.status)
generation++
user.value = null
}
export function useSession() {
return { user: readonly(user), refresh, login, logout }
}
+14 -5
View File
@@ -1,7 +1,12 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import UiIcon from '../components/UiIcon.vue'
import { useSession } from '../composables/useSession'
const { user, refresh } = useSession()
onMounted(() => {
void refresh().catch(() => {})
})
const route = useRoute()
const mobileOpen = ref(false)
const links = [
@@ -51,11 +56,15 @@ const activePath = computed(() => '/' + route.path.split('/')[1])
>
</nav>
<div class="header-account">
<el-tooltip content="页面使用示例内容,业务服务逐步接入" placement="bottom"
><span class="preview-label">界面预览</span></el-tooltip
><RouterLink to="/login"
<template v-if="user"
><span>{{ user.username }}</span
><RouterLink to="/logout"
><el-button size="small" round>退出登录</el-button></RouterLink
></template
>
<RouterLink v-else to="/login"
><el-button type="primary" size="small" round>登录</el-button></RouterLink
><RouterLink to="/register"><el-button size="small" round>注册</el-button></RouterLink>
>
</div>
</div>
</header>
+11
View File
@@ -1,4 +1,5 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useSession } from '../composables/useSession'
export const router = createRouter({
history: createWebHistory(),
scrollBehavior: (to, _from, saved) => saved ?? (to.hash ? { el: to.hash } : { top: 0 }),
@@ -95,6 +96,16 @@ export const router = createRouter({
},
],
})
router.beforeEach(async (to) => {
if (to.path === '/submissions' || to.path.startsWith('/submissions/') || to.path === '/logout') {
try {
if (await useSession().refresh()) return true
} catch {
return { path: '/login', query: { next: to.fullPath, unavailable: '1' } }
}
return { path: '/login', query: { next: to.fullPath } }
}
})
router.afterEach((to) => {
document.title = `${String(to.meta.title ?? '首页')} · KF OnlineJudge`
})
+54 -56
View File
@@ -1,44 +1,50 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import { type FormInstance, type FormRules } from 'element-plus'
import { useSession } from '../composables/useSession'
import { ApiError, errorText } from '../api/transport'
const route = useRoute()
const router = useRouter()
const registering = computed(() => route.path === '/register')
const form = ref<FormInstance>()
const model = reactive({ username: '', email: '', password: '', confirmation: '' })
const rules = computed<FormRules<typeof model>>(() => ({
const model = reactive({ username: '', password: '' })
const busy = ref(false)
const error = ref(route.query.unavailable ? '身份服务暂时无法连接,请稍后重试。' : '')
const rules: FormRules<typeof model> = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
...(registering.value
? [
{
pattern: /^[a-zA-Z0-9_]{3,24}$/,
message: '使用 3 至 24 位字母、数字或下划线',
trigger: 'blur',
},
]
: []),
],
email: [
{ required: true, message: '请输入邮箱', trigger: 'blur' },
{ type: 'email', message: '请填写有效邮箱', trigger: 'blur' },
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
...(registering.value ? [{ min: 8, message: '密码至少包含 8 个字符', trigger: 'blur' }] : []),
],
confirmation: [
{ required: true, message: '请再次输入密码', trigger: 'blur' },
{
validator: (_rule, value, callback) =>
value === model.password ? callback() : callback(new Error('两次输入的密码不一致')),
required: true,
pattern: /^[a-z0-9_]{3,24}$/,
message: '使用 3 至 24 位小写字母、数字或下划线',
trigger: 'blur',
},
],
}))
password: [
{ required: true, min: 12, max: 128, message: '请输入 12 至 128 位密码', trigger: 'blur' },
],
}
async function submit() {
if (await form.value?.validate().catch(() => false))
ElMessage.info('账号服务尚未接入,暂时无法' + (registering.value ? '注册' : '登录'))
if (busy.value || registering.value) return
busy.value = true
try {
if (!(await form.value?.validate().catch(() => false))) return
error.value = ''
await useSession().login(model.username, model.password)
model.password = ''
const next =
typeof route.query.next === 'string' &&
route.query.next.startsWith('/') &&
!route.query.next.startsWith('//')
? route.query.next
: '/problems'
await router.replace(next)
} catch (cause) {
error.value =
cause instanceof ApiError && cause.status === 401 ? '用户名或密码不正确。' : errorText(cause)
} finally {
busy.value = false
}
}
</script>
<template>
@@ -54,41 +60,33 @@ async function submit() {
<p class="eyebrow">WELCOME TO KF ONLINEJUDGE</p>
<h2>{{ registering ? '创建你的账号' : '欢迎回来' }}</h2>
<p>{{ registering ? '准备好开始新的练习了吗?' : '继续你的算法练习之旅。' }}</p>
<p v-if="registering" class="notice-strip">
暂不开放自助注册。请使用管理员通过本地账号管理命令创建的账号。
</p>
<el-form
v-else
ref="form"
:model="model"
:rules="rules"
label-position="top"
@submit.prevent="submit"
><el-form-item label="用户名" prop="username"
><el-input
v-model="model.username"
placeholder="请输入用户名"
autocomplete="username" /></el-form-item
><el-form-item v-if="registering" label="邮箱" prop="email"
><el-input
v-model="model.email"
placeholder="your@email.com"
autocomplete="email" /></el-form-item
><el-form-item label="密码" prop="password"
>
<el-form-item label="用户名" prop="username"
><el-input v-model="model.username" autocomplete="username" aria-label="用户名"
/></el-form-item>
<el-form-item label="密码" prop="password"
><el-input
v-model="model.password"
type="password"
show-password
placeholder="请输入密码"
:autocomplete="registering ? 'new-password' : 'current-password'" /></el-form-item
><el-form-item v-if="registering" label="确认密码" prop="confirmation"
><el-input
v-model="model.confirmation"
type="password"
show-password
placeholder="再次输入密码"
autocomplete="new-password" /></el-form-item
><el-button type="primary" native-type="submit">{{
registering ? '注册' : '登录'
}}</el-button></el-form
>
<p class="form-help" style="margin-top: 14px">账号功能待接入,当前不会创建账号或保存密码。</p>
autocomplete="current-password"
aria-label="密码"
/></el-form-item>
<p v-if="error" role="alert">{{ error }}</p>
<el-button type="primary" native-type="submit" :loading="busy">登录</el-button>
</el-form>
<p class="form-help" style="margin-top: 14px">
草稿只保存在当前浏览器,重新登录后可继续编辑。
</p>
<div class="auth-links">
<RouterLink to="/">← 返回首页</RouterLink
><RouterLink :to="registering ? '/login' : '/register'">{{
+27 -5
View File
@@ -1,13 +1,35 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useSession } from '../composables/useSession'
import { errorText } from '../api/transport'
import EmptyState from '../components/EmptyState.vue'
const done = ref(false)
const busy = ref(false)
const error = ref('')
async function logout() {
if (busy.value) return
busy.value = true
error.value = ''
try {
await useSession().logout()
done.value = true
} catch (cause) {
error.value = errorText(cause)
} finally {
busy.value = false
}
}
onMounted(logout)
</script>
<template>
<div class="panel" style="max-width: 650px; margin: 50px auto">
<EmptyState title="当前没有登录会话" description="账号服务接入后,这里会清除会话并退出登录。"
><RouterLink to="/"><el-button type="primary">返回首页</el-button></RouterLink
><RouterLink to="/login" style="margin-left: 12px"
><el-button>去登录</el-button></RouterLink
></EmptyState
<EmptyState
:title="done ? '已退出登录' : '正在退出登录'"
description="当前浏览器的题目草稿已保留。"
>
<p v-if="error" role="alert">{{ error }}</p>
<el-button v-if="error" :loading="busy" @click="logout">重试退出</el-button>
<RouterLink to="/login"><el-button type="primary">去登录</el-button></RouterLink>
</EmptyState>
</div>
</template>
+198 -152
View File
@@ -1,28 +1,42 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref } from 'vue'
import { useRoute } from 'vue-router'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { findProblem, discussions, submissions } from '../data/preview'
import { api } from '../api/client'
import { csrfHeaders, errorText, unwrap } from '../api/transport'
import { submissionIntent, type SubmissionInput } from '../api/submissionIntent'
import type { components } from '../api/generated/schema'
import { useSession } from '../composables/useSession'
import { useCodeDraft, codeTemplates, languageLabels } from '../composables/useCodeDraft'
import CodeEditor from '../components/CodeEditor.vue'
import DifficultyTag from '../components/DifficultyTag.vue'
import VerdictTag from '../components/VerdictTag.vue'
import EmptyState from '../components/EmptyState.vue'
import UiIcon from '../components/UiIcon.vue'
const route = useRoute()
const router = useRouter()
const id = String(route.params.id)
const problem = computed(() => findProblem(id))
const problem = ref<components['schemas']['ProblemDetail'] | null>(null)
const { language, code, state, save } = useCodeDraft(id)
const session = useSession()
const theme = ref('vs')
const fontSize = ref(14)
const tab = ref('description')
const testTab = ref('input')
const testInput = ref(problem.value?.sampleInput ?? '')
const plain = ref(false)
const error = ref('')
const submitError = ref('')
const busy = ref(false)
const loading = ref(true)
const testInput = ref('')
const settingsOpen = ref(false)
const split = ref(50)
const workspace = ref<HTMLDivElement>()
const codeRelated = computed(() => submissions.filter((s) => s.problemId === id))
const relatedDiscussion = computed(() => discussions.filter((d) => d.problemId === id))
const request = new AbortController()
let alive = true
const intents = new Map<string, ReturnType<typeof submissionIntent>>()
const wireLanguage = computed(() =>
language.value === 'c' ? 'c17' : language.value === 'cpp' ? 'cpp20' : null,
)
const supported = computed(
() => wireLanguage.value !== null && !!problem.value?.languages.includes(wireLanguage.value),
)
const draftText = computed(
() =>
({
@@ -50,36 +64,107 @@ function beginDrag(event: PointerEvent) {
window.addEventListener('pointerup', stop, { once: true })
stopDrag = stop
}
onBeforeUnmount(() => stopDrag?.())
async function load() {
loading.value = true
error.value = ''
try {
const data = unwrap(
await api.GET('/api/v1/problems/versions/{version_id}', {
params: { path: { version_id: id } },
signal: request.signal,
}),
)
if (alive) problem.value = data
} catch (cause) {
if (alive) error.value = errorText(cause)
} finally {
if (alive) loading.value = false
}
}
async function resetCode() {
try {
await ElMessageBox.confirm('将当前语言的草稿恢复为初始模板?', '重置代码', {
confirmButtonText: '重置',
cancelButtonText: '保留草稿',
type: 'info',
})
code.value = codeTemplates[language.value]
} catch {
/* User kept their draft. */
/* kept */
}
}
function downloadCode() {
const extensions = { cpp: 'cpp', python: 'py', rust: 'rs' }
const url = URL.createObjectURL(new Blob([code.value], { type: 'text/plain;charset=utf-8' }))
const link = document.createElement('a')
link.href = url
link.download = 'solution-' + id + '.' + extensions[language.value]
link.download =
'solution-' + id + '.' + { c: 'c', cpp: 'cpp', python: 'py', rust: 'rs' }[language.value]
link.click()
setTimeout(() => URL.revokeObjectURL(url), 1000)
}
async function copySample(value: string) {
function sample(value: string) {
const bytes = Uint8Array.from(atob(value), (c) => c.charCodeAt(0))
try {
await navigator.clipboard.writeText(value)
ElMessage.success('已复制样例')
return new TextDecoder('utf-8', { fatal: true }).decode(bytes)
} catch {
ElMessage.info('浏览器暂不支持复制,请选中样例文本复制')
return (
'字节(十六进制):' + Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(' ')
)
}
}
async function copySample(value: string) {
try {
await navigator.clipboard.writeText(sample(value))
ElMessage.success('已复制样例')
} catch {
ElMessage.info('请选中样例文本复制')
}
}
async function submit() {
if (busy.value || !problem.value || !supported.value || !wireLanguage.value) return
save()
busy.value = true
submitError.value = ''
try {
if (!(await session.refresh())) {
await router.push({ path: '/login', query: { next: route.fullPath } })
return
}
const owner = session.user.value!.id
const input: SubmissionInput = {
problem_version_id: problem.value.version_id,
language: wireLanguage.value,
source: code.value,
}
save()
let intent = intents.get(owner)
if (!intent) {
intent = submissionIntent(localStorage, owner)
intents.set(owner, intent)
}
const submissionId = await intent.submit(
input,
async (key) =>
unwrap(
await api.POST('/api/v1/submissions', {
body: { ...input, idempotency_key: key },
headers: csrfHeaders(),
signal: request.signal,
}),
).submission_id,
)
if (alive && session.user.value?.id === owner) await router.push('/submissions/' + submissionId)
} catch (cause) {
if (alive) submitError.value = errorText(cause)
} finally {
if (alive) busy.value = false
}
}
onMounted(load)
onBeforeUnmount(() => {
alive = false
request.abort()
stopDrag?.()
})
</script>
<template>
<div
@@ -89,112 +174,46 @@ async function copySample(value: string) {
:style="{ '--problem-split': split + '%' }"
>
<section class="panel statement-panel">
<el-tabs v-model="tab" class="statement-tabs"
><el-tab-pane label="题目描述" name="description" /><el-tab-pane
label="提交记录"
name="submissions" /><el-tab-pane label="题解与讨论" name="discussions" /><el-tab-pane
label="AI 答疑"
name="ai"
/></el-tabs>
<div class="statement-content">
<template v-if="tab === 'description'"
><div class="problem-title-row">
<div>
<p class="small muted mono">题号 {{ problem.id }}</p>
<h1>{{ problem.title }}</h1>
</div>
<DifficultyTag :value="problem.difficulty" />
<div class="problem-title-row">
<div>
<p class="small muted mono">{{ problem.slug }} · 版本 {{ problem.revision }}</p>
<h1>{{ problem.title }}</h1>
</div>
<div class="tag-list">
<span v-for="tag in problem.tags" :key="tag" class="tag">{{ tag }}</span
><span class="tag">示例题面</span>
</div>
<div class="problem-limits">
<span>时间限制:1000 ms</span><span>内存限制:256 MiB</span
><span>标准输入 / 标准输出</span>
</div>
<div class="detail-prose">
<h2>题目描述</h2>
<p>{{ problem.description }}</p>
<h2>输入格式</h2>
<p>{{ problem.input }}</p>
<h2>输出格式</h2>
<p>{{ problem.output }}</p>
<template v-if="problem.sampleInput"
><div class="sample-grid">
<div>
<h2>
样例输入
<el-button link size="small" @click="copySample(problem.sampleInput)"
>复制</el-button
>
</h2>
<pre class="sample-block">{{ problem.sampleInput }}</pre>
</div>
<div>
<h2>
样例输出
<el-button link size="small" @click="copySample(problem.sampleOutput)"
>复制</el-button
>
</h2>
<pre class="sample-block">{{ problem.sampleOutput }}</pre>
</div>
</div></template
>
<h2>数据范围与提示</h2>
<p>{{ problem.constraint }}</p>
<h2>提交说明</h2>
<p>
使用标准输入读取数据,只输出题目要求的结果。你可以在右侧编写代码,并在下方准备自测输入。
</p>
</div>
</template>
<template v-else-if="tab === 'submissions'"
><p class="notice-strip">以下为本题的示例评测记录。</p>
<el-table :data="codeRelated" empty-text="暂无本题的示例记录"
><el-table-column label="提交编号" prop="id" width="100" /><el-table-column
label="用户"
prop="username"
min-width="110"
/><el-table-column label="结果" width="100"
><template #default="{ row }"
><VerdictTag :value="row.verdict" /></template></el-table-column
><el-table-column label="详情" width="80"
><template #default="{ row }"
><RouterLink :to="'/submissions/' + row.id" class="text-link"
>查看</RouterLink
></template
></el-table-column
></el-table
></template
>
<template v-else-if="tab === 'discussions'"
><article
v-for="discussion in relatedDiscussion"
:key="discussion.id"
class="discussion-item"
>
</div>
<div class="problem-limits">
<span>CPU 限制:{{ problem.run_limits.cpu_time_us / 1000 }} ms</span
><span>墙钟限制:{{ problem.run_limits.wall_time_us / 1000 }} ms</span
><span>内存限制:{{ problem.run_limits.memory_bytes / 1048576 }} MiB</span
><span>精确字节比较</span>
</div>
<div class="detail-prose">
<h2>题目描述</h2>
<pre style="white-space: pre-wrap">{{ problem.statement }}</pre>
<div v-for="(item, index) in problem.samples" :key="index" class="sample-grid">
<div>
<h2>
<RouterLink :to="'/discussions/' + discussion.id">{{
discussion.title
}}</RouterLink>
样例输入 {{ index + 1 }}
<el-button link @click="copySample(item.input)">复制</el-button>
</h2>
<p>{{ discussion.author }} · {{ discussion.date }} · 示例文章</p>
<pre class="sample-block">{{ sample(item.input) }}</pre>
</div>
</article>
<EmptyState v-if="!relatedDiscussion.length" title="暂无本题讨论" /><RouterLink
to="/discussions"
class="text-link"
>进入讨论社区 →</RouterLink
></template
>
<EmptyState
v-else
title="AI 答疑待接入"
description="后续结合题目、代码和提交记录,提供逐步提示与针对性分析。"
/>
<div>
<h2>
样例输出 {{ index + 1 }}
<el-button link @click="copySample(item.output)">复制</el-button>
</h2>
<pre class="sample-block">{{ sample(item.output) }}</pre>
</div>
</div>
<h2>提交说明</h2>
<p>
使用标准输入 / 标准输出。支持语言:{{
problem.languages.join(' / ')
}}。隐藏测试数据不会公开。
</p>
<RouterLink to="/submissions" class="text-link">查看个人提交记录</RouterLink>
</div>
</div>
</section>
<button
@@ -218,13 +237,16 @@ async function copySample(value: string) {
:key="value"
:label="label"
:value="value" /></el-select
><el-tooltip content="重置模板"
><el-button size="small" aria-label="重置代码模板" @click="resetCode"
><UiIcon name="refresh" /></el-button></el-tooltip
><el-button size="small" aria-label="重置代码模板" @click="resetCode"
><UiIcon name="refresh" /></el-button
><span class="draft-label" aria-live="polite">{{ draftText }}</span
><el-button size="small" aria-label="下载代码" @click="downloadCode"
><UiIcon name="document" /></el-button
><el-popover v-model:visible="settingsOpen" trigger="click" width="240"
><UiIcon name="document"
/></el-button>
<el-button size="small" @click="plain = !plain">{{
plain ? '代码编辑器' : '纯文本编辑'
}}</el-button>
<el-popover v-model:visible="settingsOpen" trigger="click" width="240"
><template #reference
><el-button size="small" aria-label="编辑器设置"><UiIcon name="settings" /></el-button
></template>
@@ -238,42 +260,66 @@ async function copySample(value: string) {
></el-popover>
</header>
<div class="editor-area">
<CodeEditor v-model="code" :language="language" :theme="theme" :font-size="fontSize" />
<textarea
v-if="plain"
v-model="code"
aria-label="源码"
style="
width: 100%;
height: 100%;
min-height: 320px;
padding: 18px;
font-family: monospace;
resize: none;
"
spellcheck="false"
/><CodeEditor
v-else
v-model="code"
:language="language"
:theme="theme"
:font-size="fontSize"
/>
</div>
<div class="editor-testing">
<el-tabs v-model="testTab"
><el-tab-pane label="自测输入" name="input"
><el-input
v-model="testInput"
type="textarea"
:rows="3"
resize="none"
placeholder="输入自测数据"
aria-label="自测输入"
/><el-button
v-if="problem.sampleInput"
link
type="primary"
size="small"
@click="testInput = problem.sampleInput"
>填入题目样例</el-button
></el-tab-pane
><el-tab-pane label="运行结果" name="result"
><p class="test-result-placeholder">
尚未运行。判题服务接入后展示标准输出、错误信息与资源使用情况。
</p></el-tab-pane
></el-tabs
>
<p v-if="!supported" class="notice-strip">
当前语言暂不支持提交,草稿仍会保存。可切换到题目支持的 C17 / C++20。
</p>
<p v-if="submitError" role="alert">
{{ submitError }}
<RouterLink
v-if="!session.user.value"
:to="{ path: '/login', query: { next: route.fullPath } }"
>重新登录</RouterLink
>
</p>
<el-input
v-model="testInput"
type="textarea"
:rows="3"
aria-label="自测输入"
placeholder="自测能力暂未开放,输入不会随提交发送"
/>
</div>
<footer class="editor-buttons">
<span class="small muted">评测服务待接入</span
<span class="small muted">{{
session.user.value ? '提交后评测隐藏测试' : '登录后可提交'
}}</span
><el-button size="small" @click="save">保存草稿</el-button
><el-button size="small" type="success" plain disabled>自测运行</el-button
><el-button size="small" type="primary" disabled>提交代码</el-button>
><el-button
size="small"
type="primary"
:loading="busy"
:disabled="!supported"
@click="submit"
>提交代码</el-button
>
</footer>
</section>
</div>
<EmptyState v-else title="题目不存在"
<EmptyState v-else :title="loading ? '正在加载题目…' : '题目暂时无法读取'" :description="error"
><el-button v-if="!loading" @click="load">重试</el-button
><RouterLink to="/problems"><el-button>返回题库</el-button></RouterLink></EmptyState
>
</template>
+80 -43
View File
@@ -1,62 +1,99 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRoute } from 'vue-router'
import { problems, problemTags } from '../data/preview'
import { usePagination } from '../composables/usePagination'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { api } from '../api/client'
import { errorText, unwrap } from '../api/transport'
import type { components } from '../api/generated/schema'
import PageHeading from '../components/PageHeading.vue'
import PanelCard from '../components/PanelCard.vue'
import ProblemTable from '../components/ProblemTable.vue'
const route = useRoute()
const items = ref<components['schemas']['ProblemSummary'][]>([])
const query = ref('')
const difficulty = ref('')
const tag = ref(typeof route.query.tag === 'string' ? route.query.tag : '')
const error = ref('')
const busy = ref(false)
const next = ref<string | null>(null)
const current = ref<string | undefined>()
const previous = ref<(string | undefined)[]>([])
let request: AbortController | undefined
let generation = 0
const filtered = computed(() =>
problems.filter(
(p) =>
(!query.value || (p.id + p.title).toLowerCase().includes(query.value.trim().toLowerCase())) &&
(!difficulty.value || p.difficulty === difficulty.value) &&
(!tag.value || p.tags.includes(tag.value)),
items.value.filter((p) =>
(p.slug + p.title).toLowerCase().includes(query.value.trim().toLowerCase()),
),
)
const { page, pageSize, paged, total } = usePagination(filtered)
function reset() {
query.value = ''
difficulty.value = ''
tag.value = ''
async function load(after?: string) {
request?.abort()
const epoch = ++generation
const controller = new AbortController()
request = controller
error.value = ''
busy.value = true
try {
const page = unwrap(
await api.GET('/api/v1/problems', {
params: { query: { after, limit: 20 } },
signal: controller.signal,
}),
)
if (epoch !== generation) return
items.value = page.items
next.value = page.next_after
current.value = after
} catch (cause) {
if (epoch === generation) {
error.value = errorText(cause)
items.value = []
next.value = null
}
} finally {
if (epoch === generation) busy.value = false
}
}
function forward() {
if (!next.value) return
previous.value.push(current.value)
void load(next.value)
}
function back() {
void load(previous.value.pop())
}
onMounted(() => load())
onBeforeUnmount(() => {
generation++
request?.abort()
})
</script>
<template>
<PageHeading title="题库" eyebrow="PROBLEM BANK" description="挑选一道题,开始今天的练习。"
><RouterLink to="/trainings"><el-button>按题单练习</el-button></RouterLink></PageHeading
>
<PanelCard title="全部题目" icon="problems" subtitle="示例题库">
<PageHeading
title="题库"
eyebrow="PROBLEM BANK"
description="选择已发布的题目,使用支持的语言提交。"
/>
<PanelCard title="已发布题目" icon="problems">
<p v-if="error" role="alert">{{ error }} <el-button @click="load(current)">重试</el-button></p>
<div class="filters">
<el-input
v-model="query"
placeholder="搜索题号或题目名称"
placeholder="筛选当前页题号或标题"
aria-label="搜索题目"
clearable
/><el-select v-model="difficulty" placeholder="全部难度" aria-label="难度" clearable
><el-option
v-for="d in ['入门', '简单', '中等', '困难']"
:key="d"
:label="d"
:value="d" /></el-select
><el-select v-model="tag" placeholder="全部标签" aria-label="题目标签" clearable filterable
><el-option v-for="t in problemTags" :key="t" :label="t" :value="t" /></el-select
><el-button @click="reset">重置</el-button
><span class="filter-label">{{ total }} 道题目</span>
</div>
<ProblemTable :items="paged" />
<div class="pagination-row">
<span class="muted">共 {{ total }} 道题目</span
><el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:page-sizes="[5, 10, 20]"
:total="total"
layout="prev, pager, next, sizes"
/>
</div>
<el-table :data="filtered" :empty-text="busy ? '正在加载…' : '当前页没有题目'">
<el-table-column prop="slug" label="题号" min-width="160" />
<el-table-column label="题目" min-width="220"
><template #default="{ row }"
><RouterLink :to="'/problems/' + row.version_id" class="text-link">{{
row.title
}}</RouterLink></template
></el-table-column
>
<el-table-column label="支持语言"
><template #default="{ row }">{{ row.languages.join(' / ') }}</template></el-table-column
>
</el-table>
<div class="pagination-row">
<span>当前页 {{ items.length }} 道题目</span
><el-button :disabled="busy || !previous.length" @click="back">上一页</el-button
><el-button :disabled="busy || !next" @click="forward">下一页</el-button>
</div>
</PanelCard>
</template>
+183 -41
View File
@@ -1,63 +1,205 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { submissions, findProblem } from '../data/preview'
import { api } from '../api/client'
import { ApiError, csrfHeaders, errorText, unwrap } from '../api/transport'
import { useSession } from '../composables/useSession'
import { boundedPoll } from '../api/polling'
import type { components } from '../api/generated/schema'
import PageHeading from '../components/PageHeading.vue'
import PanelCard from '../components/PanelCard.vue'
import VerdictTag from '../components/VerdictTag.vue'
import EmptyState from '../components/EmptyState.vue'
import JudgementStatus from '../components/JudgementStatus.vue'
const route = useRoute()
const submission = computed(() => submissions.find((s) => s.id === route.params.id))
const id = String(route.params.id)
const submission = ref<components['schemas']['SubmissionDetail'] | null>(null)
const error = ref('')
const notice = ref('')
const source = ref<string | null>(null)
const sourceError = ref('')
const cancelling = ref(false)
const sourceBusy = ref(false)
const request = new AbortController()
let alive = true
const terminal = computed(
() => !!submission.value && ['finished', 'failed', 'cancelled'].includes(submission.value.state),
)
const poll = boundedPoll({
load: async (signal) =>
unwrap(
await api.GET('/api/v1/submissions/{submission_id}', {
params: { path: { submission_id: id } },
signal,
}),
),
terminal: (s) => ['finished', 'failed', 'cancelled'].includes(s.state),
result: (s) => {
submission.value = s
error.value = ''
},
error: (cause) => {
error.value = errorText(cause)
if (cause instanceof ApiError && cause.status === 401) {
submission.value = null
source.value = null
}
},
exhausted: () => {
notice.value = '自动查询已停止。评测仍可继续,点击刷新可查看最新状态。'
},
})
function refresh() {
error.value = ''
notice.value = ''
poll.start()
}
function visibility() {
poll.visible(document.visibilityState === 'visible')
}
async function cancel() {
if (cancelling.value || terminal.value) return
cancelling.value = true
error.value = ''
try {
unwrap(
await api.POST('/api/v1/submissions/{submission_id}/cancel', {
params: { path: { submission_id: id } },
headers: csrfHeaders(),
signal: request.signal,
}),
)
if (alive) {
notice.value = '取消已受理,等待执行停止与清理确认。'
poll.start()
}
} catch (cause) {
if (alive) error.value = errorText(cause)
} finally {
if (alive) cancelling.value = false
}
}
async function readSource() {
if (sourceBusy.value) return
sourceBusy.value = true
sourceError.value = ''
try {
const value = unwrap(
await api.GET('/api/v1/submissions/{submission_id}/source', {
params: { path: { submission_id: id } },
signal: request.signal,
}),
)
if (alive) source.value = value.source
} catch (cause) {
if (alive) sourceError.value = errorText(cause)
} finally {
if (alive) sourceBusy.value = false
}
}
watch(
() => useSession().user.value?.id,
(owner, previousOwner) => {
if (previousOwner && owner !== previousOwner) {
poll.dispose()
submission.value = null
source.value = null
error.value = '会话已改变,请重新登录。'
}
},
)
onMounted(() => {
visibility()
document.addEventListener('visibilitychange', visibility)
poll.start()
})
onBeforeUnmount(() => {
alive = false
poll.dispose()
request.abort()
document.removeEventListener('visibilitychange', visibility)
})
</script>
<template>
<template v-if="submission"
><div class="breadcrumbs">
<RouterLink to="/submissions">评测记录</RouterLink><span>/</span
><span>#{{ submission.id }}</span>
</div>
<PageHeading
:title="'提交 #' + submission.id"
:description="findProblem(submission.problemId)?.title"
><VerdictTag :value="submission.verdict" full
/></PageHeading>
<PageHeading :title="'提交 ' + id" description="个人提交的持久化评测状态"
><JudgementStatus v-if="submission" :state="submission.state" :verdict="submission.verdict"
/></PageHeading>
<p v-if="error" role="alert">
{{ error }}
<RouterLink :to="{ path: '/login', query: { next: route.fullPath } }">重新登录</RouterLink>
</p>
<p v-if="notice" role="status">{{ notice }}</p>
<div class="filters">
<el-button @click="refresh">刷新状态</el-button
><el-button v-if="submission && !terminal" :loading="cancelling" @click="cancel"
>取消评测</el-button
><RouterLink to="/submissions"><el-button>个人提交</el-button></RouterLink>
</div>
<template v-if="submission">
<div class="summary-grid">
<div class="summary-card">
<p>运行时间</p>
<p>运行 CPU 时间</p>
<strong
>{{ submission.time ?? '—' }} <small v-if="submission.time !== null">ms</small></strong
><small>示例数据</small>
>{{ submission.result?.cpu_time_us == null ? '—' : submission.result.cpu_time_us / 1000 }}
<small v-if="submission.result?.cpu_time_us != null">ms</small></strong
>
</div>
<div class="summary-card">
<p>内存使用</p>
<p>运行内存峰值</p>
<strong
>{{ submission.memory === null ? '—' : submission.memory / 1024 }}
<small v-if="submission.memory !== null">MiB</small></strong
><small>示例数据</small>
>{{
submission.result?.memory_peak_bytes == null
? '—'
: (submission.result.memory_peak_bytes / 1048576).toFixed(2)
}}
<small v-if="submission.result?.memory_peak_bytes != null">MiB</small></strong
>
</div>
<div class="summary-card">
<p>语言</p>
<strong>{{ submission.language }}</strong
><small>工具链版本待接入</small>
><small>尝试 {{ submission.attempts }} 次</small>
</div>
<div class="summary-card">
<p>提交用户</p>
<strong style="font-size: 21px"
><RouterLink :to="'/users/' + submission.username">{{
submission.username
}}</RouterLink></strong
><small>{{ submission.created }}</small>
<p>提交时间</p>
<small>{{ submission.created_at }}</small
><RouterLink :to="'/problems/' + submission.problem_version_id"
>返回提交时的题目版本</RouterLink
>
</div>
</div>
<PanelCard title="评测详情" icon="submissions"
><p class="notice-strip">当前展示评测界面的示例记录,实际用例结果、日志和源码尚未接入。</p>
<EmptyState
title="用例详情待接入"
description="评测完成后展示各测试点状态、资源用量及可见的编译信息。"
/><RouterLink :to="'/problems/' + submission.problemId"
><el-button>返回题目</el-button></RouterLink
></PanelCard
></template
><EmptyState v-else title="提交记录不存在"
><RouterLink to="/submissions"><el-button>返回评测列表</el-button></RouterLink></EmptyState
>
<PanelCard title="评测详情" icon="submissions">
<p v-if="submission.failure_code" role="alert">系统原因:{{ submission.failure_code }}</p>
<template v-if="submission.result">
<p>编译状态:{{ submission.result.compilation_status }}</p>
<pre
v-if="submission.result.diagnostics"
class="sample-block"
data-testid="compiler-diagnostics"
>{{ submission.result.diagnostics }}</pre>
<p v-if="submission.result.diagnostics_truncated">编译诊断已截断。</p>
<p v-if="!submission.result.cleanup_complete" role="alert">
资源清理未确认,服务保留该任务并隔离容量。
</p>
<el-table :data="submission.result.cases" empty-text="没有运行测试点"
><el-table-column label="测试点"
><template #default="{ row }">{{ row.position + 1 }}</template></el-table-column
><el-table-column prop="verdict" label="结果" /><el-table-column label="CPU ms"
><template #default="{ row }">{{
row.cpu_time_us == null ? '—' : row.cpu_time_us / 1000
}}</template></el-table-column
><el-table-column label="内存 MiB"
><template #default="{ row }">{{
row.memory_peak_bytes == null ? '—' : (row.memory_peak_bytes / 1048576).toFixed(2)
}}</template></el-table-column
></el-table
>
</template>
<p v-else>
{{ terminal ? '没有执行报告;终态以持久化状态为准。' : '尚无完整成绩,请等待评测。' }}
</p>
<el-button :loading="sourceBusy" @click="readSource">查看我的源码</el-button>
<p v-if="sourceError" role="alert">{{ sourceError }}</p>
<pre v-if="source !== null" class="sample-block">{{ source }}</pre>
</PanelCard>
</template>
<p v-else-if="!error">正在读取提交…</p>
</template>
+93 -65
View File
@@ -1,75 +1,103 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRoute } from 'vue-router'
import { submissions, verdictLabels, type Verdict } from '../data/preview'
import { usePagination } from '../composables/usePagination'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { api } from '../api/client'
import { ApiError, errorText, unwrap } from '../api/transport'
import { useSession } from '../composables/useSession'
import { boundedPoll } from '../api/polling'
import type { components } from '../api/generated/schema'
import PageHeading from '../components/PageHeading.vue'
import PanelCard from '../components/PanelCard.vue'
import SubmissionTable from '../components/SubmissionTable.vue'
const route = useRoute()
const username = ref(typeof route.query.user === 'string' ? route.query.user : '')
const problemId = ref('')
const language = ref('')
const verdict = ref<Verdict | ''>('')
const filtered = computed(() =>
submissions.filter(
(s) =>
(!username.value || s.username.toLowerCase().includes(username.value.trim().toLowerCase())) &&
(!problemId.value || s.problemId.includes(problemId.value.trim())) &&
(!language.value || s.language === language.value) &&
(!verdict.value || s.verdict === verdict.value),
),
)
const { page, pageSize, paged, total } = usePagination(filtered)
function reset() {
username.value = ''
problemId.value = ''
language.value = ''
verdict.value = ''
import JudgementStatus from '../components/JudgementStatus.vue'
const page = ref<components['schemas']['SubmissionPage'] | null>(null)
const current = ref<string | undefined>()
const previous = ref<(string | undefined)[]>([])
const error = ref('')
const notice = ref('')
let poll: ReturnType<typeof boundedPoll<components['schemas']['SubmissionPage']>> | undefined
function load(after?: string) {
poll?.dispose()
current.value = after
error.value = ''
notice.value = ''
poll = boundedPoll({
load: async (signal) =>
unwrap(
await api.GET('/api/v1/submissions', { params: { query: { after, limit: 20 } }, signal }),
),
terminal: (value) =>
value.items.every((s) => ['finished', 'failed', 'cancelled'].includes(s.state)),
result: (value) => {
page.value = value
},
error: (cause) => {
error.value = errorText(cause)
if (cause instanceof ApiError && cause.status === 401) page.value = null
},
exhausted: () => {
notice.value = '自动查询已停止,请手动刷新查看最新状态。'
},
})
visibility()
poll.start()
}
function visibility() {
poll?.visible(document.visibilityState === 'visible')
}
function forward() {
if (!page.value?.next_after) return
previous.value.push(current.value)
load(page.value.next_after)
}
function back() {
load(previous.value.pop())
}
watch(
() => useSession().user.value?.id,
(owner, previousOwner) => {
if (previousOwner && owner !== previousOwner) {
poll?.dispose()
page.value = null
error.value = '会话已改变,请重新登录。'
}
},
)
onMounted(() => {
document.addEventListener('visibilitychange', visibility)
load()
})
onBeforeUnmount(() => {
poll?.dispose()
document.removeEventListener('visibilitychange', visibility)
})
</script>
<template>
<PageHeading
title="评测记录"
title="个人提交"
eyebrow="SUBMISSIONS"
description="查看提交状态、运行情况与评测结果。"
/><PanelCard title="提交列表" icon="submissions" subtitle="示例评测记录"
><div class="filters">
<el-input
v-model="username"
placeholder="用户名"
aria-label="筛选用户名"
clearable
style="width: 180px"
/><el-input
v-model="problemId"
placeholder="题号"
aria-label="筛选题号"
clearable
style="width: 150px"
/><el-select v-model="verdict" aria-label="评测结果" placeholder="全部结果" clearable
><el-option
v-for="(label, value) in verdictLabels"
:key="value"
:label="label"
:value="value" /></el-select
><el-select v-model="language" aria-label="语言" placeholder="全部语言" clearable
><el-option
v-for="l in ['C++', 'Python', 'Rust']"
:key="l"
:label="l"
:value="l" /></el-select
><el-button @click="reset">重置</el-button>
</div>
<SubmissionTable :items="paged" />
description="仅显示当前登录账号的持久化提交记录。"
/>
<PanelCard title="我的提交" icon="submissions"
><p v-if="error" role="alert">{{ error }} <RouterLink to="/login">重新登录</RouterLink></p>
<p v-if="notice" role="status">{{ notice }}</p>
<el-table :data="page?.items ?? []" empty-text="当前页没有提交">
<el-table-column label="提交" min-width="230"
><template #default="{ row }"
><RouterLink :to="'/submissions/' + row.submission_id" class="text-link">{{
row.submission_id
}}</RouterLink></template
></el-table-column
>
<el-table-column prop="language" label="语言" width="100" /><el-table-column
label="状态"
min-width="130"
><template #default="{ row }"
><JudgementStatus :state="row.state" :verdict="row.verdict" /></template></el-table-column
><el-table-column prop="created_at" label="提交时间" min-width="180" />
</el-table>
<div class="pagination-row">
<span>{{ total }} 条记录</span
><el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:page-sizes="[5, 10, 20]"
:total="total"
layout="prev, pager, next, sizes"
/></div
></PanelCard>
<el-button @click="load(current)">刷新列表</el-button
><el-button :disabled="!previous.length" @click="back">上一页</el-button
><el-button :disabled="!page?.next_after" @click="forward">下一页</el-button>
</div>
</PanelCard>
</template>
+195
View File
@@ -0,0 +1,195 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { submissionIntent } from '../src/api/submissionIntent.ts'
import { boundedPoll } from '../src/api/polling.ts'
import { ApiError, boundedFetch, unwrap } from '../src/api/transport.ts'
function memory() {
const values = new Map<string, string>()
return {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => {
values.set(key, value)
},
}
}
const input = {
problem_version_id: 'version',
language: 'c17' as const,
source: 'synthetic ordinary text, never executed',
}
const flush = async () => {
await new Promise<void>((resolve) => setImmediate(resolve))
}
test('timeout and reload reuse persisted key; double click has one flight; content and owner change key', async () => {
const storage = memory()
const keys: string[] = []
const intent = submissionIntent(storage, 'owner-a')
let respond: (id: string) => void = () => {}
const send = async (key: string) => {
keys.push(key)
return new Promise<string>((resolve) => {
respond = resolve
})
}
const a = intent.submit(input, send)
const b = intent.submit(input, send)
assert.equal(a, b)
await flush()
assert.equal(keys.length, 1)
respond('real-id')
assert.equal(await a, 'real-id')
assert.equal(await submissionIntent(storage, 'owner-a').submit(input, send), 'real-id')
const uncertain = { ...input, source: 'changed synthetic content' }
await assert.rejects(
intent.submit(uncertain, async (key) => {
keys.push(key)
throw new Error('timeout')
}),
)
const failedKey = keys.at(-1)
assert.notEqual(keys[0], failedKey)
await submissionIntent(storage, 'owner-a').submit(uncertain, async (key) => {
assert.equal(key, failedKey)
return 'confirmed-id'
})
await submissionIntent(storage, 'owner-b').submit(uncertain, async (key) => {
assert.notEqual(key, failedKey)
return 'different-owner-id'
})
})
test('unavailable intent storage or oversized UTF-8 source fails before POST', async () => {
let calls = 0
const send = async () => {
calls++
return 'id'
}
const denied = {
getItem: () => null,
setItem: () => {
throw new Error('denied')
},
}
await assert.rejects(submissionIntent(denied, 'a').submit(input, send))
await assert.rejects(
submissionIntent(memory(), 'a').submit({ ...input, source: 'é'.repeat(131073) }, send),
)
assert.equal(calls, 0)
})
test('terminal response stops polling; failure stops until manual retry; requests and time are bounded', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] })
const results: number[] = []
let calls = 0,
errors = 0,
exhausted = 0
let fail = false
const poll = boundedPoll({
load: async () => {
calls++
if (fail) throw new ApiError(401)
return calls
},
terminal: (n) => n === 2,
result: (n) => results.push(n),
error: () => {
errors++
},
exhausted: () => {
exhausted++
},
interval: 10,
maxRequests: 2,
maxTime: 100,
})
poll.start()
await flush()
t.mock.timers.tick(10)
await flush()
assert.deepEqual(results, [1, 2])
t.mock.timers.tick(1000)
await flush()
assert.equal(calls, 2)
fail = true
poll.start()
await flush()
t.mock.timers.tick(100)
await flush()
assert.equal(calls, 3)
assert.equal(errors, 1)
fail = false
poll.start()
await flush()
t.mock.timers.tick(10)
await flush()
t.mock.timers.tick(10)
await flush()
assert.equal(calls, 5)
assert.equal(exhausted, 1)
poll.start()
await flush()
poll.visible(false)
t.mock.timers.tick(1000)
poll.visible(true)
await flush()
assert.equal(exhausted, 2)
poll.dispose()
})
test('hidden and unmounted requests abort; stale responses cannot replace current route data', async () => {
const pending: { signal: AbortSignal; done: (v: string) => void }[] = []
const results: string[] = []
const poll = boundedPoll({
load: (signal) =>
new Promise<string>((done) => {
pending.push({ signal, done })
}),
terminal: () => true,
result: (n) => results.push(n),
error: () => assert.fail('stale error'),
exhausted: () => {},
})
poll.start()
poll.visible(false)
assert.equal(pending[0]!.signal.aborted, true)
poll.visible(true)
pending[0]!.done('stale')
await flush()
assert.deepEqual(results, [])
pending[1]!.done('current')
await flush()
assert.deepEqual(results, ['current'])
poll.start()
poll.dispose()
assert.equal(pending[2]!.signal.aborted, true)
pending[2]!.done('unmounted')
await flush()
assert.deepEqual(results, ['current'])
})
test('transport expires identity on401, preserves cancellation and never fills failure with preview data', async () => {
let expired = 0
const fetcher = boundedFetch(
async (request) => {
assert.equal((request as Request).credentials, 'same-origin')
assert.equal((request as Request).cache, 'no-store')
return new Response('{}', { status: 401 })
},
() => {
expired++
},
)
const response = await fetcher('http://localhost/api/v1/auth/me')
assert.equal(expired, 1)
assert.throws(() => unwrap({ response, data: {} }), ApiError)
assert.throws(() => unwrap({ response: new Response('', { status: 200 }) }))
const offline = boundedFetch(
async () => {
throw new Error('synthetic private detail')
},
() => {},
)
await assert.rejects(offline('http://localhost/api'), /网络中断/)
})
+29 -1
View File
@@ -138,7 +138,12 @@ test('pagehide preserves the selected language and keeps problem drafts separate
assert.deepEqual(env.read('1001'), {
version: 1,
language: 'python',
drafts: { cpp: '// C++ draft', python: '# Python draft', rust: codeTemplates.rust },
drafts: {
c: codeTemplates.c,
cpp: '// C++ draft',
python: '# Python draft',
rust: codeTemplates.rust,
},
})
assert.equal(env.read('1002')?.drafts.cpp, '// another problem')
first.scope.stop()
@@ -162,3 +167,26 @@ test('a failed lifecycle save reports unavailable storage without throwing', (t)
assert.equal(draft.state.value, 'saved')
assert.equal(env.read()?.drafts.cpp, '// storage recovered')
})
test('old Python/Rust drafts survive adding C17; C and C++ retain separate contents', (t) => {
const env = browser(t)
env.storage.items.set(
'kf-oj:code-draft:1001',
JSON.stringify({
version: 1,
language: 'rust',
drafts: { cpp: '// original C++', python: '# original Python', rust: '// original Rust' },
}),
)
const draft = env.open()
assert.equal(draft.language.value, 'rust')
assert.equal(draft.code.value, '// original Rust')
draft.language.value = 'c'
draft.code.value = '// new C17'
draft.language.value = 'cpp'
assert.equal(draft.code.value, '// original C++')
draft.save()
assert.equal(env.read()?.drafts.c, '// new C17')
assert.equal(env.read()?.drafts.python, '# original Python')
assert.equal(env.read()?.drafts.rust, '// original Rust')
})