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:
@@ -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 |
@@ -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
@@ -1,36 +1,550 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { ElButton, ElCard, ElTag } from 'element-plus'
|
import { useRouter } from 'vue-router'
|
||||||
import { usePlatformHealth } from '../composables/usePlatformHealth'
|
import { notices, rankings, contests, problems, problemTags } from '../data/preview'
|
||||||
|
import PanelCard from '../components/PanelCard.vue'
|
||||||
const { state, version, refresh } = usePlatformHealth()
|
import ProblemTable from '../components/ProblemTable.vue'
|
||||||
const statusText = computed(
|
import SubmissionChart from '../components/SubmissionChart.vue'
|
||||||
() =>
|
import TagSphere from '../components/TagSphere.vue'
|
||||||
({
|
import UiIcon from '../components/UiIcon.vue'
|
||||||
loading: '正在连接平台',
|
const router = useRouter()
|
||||||
connected: '平台已连接',
|
const noticeOpen = ref(false)
|
||||||
unavailable: '平台暂未连接',
|
const selectedNotice = ref(notices[0]!)
|
||||||
})[state.value],
|
function openNotice(index: number) {
|
||||||
)
|
selectedNotice.value = notices[index]!
|
||||||
|
noticeOpen.value = true
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="welcome">
|
<div class="practice-home">
|
||||||
<el-tag effect="plain"> 开发预览 </el-tag>
|
<section class="practice-hero">
|
||||||
<h1>从一道题开始,<br />积累每一次进步。</h1>
|
<div class="practice-hero-copy">
|
||||||
<p class="welcome-description">
|
<p class="eyebrow">KF ONLINEJUDGE / PRACTICE SPACE</p>
|
||||||
KF-OnlineJudge 正在建设中。题目练习、提交评测和个人学习记录将在后续版本开放。
|
<h1>把想法写成代码,<br />让思路一步步清晰。</h1>
|
||||||
</p>
|
<p class="practice-intro">
|
||||||
<el-card shadow="never" class="connection-card">
|
从一道题开始,沿着自己的节奏练习。<br />在这里写代码、做训练,也和同伴一起迎接新的挑战。
|
||||||
<div class="connection-row">
|
</p>
|
||||||
<div>
|
<div class="practice-actions">
|
||||||
<h2>平台连接</h2>
|
<RouterLink to="/problems"
|
||||||
<p aria-live="polite">
|
><el-button type="primary" size="large"
|
||||||
{{ statusText }}<span v-if="version && state === 'connected'"> · {{ version }}</span>
|
>开始解题 <UiIcon name="arrow" /></el-button></RouterLink
|
||||||
</p>
|
><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>
|
</div>
|
||||||
<el-button :loading="state === 'loading'" @click="refresh"> 重新检查 </el-button>
|
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
<div class="practice-hero-art">
|
||||||
</section>
|
<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>
|
</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>
|
||||||
|
|||||||
Reference in New Issue
Block a user