feat(platform): connect owned submissions and real browser flows
This commit is contained in:
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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())
|
||||
Generated
+2
-2
@@ -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" },
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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 : '请求失败,请重试。'
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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`
|
||||
})
|
||||
|
||||
@@ -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'">{{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'), /网络中断/)
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user