Compare commits
6
Commits
b5b286c44f
...
8c3cca6fde
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c3cca6fde | ||
|
|
1443f00495 | ||
|
|
2ae3bfb6fb | ||
|
|
5c7a45fe41 | ||
|
|
9fa25189a2 | ||
|
|
720c69a198 |
@@ -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>
|
||||
@@ -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'],
|
||||
|
||||
@@ -19,7 +19,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",
|
||||
|
||||
Generated
+42
@@ -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: {}
|
||||
|
||||
@@ -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 |
@@ -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 >> a >> b;</text><text x="106" y="151" font-family="monospace" font-size="14" fill="#455e80">cout << 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 |
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
@@ -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' },
|
||||
]
|
||||
@@ -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 管理控制台`
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,77 @@
|
||||
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)
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
onScopeDispose(() => {
|
||||
if (state.value === 'changed') save()
|
||||
clearTimeout(timer)
|
||||
})
|
||||
return { language, code, state, save }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
// 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 const submissions: {
|
||||
id: string
|
||||
problemId: string
|
||||
username: string
|
||||
verdict: Verdict
|
||||
language: string
|
||||
time: number | null
|
||||
memory: number | null
|
||||
created: string
|
||||
}[] = [
|
||||
{
|
||||
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)
|
||||
}
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="element-plus/global" />
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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`
|
||||
})
|
||||
|
||||
+1384
-53
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,77 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { contests, problems } from '../data/preview'
|
||||
import PageHeading from '../components/PageHeading.vue'
|
||||
import PanelCard from '../components/PanelCard.vue'
|
||||
import ProblemTable from '../components/ProblemTable.vue'
|
||||
import EmptyState from '../components/EmptyState.vue'
|
||||
const route = useRoute()
|
||||
const tab = ref('overview')
|
||||
const contest = computed(() => contests.find((c) => c.id === route.params.id))
|
||||
const items = computed(() => problems.filter((p) => contest.value?.ids.includes(p.id)))
|
||||
</script>
|
||||
<template>
|
||||
<template v-if="contest"
|
||||
><div class="breadcrumbs">
|
||||
<RouterLink to="/contests">比赛</RouterLink><span>/</span><span>{{ contest.title }}</span>
|
||||
</div>
|
||||
<PageHeading :title="contest.title" :description="contest.description"
|
||||
><el-tag effect="light">{{ contest.status }} · 示例</el-tag></PageHeading
|
||||
>
|
||||
<div class="content-with-aside">
|
||||
<PanelCard title="比赛信息" icon="contests"
|
||||
><el-tabs v-model="tab"
|
||||
><el-tab-pane label="比赛说明" name="overview"
|
||||
><div class="detail-prose">
|
||||
<h2>比赛介绍</h2>
|
||||
<p>{{ contest.description }}</p>
|
||||
<h2>比赛规则</h2>
|
||||
<p>
|
||||
{{
|
||||
contest.type === 'ACM'
|
||||
? 'ACM 赛制按照通过题目数排序,通过数相同时比较罚时。'
|
||||
: 'OI 赛制按照题目得分总和排序。具体规则以实际比赛公告为准。'
|
||||
}}
|
||||
</p>
|
||||
<h2>参赛须知</h2>
|
||||
<p>
|
||||
认真阅读每道题的数据范围和限制。赛程、题目和状态当前为界面示例,报名功能将在比赛服务接入后开放。
|
||||
</p>
|
||||
</div></el-tab-pane
|
||||
><el-tab-pane label="比赛题目" name="problems"
|
||||
><ProblemTable :items="items" compact /></el-tab-pane
|
||||
><el-tab-pane label="比赛榜单" name="rank"
|
||||
><EmptyState
|
||||
title="暂无实际比赛成绩"
|
||||
description="比赛服务接入后展示实时榜单。" /></el-tab-pane
|
||||
><el-tab-pane label="提交记录" name="submissions"
|
||||
><EmptyState title="暂无实际提交记录" /></el-tab-pane></el-tabs></PanelCard
|
||||
><PanelCard title="比赛概况" icon="clock"
|
||||
><dl class="details-list">
|
||||
<div>
|
||||
<dt>赛制</dt>
|
||||
<dd>{{ contest.type }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>开始时间</dt>
|
||||
<dd>{{ contest.start }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>比赛时长</dt>
|
||||
<dd>{{ contest.duration }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>题目数量</dt>
|
||||
<dd>{{ items.length }} 道</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<el-button type="primary" disabled style="width: 100%; margin-top: 25px"
|
||||
>报名功能待接入</el-button
|
||||
></PanelCard
|
||||
>
|
||||
</div></template
|
||||
><EmptyState v-else title="比赛不存在"
|
||||
><RouterLink to="/contests"><el-button>返回比赛列表</el-button></RouterLink></EmptyState
|
||||
>
|
||||
</template>
|
||||
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { contests } from '../data/preview'
|
||||
import PanelCard from '../components/PanelCard.vue'
|
||||
import PageHeading from '../components/PageHeading.vue'
|
||||
const status = ref('全部')
|
||||
const query = ref('')
|
||||
const type = ref('')
|
||||
const items = computed(() =>
|
||||
contests.filter(
|
||||
(c) =>
|
||||
(status.value === '全部' || c.status === status.value) &&
|
||||
(!type.value || c.type === type.value) &&
|
||||
c.title.includes(query.value),
|
||||
),
|
||||
)
|
||||
</script>
|
||||
<template>
|
||||
<PageHeading title="比赛" eyebrow="CONTESTS" description="在有限的时间里,挑战新的问题。" />
|
||||
<PanelCard title="比赛列表" icon="contests" subtitle="示例赛程"
|
||||
><div class="filters">
|
||||
<el-radio-group v-model="status"
|
||||
><el-radio-button
|
||||
v-for="s in ['全部', '即将开始', '进行中', '已结束']"
|
||||
:key="s"
|
||||
:value="s"
|
||||
>{{ s }}</el-radio-button
|
||||
></el-radio-group
|
||||
><el-input v-model="query" placeholder="搜索比赛" aria-label="搜索比赛" clearable /><el-select
|
||||
v-model="type"
|
||||
placeholder="全部赛制"
|
||||
aria-label="赛制"
|
||||
clearable
|
||||
><el-option label="ACM" value="ACM" /><el-option label="OI" value="OI"
|
||||
/></el-select>
|
||||
</div>
|
||||
<el-table :data="items" stripe empty-text="没有符合条件的比赛"
|
||||
><el-table-column label="比赛名称" min-width="270"
|
||||
><template #default="{ row }"
|
||||
><RouterLink class="table-link" :to="'/contests/' + row.id">{{
|
||||
row.title
|
||||
}}</RouterLink></template
|
||||
></el-table-column
|
||||
><el-table-column label="赛制" width="85" prop="type" /><el-table-column
|
||||
label="状态"
|
||||
width="120"
|
||||
><template #default="{ row }"
|
||||
><el-tag
|
||||
size="small"
|
||||
:type="
|
||||
row.status === '进行中' ? 'success' : row.status === '已结束' ? 'info' : 'primary'
|
||||
"
|
||||
effect="light"
|
||||
>{{ row.status }}</el-tag
|
||||
></template
|
||||
></el-table-column
|
||||
><el-table-column label="开始时间" prop="start" min-width="160" /><el-table-column
|
||||
label="时长"
|
||||
prop="duration"
|
||||
width="100"
|
||||
/><el-table-column label="题目" width="90"
|
||||
><template #default="{ row }">{{ row.ids.length }} 道</template></el-table-column
|
||||
><el-table-column label="" width="100"
|
||||
><template #default="{ row }"
|
||||
><RouterLink class="text-link" :to="'/contests/' + row.id"
|
||||
>查看比赛 →</RouterLink
|
||||
></template
|
||||
></el-table-column
|
||||
></el-table
|
||||
>
|
||||
<p class="tab-summary">共 {{ items.length }} 场比赛</p></PanelCard
|
||||
>
|
||||
</template>
|
||||
@@ -0,0 +1,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>
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,520 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { findProblem, discussions, submissions } from '../data/preview'
|
||||
import { useCodeDraft, codeTemplates, languageLabels } from '../composables/useCodeDraft'
|
||||
import CodeEditor from '../components/CodeEditor.vue'
|
||||
import DifficultyTag from '../components/DifficultyTag.vue'
|
||||
import VerdictTag from '../components/VerdictTag.vue'
|
||||
import EmptyState from '../components/EmptyState.vue'
|
||||
import UiIcon from '../components/UiIcon.vue'
|
||||
const route = useRoute()
|
||||
const id = String(route.params.id)
|
||||
const problem = computed(() => findProblem(id))
|
||||
const { language, code, state, save } = useCodeDraft(id)
|
||||
const theme = ref('vs')
|
||||
const fontSize = ref(14)
|
||||
const tab = ref('description')
|
||||
const testTab = ref('input')
|
||||
const testInput = ref(problem.value?.sampleInput ?? '')
|
||||
const settingsOpen = ref(false)
|
||||
const split = ref(50)
|
||||
const workspace = ref<HTMLDivElement>()
|
||||
const codeRelated = computed(() => submissions.filter((s) => s.problemId === id))
|
||||
const relatedDiscussion = computed(() => discussions.filter((d) => d.problemId === id))
|
||||
const draftText = computed(
|
||||
() =>
|
||||
({
|
||||
initial: '本地草稿',
|
||||
saved: '草稿已保存',
|
||||
changed: '正在保存…',
|
||||
unavailable: '浏览器无法保存草稿',
|
||||
})[state.value],
|
||||
)
|
||||
let stopDrag: (() => void) | undefined
|
||||
function beginDrag(event: PointerEvent) {
|
||||
if (!workspace.value || event.button !== 0) return
|
||||
event.preventDefault()
|
||||
const bounds = workspace.value.getBoundingClientRect()
|
||||
function move(e: PointerEvent) {
|
||||
split.value = Math.max(30, Math.min(70, ((e.clientX - bounds.left) / bounds.width) * 100))
|
||||
}
|
||||
function stop() {
|
||||
window.removeEventListener('pointermove', move)
|
||||
window.removeEventListener('pointerup', stop)
|
||||
stopDrag = undefined
|
||||
}
|
||||
stopDrag?.()
|
||||
window.addEventListener('pointermove', move)
|
||||
window.addEventListener('pointerup', stop, { once: true })
|
||||
stopDrag = stop
|
||||
}
|
||||
onBeforeUnmount(() => stopDrag?.())
|
||||
async function resetCode() {
|
||||
try {
|
||||
await ElMessageBox.confirm('将当前语言的草稿恢复为初始模板?', '重置代码', {
|
||||
confirmButtonText: '重置',
|
||||
cancelButtonText: '保留草稿',
|
||||
type: 'info',
|
||||
})
|
||||
code.value = codeTemplates[language.value]
|
||||
} catch {
|
||||
/* User kept their draft. */
|
||||
}
|
||||
}
|
||||
function downloadCode() {
|
||||
const extensions = { cpp: 'cpp', python: 'py', rust: 'rs' }
|
||||
const url = URL.createObjectURL(new Blob([code.value], { type: 'text/plain;charset=utf-8' }))
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = 'solution-' + id + '.' + extensions[language.value]
|
||||
link.click()
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
}
|
||||
async function copySample(value: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value)
|
||||
ElMessage.success('已复制样例')
|
||||
} catch {
|
||||
ElMessage.info('浏览器暂不支持复制,请选中样例文本复制')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
v-if="problem"
|
||||
ref="workspace"
|
||||
class="problem-workspace"
|
||||
:style="{ '--problem-split': split + '%' }"
|
||||
>
|
||||
<section class="panel statement-panel">
|
||||
<el-tabs v-model="tab" class="statement-tabs"
|
||||
><el-tab-pane label="题目描述" name="description" /><el-tab-pane
|
||||
label="提交记录"
|
||||
name="submissions" /><el-tab-pane label="题解与讨论" name="discussions" /><el-tab-pane
|
||||
label="AI 答疑"
|
||||
name="ai"
|
||||
/></el-tabs>
|
||||
<div class="statement-content">
|
||||
<template v-if="tab === 'description'"
|
||||
><div class="problem-title-row">
|
||||
<div>
|
||||
<p class="small muted mono">题号 {{ problem.id }}</p>
|
||||
<h1>{{ problem.title }}</h1>
|
||||
</div>
|
||||
<DifficultyTag :value="problem.difficulty" />
|
||||
</div>
|
||||
<div class="tag-list">
|
||||
<span v-for="tag in problem.tags" :key="tag" class="tag">{{ tag }}</span
|
||||
><span class="tag">示例题面</span>
|
||||
</div>
|
||||
<div class="problem-limits">
|
||||
<span>时间限制:1000 ms</span><span>内存限制:256 MiB</span
|
||||
><span>标准输入 / 标准输出</span>
|
||||
</div>
|
||||
<div class="detail-prose">
|
||||
<h2>题目描述</h2>
|
||||
<p>{{ problem.description }}</p>
|
||||
<h2>输入格式</h2>
|
||||
<p>{{ problem.input }}</p>
|
||||
<h2>输出格式</h2>
|
||||
<p>{{ problem.output }}</p>
|
||||
<template v-if="problem.sampleInput"
|
||||
><div class="sample-grid">
|
||||
<div>
|
||||
<h2>
|
||||
样例输入
|
||||
<el-button link size="small" @click="copySample(problem.sampleInput)"
|
||||
>复制</el-button
|
||||
>
|
||||
</h2>
|
||||
<pre class="sample-block">{{ problem.sampleInput }}</pre>
|
||||
</div>
|
||||
<div>
|
||||
<h2>
|
||||
样例输出
|
||||
<el-button link size="small" @click="copySample(problem.sampleOutput)"
|
||||
>复制</el-button
|
||||
>
|
||||
</h2>
|
||||
<pre class="sample-block">{{ problem.sampleOutput }}</pre>
|
||||
</div>
|
||||
</div></template
|
||||
>
|
||||
<h2>数据范围与提示</h2>
|
||||
<p>{{ problem.constraint }}</p>
|
||||
<h2>提交说明</h2>
|
||||
<p>
|
||||
使用标准输入读取数据,只输出题目要求的结果。你可以在右侧编写代码,并在下方准备自测输入。
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="tab === 'submissions'"
|
||||
><p class="notice-strip">以下为本题的示例评测记录。</p>
|
||||
<el-table :data="codeRelated" empty-text="暂无本题的示例记录"
|
||||
><el-table-column label="提交编号" prop="id" width="100" /><el-table-column
|
||||
label="用户"
|
||||
prop="username"
|
||||
min-width="110"
|
||||
/><el-table-column label="结果" width="100"
|
||||
><template #default="{ row }"
|
||||
><VerdictTag :value="row.verdict" /></template></el-table-column
|
||||
><el-table-column label="详情" width="80"
|
||||
><template #default="{ row }"
|
||||
><RouterLink :to="'/submissions/' + row.id" class="text-link"
|
||||
>查看</RouterLink
|
||||
></template
|
||||
></el-table-column
|
||||
></el-table
|
||||
></template
|
||||
>
|
||||
<template v-else-if="tab === 'discussions'"
|
||||
><article
|
||||
v-for="discussion in relatedDiscussion"
|
||||
:key="discussion.id"
|
||||
class="discussion-item"
|
||||
>
|
||||
<div>
|
||||
<h2>
|
||||
<RouterLink :to="'/discussions/' + discussion.id">{{
|
||||
discussion.title
|
||||
}}</RouterLink>
|
||||
</h2>
|
||||
<p>{{ discussion.author }} · {{ discussion.date }} · 示例文章</p>
|
||||
</div>
|
||||
</article>
|
||||
<EmptyState v-if="!relatedDiscussion.length" title="暂无本题讨论" /><RouterLink
|
||||
to="/discussions"
|
||||
class="text-link"
|
||||
>进入讨论社区 →</RouterLink
|
||||
></template
|
||||
>
|
||||
<EmptyState
|
||||
v-else
|
||||
title="AI 答疑待接入"
|
||||
description="后续结合题目、代码和提交记录,提供逐步提示与针对性分析。"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<button
|
||||
class="pane-splitter"
|
||||
role="separator"
|
||||
aria-label="调整题面与编辑器宽度"
|
||||
aria-orientation="vertical"
|
||||
:aria-valuenow="Math.round(split)"
|
||||
aria-valuemin="30"
|
||||
aria-valuemax="70"
|
||||
@pointerdown="beginDrag"
|
||||
@keydown.left.prevent="split = Math.max(30, split - 2)"
|
||||
@keydown.right.prevent="split = Math.min(70, split + 2)"
|
||||
/>
|
||||
<section class="panel editor-panel">
|
||||
<header class="editor-toolbar">
|
||||
<span class="small muted">语言</span
|
||||
><el-select v-model="language" aria-label="编程语言" size="small"
|
||||
><el-option
|
||||
v-for="(label, value) in languageLabels"
|
||||
:key="value"
|
||||
:label="label"
|
||||
:value="value" /></el-select
|
||||
><el-tooltip content="重置模板"
|
||||
><el-button size="small" aria-label="重置代码模板" @click="resetCode"
|
||||
><UiIcon name="refresh" /></el-button></el-tooltip
|
||||
><span class="draft-label" aria-live="polite">{{ draftText }}</span
|
||||
><el-button size="small" aria-label="下载代码" @click="downloadCode"
|
||||
><UiIcon name="document" /></el-button
|
||||
><el-popover v-model:visible="settingsOpen" trigger="click" width="240"
|
||||
><template #reference
|
||||
><el-button size="small" aria-label="编辑器设置"><UiIcon name="settings" /></el-button
|
||||
></template>
|
||||
<div class="editor-settings">
|
||||
<label>主题</label
|
||||
><el-select v-model="theme" aria-label="编辑器主题"
|
||||
><el-option label="浅色" value="vs" /><el-option
|
||||
label="深色"
|
||||
value="vs-dark" /></el-select
|
||||
><label>字号</label><el-slider v-model="fontSize" :min="12" :max="22" show-input /></div
|
||||
></el-popover>
|
||||
</header>
|
||||
<div class="editor-area">
|
||||
<CodeEditor v-model="code" :language="language" :theme="theme" :font-size="fontSize" />
|
||||
</div>
|
||||
<div class="editor-testing">
|
||||
<el-tabs v-model="testTab"
|
||||
><el-tab-pane label="自测输入" name="input"
|
||||
><el-input
|
||||
v-model="testInput"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
resize="none"
|
||||
placeholder="输入自测数据"
|
||||
aria-label="自测输入"
|
||||
/><el-button
|
||||
v-if="problem.sampleInput"
|
||||
link
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="testInput = problem.sampleInput"
|
||||
>填入题目样例</el-button
|
||||
></el-tab-pane
|
||||
><el-tab-pane label="运行结果" name="result"
|
||||
><p class="test-result-placeholder">
|
||||
尚未运行。判题服务接入后展示标准输出、错误信息与资源使用情况。
|
||||
</p></el-tab-pane
|
||||
></el-tabs
|
||||
>
|
||||
</div>
|
||||
<footer class="editor-buttons">
|
||||
<span class="small muted">评测服务待接入</span
|
||||
><el-button size="small" @click="save">保存草稿</el-button
|
||||
><el-button size="small" type="success" plain disabled>自测运行</el-button
|
||||
><el-button size="small" type="primary" disabled>提交代码</el-button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
<EmptyState v-else title="题目不存在"
|
||||
><RouterLink to="/problems"><el-button>返回题库</el-button></RouterLink></EmptyState
|
||||
>
|
||||
</template>
|
||||
<style>
|
||||
.app-main:has(.problem-workspace) {
|
||||
width: calc(100% - 48px);
|
||||
max-width: 1900px;
|
||||
margin: 20px auto;
|
||||
}
|
||||
.app-shell:has(.problem-workspace) .app-footer {
|
||||
display: none;
|
||||
}
|
||||
.problem-workspace {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, var(--problem-split)) 12px minmax(0, 1fr);
|
||||
height: calc(100dvh - 117px);
|
||||
min-height: 600px;
|
||||
}
|
||||
.statement-panel,
|
||||
.editor-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
border-radius: 14px;
|
||||
}
|
||||
.statement-tabs {
|
||||
padding: 8px 20px 0;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #edf0f5;
|
||||
}
|
||||
.statement-tabs .el-tabs__header {
|
||||
margin: 0;
|
||||
}
|
||||
.statement-tabs .el-tabs__active-bar,
|
||||
.statement-tabs .el-tabs__nav-wrap::after {
|
||||
display: none;
|
||||
}
|
||||
.statement-tabs .el-tabs__item {
|
||||
font-size: 12px;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
margin: 0 4px 8px 0;
|
||||
border-radius: 7px;
|
||||
}
|
||||
.statement-tabs .el-tabs__item.is-active {
|
||||
background: #edf6ff;
|
||||
}
|
||||
.statement-content {
|
||||
padding: 30px;
|
||||
overflow: auto;
|
||||
flex: 1;
|
||||
}
|
||||
.problem-title-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.problem-title-row h1 {
|
||||
font-size: 27px;
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.6px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.problem-limits {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 20px;
|
||||
margin: 20px 0;
|
||||
padding: 16px 18px;
|
||||
background: #f3f7fc;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
color: #8b9cb2;
|
||||
}
|
||||
.statement-content .detail-prose h2 {
|
||||
font-size: 16px;
|
||||
color: #455e80;
|
||||
border-left: 3px solid #b5d5f7;
|
||||
padding-left: 12px;
|
||||
margin: 25px 0 12px;
|
||||
}
|
||||
.statement-content .detail-prose {
|
||||
font-size: 13px;
|
||||
line-height: 1.9;
|
||||
}
|
||||
.sample-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 18px;
|
||||
}
|
||||
.sample-grid h2 {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.pane-splitter {
|
||||
background: none;
|
||||
border: 0;
|
||||
cursor: col-resize;
|
||||
position: relative;
|
||||
padding: 0;
|
||||
touch-action: none;
|
||||
}
|
||||
.pane-splitter::after {
|
||||
content: '';
|
||||
width: 3px;
|
||||
height: 32px;
|
||||
background: #9eb7d7;
|
||||
border-radius: 3px;
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
top: 48%;
|
||||
}
|
||||
.pane-splitter:hover {
|
||||
background: #e0eaff;
|
||||
}
|
||||
.editor-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 18px;
|
||||
background: #f8fafd;
|
||||
border-bottom: 1px solid #e4ebf4;
|
||||
min-height: 61px;
|
||||
}
|
||||
.editor-toolbar .el-select {
|
||||
width: 120px;
|
||||
}
|
||||
.draft-label {
|
||||
font-size: 11px;
|
||||
color: #9aabc0;
|
||||
margin-left: auto;
|
||||
}
|
||||
.editor-area {
|
||||
flex: 1;
|
||||
min-height: 320px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.editor-testing {
|
||||
padding: 6px 18px 15px;
|
||||
background: #f8fafd;
|
||||
border-top: 1px solid #e4ebf4;
|
||||
min-height: 161px;
|
||||
}
|
||||
.editor-testing .el-tabs__header {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.editor-testing .el-tabs__item {
|
||||
font-size: 12px;
|
||||
height: 38px;
|
||||
}
|
||||
.test-result-placeholder {
|
||||
font-size: 12px;
|
||||
color: #93a4ba;
|
||||
padding: 16px 0;
|
||||
min-height: 90px;
|
||||
}
|
||||
.editor-buttons {
|
||||
border-top: 1px solid #edf0f5;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 14px 18px;
|
||||
min-height: 55px;
|
||||
}
|
||||
.editor-buttons > span {
|
||||
margin-right: auto;
|
||||
}
|
||||
.editor-settings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
@media (max-width: 960px) {
|
||||
.draft-label {
|
||||
display: none;
|
||||
}
|
||||
.statement-content {
|
||||
padding: 22px;
|
||||
}
|
||||
.problem-title-row h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
.editor-toolbar {
|
||||
padding: 10px;
|
||||
}
|
||||
.editor-buttons {
|
||||
padding: 10px;
|
||||
gap: 5px;
|
||||
}
|
||||
.editor-buttons > span {
|
||||
font-size: 10px;
|
||||
}
|
||||
.sample-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.statement-tabs .el-tabs__item {
|
||||
padding: 0 10px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.problem-workspace {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: auto;
|
||||
gap: 16px;
|
||||
min-height: 0;
|
||||
}
|
||||
.pane-splitter {
|
||||
display: none;
|
||||
}
|
||||
.statement-content {
|
||||
overflow: visible;
|
||||
}
|
||||
.editor-area {
|
||||
height: 470px;
|
||||
flex: none;
|
||||
}
|
||||
.editor-panel {
|
||||
min-height: 700px;
|
||||
}
|
||||
.editor-buttons {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.editor-buttons > span {
|
||||
width: 100%;
|
||||
font-size: 11px;
|
||||
}
|
||||
.sample-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.statement-content {
|
||||
padding: 22px;
|
||||
}
|
||||
.app-main:has(.problem-workspace) {
|
||||
width: calc(100% - 24px);
|
||||
margin-top: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { problems, problemTags } from '../data/preview'
|
||||
import { usePagination } from '../composables/usePagination'
|
||||
import PageHeading from '../components/PageHeading.vue'
|
||||
import PanelCard from '../components/PanelCard.vue'
|
||||
import ProblemTable from '../components/ProblemTable.vue'
|
||||
const route = useRoute()
|
||||
const query = ref('')
|
||||
const difficulty = ref('')
|
||||
const tag = ref(typeof route.query.tag === 'string' ? route.query.tag : '')
|
||||
const filtered = computed(() =>
|
||||
problems.filter(
|
||||
(p) =>
|
||||
(!query.value || (p.id + p.title).toLowerCase().includes(query.value.trim().toLowerCase())) &&
|
||||
(!difficulty.value || p.difficulty === difficulty.value) &&
|
||||
(!tag.value || p.tags.includes(tag.value)),
|
||||
),
|
||||
)
|
||||
const { page, pageSize, paged, total } = usePagination(filtered)
|
||||
function reset() {
|
||||
query.value = ''
|
||||
difficulty.value = ''
|
||||
tag.value = ''
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<PageHeading title="题库" eyebrow="PROBLEM BANK" description="挑选一道题,开始今天的练习。"
|
||||
><RouterLink to="/trainings"><el-button>按题单练习</el-button></RouterLink></PageHeading
|
||||
>
|
||||
<PanelCard title="全部题目" icon="problems" subtitle="示例题库">
|
||||
<div class="filters">
|
||||
<el-input
|
||||
v-model="query"
|
||||
placeholder="搜索题号或题目名称"
|
||||
aria-label="搜索题目"
|
||||
clearable
|
||||
/><el-select v-model="difficulty" placeholder="全部难度" aria-label="难度" clearable
|
||||
><el-option
|
||||
v-for="d in ['入门', '简单', '中等', '困难']"
|
||||
:key="d"
|
||||
:label="d"
|
||||
:value="d" /></el-select
|
||||
><el-select v-model="tag" placeholder="全部标签" aria-label="题目标签" clearable filterable
|
||||
><el-option v-for="t in problemTags" :key="t" :label="t" :value="t" /></el-select
|
||||
><el-button @click="reset">重置</el-button
|
||||
><span class="filter-label">{{ total }} 道题目</span>
|
||||
</div>
|
||||
<ProblemTable :items="paged" />
|
||||
<div class="pagination-row">
|
||||
<span class="muted">共 {{ total }} 道题目</span
|
||||
><el-pagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:page-sizes="[5, 10, 20]"
|
||||
:total="total"
|
||||
layout="prev, pager, next, sizes"
|
||||
/>
|
||||
</div>
|
||||
</PanelCard>
|
||||
</template>
|
||||
@@ -0,0 +1,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>
|
||||
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { rankings } from '../data/preview'
|
||||
import PanelCard from '../components/PanelCard.vue'
|
||||
import PageHeading from '../components/PageHeading.vue'
|
||||
const search = ref('')
|
||||
const order = ref('solved')
|
||||
const items = computed(() =>
|
||||
[...rankings]
|
||||
.sort((a, b) => (order.value === 'solved' ? b.solved - a.solved : b.rating - a.rating))
|
||||
.map((user, index) => ({ ...user, rank: index + 1 }))
|
||||
.filter((u) => u.username.toLowerCase().includes(search.value.trim().toLowerCase())),
|
||||
)
|
||||
</script>
|
||||
<template>
|
||||
<PageHeading title="总排名" eyebrow="RANKINGS" description="保持练习,让每一道题都成为积累。" />
|
||||
<div class="summary-grid">
|
||||
<div v-for="(user, index) in rankings.slice(0, 3)" :key="user.username" class="summary-card">
|
||||
<p>NO. {{ index + 1 }} · 示例过题榜</p>
|
||||
<strong style="font-size: 23px"
|
||||
><RouterLink :to="'/users/' + user.username">{{ user.username }}</RouterLink></strong
|
||||
><small>{{ user.solved }} 道已通过 · {{ user.motto }}</small>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<p>榜单规则</p>
|
||||
<strong style="font-size: 21px">持续进步</strong><small>默认按照通过题目数排序</small>
|
||||
</div>
|
||||
</div>
|
||||
<PanelCard title="排行榜" icon="rankings" subtitle="示例排名"
|
||||
><div class="filters">
|
||||
<el-input
|
||||
v-model="search"
|
||||
placeholder="搜索用户"
|
||||
aria-label="搜索排名用户"
|
||||
clearable
|
||||
/><el-radio-group v-model="order"
|
||||
><el-radio-button value="solved">过题数</el-radio-button
|
||||
><el-radio-button value="rating">竞赛积分</el-radio-button></el-radio-group
|
||||
>
|
||||
</div>
|
||||
<el-table :data="items" stripe empty-text="没有找到用户"
|
||||
><el-table-column label="排名" width="80" align="center"
|
||||
><template #default="{ row }"
|
||||
><span class="number-medal" :class="'top-' + row.rank">{{ row.rank }}</span></template
|
||||
></el-table-column
|
||||
><el-table-column label="用户" min-width="170"
|
||||
><template #default="{ row }"
|
||||
><RouterLink :to="'/users/' + row.username" class="user-inline table-link"
|
||||
><span class="avatar" :style="{ background: row.color }">{{ row.username[0] }}</span
|
||||
>{{ row.username }}</RouterLink
|
||||
></template
|
||||
></el-table-column
|
||||
><el-table-column label="个人签名" prop="motto" min-width="200" /><el-table-column
|
||||
label="通过题目"
|
||||
prop="solved"
|
||||
width="120"
|
||||
align="right" /><el-table-column
|
||||
label="总提交"
|
||||
prop="submissions"
|
||||
width="110"
|
||||
align="right" /><el-table-column
|
||||
label="竞赛积分"
|
||||
prop="rating"
|
||||
width="110"
|
||||
align="right" /></el-table
|
||||
></PanelCard>
|
||||
</template>
|
||||
@@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { submissions, findProblem } from '../data/preview'
|
||||
import PageHeading from '../components/PageHeading.vue'
|
||||
import PanelCard from '../components/PanelCard.vue'
|
||||
import VerdictTag from '../components/VerdictTag.vue'
|
||||
import EmptyState from '../components/EmptyState.vue'
|
||||
const route = useRoute()
|
||||
const submission = computed(() => submissions.find((s) => s.id === route.params.id))
|
||||
</script>
|
||||
<template>
|
||||
<template v-if="submission"
|
||||
><div class="breadcrumbs">
|
||||
<RouterLink to="/submissions">评测记录</RouterLink><span>/</span
|
||||
><span>#{{ submission.id }}</span>
|
||||
</div>
|
||||
<PageHeading
|
||||
:title="'提交 #' + submission.id"
|
||||
:description="findProblem(submission.problemId)?.title"
|
||||
><VerdictTag :value="submission.verdict" full
|
||||
/></PageHeading>
|
||||
<div class="summary-grid">
|
||||
<div class="summary-card">
|
||||
<p>运行时间</p>
|
||||
<strong
|
||||
>{{ submission.time ?? '—' }} <small v-if="submission.time !== null">ms</small></strong
|
||||
><small>示例数据</small>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<p>内存使用</p>
|
||||
<strong
|
||||
>{{ submission.memory === null ? '—' : submission.memory / 1024 }}
|
||||
<small v-if="submission.memory !== null">MiB</small></strong
|
||||
><small>示例数据</small>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<p>语言</p>
|
||||
<strong>{{ submission.language }}</strong
|
||||
><small>工具链版本待接入</small>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<p>提交用户</p>
|
||||
<strong style="font-size: 21px"
|
||||
><RouterLink :to="'/users/' + submission.username">{{
|
||||
submission.username
|
||||
}}</RouterLink></strong
|
||||
><small>{{ submission.created }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<PanelCard title="评测详情" icon="submissions"
|
||||
><p class="notice-strip">当前展示评测界面的示例记录,实际用例结果、日志和源码尚未接入。</p>
|
||||
<EmptyState
|
||||
title="用例详情待接入"
|
||||
description="评测完成后展示各测试点状态、资源用量及可见的编译信息。"
|
||||
/><RouterLink :to="'/problems/' + submission.problemId"
|
||||
><el-button>返回题目</el-button></RouterLink
|
||||
></PanelCard
|
||||
></template
|
||||
><EmptyState v-else title="提交记录不存在"
|
||||
><RouterLink to="/submissions"><el-button>返回评测列表</el-button></RouterLink></EmptyState
|
||||
>
|
||||
</template>
|
||||
@@ -0,0 +1,75 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { submissions, verdictLabels, type Verdict } from '../data/preview'
|
||||
import { usePagination } from '../composables/usePagination'
|
||||
import PageHeading from '../components/PageHeading.vue'
|
||||
import PanelCard from '../components/PanelCard.vue'
|
||||
import SubmissionTable from '../components/SubmissionTable.vue'
|
||||
const route = useRoute()
|
||||
const username = ref(typeof route.query.user === 'string' ? route.query.user : '')
|
||||
const problemId = ref('')
|
||||
const language = ref('')
|
||||
const verdict = ref<Verdict | ''>('')
|
||||
const filtered = computed(() =>
|
||||
submissions.filter(
|
||||
(s) =>
|
||||
(!username.value || s.username.toLowerCase().includes(username.value.trim().toLowerCase())) &&
|
||||
(!problemId.value || s.problemId.includes(problemId.value.trim())) &&
|
||||
(!language.value || s.language === language.value) &&
|
||||
(!verdict.value || s.verdict === verdict.value),
|
||||
),
|
||||
)
|
||||
const { page, pageSize, paged, total } = usePagination(filtered)
|
||||
function reset() {
|
||||
username.value = ''
|
||||
problemId.value = ''
|
||||
language.value = ''
|
||||
verdict.value = ''
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<PageHeading
|
||||
title="评测记录"
|
||||
eyebrow="SUBMISSIONS"
|
||||
description="查看提交状态、运行情况与评测结果。"
|
||||
/><PanelCard title="提交列表" icon="submissions" subtitle="示例评测记录"
|
||||
><div class="filters">
|
||||
<el-input
|
||||
v-model="username"
|
||||
placeholder="用户名"
|
||||
aria-label="筛选用户名"
|
||||
clearable
|
||||
style="width: 180px"
|
||||
/><el-input
|
||||
v-model="problemId"
|
||||
placeholder="题号"
|
||||
aria-label="筛选题号"
|
||||
clearable
|
||||
style="width: 150px"
|
||||
/><el-select v-model="verdict" aria-label="评测结果" placeholder="全部结果" clearable
|
||||
><el-option
|
||||
v-for="(label, value) in verdictLabels"
|
||||
:key="value"
|
||||
:label="label"
|
||||
:value="value" /></el-select
|
||||
><el-select v-model="language" aria-label="语言" placeholder="全部语言" clearable
|
||||
><el-option
|
||||
v-for="l in ['C++', 'Python', 'Rust']"
|
||||
:key="l"
|
||||
:label="l"
|
||||
:value="l" /></el-select
|
||||
><el-button @click="reset">重置</el-button>
|
||||
</div>
|
||||
<SubmissionTable :items="paged" />
|
||||
<div class="pagination-row">
|
||||
<span>{{ total }} 条记录</span
|
||||
><el-pagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:page-sizes="[5, 10, 20]"
|
||||
:total="total"
|
||||
layout="prev, pager, next, sizes"
|
||||
/></div
|
||||
></PanelCard>
|
||||
</template>
|
||||
@@ -0,0 +1,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>
|
||||
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { trainings, problems } from '../data/preview'
|
||||
import PanelCard from '../components/PanelCard.vue'
|
||||
import PageHeading from '../components/PageHeading.vue'
|
||||
import ProblemTable from '../components/ProblemTable.vue'
|
||||
import EmptyState from '../components/EmptyState.vue'
|
||||
const route = useRoute()
|
||||
const training = computed(() => trainings.find((t) => t.id === route.params.id))
|
||||
const items = computed(() => problems.filter((p) => training.value?.ids.includes(p.id)))
|
||||
</script>
|
||||
<template>
|
||||
<template v-if="training"
|
||||
><div class="breadcrumbs">
|
||||
<RouterLink to="/trainings">训练</RouterLink><span>/</span
|
||||
><span>{{ training.category }}</span>
|
||||
</div>
|
||||
<PageHeading :title="training.title" :description="training.description"
|
||||
><RouterLink v-if="items[0]" :to="'/problems/' + items[0].id"
|
||||
><el-button type="primary">从第一题开始</el-button></RouterLink
|
||||
></PageHeading
|
||||
>
|
||||
<div class="content-with-aside">
|
||||
<PanelCard title="训练题目" icon="problems" subtitle="按顺序练习"
|
||||
><ProblemTable :items="items" compact
|
||||
/></PanelCard>
|
||||
<div class="stack">
|
||||
<PanelCard title="题单信息" icon="trainings"
|
||||
><dl class="details-list">
|
||||
<div>
|
||||
<dt>分类</dt>
|
||||
<dd>{{ training.category }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>难度</dt>
|
||||
<dd>{{ training.level }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>题目数量</dt>
|
||||
<dd>{{ items.length }} 道</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>训练记录</dt>
|
||||
<dd>账号服务接入后开启</dd>
|
||||
</div>
|
||||
</dl></PanelCard
|
||||
><PanelCard title="练习建议" icon="reading"
|
||||
><p class="small muted">
|
||||
先写出自己的解题思路,检查数据范围和边界情况。完成后回顾代码,记录遇到的问题。
|
||||
</p>
|
||||
<RouterLink to="/discussions" class="text-link">去讨论区交流 →</RouterLink></PanelCard
|
||||
>
|
||||
</div>
|
||||
</div></template
|
||||
><EmptyState v-else title="题单不存在"
|
||||
><RouterLink to="/trainings"><el-button>返回训练列表</el-button></RouterLink></EmptyState
|
||||
>
|
||||
</template>
|
||||
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { trainings } from '../data/preview'
|
||||
import PageHeading from '../components/PageHeading.vue'
|
||||
import DifficultyTag from '../components/DifficultyTag.vue'
|
||||
const category = ref('全部')
|
||||
const query = ref('')
|
||||
const items = computed(() =>
|
||||
trainings.filter(
|
||||
(t) =>
|
||||
(category.value === '全部' || t.category === category.value) && t.title.includes(query.value),
|
||||
),
|
||||
)
|
||||
</script>
|
||||
<template>
|
||||
<PageHeading title="训练计划" eyebrow="TRAINING" description="从基础到专题,让练习更有顺序。" />
|
||||
<div class="intro-banner">
|
||||
<div>
|
||||
<p class="eyebrow">ONE STEP AT A TIME</p>
|
||||
<h2>循序渐进,把知识点练扎实。</h2>
|
||||
<p>选择适合自己的题单,先理解问题,再尝试独立完成。训练记录将在账号与评测服务接入后开启。</p>
|
||||
</div>
|
||||
<span class="intro-number">{ }</span>
|
||||
</div>
|
||||
<div class="filters">
|
||||
<el-radio-group v-model="category"
|
||||
><el-radio-button
|
||||
v-for="c in ['全部', '基础入门', '算法专题', '进阶练习']"
|
||||
:key="c"
|
||||
:value="c"
|
||||
>{{ c }}</el-radio-button
|
||||
></el-radio-group
|
||||
><el-input v-model="query" placeholder="搜索训练题单" aria-label="搜索训练" clearable />
|
||||
</div>
|
||||
<div class="card-grid">
|
||||
<article v-for="training in items" :key="training.id" class="training-card">
|
||||
<div class="training-illustration" :class="training.color">{{ training.icon }}</div>
|
||||
<div style="flex: 1">
|
||||
<div class="tag-list">
|
||||
<span class="tag">{{ training.category }}</span
|
||||
><DifficultyTag :value="training.level" />
|
||||
</div>
|
||||
<h2>
|
||||
<RouterLink :to="'/trainings/' + training.id">{{ training.title }}</RouterLink>
|
||||
</h2>
|
||||
<p>{{ training.description }}</p>
|
||||
<div class="card-meta">
|
||||
<span>{{ training.ids.length }} 道题目</span><span>示例题单</span
|
||||
><RouterLink class="text-link" :to="'/trainings/' + training.id">开始训练 →</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<el-empty v-if="!items.length" description="没有符合条件的训练" />
|
||||
</template>
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user