Author SHA1 Message Date
Kronecker 2ad35865c0 feat(frontend): show submission performance in problem workbench 2026-10-09 14:45:53 +08:00
Kronecker 62132324da feat(frontend): add submission performance distribution previews 2026-10-09 14:45:53 +08:00
Kronecker f108e88c47 fix(frontend): save pending code drafts before page exit 2026-10-05 15:26:41 +08:00
Kronecker 8c3cca6fde feat(frontend): isolate admin app with sidebar navigation
Build a separate admin entrypoint with its own router and sidebar layout.
Add preview sections for problems, contests, users, judge nodes and site settings.
Configure Vite to emit both public and admin HTML entries.
2026-10-05 01:50:07 +08:00
Kronecker 1443f00495 feat(frontend): add account and community page previews
Add profiles, discussions, team creation, authentication forms and the about page.
Validate preview forms and display the available platform health endpoint.
2026-10-05 01:50:02 +08:00
Kronecker 2ae3bfb6fb feat(frontend): build homepage dashboard and interactive tag sphere
Present announcements, submission trends, recent problems, contests and solver rankings.
Arrange blue pill labels in a TagCanvas sphere with adaptive sizing, mouse-follow and drag modes.
Allow tag selection to filter the problem list and provide keyboard-accessible links.
2026-10-05 01:49:58 +08:00
Kronecker 5c7a45fe41 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.
2026-10-05 01:49:55 +08:00
Kronecker 9fa25189a2 feat(frontend): establish shared components and blue page layouts
Add reusable headings, panels, tables, charts, pagination and explicit preview fixtures.
Apply the shared blue theme, responsive top navigation and Chinese UI locale.
2026-10-05 01:48:53 +08:00
Kronecker 720c69a198 chore(frontend): configure UI dependencies and Vue linting
Pin icon components, Monaco Editor and TagCanvas modules in the lockfile.
Let TypeScript validate identifiers in Vue script setup.
2026-10-05 01:48:51 +08:00
60 changed files with 6855 additions and 98 deletions
+1
View File
@@ -43,6 +43,7 @@ check-frontend:
cd frontend && $(PNPM) lint
cd frontend && $(PNPM) format:check
cd frontend && $(PNPM) typecheck
cd frontend && $(PNPM) test
cd frontend && $(PNPM) build
contracts:
+3
View File
@@ -7,6 +7,7 @@ Vue 3 + TypeScript + Vite + Element Plus,使用 Vue Router 与 Pinia。
pnpm install --frozen-lockfile
pnpm dev
pnpm typecheck
pnpm test
pnpm build
```
@@ -22,3 +23,5 @@ pnpm build
已有代码按实际需要落位;`components/` 和 `stores/` 是后续扩展位置,不放空状态实现。
API 类型更新执行根目录 `make contracts`;前端通过 `pnpm check:api` 拒绝过期类型。
登录凭据、模型密钥和数据库连接不能进入 `VITE_` 环境变量。
`pnpm test` 验证代码草稿在刷新、切到后台与页面离开时的保存行为,已纳入 `make check-frontend`。
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>KF 管理控制台</title>
</head>
<body>
<div id="admin-app"></div>
<script type="module" src="/src/admin/main.ts"></script>
</body>
</html>
+2 -1
View File
@@ -11,7 +11,8 @@ export default tseslint.config(
{
files: ['**/*.vue'],
languageOptions: { parserOptions: { parser: tseslint.parser } },
rules: { 'vue/multi-word-component-names': 'off' },
// TypeScript checks identifiers and DOM globals in Vue's script setup.
rules: { 'vue/multi-word-component-names': 'off', 'no-undef': 'off' },
},
{
files: ['vite.config.ts'],
+4
View File
@@ -12,6 +12,7 @@
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview --host 127.0.0.1",
"typecheck": "vue-tsc --noEmit",
"test": "node --test tests/*.test.ts",
"lint": "eslint . --max-warnings 0",
"format": "prettier --write .",
"format:check": "prettier --check .",
@@ -19,7 +20,10 @@
"check:api": "pnpm generate:api --check"
},
"dependencies": {
"@element-plus/icons-vue": "2.3.2",
"@misskey-dev/tagcanvas-es": "0.1.2",
"element-plus": "^2.9.0",
"monaco-editor": "0.57.0",
"openapi-fetch": "^0.14.0",
"pinia": "^3.0.0",
"vue": "^3.5.0",
+42
View File
@@ -8,9 +8,18 @@ importers:
.:
dependencies:
'@element-plus/icons-vue':
specifier: 2.3.2
version: 2.3.2(vue@3.5.43(typescript@5.9.3))
'@misskey-dev/tagcanvas-es':
specifier: 0.1.2
version: 0.1.2
element-plus:
specifier: ^2.9.0
version: 2.14.7(vue@3.5.43(typescript@5.9.3))
monaco-editor:
specifier: 0.57.0
version: 0.57.0
openapi-fetch:
specifier: ^0.14.0
version: 0.14.1
@@ -335,6 +344,9 @@ packages:
'@keyv/serialize@1.1.1':
resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==}
'@misskey-dev/tagcanvas-es@0.1.2':
resolution: {integrity: sha512-SGqyu30/CCQyIC95Zcmj3k1vYXwfX66/arsxpl1UUulUqvlRpTpuhB7kEAAps1lqXin0rQzXknb31EeveNKsaw==}
'@napi-rs/lzma-linux-x64-gnu@1.5.1':
resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==}
engines: {node: ^22.20 || ^24.12 || >=25}
@@ -514,6 +526,9 @@ packages:
'@types/node@24.19.1':
resolution: {integrity: sha512-aS3/DG0oM05K0RIXXP+hKjinGG5IgSSVGzswZxW3O0sS3pH4/fycXundUC9XsszgKCk4gHXylTEK6hyFxVxnoQ==}
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
'@types/web-bluetooth@0.0.21':
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
@@ -745,6 +760,9 @@ packages:
deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
dompurify@3.4.15:
resolution: {integrity: sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw==}
element-plus@2.14.7:
resolution: {integrity: sha512-lrG7pZyCS+mpCNVpzxxyu5r35+T6V0hzkAeIwn7HA6to772XSTHKuYCZG51OfzlqaxGbNegLxs650zAF1j43gA==}
peerDependencies:
@@ -959,6 +977,11 @@ packages:
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
marked@14.0.0:
resolution: {integrity: sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==}
engines: {node: '>= 18'}
hasBin: true
memoize-one@6.0.0:
resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==}
@@ -973,6 +996,9 @@ packages:
mitt@3.0.1:
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
monaco-editor@0.57.0:
resolution: {integrity: sha512-5BkI9KGoqrNvBGUe15/QlZq3OooZ8WLg1AxTpaqHRCP3HNpzPPZKE2EDz8M7c+VRmCeUw1Brp4cx/PWm3kI/5A==}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -1446,6 +1472,8 @@ snapshots:
'@keyv/serialize@1.1.1': {}
'@misskey-dev/tagcanvas-es@0.1.2': {}
'@napi-rs/lzma-linux-x64-gnu@1.5.1':
optional: true
@@ -1567,6 +1595,9 @@ snapshots:
dependencies:
undici-types: 7.24.6
'@types/trusted-types@2.0.7':
optional: true
'@types/web-bluetooth@0.0.21': {}
'@typescript-eslint/eslint-plugin@8.71.0(@typescript-eslint/parser@8.71.0(eslint@10.12.0(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3))(eslint@10.12.0(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3)':
@@ -1850,6 +1881,10 @@ snapshots:
deep-is@0.1.4: {}
dompurify@3.4.15:
optionalDependencies:
'@types/trusted-types': 2.0.7
element-plus@2.14.7(vue@3.5.43(typescript@5.9.3)):
dependencies:
'@ctrl/tinycolor': 4.2.1
@@ -2093,6 +2128,8 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.6.0
marked@14.0.0: {}
memoize-one@6.0.0: {}
minimatch@10.2.6:
@@ -2105,6 +2142,11 @@ snapshots:
mitt@3.0.1: {}
monaco-editor@0.57.0:
dependencies:
dompurify: 3.4.15
marked: 14.0.0
ms@2.1.3: {}
muggle-string@0.4.1: {}
+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 440" fill="none">
<defs><linearGradient id="sky" x1="0" y1="0" x2="1200" y2="440" gradientUnits="userSpaceOnUse"><stop stop-color="#eff7ff"/><stop offset="1" stop-color="#c9e3fa"/></linearGradient><linearGradient id="veil"><stop stop-color="#edf6ff"/><stop offset=".53" stop-color="#edf6ff" stop-opacity=".9"/><stop offset="1" stop-color="#edf6ff" stop-opacity="0"/></linearGradient></defs>
<path fill="url(#sky)" d="M0 0h1200v440H0z"/><circle cx="973" cy="100" r="66" fill="#fff" opacity=".75"/>
<path d="m350 350 182-211 197 203 107-138 136 144 92-94 136 117v69H350z" fill="#b8d6ee"/><path d="m532 139 58 60-55-17-43 28z" fill="#ecf7ff"/><path d="m836 204 51 53-51-14-30 20z" fill="#e6f4ff"/>
<path d="M0 394c132-50 237-24 380-47s217-3 359 22 296-28 461-55v126H0z" fill="#aacbe5"/><path d="M0 414c135-28 304-1 506-11s414 3 694-39v76H0z" fill="#dcecf7"/>
<path d="M836 226h183v118H836z" fill="#f5faff"/><path d="m821 226 106-51 106 51z" fill="#79a9d2"/><path d="M905 271h41v73h-41z" fill="#a4c7e3"/><path d="M851 249h28v28h-28zm123 0h28v28h-28z" fill="#c2dcef"/>
<path d="M772 342h19v-67h-19z" fill="#779cbc"/><path d="m737 290 46-118 46 118z" fill="#8ab6cc"/><path d="m730 316 53-112 54 112z" fill="#77a6bc"/>
<path d="M1082 350h18v-93h-18z" fill="#779cbc"/><path d="m1035 291 56-131 57 131z" fill="#8cb8cf"/><path d="m1023 321 68-127 67 127z" fill="#77a6bf"/>
<path d="M883 355c-20 36-65 45-111 57" stroke="#f5faff" stroke-width="14" stroke-linecap="round"/>
<g stroke="#91bce0" opacity=".6"><rect x="674" y="81" width="114" height="75" rx="8" fill="#f3f9ff" transform="rotate(-9 674 81)"/><path d="m698 105-9 10 12 7m29-18 12 7-9 10m-6-22-8 26" stroke-width="3"/><rect x="1053" y="42" width="73" height="49" rx="6" fill="#f4faff" transform="rotate(7 1053 42)"/><path d="M1069 59h38m-38 10h26" stroke-width="3"/></g><path d="M0 0h1200v440H0z" fill="url(#veil)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" width="500" height="340" viewBox="0 0 500 340">
<defs><pattern id="dots" width="24" height="24" patternUnits="userSpaceOnUse"><circle cx="2" cy="2" r="1" fill="#b6d2ef"/></pattern><linearGradient id="paper" x2="0.8" y2="1"><stop stop-color="#fff"/><stop offset="1" stop-color="#f5faff"/></linearGradient></defs>
<rect x="28" y="12" width="444" height="308" rx="28" fill="url(#dots)"/>
<path d="M92 212v57h85v-43h97v49h140v-104" fill="none" stroke="#c8dff6" stroke-width="20" stroke-linejoin="round"/>
<path d="M92 212v57h85v-43h97v49h140v-104" fill="none" stroke="#76b5ee" stroke-width="2" stroke-dasharray="5 7"/>
<g transform="rotate(-5 231 118)"><rect x="73" y="30" width="308" height="183" rx="16" fill="#cfdef0" opacity=".4"/><rect x="65" y="22" width="308" height="183" rx="16" fill="url(#paper)" stroke="#c8dff6"/><path d="M65 60h308" stroke="#e2edf8"/><circle cx="86" cy="41" r="4" fill="#b9d6f2"/><circle cx="101" cy="41" r="4" fill="#d4e5f6"/><circle cx="116" cy="41" r="4" fill="#e2edf8"/><text x="145" y="45" font-family="monospace" font-size="10" fill="#8fa8c4">solution.cpp</text><text x="89" y="92" font-family="monospace" font-size="14" fill="#657e9e">int main() {</text><text x="106" y="122" font-family="monospace" font-size="14" fill="#409eff">cin &gt;&gt; a &gt;&gt; b;</text><text x="106" y="151" font-family="monospace" font-size="14" fill="#455e80">cout &lt;&lt; a + b;</text><text x="89" y="181" font-family="monospace" font-size="14" fill="#657e9e">}</text></g>
<g transform="rotate(6 377 185)"><rect x="307" y="144" width="151" height="82" rx="12" fill="#fff" stroke="#b6d2ef"/><text x="329" y="169" font-family="monospace" font-size="9" fill="#8fa8c4">01 / SIMPLE START</text><text x="329" y="204" font-family="monospace" font-size="25" font-weight="600" fill="#409eff">1 + 2 = 3</text></g>
<circle cx="92" cy="269" r="13" fill="#fff" stroke="#b9d6f2" stroke-width="2"/><text x="87" y="273" font-family="monospace" font-size="12" fill="#76a9de">A</text><circle cx="274" cy="275" r="13" fill="#409eff"/><path d="m268 275 4 4 8-9" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round"/><circle cx="414" cy="275" r="7" fill="#fff" stroke="#b9d6f2" stroke-width="2"/>
<text x="184" y="313" font-family="monospace" font-size="10" letter-spacing="3" fill="#8fa8c4">THINK / BUILD / REPEAT</text>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

+8 -3
View File
@@ -1,9 +1,14 @@
<script setup lang="ts">
import AppLayout from './layouts/AppLayout.vue'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
</script>
<template>
<AppLayout>
<RouterView />
</AppLayout>
<el-config-provider :locale="zhCn">
<AppLayout>
<RouterView v-slot="{ Component, route }">
<component :is="Component" :key="route.path" />
</RouterView>
</AppLayout>
</el-config-provider>
</template>
+39
View File
@@ -0,0 +1,39 @@
<script setup lang="ts">
import { useRoute } from 'vue-router'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import UiIcon from '../components/UiIcon.vue'
import { adminSections } from './navigation'
const route = useRoute()
</script>
<template>
<el-config-provider :locale="zhCn">
<div class="admin-application">
<header class="admin-app-header">
<a href="/admin/index.html" class="brand"
><span class="brand-mark">KF.</span
><span class="brand-name">管理控制台<small>ADMIN CONSOLE</small></span></a
>
<span class="admin-app-label">独立管理应用 · 界面预览</span>
<a href="/" class="admin-return">返回前台 <UiIcon name="arrow" /></a>
</header>
<nav class="admin-app-nav" aria-label="管理导航">
<RouterLink
v-for="section in adminSections"
:key="section.id"
:to="section.id === 'overview' ? '/' : '/' + section.id"
:aria-label="section.title"
:class="{ active: (route.params.section || 'overview') === section.id }"
><UiIcon :name="section.icon" /><span>{{ section.title }}</span></RouterLink
>
</nav>
<main class="admin-app-main">
<RouterView v-slot="{ Component, route: currentRoute }"
><component :is="Component" :key="currentRoute.path"
/></RouterView>
</main>
<footer class="admin-app-footer">
KF OnlineJudge / 管理控制台<span>示例数据 · 管理员授权与业务接口待接入</span>
</footer>
</div>
</el-config-provider>
</template>
+272
View File
@@ -0,0 +1,272 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { useRoute } from 'vue-router'
import { problems, contests, rankings } from '../data/preview'
import PageHeading from '../components/PageHeading.vue'
import PanelCard from '../components/PanelCard.vue'
import SubmissionChart from '../components/SubmissionChart.vue'
import EmptyState from '../components/EmptyState.vue'
import { adminSections as sections } from './navigation'
const route = useRoute()
const section = computed(() => String(route.params.section || 'overview'))
const current = computed(() => sections.find((s) => s.id === section.value))
const query = ref('')
const problemItems = computed(() => problems.filter((p) => (p.id + p.title).includes(query.value)))
const contestItems = computed(() => contests.filter((c) => c.title.includes(query.value)))
const userItems = computed(() =>
rankings.filter((u) => u.username.toLowerCase().includes(query.value.toLowerCase())),
)
const editOpen = ref(false)
const draft = reactive({ id: '', title: '', difficulty: '入门', status: '公开' })
const settings = reactive({
name: 'KF OnlineJudge',
slogan: '每一次提交,都有新的收获。',
registration: true,
announcement: '欢迎来到 KF OnlineJudge',
filing: '',
})
const nodes = [
{
name: 'home-worker-01',
host: 'Debian',
capacity: '4 核 / 8 GB',
status: '在线',
load: 32,
tasks: 2,
},
{
name: 'cloud-worker-01',
host: 'Ubuntu',
capacity: '8 核 / 8 GB',
status: '在线',
load: 46,
tasks: 4,
},
{
name: 'cloud-worker-02',
host: 'Ubuntu',
capacity: '2 核 / 2 GB',
status: '离线',
load: 0,
tasks: 0,
},
]
function editProblem(id?: string) {
const row = problems.find((problem) => problem.id === id)
Object.assign(draft, {
id: row?.id ?? '',
title: row?.title ?? '',
difficulty: row?.difficulty ?? '入门',
status: '公开',
})
editOpen.value = true
}
</script>
<template>
<PageHeading
title="管理中心"
eyebrow="ADMINISTRATION"
description="管理界面预览 · 管理员身份与写入接口待接入。"
><a href="/"><el-button>返回站点</el-button></a></PageHeading
>
<div class="admin-shell">
<div v-if="current" class="stack">
<template v-if="section === 'overview'"
><div class="summary-grid">
<div class="summary-card">
<p>公开题目</p>
<strong>248</strong><small>示例统计</small>
</div>
<div class="summary-card">
<p>累计提交</p>
<strong>18,240</strong><small>示例统计</small>
</div>
<div class="summary-card">
<p>注册用户</p>
<strong>328</strong><small>示例统计</small>
</div>
<div class="summary-card">
<p>在线节点</p>
<strong>2 / 3</strong><small>示例统计</small>
</div>
</div>
<PanelCard title="提交趋势" icon="rankings"><SubmissionChart /></PanelCard
><PanelCard title="开发接入状态" icon="connection"
><el-descriptions :column="1" border
><el-descriptions-item label="当前界面">前端预览</el-descriptions-item
><el-descriptions-item label="账号与权限">待接入</el-descriptions-item
><el-descriptions-item label="评测与节点状态">待接入</el-descriptions-item
><el-descriptions-item label="管理写入与操作审计"
>待接入</el-descriptions-item
></el-descriptions
></PanelCard
></template
>
<PanelCard
v-else-if="section === 'problems'"
title="题目管理"
icon="problems"
subtitle="示例题库"
><template #actions
><el-button size="small" type="primary" @click="editProblem()"
>新建题目草稿</el-button
></template
>
<div class="filters">
<el-input
v-model="query"
placeholder="搜索题号或名称"
aria-label="搜索管理题目"
clearable
/>
</div>
<el-table :data="problemItems" stripe
><el-table-column label="题号" prop="id" width="80" /><el-table-column
label="名称"
prop="title"
min-width="190"
/><el-table-column label="难度" prop="difficulty" width="90" /><el-table-column
label="更新日期"
prop="updated"
width="130"
/><el-table-column label="操作" width="150"
><template #default="{ row }"
><el-button link type="primary" @click="editProblem(row.id)">编辑预览</el-button
><a :href="'/problems/' + row.id" class="text-link" style="margin-left: 10px"
>查看</a
></template
></el-table-column
></el-table
></PanelCard
>
<PanelCard
v-else-if="section === 'contests'"
title="比赛管理"
icon="contests"
subtitle="示例赛程"
><template #actions
><el-button size="small" type="primary" disabled>创建比赛</el-button></template
>
<div class="filters">
<el-input v-model="query" placeholder="搜索比赛" aria-label="搜索管理比赛" clearable />
</div>
<el-table :data="contestItems" stripe
><el-table-column label="比赛名称" prop="title" min-width="220" /><el-table-column
label="赛制"
prop="type"
width="75"
/><el-table-column label="状态" prop="status" width="120" /><el-table-column
label="开始时间"
prop="start"
min-width="165"
/><el-table-column label="操作" width="90"
><template #default="{ row }"
><a class="text-link" :href="'/contests/' + row.id">查看</a></template
></el-table-column
></el-table
></PanelCard
>
<PanelCard v-else-if="section === 'users'" title="用户管理" icon="teams" subtitle="示例用户"
><div class="filters">
<el-input v-model="query" placeholder="搜索用户名" aria-label="搜索管理用户" clearable />
</div>
<el-table :data="userItems" stripe
><el-table-column label="用户名" prop="username" min-width="170" /><el-table-column
label="通过题目"
prop="solved"
width="100"
/><el-table-column label="竞赛积分" prop="rating" width="110" /><el-table-column
label="角色"
width="90"
><template #default>用户</template></el-table-column
><el-table-column label="操作" width="160"
><template #default="{ row }"
><a class="text-link" :href="'/users/' + row.username">查看主页</a
><el-button link disabled style="margin-left: 12px">修改角色</el-button></template
></el-table-column
></el-table
></PanelCard
>
<PanelCard
v-else-if="section === 'judges'"
title="判题节点"
icon="connection"
subtitle="示例监控数据"
><p class="notice-strip">实际节点、负载、心跳和任务状态将在协调器接口接入后展示。</p>
<el-table :data="nodes" stripe
><el-table-column label="节点" prop="name" min-width="170" /><el-table-column
label="系统"
prop="host"
width="100"
/><el-table-column label="资源" prop="capacity" min-width="135" /><el-table-column
label="状态"
width="90"
><template #default="{ row }"
><span class="status-dot" :class="{ offline: row.status === '离线' }" />{{
row.status
}}</template
></el-table-column
><el-table-column label="负载" min-width="140"
><template #default="{ row }"
><el-progress :percentage="row.load" :stroke-width="5" /></template></el-table-column
><el-table-column label="任务" prop="tasks" width="70" /><el-table-column
label="操作"
width="100"
><template #default
><el-button link disabled>暂停接单</el-button></template
></el-table-column
></el-table
>
<div class="comment-placeholder">
队列积压、租约过期、重试记录与故障恢复操作待接入。
</div></PanelCard
>
<PanelCard v-else-if="section === 'settings'" title="站点设置" icon="settings"
><el-form label-position="top" style="max-width: 700px" @submit.prevent
><el-form-item label="站点名称"><el-input v-model="settings.name" /></el-form-item
><el-form-item label="站点标语"><el-input v-model="settings.slogan" /></el-form-item
><el-form-item label="首页公告"
><el-input v-model="settings.announcement" type="textarea" :rows="3" /></el-form-item
><el-form-item label="备案信息"
><el-input
v-model="settings.filing"
placeholder="上线前填写真实备案信息" /></el-form-item
><el-form-item label="允许注册"
><el-switch v-model="settings.registration"
/></el-form-item>
<div class="form-actions">
<el-button type="primary" disabled>保存设置</el-button
><span class="muted">当前编辑只在本页面预览</span>
</div></el-form
></PanelCard
>
</div>
<EmptyState v-else title="管理页面不存在" />
</div>
<el-dialog
v-model="editOpen"
:title="draft.id ? '编辑题目 · ' + draft.id : '新建题目草稿'"
width="min(600px, 92vw)"
><el-form label-position="top"
><el-form-item label="题目名称"
><el-input v-model="draft.title" placeholder="填写题目名称" /></el-form-item
><el-form-item label="难度"
><el-select v-model="draft.difficulty"
><el-option
v-for="d in ['入门', '简单', '中等', '困难']"
:key="d"
:label="d"
:value="d" /></el-select></el-form-item
><el-form-item label="发布状态"
><el-radio-group v-model="draft.status"
><el-radio value="公开">公开</el-radio
><el-radio value="草稿">草稿</el-radio></el-radio-group
></el-form-item
>
<p class="small muted">题面编辑、测试数据和版本发布将在题库接口接入后启用。</p></el-form
><template #footer
><el-button @click="editOpen = false">关闭预览</el-button
><el-button type="primary" disabled>保存题目</el-button></template
></el-dialog
>
</template>
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue'
import { registerUi } from '../plugins/ui'
import { adminRouter } from './router'
import AdminApp from './AdminApp.vue'
import '../styles/base.css'
import '../styles/blueprint.css'
const app = createApp(AdminApp)
registerUi(app)
app.use(adminRouter).mount('#admin-app')
+8
View File
@@ -0,0 +1,8 @@
export const adminSections = [
{ id: 'overview', title: '概览', icon: 'monitor' },
{ id: 'problems', title: '题目管理', icon: 'problems' },
{ id: 'contests', title: '比赛管理', icon: 'contests' },
{ id: 'users', title: '用户管理', icon: 'teams' },
{ id: 'judges', title: '判题节点', icon: 'connection' },
{ id: 'settings', title: '站点设置', icon: 'settings' },
]
+12
View File
@@ -0,0 +1,12 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import { adminSections } from './navigation'
export const adminRouter = createRouter({
history: createWebHashHistory('/admin/index.html'),
scrollBehavior: () => ({ top: 0 }),
routes: [{ path: '/:section?', component: () => import('./AdminView.vue') }],
})
adminRouter.afterEach((to) => {
const section = adminSections.find((item) => item.id === (to.params.section || 'overview'))
document.title = `${section?.title ?? '页面不存在'} · KF 管理控制台`
})
+84
View File
@@ -0,0 +1,84 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import * as monaco from 'monaco-editor/editor/editor.api'
import 'monaco-editor/features/bracketMatching/register'
import 'monaco-editor/features/clipboard/register'
import 'monaco-editor/features/comment/register'
import 'monaco-editor/features/contextmenu/register'
import 'monaco-editor/features/find/register'
import 'monaco-editor/features/linesOperations/register'
import 'monaco-editor/features/wordOperations/register'
import 'monaco-editor/languages/definitions/cpp/register'
import 'monaco-editor/languages/definitions/python/register'
import 'monaco-editor/languages/definitions/rust/register'
import EditorWorker from 'monaco-editor/editor/editor.worker?worker'
const props = defineProps<{
modelValue: string
language: string
theme: string
fontSize: number
}>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
const container = ref<HTMLDivElement>()
let editor: monaco.editor.IStandaloneCodeEditor | undefined
let model: monaco.editor.ITextModel | undefined
let subscription: monaco.IDisposable | undefined
;(
globalThis as typeof globalThis & { MonacoEnvironment?: { getWorker: () => Worker } }
).MonacoEnvironment = { getWorker: () => new EditorWorker() }
onMounted(() => {
if (!container.value) return
model = monaco.editor.createModel(props.modelValue, props.language)
editor = monaco.editor.create(container.value, {
model,
theme: props.theme,
fontSize: props.fontSize,
minimap: { enabled: false },
automaticLayout: true,
scrollBeyondLastLine: false,
padding: { top: 18 },
tabSize: 4,
ariaLabel: '代码编辑器',
lineNumbersMinChars: 3,
wordWrap: 'on',
renderLineHighlight: 'line',
contextmenu: true,
})
subscription = editor.onDidChangeModelContent(() => {
emit('update:modelValue', editor!.getValue())
})
})
watch(
() => props.modelValue,
(value) => {
if (model && value !== model.getValue()) model.setValue(value)
},
)
watch(
() => props.language,
(value) => {
if (model) monaco.editor.setModelLanguage(model, value)
},
)
watch(
() => props.theme,
(value) => monaco.editor.setTheme(value),
)
watch(
() => props.fontSize,
(value) => editor?.updateOptions({ fontSize: value }),
)
onBeforeUnmount(() => {
subscription?.dispose()
editor?.dispose()
model?.dispose()
})
</script>
<template><div ref="container" class="monaco-container" /></template>
<style scoped>
.monaco-container {
width: 100%;
height: 100%;
min-height: 320px;
}
</style>
+15
View File
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { Difficulty } from '../data/preview'
defineProps<{ value: Difficulty | string }>()
</script>
<template>
<span
class="difficulty"
:class="{
'difficulty-easy': value === '入门' || value === '简单',
'difficulty-medium': value === '中等',
'difficulty-hard': value === '困难',
}"
>{{ value }}</span
>
</template>
+10
View File
@@ -0,0 +1,10 @@
<script setup lang="ts">
defineProps<{ title: string; description?: string }>()
</script>
<template>
<div class="empty-state">
<el-empty :description="title" :image-size="80" />
<p v-if="description" class="muted">{{ description }}</p>
<slot />
</div>
</template>
+13
View File
@@ -0,0 +1,13 @@
<script setup lang="ts">
defineProps<{ title: string; description?: string; eyebrow?: string }>()
</script>
<template>
<header class="page-heading">
<div>
<p v-if="eyebrow" class="eyebrow">{{ eyebrow }}</p>
<h1>{{ title }}</h1>
<p v-if="description" class="muted">{{ description }}</p>
</div>
<div class="page-heading-actions"><slot /></div>
</header>
</template>
+15
View File
@@ -0,0 +1,15 @@
<script setup lang="ts">
import UiIcon from './UiIcon.vue'
defineProps<{ title: string; icon?: string; subtitle?: string }>()
</script>
<template>
<section class="panel">
<header class="panel-heading">
<h2>
<UiIcon v-if="icon" :name="icon" />{{ title }}<small v-if="subtitle">{{ subtitle }}</small>
</h2>
<div class="panel-actions"><slot name="actions" /></div>
</header>
<div class="panel-body"><slot /></div>
</section>
</template>
+39
View File
@@ -0,0 +1,39 @@
<script setup lang="ts">
import { acceptance, type Problem } from '../data/preview'
import DifficultyTag from './DifficultyTag.vue'
defineProps<{ items: Problem[]; compact?: boolean }>()
</script>
<template>
<el-table :data="items" stripe style="width: 100%" empty-text="没有符合条件的题目">
<el-table-column prop="id" label="题号" width="90"
><template #default="{ row }"
><span class="mono muted">{{ row.id }}</span></template
></el-table-column
>
<el-table-column label="题目" min-width="190"
><template #default="{ row }"
><RouterLink class="table-link" :to="`/problems/${row.id}`">{{
row.title
}}</RouterLink></template
></el-table-column
>
<el-table-column v-if="!compact" label="标签" min-width="180"
><template #default="{ row }"
><div class="tag-list">
<span v-for="tag in row.tags" :key="tag" class="tag">{{ tag }}</span>
</div></template
></el-table-column
>
<el-table-column label="难度" width="90"
><template #default="{ row }"><DifficultyTag :value="row.difficulty" /></template
></el-table-column>
<el-table-column label="通过率" width="100" align="right"
><template #default="{ row }"
><span class="muted tabular"
>{{ acceptance({ accepted: row.accepted, submissions: row.submissions }) }}%</span
></template
></el-table-column
>
<el-table-column v-if="!compact" prop="updated" label="更新日期" width="125" />
</el-table>
</template>
+150
View File
@@ -0,0 +1,150 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
const showAccepted = ref(true)
const showTotal = ref(true)
const points = [
{ day: '09-28', total: 44, accepted: 28 },
{ day: '09-29', total: 68, accepted: 43 },
{ day: '09-30', total: 52, accepted: 32 },
{ day: '10-01', total: 91, accepted: 57 },
{ day: '10-02', total: 77, accepted: 46 },
{ day: '10-03', total: 116, accepted: 74 },
{ day: '10-04', total: 96, accepted: 62 },
]
const active = ref<number | null>(null)
const x = (index: number) => 55 + index * 111
const y = (value: number) => 192 - (value / 120) * 155
const line = (field: 'total' | 'accepted') =>
points.map((point, index) => x(index) + ',' + y(point[field])).join(' ')
const selected = computed(() => (active.value === null ? undefined : points[active.value]))
</script>
<template>
<div class="submission-chart">
<div class="chart-legend">
<button :aria-pressed="showAccepted" @click="showAccepted = !showAccepted">
<span class="chart-dot accepted" />通过</button
><button :aria-pressed="showTotal" @click="showTotal = !showTotal">
<span class="chart-dot total" />总提交</button
><span class="muted">单位:次 · 示例统计</span>
</div>
<svg
viewBox="0 0 770 240"
role="img"
aria-label="近七天示例提交趋势:总提交44、68、52、91、77、116、96次"
>
<g v-for="tick in [0, 30, 60, 90, 120]" :key="tick">
<line x1="55" x2="721" :y1="y(tick)" :y2="y(tick)" stroke="#edf1f6" />
<text x="42" :y="y(tick) + 4" text-anchor="end">{{ tick }}</text>
</g>
<g v-for="(point, index) in points" :key="point.day">
<text :x="x(index)" y="219" text-anchor="middle">{{ point.day }}</text>
</g>
<polyline
v-if="showTotal"
:points="line('total')"
fill="none"
stroke="#76b5ee"
stroke-width="2"
stroke-linejoin="round"
/>
<polyline
v-if="showAccepted"
:points="line('accepted')"
fill="none"
stroke="#8ccdab"
stroke-width="2"
stroke-linejoin="round"
/>
<g v-for="(point, index) in points" :key="index">
<circle
v-if="showTotal"
:cx="x(index)"
:cy="y(point.total)"
r="3"
fill="white"
stroke="#76b5ee"
/>
<circle
v-if="showAccepted"
:cx="x(index)"
:cy="y(point.accepted)"
r="3"
fill="white"
stroke="#8ccdab"
/>
<rect
:x="x(index) - 20"
y="25"
width="40"
height="174"
fill="transparent"
@mouseenter="active = index"
@mouseleave="active = null"
>
<title>{{ point.day }}:总提交 {{ point.total }},通过 {{ point.accepted }}</title>
</rect>
</g>
</svg>
<p class="chart-detail" aria-live="polite">
{{
selected
? selected.day + ' · 总提交 ' + selected.total + ' 次 · 通过 ' + selected.accepted + ' 次'
: '近 7 天的提交与通过情况'
}}
</p>
</div>
</template>
<style scoped>
.chart-legend {
display: flex;
align-items: center;
justify-content: center;
gap: 20px;
flex-wrap: wrap;
font-size: 11px;
margin-bottom: 10px;
}
.chart-legend button {
border: 0;
background: none;
color: #8797ac;
font-size: 12px;
display: flex;
gap: 7px;
align-items: center;
padding: 3px;
}
.chart-legend button[aria-pressed='false'] {
opacity: 0.4;
}
.chart-dot {
width: 7px;
height: 7px;
border-radius: 50%;
display: inline-block;
}
.accepted {
background: #8ccdab;
}
.total {
background: #76b5ee;
}
.submission-chart svg {
display: block;
width: 100%;
height: auto;
}
.submission-chart text {
font:
11px Inter,
'Microsoft YaHei',
sans-serif;
fill: #9aa8b9;
}
.chart-detail {
text-align: center;
color: #9cabbd;
font-size: 11px;
min-height: 20px;
}
</style>
@@ -0,0 +1,387 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import type { Submission } from '../data/preview'
import { findPerformancePreview } from '../data/performancePreview'
import { summarizeDistribution } from '../lib/performance'
import EmptyState from './EmptyState.vue'
import UiIcon from './UiIcon.vue'
const props = defineProps<{ submission: Submission }>()
type Metric = 'time' | 'memory'
const metric = ref<Metric>('time')
const metrics = [
{ key: 'time' as const, title: '运行时间', unit: 'ms', icon: 'clock' },
{ key: 'memory' as const, title: '内存消耗', unit: 'MiB', icon: 'monitor' },
]
const preview = computed(() => findPerformancePreview(props.submission))
const summaries = computed(() => ({
time: summarizeDistribution(preview.value?.time ?? [], props.submission.time),
memory: summarizeDistribution(
preview.value?.memory.map((point) => ({ ...point, value: point.value / 1024 })) ?? [],
props.submission.memory === null ? null : props.submission.memory / 1024,
),
}))
const summary = computed(() => summaries.value[metric.value])
const unit = computed(() => (metric.value === 'time' ? 'ms' : 'MiB'))
const active = ref<number | null>(null)
const selected = computed(() =>
active.value === null ? undefined : summary.value.bins[active.value],
)
const chart = ref<HTMLDivElement>()
const width = ref(640)
let observer: ResizeObserver | undefined
onMounted(() => {
if (!chart.value) return
observer = new ResizeObserver(([entry]) => {
if (entry) width.value = Math.max(280, entry.contentRect.width)
})
observer.observe(chart.value)
})
onBeforeUnmount(() => observer?.disconnect())
watch([metric, () => props.submission.id], () => (active.value = null))
const plotWidth = computed(() => width.value - 62)
const binWidth = computed(() => plotWidth.value / summary.value.bins.length)
const ceiling = computed(() =>
Math.max(5, Math.ceil(Math.max(...summary.value.bins.map((bin) => bin.percent)) / 5) * 5),
)
const ticks = computed(() => Array.from({ length: 5 }, (_, index) => (ceiling.value / 4) * index))
const xTicks = computed(() => {
const count = width.value < 450 ? 3 : 5
return Array.from({ length: count + 1 }, (_, index) => (summary.value.end / count) * index)
})
const x = (value: number) => 44 + (value / summary.value.end) * plotWidth.value
const y = (percent: number) => 224 - (percent / ceiling.value) * 164
function formatValue(value: number, kind?: Metric) {
return Number((kind === 'memory' ? value / 1024 : value).toFixed(2)).toLocaleString('zh-CN')
}
function describeBin(index: number) {
const bin = summary.value.bins[index]!
const end = index === summary.value.bins.length - 1 ? '(含)' : '(不含)'
return `${formatValue(bin.start)}–${formatValue(bin.end)} ${unit.value}${end}:${bin.count} 条,占 ${bin.percent.toFixed(1)}%`
}
</script>
<template>
<section class="performance-distribution" aria-label="答题表现分布">
<header class="performance-heading">
<div>
<h2><UiIcon name="rankings" />答题表现分布</h2>
<p>同题 · {{ submission.language }} · 通过记录</p>
</div>
<span class="performance-preview">示例统计</span>
</header>
<div class="performance-metrics" aria-label="选择分布指标">
<button
v-for="item in metrics"
:key="item.key"
type="button"
:aria-pressed="metric === item.key"
:class="{ selected: metric === item.key }"
@click="metric = item.key"
>
<span class="metric-title"><UiIcon :name="item.icon" />{{ item.title }}</span>
<span class="metric-value">
{{ submission[item.key] === null ? '—' : formatValue(submission[item.key]!, item.key) }}
<small v-if="submission[item.key] !== null">{{ item.unit }}</small>
</span>
<span v-if="preview && summaries[item.key].betterThan !== null" class="metric-percentile">
优于 <strong>{{ summaries[item.key].betterThan!.toFixed(1) }}%</strong> 示例提交
</span>
<span v-else class="metric-unavailable">暂无可比较的统计</span>
</button>
</div>
<div ref="chart" class="performance-chart">
<template v-if="preview && summary.total && summary.current !== null">
<div class="distribution-legend">
<span><i class="legend-bar" />通过记录占比</span>
<span><i class="legend-marker" />当前提交</span>
<span class="distribution-count">{{ summary.total }} 条示例记录</span>
</div>
<svg
:viewBox="`0 0 ${width} 275`"
role="group"
:aria-label="`${metric === 'time' ? '运行时间' : '内存消耗'}分布,共 ${summary.total} 条示例通过记录;当前提交 ${formatValue(summary.current)} ${unit}`"
>
<g v-for="tick in ticks" :key="tick" aria-hidden="true">
<line x1="44" :x2="width - 18" :y1="y(tick)" :y2="y(tick)" class="grid-line" />
<text x="36" :y="y(tick) + 4" text-anchor="end">{{ Number(tick.toFixed(1)) }}%</text>
</g>
<g v-for="(bin, index) in summary.bins" :key="index">
<rect
:x="44 + index * binWidth + 1"
:y="y(bin.percent)"
:width="Math.max(1, binWidth - 3)"
:height="224 - y(bin.percent)"
rx="2"
class="distribution-bar"
:class="{ highlighted: active === index }"
aria-hidden="true"
/>
<rect
:x="44 + index * binWidth"
y="53"
:width="binWidth"
height="175"
fill="transparent"
tabindex="0"
role="img"
:aria-label="describeBin(index)"
class="distribution-hit"
@pointerenter="active = index"
@pointerleave="active = null"
@focus="active = index"
@blur="active = null"
@click="active = index"
>
<title>{{ describeBin(index) }}</title>
</rect>
</g>
<g v-for="tick in xTicks" :key="tick" aria-hidden="true">
<text
:x="x(tick)"
y="250"
:text-anchor="tick === 0 ? 'start' : tick === summary.end ? 'end' : 'middle'"
>
{{ formatValue(tick) }}
</text>
</g>
<g class="current-marker" aria-hidden="true">
<line :x1="x(summary.current)" :x2="x(summary.current)" y1="43" y2="224" />
<circle :cx="x(summary.current)" cy="43" r="5" />
<text
:x="x(summary.current)"
y="29"
:text-anchor="
x(summary.current) < 95
? 'start'
: x(summary.current) > width - 75
? 'end'
: 'middle'
"
>
本次 {{ formatValue(summary.current) }} {{ unit }}
</text>
</g>
<text :x="width - 18" y="272" text-anchor="end" aria-hidden="true">{{ unit }}</text>
</svg>
<p class="distribution-detail" aria-live="polite">
{{
selected && active !== null
? describeBin(active)
: '悬停或聚焦柱形,查看区间记录数与占比'
}}
</p>
<p class="distribution-note">
用量越低,表现越好。同用量记录不计入“优于”比例;本次有
{{ summary.tied }} 条同用量示例记录。
</p>
</template>
<EmptyState
v-else
:title="submission.verdict !== 'AC' ? '通过后可比较答题表现' : '暂无可比较的统计'"
:description="
submission.verdict !== 'AC'
? '分布图只统计通过记录。'
: '当前题目与语言的分布数据尚未接入。'
"
/>
</div>
</section>
</template>
<style scoped>
.performance-distribution {
padding: 26px;
border: 1px solid #e4ebf4;
border-radius: 14px;
background: #fff;
margin-bottom: 20px;
}
.performance-heading {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
margin-bottom: 22px;
}
.performance-heading h2 {
display: flex;
align-items: center;
gap: 8px;
font-size: 16px;
color: #455e80;
}
.performance-heading h2 .el-icon {
color: var(--blue);
}
.performance-heading p {
margin-top: 6px;
font-size: 12px;
color: #8490a3;
}
.performance-preview {
background: #f0f6ff;
color: #6195ce;
border-radius: 5px;
padding: 3px 8px;
font-size: 11px;
white-space: nowrap;
}
.performance-metrics {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
.performance-metrics button {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 9px;
border: 1px solid #e4ebf4;
border-radius: 10px;
padding: 18px 20px;
text-align: left;
color: #687f9c;
background: #fafcfe;
}
.performance-metrics button.selected {
background: #eff7ff;
border-color: #8bbdf1;
}
.performance-metrics button:hover {
border-color: #8bbdf1;
}
.performance-metrics button:focus-visible {
outline: 2px solid var(--blue);
outline-offset: 3px;
}
.metric-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
}
.metric-value {
font-size: 28px;
line-height: 1.2;
font-weight: 650;
color: #354f70;
font-variant-numeric: tabular-nums;
}
.metric-value small {
font-size: 12px;
font-weight: 400;
color: #8499b2;
}
.metric-percentile,
.metric-unavailable {
font-size: 11px;
}
.metric-percentile strong {
color: #409eff;
}
.metric-unavailable {
color: #98a8bb;
}
.performance-chart {
margin-top: 22px;
}
.distribution-legend {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 14px;
font-size: 11px;
color: #8499b2;
}
.distribution-legend span {
display: inline-flex;
align-items: center;
gap: 5px;
}
.distribution-count {
margin-left: auto;
}
.legend-bar {
width: 8px;
height: 8px;
background: #7ab6f0;
border-radius: 2px;
}
.legend-marker {
width: 8px;
height: 8px;
background: #355d93;
border-radius: 50%;
}
.performance-chart svg {
display: block;
width: 100%;
overflow: visible;
}
.performance-chart svg text {
font-size: 11px;
fill: #8d9eb3;
}
.grid-line {
stroke: #eaf0f7;
}
.distribution-bar {
fill: #7ab6f0;
}
.distribution-bar.highlighted {
fill: #409eff;
}
.distribution-hit:focus {
outline: none;
stroke: #409eff;
stroke-width: 1;
}
.current-marker {
pointer-events: none;
}
.current-marker line {
stroke: #355d93;
stroke-dasharray: 4 4;
}
.current-marker circle {
fill: #355d93;
stroke: #fff;
stroke-width: 2;
}
.current-marker text {
fill: #355d93 !important;
font-weight: 600;
}
.distribution-detail {
min-height: 27px;
color: #617d9f;
font-size: 12px;
padding: 6px 12px;
background: #f5f8fc;
border-radius: 6px;
}
.distribution-note {
margin-top: 10px;
font-size: 11px;
color: #8d9eb3;
}
@media (max-width: 600px) {
.performance-distribution {
padding: 18px 16px;
}
.performance-metrics {
gap: 10px;
}
.performance-metrics button {
padding: 14px 12px;
}
.metric-value {
font-size: 24px;
}
.distribution-count {
margin-left: 0;
}
}
</style>
@@ -0,0 +1,40 @@
<script setup lang="ts">
import { findProblem, type submissions } from '../data/preview'
import VerdictTag from './VerdictTag.vue'
defineProps<{ items: typeof submissions; compact?: boolean }>()
</script>
<template>
<el-table :data="items" stripe empty-text="暂无符合条件的评测记录"
><el-table-column label="提交编号" width="100"
><template #default="{ row }"
><RouterLink class="table-link mono" :to="'/submissions/' + row.id"
>#{{ row.id }}</RouterLink
></template
></el-table-column
><el-table-column label="题目" min-width="180"
><template #default="{ row }"
><RouterLink class="table-link" :to="'/problems/' + row.problemId">{{
findProblem(row.problemId)?.title
}}</RouterLink></template
></el-table-column
><el-table-column v-if="!compact" label="用户" min-width="120"
><template #default="{ row }"
><RouterLink :to="'/users/' + row.username">{{ row.username }}</RouterLink></template
></el-table-column
><el-table-column label="结果" width="110"
><template #default="{ row }"><VerdictTag :value="row.verdict" /></template></el-table-column
><el-table-column label="语言" prop="language" width="90" /><el-table-column
v-if="!compact"
label="用时"
width="90"
align="right"
><template #default="{ row }">{{
row.time === null ? '—' : row.time + ' ms'
}}</template></el-table-column
><el-table-column v-if="!compact" label="内存" width="100" align="right"
><template #default="{ row }">{{
row.memory === null ? '—' : row.memory + ' KiB'
}}</template></el-table-column
><el-table-column label="提交时间" prop="created" min-width="175"
/></el-table>
</template>
+270
View File
@@ -0,0 +1,270 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { TagCanvas } from '@misskey-dev/tagcanvas-es'
const props = defineProps<{ tags: readonly string[] }>()
const emit = defineEmits<{ select: [tag: string] }>()
const stage = ref<HTMLDivElement>()
const canvas = ref<HTMLCanvasElement>()
const source = ref<HTMLDivElement>()
const width = ref(300)
const ready = ref(false)
const rotationEnabled = ref(true)
const interaction = ref<'follow' | 'drag'>('follow')
const visible = ref(false)
const hidden = ref(false)
const colors = ['#409eff', '#638ac5', '#588db8', '#7496b8']
const fontSize = computed(() => {
const textArea = props.tags.reduce((sum, tag) => sum + [...tag].length + 1.8, 0)
const radius = Math.max(35, (Math.min(width.value, 300) - 68) / 2)
return Math.max(
12,
Math.min(19, Math.sqrt((4 * Math.PI * radius ** 2 * 0.23) / Math.max(1, textArea * 1.35))),
)
})
function weight(tag: string, index: number) {
const emphasis = ['动态规划', '图论'].includes(tag) ? 1.2 : index % 3 === 0 ? 1.08 : 1
return emphasis * Math.min(1, Math.sqrt(4 / Math.max(1, [...tag].length)))
}
let cloud: TagCanvas | undefined
let resizeObserver: ResizeObserver | undefined
let intersectionObserver: IntersectionObserver | undefined
let motionQuery: MediaQueryList | undefined
function syncAnimation() {
if (!visible.value || hidden.value) cloud?.pause()
else cloud?.resume()
}
function rebuild() {
cloud?.destroy()
cloud = undefined
ready.value = false
if (!canvas.value || !stage.value || !source.value || !props.tags.length) return
const ratio = Math.min(2, window.devicePixelRatio || 1)
canvas.value.width = Math.round(width.value * ratio)
canvas.value.height = 300 * ratio
try {
cloud = new TagCanvas(canvas.value, {
tagContainer: source.value,
hideTags: false,
shape: 'sphere',
textColor: null,
textFont: getComputedStyle(stage.value).fontFamily,
textHeight: fontSize.value * ratio,
weight: props.tags.length > 1,
weightFrom: 'data-weight',
weightMode: 'size',
weightSizeMin: (fontSize.value - 1) * ratio,
weightSizeMax: (fontSize.value + 2) * ratio,
bgColor: '#f0f7ff',
bgOutline: '#cfe2f7',
bgOutlineThickness: ratio,
bgRadius: 14 * ratio,
padding: 5 * ratio,
outlineColor: '#409eff',
outlineThickness: ratio,
outlineRadius: 14 * ratio,
outlineOffset: 1 * ratio,
outlineMethod: 'outline',
minBrightness: 0.45,
maxBrightness: 1,
depth: 0.55,
zoom: 0.98,
maxSpeed: rotationEnabled.value ? 0.02 : 0,
initial: rotationEnabled.value ? [0.16, -0.08] : [0, 0],
decel: 0.94,
dragControl: interaction.value === 'drag',
dragThreshold: 4,
freezeActive: true,
freezeDecel: true,
wheelZoom: false,
noTagsMessage: false,
activeAudio: false,
txtScale: 2,
})
ready.value = true
syncAnimation()
} catch {
// Keep the ordinary links available when canvas rendering is unavailable.
ready.value = false
}
}
function updateVisibility() {
hidden.value = document.hidden
}
function updateMotionPreference() {
if (motionQuery?.matches) rotationEnabled.value = false
}
watch([() => props.tags, width, rotationEnabled, interaction], rebuild, {
deep: true,
flush: 'post',
})
watch([visible, hidden], syncAnimation)
onMounted(() => {
if (!stage.value) return
width.value = stage.value.getBoundingClientRect().width
motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)')
rotationEnabled.value = !motionQuery.matches
if (window.matchMedia('(pointer: coarse)').matches) interaction.value = 'drag'
motionQuery.addEventListener('change', updateMotionPreference)
updateVisibility()
document.addEventListener('visibilitychange', updateVisibility)
resizeObserver = new ResizeObserver(([entry]) => {
if (entry) width.value = entry.contentRect.width
})
resizeObserver.observe(stage.value)
intersectionObserver = new IntersectionObserver(([entry]) => {
visible.value = !!entry?.isIntersecting
})
intersectionObserver.observe(stage.value)
rebuild()
})
onBeforeUnmount(() => {
cloud?.destroy()
resizeObserver?.disconnect()
intersectionObserver?.disconnect()
motionQuery?.removeEventListener('change', updateMotionPreference)
document.removeEventListener('visibilitychange', updateVisibility)
})
</script>
<template>
<div class="tag-sphere">
<div ref="stage" class="tag-sphere-stage" role="group" aria-label="球形题目标签">
<canvas
v-show="ready"
ref="canvas"
class="sphere-canvas"
:class="{ draggable: interaction === 'drag' }"
role="img"
:aria-label="`球形标签云,${interaction === 'drag' ? '拖动旋转' : '跟随鼠标旋转'},点击标签筛选题目。键盘可选择下方标签链接。`"
/>
<div
ref="source"
class="sphere-source"
:class="{ 'source-hidden': ready }"
aria-label="题目标签"
>
<a
v-for="(tag, index) in tags"
:key="tag"
:href="'/problems?tag=' + encodeURIComponent(tag)"
:style="{ color: colors[index % colors.length] }"
:data-weight="weight(tag, index)"
@click.prevent="emit('select', tag)"
>{{ tag }}</a
>
</div>
<p v-if="!tags.length" class="sphere-empty">暂无题目标签</p>
</div>
<div v-if="ready" class="sphere-controls">
<span>点击标签查看题目</span>
<div class="sphere-control-buttons">
<button
:aria-pressed="interaction === 'drag'"
aria-label="切换标签球交互方式"
@click="interaction = interaction === 'follow' ? 'drag' : 'follow'"
>
{{ interaction === 'follow' ? '鼠标跟随' : '拖动旋转' }}
</button>
<button :aria-pressed="rotationEnabled" @click="rotationEnabled = !rotationEnabled">
{{ rotationEnabled ? '暂停旋转' : '开始旋转' }}
</button>
</div>
</div>
</div>
</template>
<style scoped>
.tag-sphere-stage {
position: relative;
width: 100%;
height: 300px;
}
.sphere-canvas {
width: 100%;
height: 300px;
display: block;
touch-action: none;
user-select: none;
}
.sphere-canvas.draggable {
cursor: grab;
}
.sphere-canvas.draggable:active {
cursor: grabbing;
}
.sphere-source {
display: flex;
align-content: center;
justify-content: center;
flex-wrap: wrap;
gap: 10px;
height: 100%;
padding: 20px;
}
.sphere-source a {
padding: 5px 10px;
background: #f0f7ff;
border: 1px solid #dceaf9;
border-radius: 999px;
font-size: 14px;
}
.sphere-source.source-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
}
.sphere-source.source-hidden:focus-within {
inset: 0;
width: 100%;
height: 100%;
padding: 20px;
clip-path: none;
overflow: auto;
background: #fff;
}
.sphere-source a:focus-visible {
outline: 2px solid #409eff;
outline-offset: 2px;
}
.sphere-empty {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: #99aabd;
font-size: 14px;
}
.sphere-controls {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
color: #99aabd;
font-size: 11px;
padding-top: 4px;
}
.sphere-control-buttons {
display: flex;
gap: 6px;
}
.sphere-controls button {
color: #629edb;
background: #f5faff;
border: 1px solid #e6f0fb;
border-radius: 6px;
padding: 4px 8px;
font-size: 11px;
cursor: pointer;
}
.sphere-controls button:focus-visible {
outline: 2px solid #90c2fb;
outline-offset: 2px;
}
</style>
+54
View File
@@ -0,0 +1,54 @@
<script setup lang="ts">
import { computed } from 'vue'
import {
House,
Grid,
Collection,
Trophy,
Tickets,
Histogram,
ChatDotRound,
UserFilled,
InfoFilled,
Setting,
ArrowRight,
Refresh,
Search,
Monitor,
Bell,
ArrowDown,
Reading,
Document,
Clock,
Connection,
FolderOpened,
} from '@element-plus/icons-vue'
const props = defineProps<{ name: string }>()
const icons = {
home: House,
problems: Grid,
trainings: Collection,
contests: Trophy,
submissions: Tickets,
rankings: Histogram,
discussions: ChatDotRound,
teams: UserFilled,
about: InfoFilled,
settings: Setting,
arrow: ArrowRight,
refresh: Refresh,
search: Search,
monitor: Monitor,
bell: Bell,
down: ArrowDown,
reading: Reading,
document: Document,
clock: Clock,
connection: Connection,
folder: FolderOpened,
}
const icon = computed(() => icons[props.name as keyof typeof icons] ?? Document)
</script>
<template>
<el-icon aria-hidden="true"><component :is="icon" /></el-icon>
</template>
+9
View File
@@ -0,0 +1,9 @@
<script setup lang="ts">
import { verdictLabels, type Verdict } from '../data/preview'
defineProps<{ value: Verdict; full?: boolean }>()
</script>
<template>
<span class="verdict" :class="`verdict-${value.toLowerCase()}`">{{
full ? verdictLabels[value] : value === 'Pending' ? '等待评测' : value
}}</span>
</template>
+88
View File
@@ -0,0 +1,88 @@
import { computed, onScopeDispose, ref, watch } from 'vue'
export type Language = 'cpp' | 'python' | 'rust'
export const languageLabels: Record<Language, string> = {
cpp: 'C++',
python: 'Python',
rust: 'Rust',
}
export const codeTemplates: Record<Language, string> = {
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',
}
export function useCodeDraft(problemId: string) {
const key = 'kf-oj:code-draft:' + problemId
const language = ref<Language>('cpp')
const drafts = ref({ ...codeTemplates })
const state = ref<'initial' | 'saved' | 'changed' | 'unavailable'>('initial')
let timer: ReturnType<typeof setTimeout> | undefined
try {
const raw = localStorage.getItem(key)
if (raw) {
const saved: unknown = JSON.parse(raw)
if (
saved &&
typeof saved === 'object' &&
'version' in saved &&
saved.version === 1 &&
'drafts' in saved &&
saved.drafts &&
typeof saved.drafts === 'object'
) {
const storedDrafts = saved.drafts as Record<string, unknown>
for (const lang of ['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)))
language.value = saved.language as Language
state.value = 'saved'
}
}
} catch {
state.value = 'unavailable'
}
const code = computed({
get: () => drafts.value[language.value],
set: (value) => {
drafts.value[language.value] = value
},
})
function save() {
clearTimeout(timer)
try {
localStorage.setItem(
key,
JSON.stringify({ version: 1, language: language.value, drafts: drafts.value }),
)
state.value = 'saved'
} catch {
state.value = 'unavailable'
}
}
watch(
[drafts, language],
() => {
state.value = 'changed'
clearTimeout(timer)
timer = setTimeout(save, 400)
},
// Mark pending changes before a lifecycle event can run in the same tick.
{ deep: true, flush: 'sync' },
)
function flushPendingDraft() {
if (state.value === 'changed') save()
}
function onVisibilityChange() {
if (document.visibilityState === 'hidden') flushPendingDraft()
}
window.addEventListener('pagehide', flushPendingDraft)
document.addEventListener('visibilitychange', onVisibilityChange)
onScopeDispose(() => {
flushPendingDraft()
clearTimeout(timer)
window.removeEventListener('pagehide', flushPendingDraft)
document.removeEventListener('visibilitychange', onVisibilityChange)
})
return { language, code, state, save }
}
+13
View File
@@ -0,0 +1,13 @@
import { computed, ref, watch, type Ref } from 'vue'
export function usePagination<T>(items: Readonly<Ref<T[]>>, initialSize = 10) {
const page = ref(1)
const pageSize = ref(initialSize)
const total = computed(() => items.value.length)
const paged = computed(() =>
items.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value),
)
watch([items, pageSize], () => {
page.value = 1
})
return { page, pageSize, total, paged }
}
+104
View File
@@ -0,0 +1,104 @@
import type { DistributionPoint } from '../lib/performance'
import type { Submission } from './preview'
interface PerformancePreview {
problemId: string
language: string
time: DistributionPoint[]
memory: DistributionPoint[]
}
// Fixed display fixtures, independent of real judge results. Memory values use KiB.
const previews: PerformancePreview[] = [
{
problemId: '1001',
language: 'Rust',
time: [
{ value: 0, count: 8 },
{ value: 4, count: 12 },
{ value: 8, count: 25 },
{ value: 12, count: 16 },
{ value: 16, count: 34 },
{ value: 20, count: 46 },
{ value: 24, count: 29 },
{ value: 28, count: 18 },
{ value: 32, count: 13 },
{ value: 40, count: 9 },
{ value: 48, count: 6 },
{ value: 64, count: 4 },
],
memory: [
{ value: 1024, count: 7 },
{ value: 1536, count: 19 },
{ value: 2048, count: 58 },
{ value: 2560, count: 41 },
{ value: 3072, count: 34 },
{ value: 3584, count: 22 },
{ value: 4096, count: 15 },
{ value: 5120, count: 13 },
{ value: 6144, count: 7 },
{ value: 8192, count: 4 },
],
},
{
problemId: '1002',
language: 'Python',
time: [
{ value: 24, count: 3 },
{ value: 32, count: 8 },
{ value: 40, count: 16 },
{ value: 48, count: 28 },
{ value: 56, count: 39 },
{ value: 64, count: 31 },
{ value: 72, count: 24 },
{ value: 80, count: 18 },
{ value: 96, count: 11 },
{ value: 112, count: 6 },
{ value: 128, count: 4 },
],
memory: [
{ value: 5120, count: 6 },
{ value: 6144, count: 18 },
{ value: 7168, count: 46 },
{ value: 8192, count: 53 },
{ value: 9216, count: 31 },
{ value: 10240, count: 19 },
{ value: 12288, count: 9 },
{ value: 14336, count: 6 },
],
},
{
problemId: '1003',
language: 'C++',
time: [
{ value: 16, count: 8 },
{ value: 24, count: 16 },
{ value: 34, count: 21 },
{ value: 40, count: 32 },
{ value: 48, count: 41 },
{ value: 56, count: 28 },
{ value: 64, count: 19 },
{ value: 80, count: 12 },
{ value: 96, count: 7 },
{ value: 120, count: 4 },
],
memory: [
{ value: 2048, count: 9 },
{ value: 3072, count: 24 },
{ value: 4096, count: 51 },
{ value: 5120, count: 47 },
{ value: 6144, count: 29 },
{ value: 8192, count: 17 },
{ value: 10240, count: 8 },
{ value: 12288, count: 3 },
],
},
]
export function findPerformancePreview(submission: Submission) {
if (submission.verdict !== 'AC') return undefined
return previews.find(
(preview) =>
preview.problemId === submission.problemId && preview.language === submission.language,
)
}
+466
View File
@@ -0,0 +1,466 @@
// Explicit UI fixtures; business endpoints will be connected through API composables.
export type Difficulty = '入门' | '简单' | '中等' | '困难'
export interface Problem {
id: string
title: string
difficulty: Difficulty
tags: string[]
accepted: number
submissions: number
updated: string
description: string
input: string
output: string
sampleInput: string
sampleOutput: string
constraint: string
}
const definitions: [string, string, Difficulty, string[], number, number][] = [
['1001', 'A + B Problem', '入门', ['模拟', '输入输出'], 428, 536],
['1002', '两数之和', '简单', ['数组', '哈希表'], 315, 482],
['1003', '最长无重复子串', '中等', ['字符串', '滑动窗口'], 206, 527],
['1004', '旅行者的背包', '中等', ['动态规划', '背包'], 186, 461],
['1005', '城市间的最短路径', '中等', ['图论', '最短路'], 152, 387],
['1006', '寻找第一个位置', '简单', ['二分', '数组'], 241, 402],
['1007', '括号匹配', '简单', ['栈', '字符串'], 287, 389],
['1008', '岛屿的数量', '中等', ['搜索', '图论'], 143, 328],
['1009', '区间求和', '中等', ['数据结构', '前缀和'], 124, 306],
['1010', '最小生成树', '困难', ['图论', '并查集'], 96, 288],
['1011', '活动安排', '简单', ['贪心', '排序'], 203, 312],
['1012', '最长公共子序列', '中等', ['动态规划', '字符串'], 112, 345],
]
export const problems: Problem[] = definitions.map(
([id, title, difficulty, tags, accepted, submissions], index) => ({
id,
title,
difficulty,
tags,
accepted,
submissions,
updated: '2026-10-' + String(4 - Math.floor(index / 4)).padStart(2, '0'),
description:
id === '1001'
? '给定两个整数 A 和 B,请计算它们的和。认真阅读输入和输出格式,完成你的第一次提交。'
: '这道题的完整题面将在题库接入后展示。当前提供列表与编辑器的界面预览。',
input:
id === '1001'
? '输入一行,包含两个整数 A 和 B,以一个空格分隔。'
: '完整输入格式待题库接入。',
output: id === '1001' ? '输出一个整数,表示 A 与 B 的和。' : '完整输出格式待题库接入。',
sampleInput: id === '1001' ? '1 2' : '',
sampleOutput: id === '1001' ? '3' : '',
constraint:
id === '1001'
? '−10⁹ ≤ A, B ≤ 10⁹。建议使用 64 位整数保存计算结果。'
: '数据范围待题库接入。',
}),
)
export const problemTags = [...new Set(problems.flatMap((problem) => problem.tags))]
export const rankings = [
{
username: 'Ada',
motto: '每天进步一点点',
solved: 167,
submissions: 238,
rating: 1924,
color: '#6196ec',
},
{
username: 'Kronecker',
motto: '写下思路,再写代码。',
solved: 142,
submissions: 216,
rating: 1846,
color: '#6b70d6',
},
{
username: 'Sora',
motto: '今天也要 AC',
solved: 129,
submissions: 204,
rating: 1732,
color: '#3fb7a1',
},
{
username: 'Natsume',
motto: '慢慢来,比较快',
solved: 118,
submissions: 182,
rating: 1685,
color: '#e9a35d',
},
{
username: 'Lin',
motto: '保持好奇',
solved: 106,
submissions: 175,
rating: 1621,
color: '#dc82a4',
},
{
username: 'Mika',
motto: '从一道题开始',
solved: 98,
submissions: 153,
rating: 1594,
color: '#63a9c9',
},
{
username: 'Chen',
motto: 'Hello, algorithm',
solved: 92,
submissions: 151,
rating: 1540,
color: '#897cb7',
},
{
username: 'Yuki',
motto: '专注这一题',
solved: 87,
submissions: 144,
rating: 1506,
color: '#6aaa8a',
},
{
username: 'Joago',
motto: 'Thinking in code',
solved: 81,
submissions: 132,
rating: 1482,
color: '#bb9b67',
},
{
username: 'Hyperbola',
motto: '努力成为更好的自己',
solved: 74,
submissions: 128,
rating: 1428,
color: '#8095b9',
},
]
export const notices = [
{
id: '1',
title: '欢迎来到 KF OnlineJudge',
date: '2026-10-04',
category: '平台公告',
text: '从基础练习开始,记录每一次提交,让思路在代码里落地。当前页面使用示例内容。',
},
{
id: '2',
title: '新学期基础训练题单已更新',
date: '2026-10-03',
category: '训练',
text: '输入输出、数组、字符串和简单搜索,按顺序完成你的第一组算法练习。',
},
{
id: '3',
title: '周末练习赛 · 热身题单',
date: '2026-10-01',
category: '比赛',
text: '练习赛页面已准备好,实际比赛安排以平台发布的信息为准。',
},
]
export const trainings = [
{
id: 'basics',
title: '从零开始 · 编程基础',
category: '基础入门',
level: '入门',
description: '从输入输出到数组与字符串,熟悉在线评测的基本流程。',
ids: ['1001', '1002', '1006', '1007'],
color: 'blue',
icon: '01',
},
{
id: 'search',
title: '搜索与图论入门',
category: '算法专题',
level: '中等',
description: '理解遍历、连通性与路径,建立图论的基础解题思路。',
ids: ['1008', '1005', '1010'],
color: 'green',
icon: '02',
},
{
id: 'dp',
title: '动态规划 · 一步一步推出来',
category: '算法专题',
level: '中等',
description: '从状态定义开始,练习转移、边界和空间优化。',
ids: ['1004', '1012'],
color: 'violet',
icon: '03',
},
{
id: 'structures',
title: '常用数据结构练习',
category: '进阶练习',
level: '中等',
description: '通过具体问题理解哈希表、栈、前缀和与并查集。',
ids: ['1002', '1007', '1009', '1010'],
color: 'amber',
icon: '04',
},
]
export const contests = [
{
id: 'weekly-01',
title: 'KF 周末练习赛 #01',
type: 'ACM',
status: '即将开始',
start: '2026-10-10 14:00',
duration: '2 小时',
description: '五道由浅入深的算法题,给自己一个专注思考的下午。',
ids: ['1001', '1002', '1003', '1004', '1005'],
},
{
id: 'starter',
title: '新同学编程热身赛',
type: 'OI',
status: '即将开始',
start: '2026-10-12 19:00',
duration: '2 小时',
description: '面向刚开始学习程序设计的同学,练习输入输出与基本逻辑。',
ids: ['1001', '1006', '1007'],
},
{
id: 'graph',
title: '图论专题 · 路径与连通性',
type: 'ACM',
status: '进行中',
start: '2026-10-04 14:00',
duration: '4 小时',
description: '题单式比赛界面示例,状态与时间为展示用数据。',
ids: ['1005', '1008', '1010'],
},
{
id: 'weekly-00',
title: 'KF 周末练习赛 #00',
type: 'ACM',
status: '已结束',
start: '2026-09-27 14:00',
duration: '2 小时',
description: '往期比赛界面示例,便于浏览题目和比赛说明。',
ids: ['1001', '1003', '1011'],
},
{
id: 'dp-practice',
title: '动态规划专项练习',
type: 'OI',
status: '已结束',
start: '2026-09-20 09:00',
duration: '3 小时',
description: '集中练习常见动态规划模型。',
ids: ['1004', '1012'],
},
]
export type Verdict = 'AC' | 'WA' | 'TLE' | 'RE' | 'CE' | 'Pending'
export const verdictLabels: Record<Verdict, string> = {
AC: 'Accepted',
WA: 'Wrong Answer',
TLE: 'Time Limit Exceeded',
RE: 'Runtime Error',
CE: 'Compile Error',
Pending: '等待评测',
}
export interface Submission {
id: string
problemId: string
username: string
verdict: Verdict
language: string
time: number | null
memory: number | null
created: string
}
export const submissions: Submission[] = [
{
id: '10248',
problemId: '1001',
username: 'Kronecker',
verdict: 'AC',
language: 'Rust',
time: 12,
memory: 2048,
created: '2026-10-04 14:32:18',
},
{
id: '10247',
problemId: '1003',
username: 'Ada',
verdict: 'AC',
language: 'C++',
time: 34,
memory: 4096,
created: '2026-10-04 14:30:05',
},
{
id: '10246',
problemId: '1004',
username: 'Sora',
verdict: 'WA',
language: 'Python',
time: 62,
memory: 8192,
created: '2026-10-04 14:28:41',
},
{
id: '10245',
problemId: '1005',
username: 'Lin',
verdict: 'TLE',
language: 'C++',
time: 1000,
memory: 6144,
created: '2026-10-04 14:26:22',
},
{
id: '10244',
problemId: '1002',
username: 'Mika',
verdict: 'AC',
language: 'Python',
time: 48,
memory: 7168,
created: '2026-10-04 14:24:09',
},
{
id: '10243',
problemId: '1007',
username: 'Chen',
verdict: 'CE',
language: 'C++',
time: null,
memory: null,
created: '2026-10-04 14:21:33',
},
{
id: '10242',
problemId: '1008',
username: 'Yuki',
verdict: 'RE',
language: 'Rust',
time: 18,
memory: 3072,
created: '2026-10-04 14:19:06',
},
{
id: '10241',
problemId: '1006',
username: 'Kronecker',
verdict: 'AC',
language: 'Rust',
time: 11,
memory: 2048,
created: '2026-10-04 14:17:26',
},
{
id: '10240',
problemId: '1011',
username: 'Natsume',
verdict: 'Pending',
language: 'C++',
time: null,
memory: null,
created: '2026-10-04 14:15:12',
},
{
id: '10239',
problemId: '1009',
username: 'Ada',
verdict: 'AC',
language: 'C++',
time: 22,
memory: 4096,
created: '2026-10-04 14:12:45',
},
{
id: '10238',
problemId: '1002',
username: 'Sora',
verdict: 'WA',
language: 'Python',
time: 56,
memory: 7168,
created: '2026-10-04 14:10:18',
},
{
id: '10237',
problemId: '1012',
username: 'Hyperbola',
verdict: 'AC',
language: 'C++',
time: 71,
memory: 10240,
created: '2026-10-04 14:08:51',
},
]
export const discussions = [
{
id: '1',
title: '第一次使用在线评测,可以从哪里开始?',
category: '交流',
author: 'Kronecker',
replies: 12,
views: 328,
date: '2026-10-04',
pinned: true,
content:
'欢迎来到 KF OnlineJudge!可以先打开「从零开始 · 编程基础」题单,了解标准输入输出,再尝试 A + B Problem。\n\n遇到问题时,记录题号、语言和具体报错,把自己的思路一起写出来,讨论会更容易展开。',
},
{
id: '2',
title: 'A + B Problem:输入输出与整数范围',
category: '题解',
author: 'Ada',
replies: 8,
views: 216,
date: '2026-10-04',
pinned: false,
content:
'读取两个整数,计算它们的和,输出结果。\n\n记得检查整数范围。Rust 可以选择 i64;Python 的整数可以直接参与计算。输出只需要答案和换行。',
problemId: '1001',
},
{
id: '3',
title: '动态规划中的状态应该怎么定义?',
category: '问答',
author: 'Sora',
replies: 6,
views: 184,
date: '2026-10-03',
pinned: false,
content:
'练习背包问题时,我经常卡在状态定义上。现在尝试先描述「已经处理哪些物品」和「剩余多少容量」,再考虑每一步有哪些选择。\n\n大家平时会怎样从题意里提取状态?',
},
{
id: '4',
title: '分享我的 Rust 刷题输入模板',
category: '交流',
author: 'Lin',
replies: 4,
views: 152,
date: '2026-10-02',
pinned: false,
content:
'先用 read_to_string 获取输入,再按空白分隔。\n\n不同题目的输入结构可能不同,使用模板之前还是需要认真阅读题面。',
},
{
id: '5',
title: '关于题库筛选的一点建议',
category: '反馈',
author: 'Mika',
replies: 2,
views: 96,
date: '2026-10-01',
pinned: false,
content: '希望题库可以同时按照标签和难度筛选,这样练习某个知识点会更方便。',
},
]
export function findProblem(id: string) {
return problems.find((problem) => problem.id === id)
}
export function acceptance(problem: Pick<Problem, 'accepted' | 'submissions'>) {
return Math.round((problem.accepted / problem.submissions) * 100)
}
+2
View File
@@ -0,0 +1,2 @@
/// <reference types="vite/client" />
/// <reference types="element-plus/global" />
+88 -6
View File
@@ -1,12 +1,94 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRoute } from 'vue-router'
import UiIcon from '../components/UiIcon.vue'
const route = useRoute()
const mobileOpen = ref(false)
const links = [
{ to: '/', label: '首页', icon: 'home' },
{ to: '/problems', label: '题库', icon: 'problems' },
{ to: '/trainings', label: '训练', icon: 'trainings' },
{ to: '/contests', label: '比赛', icon: 'contests' },
{ to: '/submissions', label: '评测', icon: 'submissions' },
{ to: '/rankings', label: '排名', icon: 'rankings' },
{ to: '/discussions', label: '讨论', icon: 'discussions' },
{ to: '/teams', label: '团队', icon: 'teams' },
{ to: '/about', label: '关于', icon: 'about' },
]
const activePath = computed(() => '/' + route.path.split('/')[1])
</script>
<template>
<div class="app-shell">
<header class="app-header">
<RouterLink to="/" class="brand"> KF<span>OnlineJudge</span> </RouterLink>
<span class="header-caption">编程 · 练习 · 成长</span>
<div class="header-inner">
<RouterLink to="/" class="brand" aria-label="KF OnlineJudge 首页"
><span class="brand-mark">KF<span class="brand-dot">.</span></span
><span class="brand-name"
>OnlineJudge<small>CODE · PRACTICE · GROW</small></span
></RouterLink
>
<button
class="mobile-toggle"
:aria-expanded="mobileOpen"
aria-controls="main-navigation"
@click="mobileOpen = !mobileOpen"
>
<UiIcon name="problems" /> 导航
</button>
<nav
id="main-navigation"
class="main-nav"
:class="{ 'mobile-open': mobileOpen }"
aria-label="主导航"
>
<RouterLink
v-for="link in links"
:key="link.to"
:to="link.to"
:class="{ active: activePath === link.to }"
@click="mobileOpen = false"
><UiIcon :name="link.icon" />{{ link.label }}</RouterLink
>
</nav>
<div class="header-account">
<el-tooltip content="页面使用示例内容,业务服务逐步接入" placement="bottom"
><span class="preview-label">界面预览</span></el-tooltip
><RouterLink 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>
<main class="app-main">
<slot />
</main>
<footer class="app-footer">KF-OnlineJudge · 开发预览</footer>
<main id="main-content" class="app-main"><slot /></main>
<footer class="app-footer">
<div class="footer-inner">
<div class="footer-brand">
<span class="brand-mark">KF.</span><strong>每一次提交,都有新的收获。</strong>
<p>一个面向练习、竞赛与交流的在线评测平台。</p>
</div>
<div>
<h3>开始练习</h3>
<RouterLink to="/problems">浏览题库</RouterLink
><RouterLink to="/trainings">专题训练</RouterLink
><RouterLink to="/contests">参加比赛</RouterLink>
</div>
<div>
<h3>交流与成长</h3>
<RouterLink to="/discussions">讨论社区</RouterLink
><RouterLink to="/rankings">过题排行榜</RouterLink
><RouterLink to="/teams/create">创建团队</RouterLink>
</div>
<div>
<h3>平台</h3>
<RouterLink to="/about">关于 KF OnlineJudge</RouterLink
><RouterLink to="/about#system">系统状态</RouterLink
><a href="/admin/index.html">管理入口</a>
</div>
</div>
<div class="footer-bottom">
<span>© 2026 KF OnlineJudge</span><span>示例数据 · 备案信息待填写</span
><span>编程 · 练习 · 成长</span>
</div>
</footer>
</div>
</template>
+47
View File
@@ -0,0 +1,47 @@
export interface DistributionPoint {
value: number
count: number
}
export function summarizeDistribution(
points: readonly DistributionPoint[],
current: number | null,
) {
const valid = points.filter(
({ value, count }) =>
Number.isFinite(value) && value >= 0 && Number.isSafeInteger(count) && count > 0,
)
const total = valid.reduce((sum, point) => sum + point.count, 0)
const measured = current !== null && Number.isFinite(current) && current >= 0
const slower = measured
? valid.reduce((sum, point) => sum + (point.value > current ? point.count : 0), 0)
: 0
const tied = measured
? valid.reduce((sum, point) => sum + (point.value === current ? point.count : 0), 0)
: 0
const maximum = valid.reduce((max, point) => Math.max(max, point.value), 0)
const extent = Math.max(maximum, measured ? current : 0, 1)
const rawStep = extent / 24
const magnitude = 10 ** Math.floor(Math.log10(rawStep))
const step = [1, 2, 5, 10].find((size) => size * magnitude >= rawStep)! * magnitude
const binCount = Math.max(1, Math.ceil(extent / step))
const bins = Array.from({ length: binCount }, (_, index) => ({
start: index * step,
end: (index + 1) * step,
count: 0,
percent: 0,
}))
for (const point of valid) {
// The last interval includes its upper endpoint; other intervals exclude it.
bins[Math.min(binCount - 1, Math.floor(point.value / step))]!.count += point.count
}
for (const bin of bins) bin.percent = total ? (bin.count / total) * 100 : 0
return {
total,
bins,
end: binCount * step,
betterThan: total && measured ? (slower / total) * 100 : null,
tied,
current: measured ? current : null,
}
}
+5 -2
View File
@@ -1,8 +1,11 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import 'element-plus/dist/index.css'
import { registerUi } from './plugins/ui'
import App from './App.vue'
import { router } from './router'
import './styles/base.css'
import './styles/blueprint.css'
createApp(App).use(createPinia()).use(router).mount('#app')
const app = createApp(App)
registerUi(app)
app.use(createPinia()).use(router).mount('#app')
+65
View File
@@ -0,0 +1,65 @@
import type { App } from 'vue'
import {
ElButton,
ElConfigProvider,
ElDescriptions,
ElDescriptionsItem,
ElDialog,
ElEmpty,
ElForm,
ElFormItem,
ElIcon,
ElInput,
ElOption,
ElPagination,
ElPopover,
ElProgress,
ElRadio,
ElRadioButton,
ElRadioGroup,
ElResult,
ElSelect,
ElSlider,
ElSwitch,
ElTabPane,
ElTable,
ElTableColumn,
ElTabs,
ElTag,
ElTooltip,
} from 'element-plus'
import 'element-plus/dist/index.css'
export function registerUi(app: App) {
for (const component of [
ElButton,
ElConfigProvider,
ElDescriptions,
ElDescriptionsItem,
ElDialog,
ElEmpty,
ElForm,
ElFormItem,
ElIcon,
ElInput,
ElOption,
ElPagination,
ElPopover,
ElProgress,
ElRadio,
ElRadioButton,
ElRadioGroup,
ElResult,
ElSelect,
ElSlider,
ElSwitch,
ElTabPane,
ElTable,
ElTableColumn,
ElTabs,
ElTag,
ElTooltip,
]) {
app.use(component)
}
}
+89 -2
View File
@@ -1,13 +1,100 @@
import { createRouter, createWebHistory } from 'vue-router'
export const router = createRouter({
history: createWebHistory(),
scrollBehavior: (to, _from, saved) => saved ?? (to.hash ? { el: to.hash } : { top: 0 }),
routes: [
{ path: '/', name: 'home', component: () => import('../views/HomeView.vue') },
{
path: '/',
name: 'home',
meta: { title: '首页' },
component: () => import('../views/HomeView.vue'),
},
{
path: '/problems',
meta: { title: '题库' },
component: () => import('../views/ProblemsView.vue'),
},
{
path: '/problems/:id',
meta: { title: '题目详情' },
component: () => import('../views/ProblemDetailView.vue'),
},
{
path: '/trainings',
meta: { title: '训练' },
component: () => import('../views/TrainingsView.vue'),
},
{
path: '/trainings/:id',
meta: { title: '训练详情' },
component: () => import('../views/TrainingDetailView.vue'),
},
{
path: '/contests',
meta: { title: '比赛' },
component: () => import('../views/ContestsView.vue'),
},
{
path: '/contests/:id',
meta: { title: '比赛详情' },
component: () => import('../views/ContestDetailView.vue'),
},
{
path: '/submissions',
meta: { title: '评测' },
component: () => import('../views/SubmissionsView.vue'),
},
{
path: '/submissions/:id',
meta: { title: '评测详情' },
component: () => import('../views/SubmissionDetailView.vue'),
},
{
path: '/rankings',
meta: { title: '排名' },
component: () => import('../views/RankingsView.vue'),
},
{
path: '/discussions',
meta: { title: '讨论' },
component: () => import('../views/DiscussionsView.vue'),
},
{
path: '/discussions/:id',
meta: { title: '讨论详情' },
component: () => import('../views/DiscussionDetailView.vue'),
},
{ path: '/teams', meta: { title: '团队' }, component: () => import('../views/TeamsView.vue') },
{
path: '/teams/create',
meta: { title: '创建团队' },
component: () => import('../views/CreateTeamView.vue'),
},
{
path: '/users/:username',
meta: { title: '个人主页' },
component: () => import('../views/ProfileView.vue'),
},
{ path: '/login', meta: { title: '登录' }, component: () => import('../views/AuthView.vue') },
{
path: '/register',
meta: { title: '注册' },
component: () => import('../views/AuthView.vue'),
},
{
path: '/logout',
meta: { title: '退出登录' },
component: () => import('../views/LogoutView.vue'),
},
{ path: '/about', meta: { title: '关于' }, component: () => import('../views/AboutView.vue') },
{
path: '/:pathMatch(.*)*',
name: 'not-found',
meta: { title: '页面不存在' },
component: () => import('../views/NotFoundView.vue'),
},
],
})
router.afterEach((to) => {
document.title = `${String(to.meta.title ?? '首页')} · KF OnlineJudge`
})
File diff suppressed because it is too large Load Diff
+492
View File
@@ -0,0 +1,492 @@
:root {
--el-border-radius-base: 8px;
}
.app-header {
position: sticky;
top: 0;
z-index: 100;
box-shadow: none;
}
.header-inner {
min-height: 76px;
max-width: 1360px;
}
.brand-mark {
font-size: 25px;
padding: 10px 12px;
border-radius: 11px;
background: #eaf4ff;
letter-spacing: -1.5px;
}
.brand-dot {
color: #409eff;
}
.brand-name {
font-size: 13px;
}
.brand-name small {
letter-spacing: 1.3px;
}
.main-nav {
gap: 5px;
align-items: center;
}
.main-nav a {
align-self: center;
padding: 10px 13px;
border: 0;
border-radius: 8px;
font-size: 13px;
}
.main-nav a.active {
border: 0;
background: #edf6ff;
font-weight: 650;
}
.main-nav .el-icon {
display: none;
}
.header-account .el-button {
border-radius: 8px;
padding: 8px 14px;
}
.app-main {
width: min(100% - 64px, 1360px);
margin-top: 30px;
margin-bottom: 50px;
}
.app-footer {
background: transparent;
box-shadow: none;
padding: 38px 0 22px;
}
.footer-inner,
.footer-bottom {
max-width: 1360px;
}
.footer-brand .brand-mark {
display: inline-block;
background: #e3eefb;
}
.footer-inner h3 {
font-size: 12px;
color: #657e9e;
}
.panel {
border-radius: 16px;
box-shadow: none;
border: 1px solid #e1e8f1;
}
.panel-heading {
border: 0;
padding: 23px 24px 10px;
min-height: 68px;
align-items: flex-start;
}
.panel-heading h2 {
color: #455e80;
font-size: 17px;
font-weight: 650;
gap: 6px;
display: grid;
}
.panel-heading h2 > .el-icon {
display: none;
}
.panel-heading small {
margin: 0;
font-size: 10px;
letter-spacing: 0.3px;
}
.panel-body {
padding: 18px 24px 24px;
}
.panel-actions {
padding-top: 4px;
font-size: 11px;
}
.panel-actions a {
display: inline-flex;
align-items: center;
gap: 8px;
}
.page-heading {
margin: 8px 0 30px;
align-items: flex-end;
}
.page-heading h1 {
font-size: 34px;
letter-spacing: -1.2px;
color: #2e405b;
font-weight: 700;
margin: 10px 0 10px;
}
.page-heading .eyebrow {
color: #91a3bb;
font:
10px ui-monospace,
monospace;
letter-spacing: 2px;
}
.page-heading p {
font-size: 13px;
}
.el-button {
border-radius: 8px;
}
.el-table {
--el-table-header-bg-color: #f3f7fc;
--el-table-row-hover-bg-color: #f4f9ff;
--el-fill-color-lighter: #fff;
font-size: 13px;
border-radius: 8px;
}
.el-table .el-table__cell {
padding: 16px 0;
}
.el-table th.el-table__cell {
padding: 13px 0;
font-size: 11px;
color: #8a99ac;
letter-spacing: 0.3px;
}
.el-table .cell {
padding: 0 14px;
}
.table-link {
font-weight: 600;
}
.filters {
padding: 16px;
background: #f6f9fd;
border-radius: 10px;
gap: 10px;
margin-bottom: 22px;
}
.filters .el-input {
width: 260px;
}
.pagination-row {
margin-top: 22px;
padding-top: 18px;
border-top: 1px solid var(--line);
}
.tag,
.difficulty,
.el-tag {
border-radius: 5px;
}
.summary-card {
border-radius: 14px;
position: relative;
padding: 24px;
border-bottom: 3px solid #dbeaff;
}
.summary-card strong {
font:
650 32px ui-monospace,
monospace;
margin-top: 12px;
}
.intro-banner {
border-radius: 16px;
border-left: 4px solid #9cc8f5;
padding: 32px;
}
.training-card {
border-radius: 16px;
flex-direction: column;
gap: 10px;
position: relative;
padding: 26px;
}
.training-illustration {
width: 48px;
height: 48px;
border-radius: 12px;
font-size: 21px;
}
.training-card h2 {
font-size: 20px;
margin: 15px 0 10px;
}
.training-card p {
font-size: 13px;
line-height: 1.9;
}
.card-meta {
padding-top: 15px;
border-top: 1px solid var(--line);
flex-wrap: wrap;
}
.team-card {
border-radius: 16px;
flex-direction: column;
}
.team-logo {
width: 48px;
height: 48px;
}
.profile-banner {
border-radius: 18px;
border-left: 5px solid #a9cef5;
}
.profile-banner > .avatar {
border-radius: 22px;
}
.discussion-item {
padding: 24px 8px;
}
.discussion-item .avatar {
border-radius: 12px;
}
.discussion-item h2 {
font-size: 16px;
font-weight: 600;
}
.detail-prose h2 {
color: #455e80;
font-size: 18px;
padding-left: 12px;
border-left: 3px solid #b5d5f7;
}
.sample-block {
border-radius: 10px;
background: #f6f9fd;
border: 1px solid #e4edf7;
}
.auth-shell {
border-radius: 22px;
border: 1px solid #e1e8f1;
box-shadow: none;
}
.auth-art {
padding: 50px;
background-image: url('/images/practice-space.svg');
background-size: 90%;
background-position: center bottom;
background-repeat: no-repeat;
min-height: 520px;
}
.auth-art h1 {
font-size: 33px;
max-width: 300px;
line-height: 1.5;
}
.about-feature {
border-radius: 14px;
padding: 26px;
}
#tags {
scroll-margin-top: 98px;
}
.admin-application {
min-height: 100dvh;
}
.admin-app-header {
min-height: 80px;
display: flex;
align-items: center;
gap: 26px;
padding: 0 30px;
background: #fff;
border-bottom: 1px solid #e1e8f1;
position: sticky;
top: 0;
z-index: 100;
}
.admin-app-label {
font-size: 11px;
color: #91a3bb;
padding-left: 26px;
border-left: 1px solid #e1e8f1;
}
.admin-return {
font-size: 12px;
color: #657e9e;
display: flex;
align-items: center;
gap: 10px;
margin-left: auto;
}
.admin-app-nav {
position: fixed;
left: 0;
top: 80px;
bottom: 0;
width: 210px;
background: #fff;
border-right: 1px solid #e1e8f1;
padding: 30px 18px;
display: flex;
flex-direction: column;
gap: 7px;
}
.admin-app-nav a {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 18px;
font-size: 13px;
color: #7b8da5;
border-radius: 9px;
}
.admin-app-nav a.active {
background: #edf6ff;
color: #409eff;
font-weight: 600;
}
.admin-app-main {
margin: 32px 32px 48px 242px;
min-width: 0;
}
.admin-app-main .admin-shell {
display: block;
}
.admin-app-footer {
margin-left: 242px;
padding: 22px 32px 28px 0;
border-top: 1px solid #e1e8f1;
display: flex;
gap: 12px;
justify-content: space-between;
flex-wrap: wrap;
color: #9aa5b5;
font-size: 10px;
}
@media (min-width: 1200px) {
.card-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 1100px) {
.header-inner {
gap: 18px;
}
.main-nav a {
padding: 10px;
}
.brand-name {
display: none;
}
.preview-label {
display: none;
}
}
@media (max-width: 960px) {
.panel-body {
padding: 16px 20px 22px;
}
.panel-heading {
padding: 22px 20px 8px;
}
.page-heading h1 {
font-size: 30px;
}
.admin-app-label {
display: none;
}
}
@media (max-width: 760px) {
.header-inner {
min-height: 66px;
padding: 10px 0;
}
.brand-name {
display: block;
}
.brand-mark {
font-size: 20px;
padding: 9px 10px;
}
.main-nav a {
padding: 11px 15px;
}
.main-nav .el-icon {
display: none;
}
.header-account .el-button {
padding: 8px 11px;
}
.mobile-toggle {
border-radius: 7px;
padding: 7px;
}
.app-main {
width: calc(100% - 28px);
margin-top: 22px;
}
.page-heading {
align-items: flex-start;
}
.page-heading h1 {
font-size: 28px;
}
.panel-heading {
padding: 20px 18px 8px;
min-height: 65px;
}
.panel-heading h2 {
font-size: 16px;
}
.panel-heading small {
display: block;
}
.panel-body {
padding: 16px 18px 20px;
}
.filters {
padding: 12px;
}
.filters .el-input {
width: 100%;
}
.training-card,
.team-card {
padding: 22px;
}
.training-illustration {
display: grid;
}
.summary-card {
padding: 20px;
}
.summary-card strong {
font-size: 26px;
}
.admin-app-header {
min-height: 70px;
padding: 0 18px;
}
.admin-app-header .brand-name {
font-size: 12px;
}
.admin-app-nav {
width: 62px;
top: 70px;
padding: 20px 7px;
}
.admin-app-nav a {
padding: 14px 15px;
}
.admin-app-nav a span {
display: none;
}
.admin-app-main {
margin: 24px 14px 35px 76px;
}
.admin-app-footer {
margin-left: 76px;
padding-right: 14px;
}
.admin-return {
font-size: 11px;
}
.auth-art {
min-height: 0;
}
.footer-inner,
.footer-bottom {
width: calc(100% - 36px);
}
}
+81
View File
@@ -0,0 +1,81 @@
<script setup lang="ts">
import { computed } from 'vue'
import { usePlatformHealth } from '../composables/usePlatformHealth'
import PageHeading from '../components/PageHeading.vue'
import PanelCard from '../components/PanelCard.vue'
import UiIcon from '../components/UiIcon.vue'
const { state, version, refresh } = usePlatformHealth()
const status = computed(
() =>
({ loading: '正在检查', connected: 'API 进程已连接', unavailable: 'API 暂未连接' })[
state.value
],
)
const features = [
{ icon: 'problems', title: '练习与训练', text: '从基础题目到算法专题,通过题单组织日常练习。' },
{ icon: 'contests', title: '竞赛与评测', text: '围绕提交、评测和比赛,逐步完成在线评测流程。' },
{
icon: 'discussions',
title: '交流与成长',
text: '分享解题思路,结合练习记录,探索更合适的学习路径。',
},
]
</script>
<template>
<PageHeading
title="关于 KF OnlineJudge"
eyebrow="ABOUT"
description="从一道题开始,积累每一次进步。"
/>
<div class="intro-banner">
<div>
<p class="eyebrow">CODE · PRACTICE · GROW</p>
<h2>让思路在代码里落地。</h2>
<p>
KF OnlineJudge
是一个正在开发的在线评测项目,关注判题、沙箱、分布式执行与故障恢复,并逐步提供辅助答疑和个性化训练。
</p>
</div>
<span class="intro-number">KF.</span>
</div>
<div class="stack">
<PanelCard title="在这里,你可以" icon="reading"
><div class="about-grid">
<div v-for="feature in features" :key="feature.title" class="about-feature">
<UiIcon :name="feature.icon" />
<h3>{{ feature.title }}</h3>
<p>{{ feature.text }}</p>
</div>
</div></PanelCard
><PanelCard id="system" title="系统状态" icon="monitor"
><div class="service-row">
<div>
<h3>
平台 API
<el-tag
:type="state === 'connected' ? 'success' : 'info'"
size="small"
effect="plain"
>{{ status }}</el-tag
>
</h3>
<p>
这是实际的 API 存活检查。{{ version && state === 'connected' ? '版本 ' + version : '' }}
</p>
<p>数据库、题库、评测与学习服务将分别接入。</p>
</div>
<el-button :loading="state === 'loading'" @click="refresh">重新检查</el-button>
</div></PanelCard
><PanelCard title="使用说明" icon="about"
><div class="detail-prose">
<p>
当前为前端预览版本。题目、比赛、排名和用户信息使用示例数据;页面筛选、导航和本地代码草稿已可使用。
</p>
<p style="margin-top: 12px">
题目页可以选择语言、切换编辑器主题、保存草稿和填写自测输入。实际评测、账号和团队创建将在服务接入后开放。
</p>
<p style="margin-top: 12px">备案信息待填写,平台规则和联系方式将在上线前补充。</p>
</div></PanelCard
>
</div>
</template>
+100
View File
@@ -0,0 +1,100 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
const route = useRoute()
const registering = computed(() => route.path === '/register')
const form = ref<FormInstance>()
const model = reactive({ username: '', email: '', password: '', confirmation: '' })
const rules = computed<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('两次输入的密码不一致')),
trigger: 'blur',
},
],
}))
async function submit() {
if (await form.value?.validate().catch(() => false))
ElMessage.info('账号服务尚未接入,暂时无法' + (registering.value ? '注册' : '登录'))
}
</script>
<template>
<div class="auth-shell">
<aside class="auth-art">
<RouterLink to="/" class="brand"
><span class="brand-mark">KF.</span><span class="brand-name">OnlineJudge</span></RouterLink
>
<h1>写下思路,<br />开始这一题。</h1>
<p>练习、竞赛、讨论。<br />每一步,都算数。</p>
</aside>
<section class="auth-form">
<p class="eyebrow">WELCOME TO KF ONLINEJUDGE</p>
<h2>{{ registering ? '创建你的账号' : '欢迎回来' }}</h2>
<p>{{ registering ? '准备好开始新的练习了吗?' : '继续你的算法练习之旅。' }}</p>
<el-form
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-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>
<div class="auth-links">
<RouterLink to="/">← 返回首页</RouterLink
><RouterLink :to="registering ? '/login' : '/register'">{{
registering ? '已有账号?去登录' : '还没有账号?去注册'
}}</RouterLink>
</div>
</section>
</div>
</template>
+77
View File
@@ -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>
+73
View File
@@ -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>
+69
View File
@@ -0,0 +1,69 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import PageHeading from '../components/PageHeading.vue'
import PanelCard from '../components/PanelCard.vue'
const form = ref<FormInstance>()
const model = reactive({ name: '', description: '', visibility: 'public', joinMode: 'approval' })
const rules: FormRules<typeof model> = {
name: [
{ required: true, message: '请填写团队名称', trigger: 'blur' },
{ min: 2, max: 30, message: '名称长度为 2 到 30 个字符', trigger: 'blur' },
],
description: [
{ required: true, message: '请填写团队介绍', trigger: 'blur' },
{ max: 500, message: '介绍最多 500 个字符', trigger: 'blur' },
],
}
async function validate() {
if (await form.value?.validate().catch(() => false))
ElMessage.info('填写已通过检查,团队创建功能待服务接入')
}
</script>
<template>
<PageHeading
title="创建团队"
eyebrow="CREATE A TEAM"
description="为团队起一个名字,写下你们想一起完成的事情。"
><RouterLink to="/teams"><el-button>返回团队列表</el-button></RouterLink></PageHeading
><PanelCard title="团队资料" icon="teams" class="form-panel"
><el-form
ref="form"
:model="model"
:rules="rules"
label-position="top"
@submit.prevent="validate"
><el-form-item label="团队名称" prop="name"
><el-input
v-model="model.name"
placeholder="例如:KF 算法研习组"
maxlength="30"
show-word-limit /></el-form-item
><el-form-item label="团队介绍" prop="description"
><el-input
v-model="model.description"
type="textarea"
:rows="5"
placeholder="介绍团队的练习方向、交流方式和目标"
maxlength="500"
show-word-limit /></el-form-item
><el-form-item label="可见性"
><el-radio-group v-model="model.visibility"
><el-radio value="public">公开团队</el-radio
><el-radio value="private">私有团队</el-radio></el-radio-group
>
<p class="form-help">公开团队可在团队列表中发现,私有团队通过邀请加入。</p></el-form-item
><el-form-item label="加入方式"
><el-radio-group v-model="model.joinMode"
><el-radio value="approval">需要审核</el-radio
><el-radio value="invite">仅接受邀请</el-radio></el-radio-group
></el-form-item
>
<div class="form-actions">
<el-button native-type="submit">检查填写</el-button
><el-button type="primary" disabled>创建团队</el-button
><span class="muted">团队服务待接入</span>
</div></el-form
></PanelCard
>
</template>
@@ -0,0 +1,40 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { discussions } from '../data/preview'
import PageHeading from '../components/PageHeading.vue'
import PanelCard from '../components/PanelCard.vue'
import EmptyState from '../components/EmptyState.vue'
const route = useRoute()
const discussion = computed(() => discussions.find((d) => d.id === route.params.id))
</script>
<template>
<template v-if="discussion"
><div class="breadcrumbs">
<RouterLink to="/discussions">讨论</RouterLink><span>/</span
><span>{{ discussion.category }}</span>
</div>
<PageHeading :title="discussion.title"
><el-tag effect="plain">{{ discussion.category }}</el-tag></PageHeading
><PanelCard title="正文" icon="reading" subtitle="示例文章"
><div class="detail-meta">
<RouterLink :to="'/users/' + discussion.author">{{ discussion.author }}</RouterLink
><time>{{ discussion.date }}</time
><span>{{ discussion.views }} 次浏览</span>
</div>
<div class="detail-prose">
<p>{{ discussion.content }}</p>
</div>
<RouterLink
v-if="discussion.problemId"
class="text-link"
:to="'/problems/' + discussion.problemId"
style="display: inline-block; margin-top: 24px"
>查看相关题目 →</RouterLink
>
<div class="comment-placeholder">评论功能将在社区服务接入后开放。</div></PanelCard
></template
><EmptyState v-else title="讨论不存在"
><RouterLink to="/discussions"><el-button>返回讨论列表</el-button></RouterLink></EmptyState
>
</template>
+77
View File
@@ -0,0 +1,77 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { discussions, rankings } from '../data/preview'
import PageHeading from '../components/PageHeading.vue'
import PanelCard from '../components/PanelCard.vue'
const category = ref('全部')
const query = ref('')
const items = computed(() =>
discussions.filter(
(d) =>
(category.value === '全部' || d.category === category.value) && d.title.includes(query.value),
),
)
</script>
<template>
<PageHeading
title="讨论社区"
eyebrow="COMMUNITY"
description="分享思路,提出问题,一起把代码写得更好。"
><el-tooltip content="讨论发布将在账号服务接入后开放"
><el-button type="primary" disabled>发起讨论</el-button></el-tooltip
></PageHeading
>
<div class="content-with-aside">
<PanelCard title="最新讨论" icon="discussions" subtitle="示例文章"
><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>
<article v-for="discussion in items" :key="discussion.id" class="discussion-item">
<RouterLink
:to="'/users/' + discussion.author"
class="avatar"
:style="{ background: rankings.find((r) => r.username === discussion.author)?.color }"
>{{ discussion.author[0] }}</RouterLink
>
<div>
<h2>
<el-tag v-if="discussion.pinned" size="small" effect="plain">置顶</el-tag
><RouterLink :to="'/discussions/' + discussion.id">{{ discussion.title }}</RouterLink>
</h2>
<p>
<span class="tag">{{ discussion.category }}</span
><RouterLink :to="'/users/' + discussion.author">{{ discussion.author }}</RouterLink
><span>{{ discussion.date }}</span>
</p>
</div>
<div class="discussion-numbers">
<span
><strong>{{ discussion.replies }}</strong> 回复</span
><span>{{ discussion.views }} 浏览</span>
</div>
</article>
<el-empty v-if="!items.length" description="没有符合条件的讨论"
/></PanelCard>
<div class="stack">
<PanelCard title="欢迎交流" icon="reading"
><p class="small muted">
提问时写清楚题号、语言、报错和已经尝试过的思路。分享题解时,也可以记录从哪里开始卡住,以及后来怎样找到解法。
</p></PanelCard
><PanelCard title="社区分区" icon="folder"
><dl class="details-list">
<div v-for="c in ['交流', '问答', '题解', '反馈']" :key="c">
<dt>{{ c }}</dt>
<dd>{{ discussions.filter((d) => d.category === c).length }} 篇示例文章</dd>
</div>
</dl></PanelCard
>
</div>
</div>
</template>
+544 -30
View File
@@ -1,36 +1,550 @@
<script setup lang="ts">
import { computed } from 'vue'
import { ElButton, ElCard, ElTag } from 'element-plus'
import { usePlatformHealth } from '../composables/usePlatformHealth'
const { state, version, refresh } = usePlatformHealth()
const statusText = computed(
() =>
({
loading: '正在连接平台',
connected: '平台已连接',
unavailable: '平台暂未连接',
})[state.value],
)
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { notices, rankings, contests, problems, problemTags } from '../data/preview'
import PanelCard from '../components/PanelCard.vue'
import ProblemTable from '../components/ProblemTable.vue'
import SubmissionChart from '../components/SubmissionChart.vue'
import TagSphere from '../components/TagSphere.vue'
import UiIcon from '../components/UiIcon.vue'
const router = useRouter()
const noticeOpen = ref(false)
const selectedNotice = ref(notices[0]!)
function openNotice(index: number) {
selectedNotice.value = notices[index]!
noticeOpen.value = true
}
</script>
<template>
<section class="welcome">
<el-tag effect="plain"> 开发预览 </el-tag>
<h1>从一道题开始,<br />积累每一次进步。</h1>
<p class="welcome-description">
KF-OnlineJudge 正在建设中。题目练习、提交评测和个人学习记录将在后续版本开放。
</p>
<el-card shadow="never" class="connection-card">
<div class="connection-row">
<div>
<h2>平台连接</h2>
<p aria-live="polite">
{{ statusText }}<span v-if="version && state === 'connected'"> · {{ version }}</span>
</p>
<div class="practice-home">
<section class="practice-hero">
<div class="practice-hero-copy">
<p class="eyebrow">KF ONLINEJUDGE / PRACTICE SPACE</p>
<h1>把想法写成代码,<br />让思路一步步清晰。</h1>
<p class="practice-intro">
从一道题开始,沿着自己的节奏练习。<br />在这里写代码、做训练,也和同伴一起迎接新的挑战。
</p>
<div class="practice-actions">
<RouterLink to="/problems"
><el-button type="primary" size="large"
>开始解题 <UiIcon name="arrow" /></el-button></RouterLink
><RouterLink to="/trainings" class="practice-secondary"
>浏览训练题单 <UiIcon name="arrow"
/></RouterLink>
</div>
<div class="practice-hero-meta">
<span
><strong>{{ problems.length }}</strong> 道示例题目</span
><span
><strong>{{ problemTags.length }}</strong> 个算法标签</span
><span>ACM / OI</span>
</div>
<el-button :loading="state === 'loading'" @click="refresh"> 重新检查 </el-button>
</div>
</el-card>
</section>
<div class="practice-hero-art">
<img
src="/images/practice-space.svg"
alt="蓝色路径连接代码、算法节点与完成标记的编程空间插画"
/>
</div>
</section>
<section class="practice-announcements" aria-label="站内公告">
<div class="announcements-label">
<UiIcon name="bell" /><strong>站内公告</strong><span>示例内容</span>
</div>
<button v-for="(notice, index) in notices" :key="notice.id" @click="openNotice(index)">
<time>{{ notice.date.slice(5) }}</time
><span>{{ notice.title }}</span
><UiIcon name="arrow" />
</button>
</section>
<div class="practice-activity">
<PanelCard class="activity-panel" title="练习的每一步" subtitle="最近一周 · 示例统计">
<template #actions
><RouterLink to="/submissions">查看评测 <UiIcon name="arrow" /></RouterLink
></template>
<div class="activity-totals">
<div>
<span>累计提交</span><strong>544<small>次</small></strong>
</div>
<div>
<span>通过提交</span><strong>342<small>次</small></strong>
</div>
<div>
<span>统计周期</span
><strong class="activity-period">09.28 <small>—</small> 10.04</strong>
</div>
</div>
<SubmissionChart />
</PanelCard>
<PanelCard
id="tags"
class="explore-panel"
title="算法探索"
:subtitle="problemTags.length + ' 个标签'"
>
<TagSphere
:tags="problemTags"
@select="(tag) => router.push({ path: '/problems', query: { tag } })"
/>
</PanelCard>
</div>
<div class="practice-library">
<PanelCard title="从这里开始解题" subtitle="最新题目">
<template #actions
><RouterLink to="/problems">完整题库 <UiIcon name="arrow" /></RouterLink
></template>
<ProblemTable :items="problems.slice(0, 6)" compact />
<div class="practice-library-note">
<span>换一个角度,试试新的解法。</span
><RouterLink to="/trainings">按专题练习 <UiIcon name="arrow" /></RouterLink>
</div>
</PanelCard>
<PanelCard class="practice-rank" title="一起向前" subtitle="过题榜 · 示例排名">
<template #actions
><RouterLink to="/rankings">全部 <UiIcon name="arrow" /></RouterLink
></template>
<RouterLink
v-for="(user, index) in rankings.slice(0, 5)"
:key="user.username"
:to="'/users/' + user.username"
class="practice-rank-row"
><span class="rank-index">{{ String(index + 1).padStart(2, '0') }}</span
><span class="avatar" :style="{ background: user.color }">{{ user.username[0] }}</span>
<div>
<strong>{{ user.username }}</strong>
<p>{{ user.motto }}</p>
</div>
<span class="rank-solved"
><strong>{{ user.solved }}</strong
><small>过题</small></span
></RouterLink
>
</PanelCard>
</div>
<section class="practice-contests">
<div class="section-title">
<div>
<p class="eyebrow">NEXT CHALLENGE</p>
<h2>给下一场挑战留点时间。</h2>
</div>
<RouterLink to="/contests">比赛日历 <UiIcon name="arrow" /></RouterLink>
</div>
<div class="practice-contest-grid">
<RouterLink
v-for="contest in contests.slice(0, 3)"
:key="contest.id"
:to="'/contests/' + contest.id"
class="practice-contest-card"
><div>
<span class="tag">{{ contest.type }}</span
><span>{{ contest.status }}</span>
</div>
<h3>{{ contest.title }}</h3>
<p><UiIcon name="clock" />{{ contest.start }} · {{ contest.duration }}</p>
<span class="contest-enter">查看比赛 <UiIcon name="arrow" /></span
></RouterLink>
</div>
<p class="practice-demo-note">示例赛程,实际安排以平台发布的信息为准。</p>
</section>
<div class="practice-links">
<span>练习之外,也可以一起聊聊。</span
><RouterLink to="/discussions">讨论交流 <UiIcon name="arrow" /></RouterLink
><RouterLink to="/teams/create">创建团队 <UiIcon name="arrow" /></RouterLink
><RouterLink to="/about">关于 KF OJ <UiIcon name="arrow" /></RouterLink>
</div>
</div>
<el-dialog v-model="noticeOpen" :title="selectedNotice.title" width="min(600px,92vw)"
><p class="small muted">{{ selectedNotice.date }} · {{ selectedNotice.category }} · 示例公告</p>
<p style="margin-top: 20px; line-height: 1.9">{{ selectedNotice.text }}</p></el-dialog
>
</template>
<style scoped>
.practice-home {
display: grid;
gap: 28px;
}
.practice-hero {
display: grid;
grid-template-columns: 1.2fr 1fr;
min-height: 360px;
border-radius: 20px;
background: linear-gradient(110deg, #fff 36%, #eaf4ff);
overflow: hidden;
border: 1px solid #e1eaf4;
}
.practice-hero-copy {
padding: 42px 44px;
}
.practice-hero h1 {
font-size: clamp(28px, 3vw, 42px);
line-height: 1.45;
letter-spacing: -1.5px;
color: #2d4261;
margin: 12px 0 18px;
}
.practice-intro {
font-size: 14px;
color: #7d8fa6;
}
.practice-actions {
display: flex;
align-items: center;
gap: 24px;
flex-wrap: wrap;
margin: 26px 0;
}
.practice-secondary {
display: flex;
gap: 10px;
align-items: center;
font-size: 13px;
color: #657e9e;
}
.practice-hero-meta {
display: flex;
gap: 22px;
flex-wrap: wrap;
font-size: 11px;
color: #8b9db3;
}
.practice-hero-meta strong {
font:
600 16px ui-monospace,
monospace;
color: #566f92;
}
.practice-hero-art {
display: flex;
align-items: center;
justify-content: center;
padding: 20px 28px 20px 0;
}
.practice-hero-art img {
display: block;
width: 100%;
max-width: 460px;
}
.practice-announcements {
display: grid;
grid-template-columns: 135px repeat(3, minmax(0, 1fr));
gap: 18px;
padding: 8px 0;
align-items: center;
}
.announcements-label {
display: grid;
grid-template-columns: 18px 1fr;
gap: 5px 8px;
font-size: 13px;
color: #657e9e;
}
.announcements-label > span {
grid-column: 2;
font-size: 10px;
color: #9cabbc;
}
.practice-announcements button {
border: 0;
background: none;
text-align: left;
display: grid;
grid-template-columns: 1fr 18px;
gap: 5px;
padding: 8px 18px;
border-left: 1px solid #dce4ef;
min-width: 0;
color: #455e80;
}
.practice-announcements time {
grid-column: 1/-1;
font:
11px ui-monospace,
monospace;
color: #96a5b8;
}
.practice-announcements button > span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 12px;
}
.practice-announcements button:hover {
color: var(--blue);
}
.practice-activity,
.practice-library {
display: grid;
grid-template-columns: minmax(0, 1.65fr) minmax(320px, 1fr);
gap: 24px;
}
.activity-totals {
display: flex;
justify-content: space-between;
gap: 18px;
margin: 8px 0 22px;
}
.activity-totals > div {
display: grid;
gap: 8px;
}
.activity-totals span {
color: #91a0b5;
font-size: 11px;
}
.activity-totals strong {
color: #455e80;
font:
600 30px ui-monospace,
monospace;
}
.activity-totals small {
font:
11px 'Microsoft YaHei',
sans-serif;
color: #9aacbf;
margin-left: 7px;
}
.activity-totals strong.activity-period {
font-size: 17px;
margin-top: 10px;
}
.activity-panel :deep(.submission-chart) {
padding-top: 6px;
}
.practice-library-note {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 10px;
padding-top: 18px;
font-size: 11px;
color: #9cabbc;
}
.practice-library-note a {
color: var(--blue);
}
.practice-rank-row {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 0;
border-bottom: 1px solid var(--line);
}
.practice-rank-row:last-child {
border-bottom: 0;
}
.rank-index {
font:
11px ui-monospace,
monospace;
color: #a6b4c6;
}
.practice-rank-row .avatar {
width: 34px;
height: 34px;
border-radius: 10px;
}
.practice-rank-row > div {
min-width: 0;
}
.practice-rank-row strong {
font-size: 13px;
font-weight: 600;
}
.practice-rank-row p {
font-size: 10px;
color: #9cabbc;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.rank-solved {
margin-left: auto;
display: grid;
text-align: right;
gap: 2px;
}
.rank-solved strong {
font:
600 18px ui-monospace,
monospace;
color: #4995e5;
}
.rank-solved small {
font-size: 9px;
color: #9cabbc;
}
.section-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
margin: 8px 0 20px;
}
.section-title h2 {
font-size: 23px;
font-weight: 600;
color: #354c6e;
}
.section-title > a {
font-size: 12px;
color: var(--blue);
}
.practice-contest-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 20px;
}
.practice-contest-card {
position: relative;
display: block;
padding: 26px;
background: #fff;
border: 1px solid #e1eaf4;
border-radius: 16px;
}
.practice-contest-card > div {
display: flex;
justify-content: space-between;
font-size: 11px;
color: #8e9eb3;
}
.practice-contest-card h3 {
font-size: 16px;
font-weight: 600;
line-height: 1.6;
margin: 22px 0 12px;
color: #455e80;
}
.practice-contest-card p {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: #91a0b5;
}
.contest-enter {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 25px;
font-size: 12px;
color: var(--blue);
}
.practice-demo-note {
margin-top: 12px;
font-size: 10px;
color: #9cabbc;
}
.practice-links {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 24px;
padding: 22px 0 4px;
border-top: 1px solid #dce4ef;
font-size: 12px;
}
.practice-links > span {
margin-right: auto;
color: #91a0b5;
}
.practice-links > a {
color: #657e9e;
}
@media (max-width: 960px) {
.practice-hero-copy {
padding: 32px;
}
.practice-hero-art {
padding-right: 10px;
}
.practice-activity,
.practice-library {
gap: 18px;
grid-template-columns: minmax(0, 1.4fr) minmax(280px, 1fr);
}
.practice-announcements {
grid-template-columns: 110px repeat(3, minmax(0, 1fr));
gap: 6px;
}
.practice-announcements button {
padding: 8px 12px;
}
.activity-totals strong {
font-size: 25px;
}
.activity-period {
display: none;
}
.practice-contest-card {
padding: 20px;
}
}
@media (max-width: 760px) {
.practice-home {
gap: 22px;
}
.practice-hero {
grid-template-columns: 1fr;
}
.practice-hero-copy {
padding: 30px 26px 18px;
}
.practice-hero-art {
padding: 0 28px 10px;
height: 190px;
overflow: hidden;
}
.practice-hero-art img {
max-width: 330px;
}
.practice-announcements {
grid-template-columns: 1fr;
gap: 2px;
}
.announcements-label {
display: flex;
gap: 8px;
margin: 0 0 5px;
}
.practice-announcements button {
grid-template-columns: 46px 1fr 18px;
border: 0;
padding: 10px 0;
}
.practice-announcements time {
grid-column: auto;
}
.practice-activity,
.practice-library,
.practice-contest-grid {
grid-template-columns: 1fr;
}
.section-title h2 {
font-size: 20px;
}
.practice-links {
gap: 18px;
}
.practice-links > span {
width: 100%;
}
.practice-contest-card h3 {
margin-top: 16px;
}
.practice-hero h1 {
font-size: 28px;
}
.activity-totals > div:last-child {
display: none;
}
.activity-totals {
justify-content: flex-start;
gap: 50px;
}
.practice-actions {
gap: 18px;
}
}
</style>
+13
View File
@@ -0,0 +1,13 @@
<script setup lang="ts">
import EmptyState from '../components/EmptyState.vue'
</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
>
</div>
</template>
+630
View File
@@ -0,0 +1,630 @@
<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, type Submission } 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'
import SubmissionPerformance from '../components/SubmissionPerformance.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 workspaceTab = ref<'code' | 'submission'>('code')
const selectedSubmission = ref<Submission>()
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))
function viewSubmission(submissionId: string) {
const submission = codeRelated.value.find((item) => item.id === submissionId)
if (!submission) return
selectedSubmission.value = submission
workspaceTab.value = 'submission'
}
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="90"
><template #default="{ row }">{{
row.time === null ? '—' : row.time + ' ms'
}}</template> </el-table-column
><el-table-column label="详情" width="105"
><template #default="{ row }"
><el-button link type="primary" @click="viewSubmission(row.id)"
>查看表现</el-button
></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="workspace-view-tabs" aria-label="工作台视图">
<button
type="button"
:aria-pressed="workspaceTab === 'code'"
@click="workspaceTab = 'code'"
>
<UiIcon name="document" />代码编辑器
</button>
<button
type="button"
:disabled="!selectedSubmission"
:aria-pressed="workspaceTab === 'submission'"
@click="workspaceTab = 'submission'"
>
<UiIcon name="rankings" />{{
selectedSubmission ? '提交 #' + selectedSubmission.id : '评测详情'
}}
</button>
</header>
<div v-show="workspaceTab === 'code'" class="editor-workspace">
<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>
</div>
<div
v-if="selectedSubmission"
v-show="workspaceTab === 'submission'"
class="submission-workspace"
>
<header class="workspace-submission-heading">
<div>
<VerdictTag :value="selectedSubmission.verdict" full />
<p>{{ selectedSubmission.username }} · {{ selectedSubmission.created }}</p>
</div>
<RouterLink :to="'/submissions/' + selectedSubmission.id" class="text-link"
>完整详情 →</RouterLink
>
</header>
<SubmissionPerformance :submission="selectedSubmission" />
<p class="notice-strip">当前展示示例评测记录,源码与测试点详情待接入。</p>
</div>
</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;
}
.editor-workspace {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
.workspace-view-tabs {
display: flex;
gap: 8px;
padding: 10px 14px;
border-bottom: 1px solid #edf0f5;
}
.workspace-view-tabs button {
display: flex;
align-items: center;
gap: 7px;
padding: 8px 12px;
border: 0;
border-radius: 7px;
background: transparent;
color: #8396af;
font-size: 12px;
}
.workspace-view-tabs button[aria-pressed='true'] {
color: var(--blue);
background: #edf6ff;
}
.workspace-view-tabs button:disabled {
color: #aab6c5;
}
.workspace-view-tabs button:focus-visible {
outline: 2px solid var(--blue);
}
.submission-workspace {
flex: 1;
min-height: 0;
overflow: auto;
padding: 24px;
}
.workspace-submission-heading {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 20px;
}
.workspace-submission-heading p {
margin-top: 8px;
font-size: 11px;
color: #93a4ba;
}
.workspace-submission-heading > a {
font-size: 12px;
}
.submission-workspace .performance-distribution {
padding: 20px;
}
.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>
+62
View File
@@ -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>
+105
View File
@@ -0,0 +1,105 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRoute } from 'vue-router'
import { rankings, submissions, problems } from '../data/preview'
import PanelCard from '../components/PanelCard.vue'
import SubmissionTable from '../components/SubmissionTable.vue'
import ProblemTable from '../components/ProblemTable.vue'
import EmptyState from '../components/EmptyState.vue'
const route = useRoute()
const user = computed(() => rankings.find((u) => u.username === route.params.username))
const tab = ref('activity')
const records = computed(() => submissions.filter((s) => s.username === user.value?.username))
const solved = computed(() =>
problems.filter((p) => records.value.some((s) => s.problemId === p.id && s.verdict === 'AC')),
)
const activity = Array.from({ length: 140 }, (_, index) =>
index % 11 === 0 ? 4 : index % 7 === 0 ? 3 : index % 3 === 0 ? 2 : index % 2 === 0 ? 1 : 0,
)
</script>
<template>
<template v-if="user"
><div class="profile-banner">
<span class="avatar" :style="{ background: user.color }">{{ user.username[0] }}</span>
<div>
<p class="eyebrow">PERSONAL PROFILE · 示例用户</p>
<h1>{{ user.username }}</h1>
<p>{{ user.motto }}</p>
<div class="profile-tags">
<span class="tag">算法爱好者</span><span class="tag">持续练习</span>
</div>
</div>
<div class="profile-actions">
<RouterLink :to="{ path: '/submissions', query: { user: user.username } }"
><el-button>查看提交</el-button></RouterLink
><el-button disabled>关注</el-button>
</div>
</div>
<div class="summary-grid">
<div class="summary-card">
<p>已通过题目</p>
<strong>{{ user.solved }}</strong
><small>示例统计</small>
</div>
<div class="summary-card">
<p>总提交数</p>
<strong>{{ user.submissions }}</strong
><small>示例统计</small>
</div>
<div class="summary-card">
<p>通过率</p>
<strong>{{ Math.round((user.solved / user.submissions) * 100) }}%</strong
><small>示例统计</small>
</div>
<div class="summary-card">
<p>竞赛积分</p>
<strong>{{ user.rating }}</strong
><small>示例积分</small>
</div>
</div>
<div class="content-with-aside">
<PanelCard title="练习记录" icon="rankings"
><el-tabs v-model="tab"
><el-tab-pane label="提交活动" name="activity"
><p class="small muted">提交活动热力图 · 布局示例</p>
<div class="heatmap" role="img" aria-label="示例提交活动热力图">
<span
v-for="(level, index) in activity"
:key="index"
class="heat-cell"
:data-level="level"
/>
</div>
<div class="detail-meta"><span>少</span><span>← 提交活跃度 →</span><span>多</span></div>
<SubmissionTable :items="records" compact /></el-tab-pane
><el-tab-pane label="已通过题目" name="problems"
><p class="tab-summary">以下题目来自当前示例提交记录。</p>
<ProblemTable :items="solved" compact /></el-tab-pane></el-tabs
></PanelCard>
<div class="stack">
<PanelCard title="个人介绍" icon="about"
><p class="small muted">{{ user.motto }}</p>
<dl class="details-list" style="margin-top: 20px">
<div>
<dt>用户名</dt>
<dd>{{ user.username }}</dd>
</div>
<div>
<dt>榜单位置</dt>
<dd>#{{ rankings.indexOf(user) + 1 }}</dd>
</div>
</dl></PanelCard
><PanelCard title="学习分析" icon="reading"
><p class="small muted">
后续结合历史代码、提交结果和题目标签,分析练习情况并生成个性化训练建议。
</p>
<el-button disabled style="margin-top: 18px; width: 100%"
>分析服务待接入</el-button
></PanelCard
>
</div>
</div></template
><EmptyState v-else title="用户不存在"
><RouterLink to="/rankings"><el-button>查看排行榜</el-button></RouterLink></EmptyState
>
</template>
+67
View File
@@ -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,65 @@
<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'
import SubmissionPerformance from '../components/SubmissionPerformance.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>
<SubmissionPerformance :submission="submission" />
<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>
+75
View File
@@ -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>
+61
View File
@@ -0,0 +1,61 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import PageHeading from '../components/PageHeading.vue'
const query = ref('')
const teams = [
{
name: 'KF 算法研习组',
initials: 'KF',
description: '一起练习基础算法,分享解题笔记。',
members: 12,
tags: ['基础训练', '算法交流'],
},
{
name: 'Rust 解题小队',
initials: 'RS',
description: '用 Rust 完成题目,交流语言与算法的使用经验。',
members: 8,
tags: ['Rust', '专题练习'],
},
{
name: '周末练习伙伴',
initials: 'AC',
description: '每周留一点时间,认真做几道题。',
members: 16,
tags: ['练习赛', '学习打卡'],
},
]
const items = computed(() =>
teams.filter((t) => t.name.toLowerCase().includes(query.value.toLowerCase())),
)
</script>
<template>
<PageHeading title="团队" eyebrow="TEAMS" description="找到一起练习和交流的伙伴。"
><RouterLink to="/teams/create"
><el-button type="primary">创建团队</el-button></RouterLink
></PageHeading
>
<div class="filters">
<el-input v-model="query" placeholder="搜索团队" aria-label="搜索团队" clearable /><span
class="filter-label"
>示例团队 · 加入功能待接入</span
>
</div>
<div class="card-grid">
<article v-for="team in items" :key="team.name" class="panel team-card">
<span class="team-logo">{{ team.initials }}</span>
<div style="flex: 1">
<h2>{{ team.name }}</h2>
<p>{{ team.description }}</p>
<div class="tag-list">
<span v-for="tag in team.tags" :key="tag" class="tag">{{ tag }}</span>
</div>
<div class="card-meta" style="margin-top: 20px">
<span>{{ team.members }} 名成员 · 示例</span
><el-button disabled size="small" style="margin-left: auto">申请加入</el-button>
</div>
</div>
</article>
</div>
<el-empty v-if="!items.length" description="没有找到团队" />
</template>
+59
View File
@@ -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>
+55
View File
@@ -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>
+64
View File
@@ -0,0 +1,64 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { summarizeDistribution } from '../src/lib/performance.ts'
test('zero usage is measured and ties do not count as better performance', () => {
const summary = summarizeDistribution(
[
{ value: 0, count: 3 },
{ value: 12, count: 2 },
{ value: 24, count: 5 },
],
12,
)
assert.equal(summary.total, 10)
assert.equal(summary.betterThan, 50)
assert.equal(summary.tied, 2)
assert.equal(summarizeDistribution([{ value: 0, count: 3 }], 0).betterThan, 0)
})
test('bin boundaries and the final upper endpoint preserve every sample', () => {
const summary = summarizeDistribution(
[
{ value: 0, count: 2 },
{ value: 5, count: 3 },
{ value: 100, count: 7 },
],
100,
)
assert.equal(summary.bins[0].count, 2)
assert.equal(summary.bins[1].count, 3)
assert.equal(summary.bins.at(-1)!.count, 7)
assert.equal(
summary.bins.reduce((sum, bin) => sum + bin.count, 0),
summary.total,
)
assert.ok(Math.abs(summary.bins.reduce((sum, bin) => sum + bin.percent, 0) - 100) < 1e-9)
})
test('missing measurements and invalid samples produce no percentile', () => {
const summary = summarizeDistribution(
[
{ value: -1, count: 3 },
{ value: NaN, count: 2 },
{ value: Infinity, count: 1 },
{ value: 10, count: -1 },
{ value: 20, count: 1.5 },
{ value: 24, count: 0 },
],
null,
)
assert.equal(summary.total, 0)
assert.equal(summary.betterThan, null)
assert.equal(summary.current, null)
assert.ok(summary.bins.every((bin) => bin.percent === 0))
assert.equal(summarizeDistribution([{ value: 12, count: 2 }], null).betterThan, null)
assert.equal(summarizeDistribution([{ value: 12, count: 2 }], NaN).current, null)
})
test('current measurements outside the cohort remain within the plot', () => {
const summary = summarizeDistribution([{ value: 12, count: 2 }], 140)
assert.ok(summary.end >= 140)
assert.equal(summary.betterThan, 0)
assert.equal(summary.total, 2)
})
+164
View File
@@ -0,0 +1,164 @@
import assert from 'node:assert/strict'
import { test, type TestContext } from 'node:test'
import { effectScope, nextTick } from 'vue'
import { codeTemplates, useCodeDraft, type Language } from '../src/composables/useCodeDraft.ts'
type SavedDraft = { version: number; language: Language; drafts: Record<Language, string> }
function browser(t: TestContext) {
const page = new EventTarget()
const document = Object.assign(new EventTarget(), {
visibilityState: 'visible' as DocumentVisibilityState,
})
const storage = {
items: new Map<string, string>(),
writes: 0,
failWrites: false,
getItem(key: string) {
return this.items.get(key) ?? null
},
setItem(key: string, value: string) {
this.writes++
if (this.failWrites) throw new Error('Storage unavailable')
this.items.set(key, value)
},
}
const globals = { window: page, document, localStorage: storage }
const descriptors = Object.keys(globals).map(
(name) => [name, Object.getOwnPropertyDescriptor(globalThis, name)] as const,
)
for (const [name, value] of Object.entries(globals)) {
Object.defineProperty(globalThis, name, { configurable: true, writable: true, value })
}
t.mock.timers.enable({ apis: ['setTimeout'] })
const scopes: ReturnType<typeof effectScope>[] = []
t.after(() => {
for (const scope of scopes) scope.stop()
for (const [name, descriptor] of descriptors) {
if (descriptor) Object.defineProperty(globalThis, name, descriptor)
else Reflect.deleteProperty(globalThis, name)
}
})
return {
page,
document,
storage,
open(problemId = '1001') {
const scope = effectScope()
scopes.push(scope)
return { scope, ...scope.run(() => useCodeDraft(problemId))! }
},
read(problemId = '1001'): SavedDraft | null {
return JSON.parse(storage.getItem('kf-oj:code-draft:' + problemId) ?? 'null')
},
}
}
test('pagehide saves the latest edit before the debounce and restores it after reload', (t) => {
const env = browser(t)
const draft = env.open()
draft.code.value = '// latest edit before refresh'
env.page.dispatchEvent(new Event('pagehide'))
assert.equal(env.read()?.drafts.cpp, draft.code.value)
assert.equal(draft.state.value, 'saved')
t.mock.timers.tick(400)
assert.equal(env.storage.writes, 1)
draft.scope.stop()
assert.equal(env.open().code.value, '// latest edit before refresh')
})
test('hiding the page flushes pending edits; remaining visible does not', async (t) => {
const env = browser(t)
const draft = env.open()
draft.code.value = '// edited while visible'
await nextTick()
env.document.dispatchEvent(new Event('visibilitychange'))
assert.equal(env.storage.writes, 0)
env.document.visibilityState = 'hidden'
env.document.dispatchEvent(new Event('visibilitychange'))
assert.equal(env.read()?.drafts.cpp, draft.code.value)
t.mock.timers.tick(400)
assert.equal(env.storage.writes, 1)
})
test('normal typing still debounces writes and saves the most recent edit', async (t) => {
const env = browser(t)
const draft = env.open()
draft.code.value = '// first edit'
await nextTick()
t.mock.timers.tick(399)
assert.equal(env.storage.writes, 0)
draft.code.value = '// second edit'
await nextTick()
t.mock.timers.tick(399)
assert.equal(env.storage.writes, 0)
t.mock.timers.tick(1)
assert.equal(env.read()?.drafts.cpp, '// second edit')
assert.equal(env.storage.writes, 1)
})
test('leaving a Vue page saves even an edit made in the same tick', (t) => {
const env = browser(t)
const draft = env.open()
draft.code.value = '// latest edit before navigation'
draft.scope.stop()
assert.equal(env.read()?.drafts.cpp, '// latest edit before navigation')
t.mock.timers.tick(400)
assert.equal(env.storage.writes, 1)
})
test('disposing the scope removes page lifecycle listeners', (t) => {
const env = browser(t)
const removePage = t.mock.method(env.page, 'removeEventListener')
const removeDocument = t.mock.method(env.document, 'removeEventListener')
env.open().scope.stop()
assert.deepEqual(
removePage.mock.calls.map((call) => call.arguments[0]),
['pagehide'],
)
assert.deepEqual(
removeDocument.mock.calls.map((call) => call.arguments[0]),
['visibilitychange'],
)
env.page.dispatchEvent(new Event('pagehide'))
env.document.visibilityState = 'hidden'
env.document.dispatchEvent(new Event('visibilitychange'))
assert.equal(env.storage.writes, 0)
})
test('pagehide preserves the selected language and keeps problem drafts separate', (t) => {
const env = browser(t)
const first = env.open('1001')
first.code.value = '// C++ draft'
first.language.value = 'python'
first.code.value = '# Python draft'
const second = env.open('1002')
second.code.value = '// another problem'
env.page.dispatchEvent(new Event('pagehide'))
assert.deepEqual(env.read('1001'), {
version: 1,
language: 'python',
drafts: { cpp: '// C++ draft', python: '# Python draft', rust: codeTemplates.rust },
})
assert.equal(env.read('1002')?.drafts.cpp, '// another problem')
first.scope.stop()
const restored = env.open('1001')
assert.equal(restored.language.value, 'python')
assert.equal(restored.code.value, '# Python draft')
})
test('a failed lifecycle save reports unavailable storage without throwing', (t) => {
const env = browser(t)
const draft = env.open()
env.storage.failWrites = true
draft.code.value = '// cannot persist yet'
assert.doesNotThrow(() => env.page.dispatchEvent(new Event('pagehide')))
assert.equal(draft.state.value, 'unavailable')
t.mock.timers.tick(400)
assert.equal(env.storage.writes, 1)
env.storage.failWrites = false
draft.code.value = '// storage recovered'
env.page.dispatchEvent(new Event('pagehide'))
assert.equal(draft.state.value, 'saved')
assert.equal(env.read()?.drafts.cpp, '// storage recovered')
})
+2 -1
View File
@@ -6,6 +6,7 @@
"moduleResolution": "Bundler",
"strict": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"skipLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true,
@@ -15,5 +16,5 @@
"noUnusedParameters": true,
"types": ["vite/client", "node"]
},
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"]
"include": ["src/**/*.ts", "src/**/*.vue", "tests/**/*.ts", "vite.config.ts"]
}
+9
View File
@@ -1,10 +1,19 @@
import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
return {
plugins: [vue()],
build: {
rollupOptions: {
input: {
main: fileURLToPath(new URL('./index.html', import.meta.url)),
admin: fileURLToPath(new URL('./admin/index.html', import.meta.url)),
},
},
},
server: {
port: 5173,
strictPort: true,