feat(frontend): add practice page previews and code workbench
Add problem filtering, trainings, contests, submission details and rankings. Provide a split problem/editor layout with multilingual local drafts and self-test panels. Run and submit actions show pending service integration.
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { contests, problems } from '../data/preview'
|
||||
import PageHeading from '../components/PageHeading.vue'
|
||||
import PanelCard from '../components/PanelCard.vue'
|
||||
import ProblemTable from '../components/ProblemTable.vue'
|
||||
import EmptyState from '../components/EmptyState.vue'
|
||||
const route = useRoute()
|
||||
const tab = ref('overview')
|
||||
const contest = computed(() => contests.find((c) => c.id === route.params.id))
|
||||
const items = computed(() => problems.filter((p) => contest.value?.ids.includes(p.id)))
|
||||
</script>
|
||||
<template>
|
||||
<template v-if="contest"
|
||||
><div class="breadcrumbs">
|
||||
<RouterLink to="/contests">比赛</RouterLink><span>/</span><span>{{ contest.title }}</span>
|
||||
</div>
|
||||
<PageHeading :title="contest.title" :description="contest.description"
|
||||
><el-tag effect="light">{{ contest.status }} · 示例</el-tag></PageHeading
|
||||
>
|
||||
<div class="content-with-aside">
|
||||
<PanelCard title="比赛信息" icon="contests"
|
||||
><el-tabs v-model="tab"
|
||||
><el-tab-pane label="比赛说明" name="overview"
|
||||
><div class="detail-prose">
|
||||
<h2>比赛介绍</h2>
|
||||
<p>{{ contest.description }}</p>
|
||||
<h2>比赛规则</h2>
|
||||
<p>
|
||||
{{
|
||||
contest.type === 'ACM'
|
||||
? 'ACM 赛制按照通过题目数排序,通过数相同时比较罚时。'
|
||||
: 'OI 赛制按照题目得分总和排序。具体规则以实际比赛公告为准。'
|
||||
}}
|
||||
</p>
|
||||
<h2>参赛须知</h2>
|
||||
<p>
|
||||
认真阅读每道题的数据范围和限制。赛程、题目和状态当前为界面示例,报名功能将在比赛服务接入后开放。
|
||||
</p>
|
||||
</div></el-tab-pane
|
||||
><el-tab-pane label="比赛题目" name="problems"
|
||||
><ProblemTable :items="items" compact /></el-tab-pane
|
||||
><el-tab-pane label="比赛榜单" name="rank"
|
||||
><EmptyState
|
||||
title="暂无实际比赛成绩"
|
||||
description="比赛服务接入后展示实时榜单。" /></el-tab-pane
|
||||
><el-tab-pane label="提交记录" name="submissions"
|
||||
><EmptyState title="暂无实际提交记录" /></el-tab-pane></el-tabs></PanelCard
|
||||
><PanelCard title="比赛概况" icon="clock"
|
||||
><dl class="details-list">
|
||||
<div>
|
||||
<dt>赛制</dt>
|
||||
<dd>{{ contest.type }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>开始时间</dt>
|
||||
<dd>{{ contest.start }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>比赛时长</dt>
|
||||
<dd>{{ contest.duration }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>题目数量</dt>
|
||||
<dd>{{ items.length }} 道</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<el-button type="primary" disabled style="width: 100%; margin-top: 25px"
|
||||
>报名功能待接入</el-button
|
||||
></PanelCard
|
||||
>
|
||||
</div></template
|
||||
><EmptyState v-else title="比赛不存在"
|
||||
><RouterLink to="/contests"><el-button>返回比赛列表</el-button></RouterLink></EmptyState
|
||||
>
|
||||
</template>
|
||||
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { contests } from '../data/preview'
|
||||
import PanelCard from '../components/PanelCard.vue'
|
||||
import PageHeading from '../components/PageHeading.vue'
|
||||
const status = ref('全部')
|
||||
const query = ref('')
|
||||
const type = ref('')
|
||||
const items = computed(() =>
|
||||
contests.filter(
|
||||
(c) =>
|
||||
(status.value === '全部' || c.status === status.value) &&
|
||||
(!type.value || c.type === type.value) &&
|
||||
c.title.includes(query.value),
|
||||
),
|
||||
)
|
||||
</script>
|
||||
<template>
|
||||
<PageHeading title="比赛" eyebrow="CONTESTS" description="在有限的时间里,挑战新的问题。" />
|
||||
<PanelCard title="比赛列表" icon="contests" subtitle="示例赛程"
|
||||
><div class="filters">
|
||||
<el-radio-group v-model="status"
|
||||
><el-radio-button
|
||||
v-for="s in ['全部', '即将开始', '进行中', '已结束']"
|
||||
:key="s"
|
||||
:value="s"
|
||||
>{{ s }}</el-radio-button
|
||||
></el-radio-group
|
||||
><el-input v-model="query" placeholder="搜索比赛" aria-label="搜索比赛" clearable /><el-select
|
||||
v-model="type"
|
||||
placeholder="全部赛制"
|
||||
aria-label="赛制"
|
||||
clearable
|
||||
><el-option label="ACM" value="ACM" /><el-option label="OI" value="OI"
|
||||
/></el-select>
|
||||
</div>
|
||||
<el-table :data="items" stripe empty-text="没有符合条件的比赛"
|
||||
><el-table-column label="比赛名称" min-width="270"
|
||||
><template #default="{ row }"
|
||||
><RouterLink class="table-link" :to="'/contests/' + row.id">{{
|
||||
row.title
|
||||
}}</RouterLink></template
|
||||
></el-table-column
|
||||
><el-table-column label="赛制" width="85" prop="type" /><el-table-column
|
||||
label="状态"
|
||||
width="120"
|
||||
><template #default="{ row }"
|
||||
><el-tag
|
||||
size="small"
|
||||
:type="
|
||||
row.status === '进行中' ? 'success' : row.status === '已结束' ? 'info' : 'primary'
|
||||
"
|
||||
effect="light"
|
||||
>{{ row.status }}</el-tag
|
||||
></template
|
||||
></el-table-column
|
||||
><el-table-column label="开始时间" prop="start" min-width="160" /><el-table-column
|
||||
label="时长"
|
||||
prop="duration"
|
||||
width="100"
|
||||
/><el-table-column label="题目" width="90"
|
||||
><template #default="{ row }">{{ row.ids.length }} 道</template></el-table-column
|
||||
><el-table-column label="" width="100"
|
||||
><template #default="{ row }"
|
||||
><RouterLink class="text-link" :to="'/contests/' + row.id"
|
||||
>查看比赛 →</RouterLink
|
||||
></template
|
||||
></el-table-column
|
||||
></el-table
|
||||
>
|
||||
<p class="tab-summary">共 {{ items.length }} 场比赛</p></PanelCard
|
||||
>
|
||||
</template>
|
||||
@@ -0,0 +1,520 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { findProblem, discussions, submissions } from '../data/preview'
|
||||
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 id = String(route.params.id)
|
||||
const problem = computed(() => findProblem(id))
|
||||
const { language, code, state, save } = useCodeDraft(id)
|
||||
const theme = ref('vs')
|
||||
const fontSize = ref(14)
|
||||
const tab = ref('description')
|
||||
const testTab = ref('input')
|
||||
const testInput = ref(problem.value?.sampleInput ?? '')
|
||||
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 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
|
||||
}
|
||||
onBeforeUnmount(() => stopDrag?.())
|
||||
async function resetCode() {
|
||||
try {
|
||||
await ElMessageBox.confirm('将当前语言的草稿恢复为初始模板?', '重置代码', {
|
||||
confirmButtonText: '重置',
|
||||
cancelButtonText: '保留草稿',
|
||||
type: 'info',
|
||||
})
|
||||
code.value = codeTemplates[language.value]
|
||||
} catch {
|
||||
/* User kept their draft. */
|
||||
}
|
||||
}
|
||||
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.click()
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
}
|
||||
async function copySample(value: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value)
|
||||
ElMessage.success('已复制样例')
|
||||
} catch {
|
||||
ElMessage.info('浏览器暂不支持复制,请选中样例文本复制')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
v-if="problem"
|
||||
ref="workspace"
|
||||
class="problem-workspace"
|
||||
: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>
|
||||
<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>
|
||||
<h2>
|
||||
<RouterLink :to="'/discussions/' + discussion.id">{{
|
||||
discussion.title
|
||||
}}</RouterLink>
|
||||
</h2>
|
||||
<p>{{ discussion.author }} · {{ discussion.date }} · 示例文章</p>
|
||||
</div>
|
||||
</article>
|
||||
<EmptyState v-if="!relatedDiscussion.length" title="暂无本题讨论" /><RouterLink
|
||||
to="/discussions"
|
||||
class="text-link"
|
||||
>进入讨论社区 →</RouterLink
|
||||
></template
|
||||
>
|
||||
<EmptyState
|
||||
v-else
|
||||
title="AI 答疑待接入"
|
||||
description="后续结合题目、代码和提交记录,提供逐步提示与针对性分析。"
|
||||
/>
|
||||
</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-tooltip content="重置模板"
|
||||
><el-button size="small" aria-label="重置代码模板" @click="resetCode"
|
||||
><UiIcon name="refresh" /></el-button></el-tooltip
|
||||
><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"
|
||||
><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">
|
||||
<CodeEditor 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
|
||||
>
|
||||
</div>
|
||||
<footer class="editor-buttons">
|
||||
<span class="small muted">评测服务待接入</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>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
<EmptyState v-else title="题目不存在"
|
||||
><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>
|
||||
@@ -0,0 +1,62 @@
|
||||
<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 PageHeading from '../components/PageHeading.vue'
|
||||
import PanelCard from '../components/PanelCard.vue'
|
||||
import ProblemTable from '../components/ProblemTable.vue'
|
||||
const route = useRoute()
|
||||
const query = ref('')
|
||||
const difficulty = ref('')
|
||||
const tag = ref(typeof route.query.tag === 'string' ? route.query.tag : '')
|
||||
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)),
|
||||
),
|
||||
)
|
||||
const { page, pageSize, paged, total } = usePagination(filtered)
|
||||
function reset() {
|
||||
query.value = ''
|
||||
difficulty.value = ''
|
||||
tag.value = ''
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<PageHeading title="题库" eyebrow="PROBLEM BANK" description="挑选一道题,开始今天的练习。"
|
||||
><RouterLink to="/trainings"><el-button>按题单练习</el-button></RouterLink></PageHeading
|
||||
>
|
||||
<PanelCard title="全部题目" icon="problems" subtitle="示例题库">
|
||||
<div class="filters">
|
||||
<el-input
|
||||
v-model="query"
|
||||
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>
|
||||
</PanelCard>
|
||||
</template>
|
||||
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { rankings } from '../data/preview'
|
||||
import PanelCard from '../components/PanelCard.vue'
|
||||
import PageHeading from '../components/PageHeading.vue'
|
||||
const search = ref('')
|
||||
const order = ref('solved')
|
||||
const items = computed(() =>
|
||||
[...rankings]
|
||||
.sort((a, b) => (order.value === 'solved' ? b.solved - a.solved : b.rating - a.rating))
|
||||
.map((user, index) => ({ ...user, rank: index + 1 }))
|
||||
.filter((u) => u.username.toLowerCase().includes(search.value.trim().toLowerCase())),
|
||||
)
|
||||
</script>
|
||||
<template>
|
||||
<PageHeading title="总排名" eyebrow="RANKINGS" description="保持练习,让每一道题都成为积累。" />
|
||||
<div class="summary-grid">
|
||||
<div v-for="(user, index) in rankings.slice(0, 3)" :key="user.username" class="summary-card">
|
||||
<p>NO. {{ index + 1 }} · 示例过题榜</p>
|
||||
<strong style="font-size: 23px"
|
||||
><RouterLink :to="'/users/' + user.username">{{ user.username }}</RouterLink></strong
|
||||
><small>{{ user.solved }} 道已通过 · {{ user.motto }}</small>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<p>榜单规则</p>
|
||||
<strong style="font-size: 21px">持续进步</strong><small>默认按照通过题目数排序</small>
|
||||
</div>
|
||||
</div>
|
||||
<PanelCard title="排行榜" icon="rankings" subtitle="示例排名"
|
||||
><div class="filters">
|
||||
<el-input
|
||||
v-model="search"
|
||||
placeholder="搜索用户"
|
||||
aria-label="搜索排名用户"
|
||||
clearable
|
||||
/><el-radio-group v-model="order"
|
||||
><el-radio-button value="solved">过题数</el-radio-button
|
||||
><el-radio-button value="rating">竞赛积分</el-radio-button></el-radio-group
|
||||
>
|
||||
</div>
|
||||
<el-table :data="items" stripe empty-text="没有找到用户"
|
||||
><el-table-column label="排名" width="80" align="center"
|
||||
><template #default="{ row }"
|
||||
><span class="number-medal" :class="'top-' + row.rank">{{ row.rank }}</span></template
|
||||
></el-table-column
|
||||
><el-table-column label="用户" min-width="170"
|
||||
><template #default="{ row }"
|
||||
><RouterLink :to="'/users/' + row.username" class="user-inline table-link"
|
||||
><span class="avatar" :style="{ background: row.color }">{{ row.username[0] }}</span
|
||||
>{{ row.username }}</RouterLink
|
||||
></template
|
||||
></el-table-column
|
||||
><el-table-column label="个人签名" prop="motto" min-width="200" /><el-table-column
|
||||
label="通过题目"
|
||||
prop="solved"
|
||||
width="120"
|
||||
align="right" /><el-table-column
|
||||
label="总提交"
|
||||
prop="submissions"
|
||||
width="110"
|
||||
align="right" /><el-table-column
|
||||
label="竞赛积分"
|
||||
prop="rating"
|
||||
width="110"
|
||||
align="right" /></el-table
|
||||
></PanelCard>
|
||||
</template>
|
||||
@@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { submissions, findProblem } from '../data/preview'
|
||||
import PageHeading from '../components/PageHeading.vue'
|
||||
import PanelCard from '../components/PanelCard.vue'
|
||||
import VerdictTag from '../components/VerdictTag.vue'
|
||||
import EmptyState from '../components/EmptyState.vue'
|
||||
const route = useRoute()
|
||||
const submission = computed(() => submissions.find((s) => s.id === route.params.id))
|
||||
</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>
|
||||
<div class="summary-grid">
|
||||
<div class="summary-card">
|
||||
<p>运行时间</p>
|
||||
<strong
|
||||
>{{ submission.time ?? '—' }} <small v-if="submission.time !== null">ms</small></strong
|
||||
><small>示例数据</small>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<p>内存使用</p>
|
||||
<strong
|
||||
>{{ submission.memory === null ? '—' : submission.memory / 1024 }}
|
||||
<small v-if="submission.memory !== null">MiB</small></strong
|
||||
><small>示例数据</small>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<p>语言</p>
|
||||
<strong>{{ submission.language }}</strong
|
||||
><small>工具链版本待接入</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>
|
||||
</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
|
||||
>
|
||||
</template>
|
||||
@@ -0,0 +1,75 @@
|
||||
<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 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 = ''
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<PageHeading
|
||||
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" />
|
||||
<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>
|
||||
</template>
|
||||
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { trainings, problems } from '../data/preview'
|
||||
import PanelCard from '../components/PanelCard.vue'
|
||||
import PageHeading from '../components/PageHeading.vue'
|
||||
import ProblemTable from '../components/ProblemTable.vue'
|
||||
import EmptyState from '../components/EmptyState.vue'
|
||||
const route = useRoute()
|
||||
const training = computed(() => trainings.find((t) => t.id === route.params.id))
|
||||
const items = computed(() => problems.filter((p) => training.value?.ids.includes(p.id)))
|
||||
</script>
|
||||
<template>
|
||||
<template v-if="training"
|
||||
><div class="breadcrumbs">
|
||||
<RouterLink to="/trainings">训练</RouterLink><span>/</span
|
||||
><span>{{ training.category }}</span>
|
||||
</div>
|
||||
<PageHeading :title="training.title" :description="training.description"
|
||||
><RouterLink v-if="items[0]" :to="'/problems/' + items[0].id"
|
||||
><el-button type="primary">从第一题开始</el-button></RouterLink
|
||||
></PageHeading
|
||||
>
|
||||
<div class="content-with-aside">
|
||||
<PanelCard title="训练题目" icon="problems" subtitle="按顺序练习"
|
||||
><ProblemTable :items="items" compact
|
||||
/></PanelCard>
|
||||
<div class="stack">
|
||||
<PanelCard title="题单信息" icon="trainings"
|
||||
><dl class="details-list">
|
||||
<div>
|
||||
<dt>分类</dt>
|
||||
<dd>{{ training.category }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>难度</dt>
|
||||
<dd>{{ training.level }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>题目数量</dt>
|
||||
<dd>{{ items.length }} 道</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>训练记录</dt>
|
||||
<dd>账号服务接入后开启</dd>
|
||||
</div>
|
||||
</dl></PanelCard
|
||||
><PanelCard title="练习建议" icon="reading"
|
||||
><p class="small muted">
|
||||
先写出自己的解题思路,检查数据范围和边界情况。完成后回顾代码,记录遇到的问题。
|
||||
</p>
|
||||
<RouterLink to="/discussions" class="text-link">去讨论区交流 →</RouterLink></PanelCard
|
||||
>
|
||||
</div>
|
||||
</div></template
|
||||
><EmptyState v-else title="题单不存在"
|
||||
><RouterLink to="/trainings"><el-button>返回训练列表</el-button></RouterLink></EmptyState
|
||||
>
|
||||
</template>
|
||||
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { trainings } from '../data/preview'
|
||||
import PageHeading from '../components/PageHeading.vue'
|
||||
import DifficultyTag from '../components/DifficultyTag.vue'
|
||||
const category = ref('全部')
|
||||
const query = ref('')
|
||||
const items = computed(() =>
|
||||
trainings.filter(
|
||||
(t) =>
|
||||
(category.value === '全部' || t.category === category.value) && t.title.includes(query.value),
|
||||
),
|
||||
)
|
||||
</script>
|
||||
<template>
|
||||
<PageHeading title="训练计划" eyebrow="TRAINING" description="从基础到专题,让练习更有顺序。" />
|
||||
<div class="intro-banner">
|
||||
<div>
|
||||
<p class="eyebrow">ONE STEP AT A TIME</p>
|
||||
<h2>循序渐进,把知识点练扎实。</h2>
|
||||
<p>选择适合自己的题单,先理解问题,再尝试独立完成。训练记录将在账号与评测服务接入后开启。</p>
|
||||
</div>
|
||||
<span class="intro-number">{ }</span>
|
||||
</div>
|
||||
<div class="filters">
|
||||
<el-radio-group v-model="category"
|
||||
><el-radio-button
|
||||
v-for="c in ['全部', '基础入门', '算法专题', '进阶练习']"
|
||||
:key="c"
|
||||
:value="c"
|
||||
>{{ c }}</el-radio-button
|
||||
></el-radio-group
|
||||
><el-input v-model="query" placeholder="搜索训练题单" aria-label="搜索训练" clearable />
|
||||
</div>
|
||||
<div class="card-grid">
|
||||
<article v-for="training in items" :key="training.id" class="training-card">
|
||||
<div class="training-illustration" :class="training.color">{{ training.icon }}</div>
|
||||
<div style="flex: 1">
|
||||
<div class="tag-list">
|
||||
<span class="tag">{{ training.category }}</span
|
||||
><DifficultyTag :value="training.level" />
|
||||
</div>
|
||||
<h2>
|
||||
<RouterLink :to="'/trainings/' + training.id">{{ training.title }}</RouterLink>
|
||||
</h2>
|
||||
<p>{{ training.description }}</p>
|
||||
<div class="card-meta">
|
||||
<span>{{ training.ids.length }} 道题目</span><span>示例题单</span
|
||||
><RouterLink class="text-link" :to="'/trainings/' + training.id">开始训练 →</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<el-empty v-if="!items.length" description="没有符合条件的训练" />
|
||||
</template>
|
||||
Reference in New Issue
Block a user