feat(frontend): build homepage dashboard and interactive tag sphere

Present announcements, submission trends, recent problems, contests and solver rankings.
Arrange blue pill labels in a TagCanvas sphere with adaptive sizing, mouse-follow and drag modes.
Allow tag selection to filter the problem list and provide keyboard-accessible links.
This commit is contained in:
2026-10-05 01:49:58 +08:00
parent 5c7a45fe41
commit 2ae3bfb6fb
4 changed files with 835 additions and 30 deletions
+270
View File
@@ -0,0 +1,270 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { TagCanvas } from '@misskey-dev/tagcanvas-es'
const props = defineProps<{ tags: readonly string[] }>()
const emit = defineEmits<{ select: [tag: string] }>()
const stage = ref<HTMLDivElement>()
const canvas = ref<HTMLCanvasElement>()
const source = ref<HTMLDivElement>()
const width = ref(300)
const ready = ref(false)
const rotationEnabled = ref(true)
const interaction = ref<'follow' | 'drag'>('follow')
const visible = ref(false)
const hidden = ref(false)
const colors = ['#409eff', '#638ac5', '#588db8', '#7496b8']
const fontSize = computed(() => {
const textArea = props.tags.reduce((sum, tag) => sum + [...tag].length + 1.8, 0)
const radius = Math.max(35, (Math.min(width.value, 300) - 68) / 2)
return Math.max(
12,
Math.min(19, Math.sqrt((4 * Math.PI * radius ** 2 * 0.23) / Math.max(1, textArea * 1.35))),
)
})
function weight(tag: string, index: number) {
const emphasis = ['动态规划', '图论'].includes(tag) ? 1.2 : index % 3 === 0 ? 1.08 : 1
return emphasis * Math.min(1, Math.sqrt(4 / Math.max(1, [...tag].length)))
}
let cloud: TagCanvas | undefined
let resizeObserver: ResizeObserver | undefined
let intersectionObserver: IntersectionObserver | undefined
let motionQuery: MediaQueryList | undefined
function syncAnimation() {
if (!visible.value || hidden.value) cloud?.pause()
else cloud?.resume()
}
function rebuild() {
cloud?.destroy()
cloud = undefined
ready.value = false
if (!canvas.value || !stage.value || !source.value || !props.tags.length) return
const ratio = Math.min(2, window.devicePixelRatio || 1)
canvas.value.width = Math.round(width.value * ratio)
canvas.value.height = 300 * ratio
try {
cloud = new TagCanvas(canvas.value, {
tagContainer: source.value,
hideTags: false,
shape: 'sphere',
textColor: null,
textFont: getComputedStyle(stage.value).fontFamily,
textHeight: fontSize.value * ratio,
weight: props.tags.length > 1,
weightFrom: 'data-weight',
weightMode: 'size',
weightSizeMin: (fontSize.value - 1) * ratio,
weightSizeMax: (fontSize.value + 2) * ratio,
bgColor: '#f0f7ff',
bgOutline: '#cfe2f7',
bgOutlineThickness: ratio,
bgRadius: 14 * ratio,
padding: 5 * ratio,
outlineColor: '#409eff',
outlineThickness: ratio,
outlineRadius: 14 * ratio,
outlineOffset: 1 * ratio,
outlineMethod: 'outline',
minBrightness: 0.45,
maxBrightness: 1,
depth: 0.55,
zoom: 0.98,
maxSpeed: rotationEnabled.value ? 0.02 : 0,
initial: rotationEnabled.value ? [0.16, -0.08] : [0, 0],
decel: 0.94,
dragControl: interaction.value === 'drag',
dragThreshold: 4,
freezeActive: true,
freezeDecel: true,
wheelZoom: false,
noTagsMessage: false,
activeAudio: false,
txtScale: 2,
})
ready.value = true
syncAnimation()
} catch {
// Keep the ordinary links available when canvas rendering is unavailable.
ready.value = false
}
}
function updateVisibility() {
hidden.value = document.hidden
}
function updateMotionPreference() {
if (motionQuery?.matches) rotationEnabled.value = false
}
watch([() => props.tags, width, rotationEnabled, interaction], rebuild, {
deep: true,
flush: 'post',
})
watch([visible, hidden], syncAnimation)
onMounted(() => {
if (!stage.value) return
width.value = stage.value.getBoundingClientRect().width
motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)')
rotationEnabled.value = !motionQuery.matches
if (window.matchMedia('(pointer: coarse)').matches) interaction.value = 'drag'
motionQuery.addEventListener('change', updateMotionPreference)
updateVisibility()
document.addEventListener('visibilitychange', updateVisibility)
resizeObserver = new ResizeObserver(([entry]) => {
if (entry) width.value = entry.contentRect.width
})
resizeObserver.observe(stage.value)
intersectionObserver = new IntersectionObserver(([entry]) => {
visible.value = !!entry?.isIntersecting
})
intersectionObserver.observe(stage.value)
rebuild()
})
onBeforeUnmount(() => {
cloud?.destroy()
resizeObserver?.disconnect()
intersectionObserver?.disconnect()
motionQuery?.removeEventListener('change', updateMotionPreference)
document.removeEventListener('visibilitychange', updateVisibility)
})
</script>
<template>
<div class="tag-sphere">
<div ref="stage" class="tag-sphere-stage" role="group" aria-label="球形题目标签">
<canvas
v-show="ready"
ref="canvas"
class="sphere-canvas"
:class="{ draggable: interaction === 'drag' }"
role="img"
:aria-label="`球形标签云,${interaction === 'drag' ? '拖动旋转' : '跟随鼠标旋转'},点击标签筛选题目。键盘可选择下方标签链接。`"
/>
<div
ref="source"
class="sphere-source"
:class="{ 'source-hidden': ready }"
aria-label="题目标签"
>
<a
v-for="(tag, index) in tags"
:key="tag"
:href="'/problems?tag=' + encodeURIComponent(tag)"
:style="{ color: colors[index % colors.length] }"
:data-weight="weight(tag, index)"
@click.prevent="emit('select', tag)"
>{{ tag }}</a
>
</div>
<p v-if="!tags.length" class="sphere-empty">暂无题目标签</p>
</div>
<div v-if="ready" class="sphere-controls">
<span>点击标签查看题目</span>
<div class="sphere-control-buttons">
<button
:aria-pressed="interaction === 'drag'"
aria-label="切换标签球交互方式"
@click="interaction = interaction === 'follow' ? 'drag' : 'follow'"
>
{{ interaction === 'follow' ? '鼠标跟随' : '拖动旋转' }}
</button>
<button :aria-pressed="rotationEnabled" @click="rotationEnabled = !rotationEnabled">
{{ rotationEnabled ? '暂停旋转' : '开始旋转' }}
</button>
</div>
</div>
</div>
</template>
<style scoped>
.tag-sphere-stage {
position: relative;
width: 100%;
height: 300px;
}
.sphere-canvas {
width: 100%;
height: 300px;
display: block;
touch-action: none;
user-select: none;
}
.sphere-canvas.draggable {
cursor: grab;
}
.sphere-canvas.draggable:active {
cursor: grabbing;
}
.sphere-source {
display: flex;
align-content: center;
justify-content: center;
flex-wrap: wrap;
gap: 10px;
height: 100%;
padding: 20px;
}
.sphere-source a {
padding: 5px 10px;
background: #f0f7ff;
border: 1px solid #dceaf9;
border-radius: 999px;
font-size: 14px;
}
.sphere-source.source-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
}
.sphere-source.source-hidden:focus-within {
inset: 0;
width: 100%;
height: 100%;
padding: 20px;
clip-path: none;
overflow: auto;
background: #fff;
}
.sphere-source a:focus-visible {
outline: 2px solid #409eff;
outline-offset: 2px;
}
.sphere-empty {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: #99aabd;
font-size: 14px;
}
.sphere-controls {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
color: #99aabd;
font-size: 11px;
padding-top: 4px;
}
.sphere-control-buttons {
display: flex;
gap: 6px;
}
.sphere-controls button {
color: #629edb;
background: #f5faff;
border: 1px solid #e6f0fb;
border-radius: 6px;
padding: 4px 8px;
font-size: 11px;
cursor: pointer;
}
.sphere-controls button:focus-visible {
outline: 2px solid #90c2fb;
outline-offset: 2px;
}
</style>
+544 -30
View File
@@ -1,36 +1,550 @@
<script setup lang="ts">
import { computed } from 'vue'
import { ElButton, ElCard, ElTag } from 'element-plus'
import { usePlatformHealth } from '../composables/usePlatformHealth'
const { state, version, refresh } = usePlatformHealth()
const statusText = computed(
() =>
({
loading: '正在连接平台',
connected: '平台已连接',
unavailable: '平台暂未连接',
})[state.value],
)
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { notices, rankings, contests, problems, problemTags } from '../data/preview'
import PanelCard from '../components/PanelCard.vue'
import ProblemTable from '../components/ProblemTable.vue'
import SubmissionChart from '../components/SubmissionChart.vue'
import TagSphere from '../components/TagSphere.vue'
import UiIcon from '../components/UiIcon.vue'
const router = useRouter()
const noticeOpen = ref(false)
const selectedNotice = ref(notices[0]!)
function openNotice(index: number) {
selectedNotice.value = notices[index]!
noticeOpen.value = true
}
</script>
<template>
<section class="welcome">
<el-tag effect="plain"> 开发预览 </el-tag>
<h1>从一道题开始,<br />积累每一次进步。</h1>
<p class="welcome-description">
KF-OnlineJudge 正在建设中。题目练习、提交评测和个人学习记录将在后续版本开放。
</p>
<el-card shadow="never" class="connection-card">
<div class="connection-row">
<div>
<h2>平台连接</h2>
<p aria-live="polite">
{{ statusText }}<span v-if="version && state === 'connected'"> · {{ version }}</span>
</p>
<div class="practice-home">
<section class="practice-hero">
<div class="practice-hero-copy">
<p class="eyebrow">KF ONLINEJUDGE / PRACTICE SPACE</p>
<h1>把想法写成代码,<br />让思路一步步清晰。</h1>
<p class="practice-intro">
从一道题开始,沿着自己的节奏练习。<br />在这里写代码、做训练,也和同伴一起迎接新的挑战。
</p>
<div class="practice-actions">
<RouterLink to="/problems"
><el-button type="primary" size="large"
>开始解题 <UiIcon name="arrow" /></el-button></RouterLink
><RouterLink to="/trainings" class="practice-secondary"
>浏览训练题单 <UiIcon name="arrow"
/></RouterLink>
</div>
<div class="practice-hero-meta">
<span
><strong>{{ problems.length }}</strong> 道示例题目</span
><span
><strong>{{ problemTags.length }}</strong> 个算法标签</span
><span>ACM / OI</span>
</div>
<el-button :loading="state === 'loading'" @click="refresh"> 重新检查 </el-button>
</div>
</el-card>
</section>
<div class="practice-hero-art">
<img
src="/images/practice-space.svg"
alt="蓝色路径连接代码、算法节点与完成标记的编程空间插画"
/>
</div>
</section>
<section class="practice-announcements" aria-label="站内公告">
<div class="announcements-label">
<UiIcon name="bell" /><strong>站内公告</strong><span>示例内容</span>
</div>
<button v-for="(notice, index) in notices" :key="notice.id" @click="openNotice(index)">
<time>{{ notice.date.slice(5) }}</time
><span>{{ notice.title }}</span
><UiIcon name="arrow" />
</button>
</section>
<div class="practice-activity">
<PanelCard class="activity-panel" title="练习的每一步" subtitle="最近一周 · 示例统计">
<template #actions
><RouterLink to="/submissions">查看评测 <UiIcon name="arrow" /></RouterLink
></template>
<div class="activity-totals">
<div>
<span>累计提交</span><strong>544<small>次</small></strong>
</div>
<div>
<span>通过提交</span><strong>342<small>次</small></strong>
</div>
<div>
<span>统计周期</span
><strong class="activity-period">09.28 <small>—</small> 10.04</strong>
</div>
</div>
<SubmissionChart />
</PanelCard>
<PanelCard
id="tags"
class="explore-panel"
title="算法探索"
:subtitle="problemTags.length + ' 个标签'"
>
<TagSphere
:tags="problemTags"
@select="(tag) => router.push({ path: '/problems', query: { tag } })"
/>
</PanelCard>
</div>
<div class="practice-library">
<PanelCard title="从这里开始解题" subtitle="最新题目">
<template #actions
><RouterLink to="/problems">完整题库 <UiIcon name="arrow" /></RouterLink
></template>
<ProblemTable :items="problems.slice(0, 6)" compact />
<div class="practice-library-note">
<span>换一个角度,试试新的解法。</span
><RouterLink to="/trainings">按专题练习 <UiIcon name="arrow" /></RouterLink>
</div>
</PanelCard>
<PanelCard class="practice-rank" title="一起向前" subtitle="过题榜 · 示例排名">
<template #actions
><RouterLink to="/rankings">全部 <UiIcon name="arrow" /></RouterLink
></template>
<RouterLink
v-for="(user, index) in rankings.slice(0, 5)"
:key="user.username"
:to="'/users/' + user.username"
class="practice-rank-row"
><span class="rank-index">{{ String(index + 1).padStart(2, '0') }}</span
><span class="avatar" :style="{ background: user.color }">{{ user.username[0] }}</span>
<div>
<strong>{{ user.username }}</strong>
<p>{{ user.motto }}</p>
</div>
<span class="rank-solved"
><strong>{{ user.solved }}</strong
><small>过题</small></span
></RouterLink
>
</PanelCard>
</div>
<section class="practice-contests">
<div class="section-title">
<div>
<p class="eyebrow">NEXT CHALLENGE</p>
<h2>给下一场挑战留点时间。</h2>
</div>
<RouterLink to="/contests">比赛日历 <UiIcon name="arrow" /></RouterLink>
</div>
<div class="practice-contest-grid">
<RouterLink
v-for="contest in contests.slice(0, 3)"
:key="contest.id"
:to="'/contests/' + contest.id"
class="practice-contest-card"
><div>
<span class="tag">{{ contest.type }}</span
><span>{{ contest.status }}</span>
</div>
<h3>{{ contest.title }}</h3>
<p><UiIcon name="clock" />{{ contest.start }} · {{ contest.duration }}</p>
<span class="contest-enter">查看比赛 <UiIcon name="arrow" /></span
></RouterLink>
</div>
<p class="practice-demo-note">示例赛程,实际安排以平台发布的信息为准。</p>
</section>
<div class="practice-links">
<span>练习之外,也可以一起聊聊。</span
><RouterLink to="/discussions">讨论交流 <UiIcon name="arrow" /></RouterLink
><RouterLink to="/teams/create">创建团队 <UiIcon name="arrow" /></RouterLink
><RouterLink to="/about">关于 KF OJ <UiIcon name="arrow" /></RouterLink>
</div>
</div>
<el-dialog v-model="noticeOpen" :title="selectedNotice.title" width="min(600px,92vw)"
><p class="small muted">{{ selectedNotice.date }} · {{ selectedNotice.category }} · 示例公告</p>
<p style="margin-top: 20px; line-height: 1.9">{{ selectedNotice.text }}</p></el-dialog
>
</template>
<style scoped>
.practice-home {
display: grid;
gap: 28px;
}
.practice-hero {
display: grid;
grid-template-columns: 1.2fr 1fr;
min-height: 360px;
border-radius: 20px;
background: linear-gradient(110deg, #fff 36%, #eaf4ff);
overflow: hidden;
border: 1px solid #e1eaf4;
}
.practice-hero-copy {
padding: 42px 44px;
}
.practice-hero h1 {
font-size: clamp(28px, 3vw, 42px);
line-height: 1.45;
letter-spacing: -1.5px;
color: #2d4261;
margin: 12px 0 18px;
}
.practice-intro {
font-size: 14px;
color: #7d8fa6;
}
.practice-actions {
display: flex;
align-items: center;
gap: 24px;
flex-wrap: wrap;
margin: 26px 0;
}
.practice-secondary {
display: flex;
gap: 10px;
align-items: center;
font-size: 13px;
color: #657e9e;
}
.practice-hero-meta {
display: flex;
gap: 22px;
flex-wrap: wrap;
font-size: 11px;
color: #8b9db3;
}
.practice-hero-meta strong {
font:
600 16px ui-monospace,
monospace;
color: #566f92;
}
.practice-hero-art {
display: flex;
align-items: center;
justify-content: center;
padding: 20px 28px 20px 0;
}
.practice-hero-art img {
display: block;
width: 100%;
max-width: 460px;
}
.practice-announcements {
display: grid;
grid-template-columns: 135px repeat(3, minmax(0, 1fr));
gap: 18px;
padding: 8px 0;
align-items: center;
}
.announcements-label {
display: grid;
grid-template-columns: 18px 1fr;
gap: 5px 8px;
font-size: 13px;
color: #657e9e;
}
.announcements-label > span {
grid-column: 2;
font-size: 10px;
color: #9cabbc;
}
.practice-announcements button {
border: 0;
background: none;
text-align: left;
display: grid;
grid-template-columns: 1fr 18px;
gap: 5px;
padding: 8px 18px;
border-left: 1px solid #dce4ef;
min-width: 0;
color: #455e80;
}
.practice-announcements time {
grid-column: 1/-1;
font:
11px ui-monospace,
monospace;
color: #96a5b8;
}
.practice-announcements button > span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 12px;
}
.practice-announcements button:hover {
color: var(--blue);
}
.practice-activity,
.practice-library {
display: grid;
grid-template-columns: minmax(0, 1.65fr) minmax(320px, 1fr);
gap: 24px;
}
.activity-totals {
display: flex;
justify-content: space-between;
gap: 18px;
margin: 8px 0 22px;
}
.activity-totals > div {
display: grid;
gap: 8px;
}
.activity-totals span {
color: #91a0b5;
font-size: 11px;
}
.activity-totals strong {
color: #455e80;
font:
600 30px ui-monospace,
monospace;
}
.activity-totals small {
font:
11px 'Microsoft YaHei',
sans-serif;
color: #9aacbf;
margin-left: 7px;
}
.activity-totals strong.activity-period {
font-size: 17px;
margin-top: 10px;
}
.activity-panel :deep(.submission-chart) {
padding-top: 6px;
}
.practice-library-note {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 10px;
padding-top: 18px;
font-size: 11px;
color: #9cabbc;
}
.practice-library-note a {
color: var(--blue);
}
.practice-rank-row {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 0;
border-bottom: 1px solid var(--line);
}
.practice-rank-row:last-child {
border-bottom: 0;
}
.rank-index {
font:
11px ui-monospace,
monospace;
color: #a6b4c6;
}
.practice-rank-row .avatar {
width: 34px;
height: 34px;
border-radius: 10px;
}
.practice-rank-row > div {
min-width: 0;
}
.practice-rank-row strong {
font-size: 13px;
font-weight: 600;
}
.practice-rank-row p {
font-size: 10px;
color: #9cabbc;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.rank-solved {
margin-left: auto;
display: grid;
text-align: right;
gap: 2px;
}
.rank-solved strong {
font:
600 18px ui-monospace,
monospace;
color: #4995e5;
}
.rank-solved small {
font-size: 9px;
color: #9cabbc;
}
.section-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
margin: 8px 0 20px;
}
.section-title h2 {
font-size: 23px;
font-weight: 600;
color: #354c6e;
}
.section-title > a {
font-size: 12px;
color: var(--blue);
}
.practice-contest-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 20px;
}
.practice-contest-card {
position: relative;
display: block;
padding: 26px;
background: #fff;
border: 1px solid #e1eaf4;
border-radius: 16px;
}
.practice-contest-card > div {
display: flex;
justify-content: space-between;
font-size: 11px;
color: #8e9eb3;
}
.practice-contest-card h3 {
font-size: 16px;
font-weight: 600;
line-height: 1.6;
margin: 22px 0 12px;
color: #455e80;
}
.practice-contest-card p {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: #91a0b5;
}
.contest-enter {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 25px;
font-size: 12px;
color: var(--blue);
}
.practice-demo-note {
margin-top: 12px;
font-size: 10px;
color: #9cabbc;
}
.practice-links {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 24px;
padding: 22px 0 4px;
border-top: 1px solid #dce4ef;
font-size: 12px;
}
.practice-links > span {
margin-right: auto;
color: #91a0b5;
}
.practice-links > a {
color: #657e9e;
}
@media (max-width: 960px) {
.practice-hero-copy {
padding: 32px;
}
.practice-hero-art {
padding-right: 10px;
}
.practice-activity,
.practice-library {
gap: 18px;
grid-template-columns: minmax(0, 1.4fr) minmax(280px, 1fr);
}
.practice-announcements {
grid-template-columns: 110px repeat(3, minmax(0, 1fr));
gap: 6px;
}
.practice-announcements button {
padding: 8px 12px;
}
.activity-totals strong {
font-size: 25px;
}
.activity-period {
display: none;
}
.practice-contest-card {
padding: 20px;
}
}
@media (max-width: 760px) {
.practice-home {
gap: 22px;
}
.practice-hero {
grid-template-columns: 1fr;
}
.practice-hero-copy {
padding: 30px 26px 18px;
}
.practice-hero-art {
padding: 0 28px 10px;
height: 190px;
overflow: hidden;
}
.practice-hero-art img {
max-width: 330px;
}
.practice-announcements {
grid-template-columns: 1fr;
gap: 2px;
}
.announcements-label {
display: flex;
gap: 8px;
margin: 0 0 5px;
}
.practice-announcements button {
grid-template-columns: 46px 1fr 18px;
border: 0;
padding: 10px 0;
}
.practice-announcements time {
grid-column: auto;
}
.practice-activity,
.practice-library,
.practice-contest-grid {
grid-template-columns: 1fr;
}
.section-title h2 {
font-size: 20px;
}
.practice-links {
gap: 18px;
}
.practice-links > span {
width: 100%;
}
.practice-contest-card h3 {
margin-top: 16px;
}
.practice-hero h1 {
font-size: 28px;
}
.activity-totals > div:last-child {
display: none;
}
.activity-totals {
justify-content: flex-start;
gap: 50px;
}
.practice-actions {
gap: 18px;
}
}
</style>