refactor: move task components to components/task/

This commit is contained in:
2026-04-01 03:55:01 -06:00
parent e4359e8093
commit 6fb3bc0198
8 changed files with 38 additions and 37 deletions

View File

@@ -0,0 +1,71 @@
<template>
<div class="container" v-if="taskTab === TASK_TYPE.Challenge">
<n-empty v-if="!challenges.length">暂无挑战敬请期待</n-empty>
<n-flex v-else vertical :size="12">
<n-card
v-for="item in challenges"
:key="item.display"
hoverable
:class="['challenge-card', { submitted: item.submitted }]"
@click="select(item)"
>
<template #header>
<n-flex align="center" :size="6">
<span v-if="item.submitted" class="check-icon"></span>
<span :class="{ 'submitted-title': item.submitted }">{{ item.title }}</span>
</n-flex>
</template>
<template #header-extra>
<n-flex :size="6">
<n-tag type="warning" size="small">{{ item.score }} </n-tag>
<n-tag v-if="item.pass_score != null" size="small">及格 {{ item.pass_score }} </n-tag>
</n-flex>
</template>
</n-card>
</n-flex>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from "vue"
import { useRouter } from "vue-router"
import { Challenge } from "../../api"
import { taskTab } from "../../store/task"
import { TASK_TYPE } from "../../utils/const"
import type { ChallengeSlim } from "../../utils/type"
const router = useRouter()
const challenges = ref<ChallengeSlim[]>([])
function select(item: ChallengeSlim) {
router.push({ name: "home-challenge", params: { display: item.display } })
}
onMounted(async () => {
challenges.value = await Challenge.listDisplay()
})
</script>
<style scoped>
.container {
padding: 16px;
overflow: auto;
}
.challenge-card {
cursor: pointer;
}
.challenge-card.submitted {
background-color: #f6ffed;
border-color: #b7eb8f;
}
.check-icon {
color: #52c41a;
font-size: 14px;
font-weight: bold;
}
.submitted-title {
color: #888;
}
</style>

View File

@@ -0,0 +1,146 @@
<template>
<div class="container">
<n-flex align="center" justify="space-between" class="title">
<n-flex align="center">
<Icon
:icon="
taskTab === TASK_TYPE.Tutorial
? 'twemoji:books'
: 'twemoji:crossed-swords'
"
:width="20"
></Icon>
<n-tabs
style="width: 150px"
type="segment"
animated
:value="taskTab"
@update:value="changeTab"
>
<n-tab name="tutorial" tab="教程"></n-tab>
<n-tab name="challenge" tab="挑战"></n-tab>
</n-tabs>
<template v-if="!hideNav">
<n-button text @click="prev()" :disabled="prevDisabled()">
<Icon :width="24" icon="pepicons-pencil:arrow-left"></Icon>
</n-button>
<span v-if="progressText" class="progress-text">{{ progressText }}</span>
<n-button text @click="next()" :disabled="nextDisabled()">
<Icon :width="24" icon="pepicons-pencil:arrow-right"></Icon>
</n-button>
</template>
</n-flex>
<n-flex>
<n-button v-if="roleSuper" text @click="statsModal = true">
<Icon :width="16" icon="lucide:bar-chart-2"></Icon>
</n-button>
<n-button
v-if="authed"
text
@click="$router.push({ name: 'submissions', params: { page: 1 } })"
>
<Icon :width="16" icon="lucide:list"></Icon>
</n-button>
<n-button text v-if="roleSuper" @click="edit">
<Icon :width="16" icon="lucide:edit"></Icon>
</n-button>
<n-button text @click="$emit('hide')">
<Icon :width="24" icon="material-symbols:close-rounded"></Icon>
</n-button>
</n-flex>
</n-flex>
<TutorialContent v-if="taskTab === TASK_TYPE.Tutorial" />
<ChallengeList v-else />
</div>
<TaskStatsModal v-model:show="statsModal" :task-id="taskId" />
</template>
<script lang="ts" setup>
import { Icon } from "@iconify/vue"
import { computed, ref, watch } from "vue"
import { step, tutorialIds, prev, next, prevDisabled, nextDisabled } from "../../store/tutorial"
import { authed, roleSuper } from "../../store/user"
import { taskTab, taskId, challengeDisplay } from "../../store/task"
import { useRoute, useRouter } from "vue-router"
import { TASK_TYPE } from "../../utils/const"
import ChallengeList from "./ChallengeList.vue"
import TutorialContent from "./TutorialContent.vue"
import TaskStatsModal from "./TaskStatsModal.vue"
const route = useRoute()
const router = useRouter()
const statsModal = ref(false)
// 路由同步:初始执行 + watch 响应 SPA 内部导航
function syncRoute(routeName: string) {
if (routeName.startsWith("home-tutorial")) {
taskTab.value = TASK_TYPE.Tutorial
if (route.params.display) step.value = Number(route.params.display)
} else if (routeName.startsWith("home-challenge")) {
taskTab.value = TASK_TYPE.Challenge
if (route.params.display) challengeDisplay.value = Number(route.params.display)
}
}
syncRoute(route.name as string)
watch(() => route.name as string, syncRoute)
defineEmits(["hide"])
const hideNav = computed(
() =>
taskTab.value !== TASK_TYPE.Tutorial || tutorialIds.value.length <= 1,
)
const progressText = computed(() => {
const ids = tutorialIds.value
if (!ids.length) return ""
const i = ids.indexOf(step.value)
return i === -1 ? "" : `${i + 1} / ${ids.length}`
})
function changeTab(v: TASK_TYPE) {
taskId.value = 0
taskTab.value = v
if (v === TASK_TYPE.Tutorial) {
router.push(
step.value
? { name: "home-tutorial", params: { display: step.value } }
: { name: "home-tutorial-list" },
)
} else if (v === TASK_TYPE.Challenge) {
router.push({ name: "home-challenge-list" })
}
}
function edit() {
const name =
taskTab.value === TASK_TYPE.Tutorial
? "tutorial-editor"
: "challenge-editor"
const display =
taskTab.value === TASK_TYPE.Tutorial ? step.value : challengeDisplay.value
router.push({ name, params: { display } })
}
</script>
<style scoped>
.container {
display: flex;
flex-direction: column;
height: 100%;
}
.title {
height: 43px;
padding: 0 20px;
flex-shrink: 0;
border-bottom: 1px solid rgb(239, 239, 245);
box-sizing: border-box;
}
.progress-text {
font-size: 12px;
color: #999;
min-width: 36px;
text-align: center;
user-select: none;
}
</style>

View File

@@ -0,0 +1,547 @@
<template>
<n-modal
:show="show"
@update:show="$emit('update:show', $event)"
preset="card"
title="提交统计"
style="max-width: 660px"
:bordered="false"
>
<div style="max-height: 75vh; overflow-y: auto">
<!-- 初始加载 -->
<template v-if="!stats && loading">
<n-flex justify="center" style="padding: 40px">
<n-spin size="large" />
</n-flex>
</template>
<template v-else-if="stats">
<!-- 班级筛选 -->
<n-flex
align="center"
style="margin-bottom: 16px; flex-wrap: wrap; gap: 6px"
>
<span style="color: #666; font-size: 12px">班级筛选</span>
<n-button
size="small"
:type="selectedClass === null ? 'primary' : 'default'"
@click="selectClass(null)"
>全部</n-button
>
<n-button
v-for="c in stats.classes"
:key="c"
size="small"
:type="selectedClass === c ? 'primary' : 'default'"
@click="selectClass(c)"
>{{ c }}</n-button
>
</n-flex>
<n-spin :show="loading">
<!-- 关键指标 -->
<div
style="
display: grid;
grid-template-columns: repeat(5, 1fr);
border: 1px solid #eee;
border-radius: 6px;
overflow: hidden;
margin-bottom: 12px;
"
>
<div
v-for="(metric, i) in metrics"
:key="metric.label"
:style="{
padding: '12px 8px',
textAlign: 'center',
borderRight: i < metrics.length - 1 ? '1px solid #eee' : 'none',
}"
>
<div
:style="{
fontSize: '20px',
fontWeight: '700',
color: metric.color,
}"
>
{{ metric.value }}
</div>
<div style="color: #888; font-size: 11px; margin-top: 2px">
{{ metric.label }}
</div>
</div>
</div>
<!-- 未提交名单可折叠 -->
<div
style="
border: 1px solid #eee;
border-radius: 6px;
margin-bottom: 8px;
overflow: hidden;
"
>
<div
style="
padding: 10px 14px;
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
background: #fff8f8;
"
@click="showUnsubmitted = !showUnsubmitted"
>
<n-flex align="center" :size="6">
<span
style="
width: 7px;
height: 7px;
background: #d03050;
border-radius: 50%;
display: inline-block;
"
></span>
<span style="font-weight: 600; color: #d03050; font-size: 12px"
>未提交{{ stats.unsubmitted_count }}</span
>
</n-flex>
<Icon
:icon="
showUnsubmitted
? 'lucide:chevron-down'
: 'lucide:chevron-right'
"
:width="14"
style="color: #aaa"
/>
</div>
<div
v-if="showUnsubmitted"
style="
padding: 10px 14px;
background: #fff8f8;
display: flex;
flex-wrap: wrap;
gap: 5px;
"
>
<n-tag
v-for="u in stats.unsubmitted_users"
:key="u.username"
size="small"
:bordered="true"
style="border-color: #ffd0d0; background: #fff"
>{{ displayName(u.username, u.classname) }}</n-tag
>
<span
v-if="!stats.unsubmitted_users.length"
style="color: #aaa; font-size: 12px"
>暂无</span
>
</div>
</div>
<!-- 未打分名单可折叠 -->
<div
style="
border: 1px solid #eee;
border-radius: 6px;
margin-bottom: 12px;
overflow: hidden;
"
>
<div
style="
padding: 10px 14px;
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
background: #fffaf5;
"
@click="showUnrated = !showUnrated"
>
<n-flex align="center" :size="6">
<span
style="
width: 7px;
height: 7px;
background: #e07800;
border-radius: 50%;
display: inline-block;
"
></span>
<span style="font-weight: 600; color: #e07800; font-size: 12px"
>未打分{{ stats.unrated_count }}</span
>
<span style="font-size: 11px; color: #aaa"
> 还没给任何提交打过分</span
>
</n-flex>
<Icon
:icon="
showUnrated ? 'lucide:chevron-down' : 'lucide:chevron-right'
"
:width="14"
style="color: #aaa"
/>
</div>
<div
v-if="showUnrated"
style="
padding: 10px 14px;
background: #fffaf5;
display: flex;
flex-wrap: wrap;
gap: 5px;
"
>
<n-tag
v-for="u in stats.unrated_users"
:key="u.username"
size="small"
:bordered="true"
style="border-color: #ffd0a0; background: #fff"
>{{ displayName(u.username, u.classname) }}</n-tag
>
<span
v-if="!stats.unrated_users.length"
style="color: #aaa; font-size: 12px"
>暂无</span
>
</div>
</div>
<!-- 提交次数分布 -->
<div style="margin-bottom: 12px">
<div
style="
font-weight: 600;
font-size: 13px;
margin-bottom: 8px;
color: #333;
"
>
提交次数分布
<span style="font-size: 11px; color: #aaa; font-weight: 400"
>已提交的 {{ stats.submitted_count }} </span
>
</div>
<div style="display: flex; gap: 8px">
<div
v-for="bucket in countBuckets"
:key="bucket.label"
:style="{
flex: 1,
border: `1px solid ${bucket.borderColor}`,
borderRadius: '6px',
padding: '10px 8px',
textAlign: 'center',
background: bucket.bg,
}"
>
<div
:style="{
fontSize: '20px',
fontWeight: '700',
color: bucket.color,
}"
>
{{ bucket.value }}
</div>
<div style="color: #aaa; font-size: 11px; margin-top: 2px">
{{ bucket.label }}
</div>
<div
style="
margin-top: 8px;
background: #e8e8e8;
border-radius: 3px;
height: 4px;
"
>
<div
:style="{
background: bucket.color,
height: '4px',
borderRadius: '3px',
width: bucketPct(bucket.value),
}"
></div>
</div>
<div style="color: #bbb; font-size: 10px; margin-top: 3px">
{{ bucketPct(bucket.value) }}
</div>
</div>
</div>
</div>
<!-- 标记统计 -->
<div>
<div
style="
font-weight: 600;
font-size: 13px;
margin-bottom: 8px;
color: #333;
"
>
标记统计
</div>
<n-flex :size="8" style="flex-wrap: wrap">
<div
v-for="flag in flagBadges"
:key="flag.label"
:style="{
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '4px 10px',
background: flag.bg,
borderRadius: '4px',
border: `1px solid ${flag.border}`,
}"
>
<span
:style="{
width: '7px',
height: '7px',
background: flag.color,
borderRadius: '50%',
display: 'inline-block',
}"
></span>
<span :style="{ color: flag.color, fontSize: '12px' }">{{
flag.label
}}</span>
<span style="font-weight: 700; font-size: 13px; color: #333">{{
flag.value
}}</span>
</div>
</n-flex>
</div>
<!-- 人气前五 -->
<div style="margin-top: 12px">
<div
style="
font-weight: 600;
font-size: 13px;
margin-bottom: 8px;
color: #333;
"
>
人气前五
</div>
<div
v-if="stats.top_viewed.length === 0"
style="color: #aaa; font-size: 12px"
>
暂无
</div>
<div v-else style="display: flex; flex-direction: column; gap: 6px">
<div
v-for="(item, i) in stats.top_viewed"
:key="item.submission_id"
style="
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border: 1px solid #eee;
border-radius: 6px;
cursor: pointer;
background: #fafafa;
"
@click="viewSubmission(item.submission_id)"
>
<span
:style="{
width: '20px',
height: '20px',
borderRadius: '50%',
background: i < 3 ? ['#f0a020', '#888', '#a07040'][i] : '#ddd',
color: '#fff',
fontSize: '11px',
fontWeight: '700',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}"
>{{ i + 1 }}</span>
<span style="flex: 1; font-size: 13px; color: #333">
{{ displayName(item.username, item.classname) }}
</span>
<span style="font-size: 12px; color: #2080f0; font-weight: 600">
{{ item.view_count }}
</span>
</div>
</div>
</div>
</n-spin>
</template>
</div>
</n-modal>
</template>
<script lang="ts" setup>
import { ref, computed, watch } from "vue"
import { Icon } from "@iconify/vue"
import { useRouter } from "vue-router"
import { Submission } from "../../api"
import type { TaskStatsOut } from "../../utils/type"
const props = defineProps<{ taskId: number; show: boolean }>()
const emit = defineEmits<{ (e: "update:show", v: boolean): void }>()
const router = useRouter()
const stats = ref<TaskStatsOut | null>(null)
const loading = ref(false)
const selectedClass = ref<string | null>(null)
const showUnsubmitted = ref(false)
const showUnrated = ref(false)
async function load(classname?: string) {
loading.value = true
try {
stats.value = await Submission.getStats(props.taskId, classname)
} finally {
loading.value = false
}
}
function displayName(username: string, classname: string) {
const prefix = "web" + classname
return username.startsWith(prefix) ? username.slice(prefix.length) : username
}
function selectClass(c: string | null) {
selectedClass.value = c
load(c ?? undefined)
}
function viewSubmission(id: string) {
const { href } = router.resolve({ name: "submission", params: { id } })
window.open(href, "_blank")
}
function bucketPct(value: number) {
const total = stats.value?.submitted_count ?? 0
if (!total) return "0%"
return Math.round((value / total) * 100) + "%"
}
const metrics = computed(() => {
if (!stats.value) return []
return [
{ label: "已提交", value: stats.value.submitted_count, color: "#18a058" },
{ label: "未提交", value: stats.value.unsubmitted_count, color: "#d03050" },
{
label: "平均分",
value: stats.value.average_score?.toFixed(1) ?? "—",
color: "#2080f0",
},
{ label: "未打分", value: stats.value.unrated_count, color: "#d03050" },
]
})
const countBuckets = computed(() => {
if (!stats.value) return []
const d = stats.value.submission_count_distribution
return [
{
label: "4 次+",
value: d.count_4_plus,
color: "#f0a020",
bg: "#fffbf0",
borderColor: "#ffe0a0",
},
{
label: "3 次",
value: d.count_3,
color: "#18a058",
bg: "#f0fff4",
borderColor: "#c8e8d0",
},
{
label: "2 次",
value: d.count_2,
color: "#2080f0",
bg: "#f0f7ff",
borderColor: "#d0e8ff",
},
{
label: "仅 1 次",
value: d.count_1,
color: "#888",
bg: "#fafafa",
borderColor: "#e0e0e0",
},
]
})
const scoreBars = computed(() => {
if (!stats.value) return []
const d = stats.value.score_distribution
const vals = [d.range_1_2, d.range_2_3, d.range_3_4, d.range_4_5, d.range_5]
const max = Math.max(...vals, 1)
const colors = ["#d03050", "#f0a020", "#2080f0", "#18a058", "#18a058"]
const labels = ["★", "★★", "★★★", "★★★★", "★★★★★"]
return vals.map((v, i) => ({
value: v,
label: labels[i],
color: colors[i],
height: Math.max(Math.round((v / max) * 68), 3) + "px",
}))
})
const flagBadges = computed(() => {
if (!stats.value) return []
const f = stats.value.flag_stats
return [
{
label: "值得展示",
value: f.red,
color: "#d03050",
bg: "#fff0f0",
border: "#ffd0d0",
},
{
label: "需要讲解",
value: f.blue,
color: "#2080f0",
bg: "#f0f7ff",
border: "#c8deff",
},
{
label: "优秀作品",
value: f.green,
color: "#18a058",
bg: "#f0fff4",
border: "#b8e8c8",
},
{
label: "需要改进",
value: f.yellow,
color: "#f0a020",
bg: "#fffbf0",
border: "#ffe8a0",
},
]
})
// Load when modal opens
watch(
() => props.show,
(val) => {
if (val && props.taskId) {
selectedClass.value = null
load()
}
},
)
</script>

View File

@@ -0,0 +1,178 @@
<template>
<div class="markdown-body" v-html="content" ref="$content" />
</template>
<script lang="ts" setup>
import { onMounted, ref, useTemplateRef, watch } from "vue"
import { marked } from "marked"
import copyFn from "copy-text-to-clipboard"
import { css, html, js, tab } from "../../store/editors"
import { Tutorial } from "../../api"
import { step, tutorialIds } from "../../store/tutorial"
import { taskId } from "../../store/task"
import { useRouter } from "vue-router"
marked.use({
renderer: {
code({ text, lang }) {
const language = lang?.toLowerCase() ?? "html"
return `<div class="codeblock-wrapper" data-lang="${language}">
<div class="codeblock-action">
<span class="lang">${language.toUpperCase()}</span>
<div class="btn-group">
<button class="action-btn" data-action="copy">复制</button>
<button class="action-btn" data-action="replace">替换</button>
</div>
</div>
<pre><code class="language-${language}">${text}</code></pre>
</div>`
},
link({ href, text }) {
return `<a href="${href}" target="_blank">${text}</a>`
},
},
})
const router = useRouter()
const content = ref("")
const $content = useTemplateRef<any>("$content")
async function prepare() {
tutorialIds.value = await Tutorial.listDisplay()
if (!tutorialIds.value.length) {
content.value = "暂无教程"
return
}
if (!tutorialIds.value.includes(step.value)) {
step.value = tutorialIds.value[0] as number
}
}
async function render() {
const data = await Tutorial.get(step.value)
taskId.value = data.task_ptr
const merged = `# ${data.display}. ${data.title}\n${data.content}`
content.value = await marked.parse(merged, { async: true })
}
function flash(btn: HTMLButtonElement, done: string, original: string) {
btn.textContent = done
setTimeout(() => {
btn.textContent = original
}, 1000)
}
function setupCodeActions() {
$content.value?.addEventListener("click", (e: MouseEvent) => {
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>(
"[data-action]",
)
if (!btn) return
const wrapper = btn.closest<HTMLElement>("[data-lang]")!
const lang = wrapper.dataset.lang ?? "html"
const code = wrapper.querySelector("code")?.textContent ?? ""
if (btn.dataset.action === "copy") {
copyFn(code)
flash(btn, "已复制", "复制")
} else if (btn.dataset.action === "replace") {
tab.value = lang
if (lang === "html") html.value = code
if (lang === "css") css.value = code
if (lang === "js") js.value = code
flash(btn, "已替换", "替换")
}
})
}
async function init() {
await prepare()
render()
}
onMounted(() => {
setupCodeActions()
init()
})
watch(step, (v) => {
router.push({ name: "home-tutorial", params: { display: v } })
render()
})
</script>
<style scoped>
.markdown-body {
padding: 16px;
box-sizing: border-box;
overflow: auto;
}
</style>
<style>
.markdown-body pre {
position: relative;
}
.markdown-body pre code {
padding: 0;
font-size: 1rem;
font-family: Monaco;
}
.markdown-body .codeblock-wrapper {
padding: 1rem;
background-color: #f6f8fa;
border-radius: 6px;
margin-bottom: 1rem;
overflow: auto;
}
.markdown-body .codeblock-wrapper pre {
padding: 0;
background-color: transparent;
border-radius: 0;
margin-bottom: 0;
overflow: visible;
}
.codeblock-action {
margin-bottom: 0.5rem;
font-family:
v-sans,
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif,
"Apple Color Emoji",
"Segoe UI Emoji",
"Segoe UI Symbol";
display: flex;
align-items: center;
justify-content: space-between;
}
.codeblock-action .lang {
font-size: 0.9rem;
font-weight: bold;
}
.codeblock-action .btn-group {
display: flex;
gap: 0.5rem;
}
.codeblock-action .action-btn {
height: 28px;
padding: 0 14px;
font-size: 14px;
border-radius: 3px;
border: 1px solid #d9d9d9;
background-color: #fff;
color: #333;
cursor: pointer;
transition: all 0.2s ease;
}
.codeblock-action .action-btn:hover {
border-color: #18a058;
color: #18a058;
}
</style>