feat(frontend): establish shared components and blue page layouts

Add reusable headings, panels, tables, charts, pagination and explicit preview fixtures.
Apply the shared blue theme, responsive top navigation and Chinese UI locale.
This commit is contained in:
2026-10-05 01:48:53 +08:00
parent 720c69a198
commit 9fa25189a2
18 changed files with 2867 additions and 64 deletions
+8 -3
View File
@@ -1,9 +1,14 @@
<script setup lang="ts">
import AppLayout from './layouts/AppLayout.vue'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
</script>
<template>
<AppLayout>
<RouterView />
</AppLayout>
<el-config-provider :locale="zhCn">
<AppLayout>
<RouterView v-slot="{ Component, route }">
<component :is="Component" :key="route.path" />
</RouterView>
</AppLayout>
</el-config-provider>
</template>
+15
View File
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { Difficulty } from '../data/preview'
defineProps<{ value: Difficulty | string }>()
</script>
<template>
<span
class="difficulty"
:class="{
'difficulty-easy': value === '入门' || value === '简单',
'difficulty-medium': value === '中等',
'difficulty-hard': value === '困难',
}"
>{{ value }}</span
>
</template>
+10
View File
@@ -0,0 +1,10 @@
<script setup lang="ts">
defineProps<{ title: string; description?: string }>()
</script>
<template>
<div class="empty-state">
<el-empty :description="title" :image-size="80" />
<p v-if="description" class="muted">{{ description }}</p>
<slot />
</div>
</template>
+13
View File
@@ -0,0 +1,13 @@
<script setup lang="ts">
defineProps<{ title: string; description?: string; eyebrow?: string }>()
</script>
<template>
<header class="page-heading">
<div>
<p v-if="eyebrow" class="eyebrow">{{ eyebrow }}</p>
<h1>{{ title }}</h1>
<p v-if="description" class="muted">{{ description }}</p>
</div>
<div class="page-heading-actions"><slot /></div>
</header>
</template>
+15
View File
@@ -0,0 +1,15 @@
<script setup lang="ts">
import UiIcon from './UiIcon.vue'
defineProps<{ title: string; icon?: string; subtitle?: string }>()
</script>
<template>
<section class="panel">
<header class="panel-heading">
<h2>
<UiIcon v-if="icon" :name="icon" />{{ title }}<small v-if="subtitle">{{ subtitle }}</small>
</h2>
<div class="panel-actions"><slot name="actions" /></div>
</header>
<div class="panel-body"><slot /></div>
</section>
</template>
+39
View File
@@ -0,0 +1,39 @@
<script setup lang="ts">
import { acceptance, type Problem } from '../data/preview'
import DifficultyTag from './DifficultyTag.vue'
defineProps<{ items: Problem[]; compact?: boolean }>()
</script>
<template>
<el-table :data="items" stripe style="width: 100%" empty-text="没有符合条件的题目">
<el-table-column prop="id" label="题号" width="90"
><template #default="{ row }"
><span class="mono muted">{{ row.id }}</span></template
></el-table-column
>
<el-table-column label="题目" min-width="190"
><template #default="{ row }"
><RouterLink class="table-link" :to="`/problems/${row.id}`">{{
row.title
}}</RouterLink></template
></el-table-column
>
<el-table-column v-if="!compact" label="标签" min-width="180"
><template #default="{ row }"
><div class="tag-list">
<span v-for="tag in row.tags" :key="tag" class="tag">{{ tag }}</span>
</div></template
></el-table-column
>
<el-table-column label="难度" width="90"
><template #default="{ row }"><DifficultyTag :value="row.difficulty" /></template
></el-table-column>
<el-table-column label="通过率" width="100" align="right"
><template #default="{ row }"
><span class="muted tabular"
>{{ acceptance({ accepted: row.accepted, submissions: row.submissions }) }}%</span
></template
></el-table-column
>
<el-table-column v-if="!compact" prop="updated" label="更新日期" width="125" />
</el-table>
</template>
+150
View File
@@ -0,0 +1,150 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
const showAccepted = ref(true)
const showTotal = ref(true)
const points = [
{ day: '09-28', total: 44, accepted: 28 },
{ day: '09-29', total: 68, accepted: 43 },
{ day: '09-30', total: 52, accepted: 32 },
{ day: '10-01', total: 91, accepted: 57 },
{ day: '10-02', total: 77, accepted: 46 },
{ day: '10-03', total: 116, accepted: 74 },
{ day: '10-04', total: 96, accepted: 62 },
]
const active = ref<number | null>(null)
const x = (index: number) => 55 + index * 111
const y = (value: number) => 192 - (value / 120) * 155
const line = (field: 'total' | 'accepted') =>
points.map((point, index) => x(index) + ',' + y(point[field])).join(' ')
const selected = computed(() => (active.value === null ? undefined : points[active.value]))
</script>
<template>
<div class="submission-chart">
<div class="chart-legend">
<button :aria-pressed="showAccepted" @click="showAccepted = !showAccepted">
<span class="chart-dot accepted" />通过</button
><button :aria-pressed="showTotal" @click="showTotal = !showTotal">
<span class="chart-dot total" />总提交</button
><span class="muted">单位:次 · 示例统计</span>
</div>
<svg
viewBox="0 0 770 240"
role="img"
aria-label="近七天示例提交趋势:总提交44、68、52、91、77、116、96次"
>
<g v-for="tick in [0, 30, 60, 90, 120]" :key="tick">
<line x1="55" x2="721" :y1="y(tick)" :y2="y(tick)" stroke="#edf1f6" />
<text x="42" :y="y(tick) + 4" text-anchor="end">{{ tick }}</text>
</g>
<g v-for="(point, index) in points" :key="point.day">
<text :x="x(index)" y="219" text-anchor="middle">{{ point.day }}</text>
</g>
<polyline
v-if="showTotal"
:points="line('total')"
fill="none"
stroke="#76b5ee"
stroke-width="2"
stroke-linejoin="round"
/>
<polyline
v-if="showAccepted"
:points="line('accepted')"
fill="none"
stroke="#8ccdab"
stroke-width="2"
stroke-linejoin="round"
/>
<g v-for="(point, index) in points" :key="index">
<circle
v-if="showTotal"
:cx="x(index)"
:cy="y(point.total)"
r="3"
fill="white"
stroke="#76b5ee"
/>
<circle
v-if="showAccepted"
:cx="x(index)"
:cy="y(point.accepted)"
r="3"
fill="white"
stroke="#8ccdab"
/>
<rect
:x="x(index) - 20"
y="25"
width="40"
height="174"
fill="transparent"
@mouseenter="active = index"
@mouseleave="active = null"
>
<title>{{ point.day }}:总提交 {{ point.total }},通过 {{ point.accepted }}</title>
</rect>
</g>
</svg>
<p class="chart-detail" aria-live="polite">
{{
selected
? selected.day + ' · 总提交 ' + selected.total + ' 次 · 通过 ' + selected.accepted + ' 次'
: '近 7 天的提交与通过情况'
}}
</p>
</div>
</template>
<style scoped>
.chart-legend {
display: flex;
align-items: center;
justify-content: center;
gap: 20px;
flex-wrap: wrap;
font-size: 11px;
margin-bottom: 10px;
}
.chart-legend button {
border: 0;
background: none;
color: #8797ac;
font-size: 12px;
display: flex;
gap: 7px;
align-items: center;
padding: 3px;
}
.chart-legend button[aria-pressed='false'] {
opacity: 0.4;
}
.chart-dot {
width: 7px;
height: 7px;
border-radius: 50%;
display: inline-block;
}
.accepted {
background: #8ccdab;
}
.total {
background: #76b5ee;
}
.submission-chart svg {
display: block;
width: 100%;
height: auto;
}
.submission-chart text {
font:
11px Inter,
'Microsoft YaHei',
sans-serif;
fill: #9aa8b9;
}
.chart-detail {
text-align: center;
color: #9cabbd;
font-size: 11px;
min-height: 20px;
}
</style>
@@ -0,0 +1,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>
+54
View File
@@ -0,0 +1,54 @@
<script setup lang="ts">
import { computed } from 'vue'
import {
House,
Grid,
Collection,
Trophy,
Tickets,
Histogram,
ChatDotRound,
UserFilled,
InfoFilled,
Setting,
ArrowRight,
Refresh,
Search,
Monitor,
Bell,
ArrowDown,
Reading,
Document,
Clock,
Connection,
FolderOpened,
} from '@element-plus/icons-vue'
const props = defineProps<{ name: string }>()
const icons = {
home: House,
problems: Grid,
trainings: Collection,
contests: Trophy,
submissions: Tickets,
rankings: Histogram,
discussions: ChatDotRound,
teams: UserFilled,
about: InfoFilled,
settings: Setting,
arrow: ArrowRight,
refresh: Refresh,
search: Search,
monitor: Monitor,
bell: Bell,
down: ArrowDown,
reading: Reading,
document: Document,
clock: Clock,
connection: Connection,
folder: FolderOpened,
}
const icon = computed(() => icons[props.name as keyof typeof icons] ?? Document)
</script>
<template>
<el-icon aria-hidden="true"><component :is="icon" /></el-icon>
</template>
+9
View File
@@ -0,0 +1,9 @@
<script setup lang="ts">
import { verdictLabels, type Verdict } from '../data/preview'
defineProps<{ value: Verdict; full?: boolean }>()
</script>
<template>
<span class="verdict" :class="`verdict-${value.toLowerCase()}`">{{
full ? verdictLabels[value] : value === 'Pending' ? '等待评测' : value
}}</span>
</template>
+13
View File
@@ -0,0 +1,13 @@
import { computed, ref, watch, type Ref } from 'vue'
export function usePagination<T>(items: Readonly<Ref<T[]>>, initialSize = 10) {
const page = ref(1)
const pageSize = ref(initialSize)
const total = computed(() => items.value.length)
const paged = computed(() =>
items.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value),
)
watch([items, pageSize], () => {
page.value = 1
})
return { page, pageSize, total, paged }
}
+465
View File
@@ -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)
}
+2
View File
@@ -0,0 +1,2 @@
/// <reference types="vite/client" />
/// <reference types="element-plus/global" />
+88 -6
View File
@@ -1,12 +1,94 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRoute } from 'vue-router'
import UiIcon from '../components/UiIcon.vue'
const route = useRoute()
const mobileOpen = ref(false)
const links = [
{ to: '/', label: '首页', icon: 'home' },
{ to: '/problems', label: '题库', icon: 'problems' },
{ to: '/trainings', label: '训练', icon: 'trainings' },
{ to: '/contests', label: '比赛', icon: 'contests' },
{ to: '/submissions', label: '评测', icon: 'submissions' },
{ to: '/rankings', label: '排名', icon: 'rankings' },
{ to: '/discussions', label: '讨论', icon: 'discussions' },
{ to: '/teams', label: '团队', icon: 'teams' },
{ to: '/about', label: '关于', icon: 'about' },
]
const activePath = computed(() => '/' + route.path.split('/')[1])
</script>
<template>
<div class="app-shell">
<header class="app-header">
<RouterLink to="/" class="brand"> KF<span>OnlineJudge</span> </RouterLink>
<span class="header-caption">编程 · 练习 · 成长</span>
<div class="header-inner">
<RouterLink to="/" class="brand" aria-label="KF OnlineJudge 首页"
><span class="brand-mark">KF<span class="brand-dot">.</span></span
><span class="brand-name"
>OnlineJudge<small>CODE · PRACTICE · GROW</small></span
></RouterLink
>
<button
class="mobile-toggle"
:aria-expanded="mobileOpen"
aria-controls="main-navigation"
@click="mobileOpen = !mobileOpen"
>
<UiIcon name="problems" /> 导航
</button>
<nav
id="main-navigation"
class="main-nav"
:class="{ 'mobile-open': mobileOpen }"
aria-label="主导航"
>
<RouterLink
v-for="link in links"
:key="link.to"
:to="link.to"
:class="{ active: activePath === link.to }"
@click="mobileOpen = false"
><UiIcon :name="link.icon" />{{ link.label }}</RouterLink
>
</nav>
<div class="header-account">
<el-tooltip content="页面使用示例内容,业务服务逐步接入" placement="bottom"
><span class="preview-label">界面预览</span></el-tooltip
><RouterLink to="/login"
><el-button type="primary" size="small" round>登录</el-button></RouterLink
><RouterLink to="/register"><el-button size="small" round>注册</el-button></RouterLink>
</div>
</div>
</header>
<main class="app-main">
<slot />
</main>
<footer class="app-footer">KF-OnlineJudge · 开发预览</footer>
<main id="main-content" class="app-main"><slot /></main>
<footer class="app-footer">
<div class="footer-inner">
<div class="footer-brand">
<span class="brand-mark">KF.</span><strong>每一次提交,都有新的收获。</strong>
<p>一个面向练习、竞赛与交流的在线评测平台。</p>
</div>
<div>
<h3>开始练习</h3>
<RouterLink to="/problems">浏览题库</RouterLink
><RouterLink to="/trainings">专题训练</RouterLink
><RouterLink to="/contests">参加比赛</RouterLink>
</div>
<div>
<h3>交流与成长</h3>
<RouterLink to="/discussions">讨论社区</RouterLink
><RouterLink to="/rankings">过题排行榜</RouterLink
><RouterLink to="/teams/create">创建团队</RouterLink>
</div>
<div>
<h3>平台</h3>
<RouterLink to="/about">关于 KF OnlineJudge</RouterLink
><RouterLink to="/about#system">系统状态</RouterLink
><a href="/admin/index.html">管理入口</a>
</div>
</div>
<div class="footer-bottom">
<span>© 2026 KF OnlineJudge</span><span>示例数据 · 备案信息待填写</span
><span>编程 · 练习 · 成长</span>
</div>
</footer>
</div>
</template>
+5 -2
View File
@@ -1,8 +1,11 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import 'element-plus/dist/index.css'
import { registerUi } from './plugins/ui'
import App from './App.vue'
import { router } from './router'
import './styles/base.css'
import './styles/blueprint.css'
createApp(App).use(createPinia()).use(router).mount('#app')
const app = createApp(App)
registerUi(app)
app.use(createPinia()).use(router).mount('#app')
+65
View File
@@ -0,0 +1,65 @@
import type { App } from 'vue'
import {
ElButton,
ElConfigProvider,
ElDescriptions,
ElDescriptionsItem,
ElDialog,
ElEmpty,
ElForm,
ElFormItem,
ElIcon,
ElInput,
ElOption,
ElPagination,
ElPopover,
ElProgress,
ElRadio,
ElRadioButton,
ElRadioGroup,
ElResult,
ElSelect,
ElSlider,
ElSwitch,
ElTabPane,
ElTable,
ElTableColumn,
ElTabs,
ElTag,
ElTooltip,
} from 'element-plus'
import 'element-plus/dist/index.css'
export function registerUi(app: App) {
for (const component of [
ElButton,
ElConfigProvider,
ElDescriptions,
ElDescriptionsItem,
ElDialog,
ElEmpty,
ElForm,
ElFormItem,
ElIcon,
ElInput,
ElOption,
ElPagination,
ElPopover,
ElProgress,
ElRadio,
ElRadioButton,
ElRadioGroup,
ElResult,
ElSelect,
ElSlider,
ElSwitch,
ElTabPane,
ElTable,
ElTableColumn,
ElTabs,
ElTag,
ElTooltip,
]) {
app.use(component)
}
}
File diff suppressed because it is too large Load Diff
+492
View File
@@ -0,0 +1,492 @@
:root {
--el-border-radius-base: 8px;
}
.app-header {
position: sticky;
top: 0;
z-index: 100;
box-shadow: none;
}
.header-inner {
min-height: 76px;
max-width: 1360px;
}
.brand-mark {
font-size: 25px;
padding: 10px 12px;
border-radius: 11px;
background: #eaf4ff;
letter-spacing: -1.5px;
}
.brand-dot {
color: #409eff;
}
.brand-name {
font-size: 13px;
}
.brand-name small {
letter-spacing: 1.3px;
}
.main-nav {
gap: 5px;
align-items: center;
}
.main-nav a {
align-self: center;
padding: 10px 13px;
border: 0;
border-radius: 8px;
font-size: 13px;
}
.main-nav a.active {
border: 0;
background: #edf6ff;
font-weight: 650;
}
.main-nav .el-icon {
display: none;
}
.header-account .el-button {
border-radius: 8px;
padding: 8px 14px;
}
.app-main {
width: min(100% - 64px, 1360px);
margin-top: 30px;
margin-bottom: 50px;
}
.app-footer {
background: transparent;
box-shadow: none;
padding: 38px 0 22px;
}
.footer-inner,
.footer-bottom {
max-width: 1360px;
}
.footer-brand .brand-mark {
display: inline-block;
background: #e3eefb;
}
.footer-inner h3 {
font-size: 12px;
color: #657e9e;
}
.panel {
border-radius: 16px;
box-shadow: none;
border: 1px solid #e1e8f1;
}
.panel-heading {
border: 0;
padding: 23px 24px 10px;
min-height: 68px;
align-items: flex-start;
}
.panel-heading h2 {
color: #455e80;
font-size: 17px;
font-weight: 650;
gap: 6px;
display: grid;
}
.panel-heading h2 > .el-icon {
display: none;
}
.panel-heading small {
margin: 0;
font-size: 10px;
letter-spacing: 0.3px;
}
.panel-body {
padding: 18px 24px 24px;
}
.panel-actions {
padding-top: 4px;
font-size: 11px;
}
.panel-actions a {
display: inline-flex;
align-items: center;
gap: 8px;
}
.page-heading {
margin: 8px 0 30px;
align-items: flex-end;
}
.page-heading h1 {
font-size: 34px;
letter-spacing: -1.2px;
color: #2e405b;
font-weight: 700;
margin: 10px 0 10px;
}
.page-heading .eyebrow {
color: #91a3bb;
font:
10px ui-monospace,
monospace;
letter-spacing: 2px;
}
.page-heading p {
font-size: 13px;
}
.el-button {
border-radius: 8px;
}
.el-table {
--el-table-header-bg-color: #f3f7fc;
--el-table-row-hover-bg-color: #f4f9ff;
--el-fill-color-lighter: #fff;
font-size: 13px;
border-radius: 8px;
}
.el-table .el-table__cell {
padding: 16px 0;
}
.el-table th.el-table__cell {
padding: 13px 0;
font-size: 11px;
color: #8a99ac;
letter-spacing: 0.3px;
}
.el-table .cell {
padding: 0 14px;
}
.table-link {
font-weight: 600;
}
.filters {
padding: 16px;
background: #f6f9fd;
border-radius: 10px;
gap: 10px;
margin-bottom: 22px;
}
.filters .el-input {
width: 260px;
}
.pagination-row {
margin-top: 22px;
padding-top: 18px;
border-top: 1px solid var(--line);
}
.tag,
.difficulty,
.el-tag {
border-radius: 5px;
}
.summary-card {
border-radius: 14px;
position: relative;
padding: 24px;
border-bottom: 3px solid #dbeaff;
}
.summary-card strong {
font:
650 32px ui-monospace,
monospace;
margin-top: 12px;
}
.intro-banner {
border-radius: 16px;
border-left: 4px solid #9cc8f5;
padding: 32px;
}
.training-card {
border-radius: 16px;
flex-direction: column;
gap: 10px;
position: relative;
padding: 26px;
}
.training-illustration {
width: 48px;
height: 48px;
border-radius: 12px;
font-size: 21px;
}
.training-card h2 {
font-size: 20px;
margin: 15px 0 10px;
}
.training-card p {
font-size: 13px;
line-height: 1.9;
}
.card-meta {
padding-top: 15px;
border-top: 1px solid var(--line);
flex-wrap: wrap;
}
.team-card {
border-radius: 16px;
flex-direction: column;
}
.team-logo {
width: 48px;
height: 48px;
}
.profile-banner {
border-radius: 18px;
border-left: 5px solid #a9cef5;
}
.profile-banner > .avatar {
border-radius: 22px;
}
.discussion-item {
padding: 24px 8px;
}
.discussion-item .avatar {
border-radius: 12px;
}
.discussion-item h2 {
font-size: 16px;
font-weight: 600;
}
.detail-prose h2 {
color: #455e80;
font-size: 18px;
padding-left: 12px;
border-left: 3px solid #b5d5f7;
}
.sample-block {
border-radius: 10px;
background: #f6f9fd;
border: 1px solid #e4edf7;
}
.auth-shell {
border-radius: 22px;
border: 1px solid #e1e8f1;
box-shadow: none;
}
.auth-art {
padding: 50px;
background-image: url('/images/practice-space.svg');
background-size: 90%;
background-position: center bottom;
background-repeat: no-repeat;
min-height: 520px;
}
.auth-art h1 {
font-size: 33px;
max-width: 300px;
line-height: 1.5;
}
.about-feature {
border-radius: 14px;
padding: 26px;
}
#tags {
scroll-margin-top: 98px;
}
.admin-application {
min-height: 100dvh;
}
.admin-app-header {
min-height: 80px;
display: flex;
align-items: center;
gap: 26px;
padding: 0 30px;
background: #fff;
border-bottom: 1px solid #e1e8f1;
position: sticky;
top: 0;
z-index: 100;
}
.admin-app-label {
font-size: 11px;
color: #91a3bb;
padding-left: 26px;
border-left: 1px solid #e1e8f1;
}
.admin-return {
font-size: 12px;
color: #657e9e;
display: flex;
align-items: center;
gap: 10px;
margin-left: auto;
}
.admin-app-nav {
position: fixed;
left: 0;
top: 80px;
bottom: 0;
width: 210px;
background: #fff;
border-right: 1px solid #e1e8f1;
padding: 30px 18px;
display: flex;
flex-direction: column;
gap: 7px;
}
.admin-app-nav a {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 18px;
font-size: 13px;
color: #7b8da5;
border-radius: 9px;
}
.admin-app-nav a.active {
background: #edf6ff;
color: #409eff;
font-weight: 600;
}
.admin-app-main {
margin: 32px 32px 48px 242px;
min-width: 0;
}
.admin-app-main .admin-shell {
display: block;
}
.admin-app-footer {
margin-left: 242px;
padding: 22px 32px 28px 0;
border-top: 1px solid #e1e8f1;
display: flex;
gap: 12px;
justify-content: space-between;
flex-wrap: wrap;
color: #9aa5b5;
font-size: 10px;
}
@media (min-width: 1200px) {
.card-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 1100px) {
.header-inner {
gap: 18px;
}
.main-nav a {
padding: 10px;
}
.brand-name {
display: none;
}
.preview-label {
display: none;
}
}
@media (max-width: 960px) {
.panel-body {
padding: 16px 20px 22px;
}
.panel-heading {
padding: 22px 20px 8px;
}
.page-heading h1 {
font-size: 30px;
}
.admin-app-label {
display: none;
}
}
@media (max-width: 760px) {
.header-inner {
min-height: 66px;
padding: 10px 0;
}
.brand-name {
display: block;
}
.brand-mark {
font-size: 20px;
padding: 9px 10px;
}
.main-nav a {
padding: 11px 15px;
}
.main-nav .el-icon {
display: none;
}
.header-account .el-button {
padding: 8px 11px;
}
.mobile-toggle {
border-radius: 7px;
padding: 7px;
}
.app-main {
width: calc(100% - 28px);
margin-top: 22px;
}
.page-heading {
align-items: flex-start;
}
.page-heading h1 {
font-size: 28px;
}
.panel-heading {
padding: 20px 18px 8px;
min-height: 65px;
}
.panel-heading h2 {
font-size: 16px;
}
.panel-heading small {
display: block;
}
.panel-body {
padding: 16px 18px 20px;
}
.filters {
padding: 12px;
}
.filters .el-input {
width: 100%;
}
.training-card,
.team-card {
padding: 22px;
}
.training-illustration {
display: grid;
}
.summary-card {
padding: 20px;
}
.summary-card strong {
font-size: 26px;
}
.admin-app-header {
min-height: 70px;
padding: 0 18px;
}
.admin-app-header .brand-name {
font-size: 12px;
}
.admin-app-nav {
width: 62px;
top: 70px;
padding: 20px 7px;
}
.admin-app-nav a {
padding: 14px 15px;
}
.admin-app-nav a span {
display: none;
}
.admin-app-main {
margin: 24px 14px 35px 76px;
}
.admin-app-footer {
margin-left: 76px;
padding-right: 14px;
}
.admin-return {
font-size: 11px;
}
.auth-art {
min-height: 0;
}
.footer-inner,
.footer-bottom {
width: calc(100% - 36px);
}
}