Files
KF-OnlineJudge/frontend/src/views/ProblemDetailView.vue
T

585 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
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 { languageCapability, preparationName } from '../composables/judgingLanguage'
import CodeEditor from '../components/CodeEditor.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 = 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 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 request = new AbortController()
let alive = true
const intents = new Map<string, ReturnType<typeof submissionIntent>>()
const capability = computed(() => languageCapability(problem.value, language.value))
const wireLanguage = computed(() => capability.value?.wire ?? null)
const supported = computed(() => capability.value !== null)
const runLimits = computed(() => capability.value?.runLimits)
const draftText = computed(
() =>
({
initial: '本地草稿',
saved: '草稿已保存',
changed: '正在保存…',
unavailable: '浏览器无法保存草稿',
})[state.value],
)
let stopDrag: (() => void) | undefined
function beginDrag(event: PointerEvent) {
if (!workspace.value || event.button !== 0) return
event.preventDefault()
const bounds = workspace.value.getBoundingClientRect()
function move(e: PointerEvent) {
split.value = Math.max(30, Math.min(70, ((e.clientX - bounds.left) / bounds.width) * 100))
}
function stop() {
window.removeEventListener('pointermove', move)
window.removeEventListener('pointerup', stop)
stopDrag = undefined
}
stopDrag?.()
window.addEventListener('pointermove', move)
window.addEventListener('pointerup', stop, { once: true })
stopDrag = stop
}
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: '保留草稿',
})
code.value = codeTemplates[language.value]
} catch {
/* kept */
}
}
function downloadCode() {
const url = URL.createObjectURL(new Blob([code.value], { type: 'text/plain;charset=utf-8' }))
const link = document.createElement('a')
link.href = url
link.download =
language.value === 'java'
? 'Main.java'
: language.value === 'python'
? 'main.py'
: 'solution-' + id + '.' + { c: 'c', cpp: 'cpp', rust: 'rs' }[language.value]
link.click()
setTimeout(() => URL.revokeObjectURL(url), 1000)
}
function sample(value: string) {
const bytes = Uint8Array.from(atob(value), (c) => c.charCodeAt(0))
try {
return new TextDecoder('utf-8', { fatal: true }).decode(bytes)
} catch {
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
v-if="problem"
ref="workspace"
class="problem-workspace"
:style="{ '--problem-split': split + '%' }"
>
<section class="panel statement-panel">
<div class="statement-content">
<div class="problem-title-row">
<div>
<p class="small muted mono">{{ problem.slug }} · 版本 {{ problem.revision }}</p>
<h1>{{ problem.title }}</h1>
</div>
</div>
<div class="problem-limits">
<span>CPU 限制:{{ runLimits ? runLimits.cpu_time_us / 1000 + ' ms' : '未提供' }}</span
><span>墙钟限制:{{ runLimits ? runLimits.wall_time_us / 1000 + ' ms' : '未提供' }}</span
><span
>内存限制:{{ runLimits ? runLimits.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>
样例输入 {{ index + 1 }}
<el-button link @click="copySample(item.input)">复制</el-button>
</h2>
<pre class="sample-block">{{ sample(item.input) }}</pre>
</div>
<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
class="pane-splitter"
role="separator"
aria-label="调整题面与编辑器宽度"
aria-orientation="vertical"
:aria-valuenow="Math.round(split)"
aria-valuemin="30"
aria-valuemax="70"
@pointerdown="beginDrag"
@keydown.left.prevent="split = Math.max(30, split - 2)"
@keydown.right.prevent="split = Math.min(70, split + 2)"
/>
<section class="panel editor-panel">
<header class="editor-toolbar">
<span class="small muted">语言</span
><el-select v-model="language" aria-label="编程语言" size="small"
><el-option
v-for="(label, value) in languageLabels"
:key="value"
:label="label"
:value="value" /></el-select
><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-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>
<div class="editor-settings">
<label>主题</label
><el-select v-model="theme" aria-label="编辑器主题"
><el-option label="浅色" value="vs" /><el-option
label="深色"
value="vs-dark" /></el-select
><label>字号</label><el-slider v-model="fontSize" :min="12" :max="22" show-input /></div
></el-popover>
</header>
<div class="editor-area">
<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">
<p v-if="!supported" class="notice-strip">
当前题目版本不支持此语言,草稿仍会保存。请选择题目公布的可用语言。
</p>
<p
v-else-if="capability?.configuration"
class="notice-strip"
data-testid="language-capability"
>
{{ languageLabels[language] }} · {{ capability.configuration.runtime_version }} ·
{{ preparationName(capability.wire) }}后逐点运行。
<template v-if="capability.wire === 'java21'">
默认包 Main.java,入口为 public static void main(String[])。
</template>
<template v-else-if="capability.wire === 'python313'">
单文件 main.py,仅标准库。
</template>
</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">{{
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"
:loading="busy"
:disabled="!supported"
@click="submit"
>提交代码</el-button
>
</footer>
</section>
</div>
<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>
<style>
.app-main:has(.problem-workspace) {
width: calc(100% - 48px);
max-width: 1900px;
margin: 20px auto;
}
.app-shell:has(.problem-workspace) .app-footer {
display: none;
}
.problem-workspace {
display: grid;
grid-template-columns: minmax(0, var(--problem-split)) 12px minmax(0, 1fr);
height: calc(100dvh - 117px);
min-height: 600px;
}
.statement-panel,
.editor-panel {
display: flex;
flex-direction: column;
min-height: 0;
border-radius: 14px;
}
.statement-tabs {
padding: 8px 20px 0;
background: #fff;
border-bottom: 1px solid #edf0f5;
}
.statement-tabs .el-tabs__header {
margin: 0;
}
.statement-tabs .el-tabs__active-bar,
.statement-tabs .el-tabs__nav-wrap::after {
display: none;
}
.statement-tabs .el-tabs__item {
font-size: 12px;
height: 36px;
padding: 0 12px;
margin: 0 4px 8px 0;
border-radius: 7px;
}
.statement-tabs .el-tabs__item.is-active {
background: #edf6ff;
}
.statement-content {
padding: 30px;
overflow: auto;
flex: 1;
}
.problem-title-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 15px;
}
.problem-title-row h1 {
font-size: 27px;
font-weight: 650;
letter-spacing: -0.6px;
margin-top: 6px;
}
.problem-limits {
display: flex;
flex-wrap: wrap;
gap: 8px 20px;
margin: 20px 0;
padding: 16px 18px;
background: #f3f7fc;
border: 0;
border-radius: 10px;
font-size: 12px;
color: #8b9cb2;
}
.statement-content .detail-prose h2 {
font-size: 16px;
color: #455e80;
border-left: 3px solid #b5d5f7;
padding-left: 12px;
margin: 25px 0 12px;
}
.statement-content .detail-prose {
font-size: 13px;
line-height: 1.9;
}
.sample-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18px;
}
.sample-grid h2 {
display: flex;
justify-content: space-between;
align-items: center;
}
.pane-splitter {
background: none;
border: 0;
cursor: col-resize;
position: relative;
padding: 0;
touch-action: none;
}
.pane-splitter::after {
content: '';
width: 3px;
height: 32px;
background: #9eb7d7;
border-radius: 3px;
position: absolute;
left: 4px;
top: 48%;
}
.pane-splitter:hover {
background: #e0eaff;
}
.editor-toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 18px;
background: #f8fafd;
border-bottom: 1px solid #e4ebf4;
min-height: 61px;
}
.editor-toolbar .el-select {
width: 120px;
}
.draft-label {
font-size: 11px;
color: #9aabc0;
margin-left: auto;
}
.editor-area {
flex: 1;
min-height: 320px;
overflow: hidden;
}
.editor-testing {
padding: 6px 18px 15px;
background: #f8fafd;
border-top: 1px solid #e4ebf4;
min-height: 161px;
}
.editor-testing .el-tabs__header {
margin-bottom: 12px;
}
.editor-testing .el-tabs__item {
font-size: 12px;
height: 38px;
}
.test-result-placeholder {
font-size: 12px;
color: #93a4ba;
padding: 16px 0;
min-height: 90px;
}
.editor-buttons {
border-top: 1px solid #edf0f5;
display: flex;
gap: 8px;
align-items: center;
padding: 14px 18px;
min-height: 55px;
}
.editor-buttons > span {
margin-right: auto;
}
.editor-settings {
display: flex;
flex-direction: column;
gap: 12px;
font-size: 12px;
}
@media (max-width: 960px) {
.draft-label {
display: none;
}
.statement-content {
padding: 22px;
}
.problem-title-row h1 {
font-size: 24px;
}
.editor-toolbar {
padding: 10px;
}
.editor-buttons {
padding: 10px;
gap: 5px;
}
.editor-buttons > span {
font-size: 10px;
}
.sample-grid {
grid-template-columns: 1fr;
}
.statement-tabs .el-tabs__item {
padding: 0 10px;
}
}
@media (max-width: 760px) {
.problem-workspace {
display: flex;
flex-direction: column;
height: auto;
gap: 16px;
min-height: 0;
}
.pane-splitter {
display: none;
}
.statement-content {
overflow: visible;
}
.editor-area {
height: 470px;
flex: none;
}
.editor-panel {
min-height: 700px;
}
.editor-buttons {
flex-wrap: wrap;
}
.editor-buttons > span {
width: 100%;
font-size: 11px;
}
.sample-grid {
grid-template-columns: 1fr 1fr;
}
.statement-content {
padding: 22px;
}
.app-main:has(.problem-workspace) {
width: calc(100% - 24px);
margin-top: 14px;
}
}
</style>