feat(阶段1): 搬入 ojnext 为 apps/web,未改业务代码

This commit is contained in:
xuyue committed 2026-08-06 21:18:16 -06:00
1 parent 3c975e85ee
commit ae1fb329b5
258 files changed
+40490 -91

No files matched your search

@@ -0,0 +1,184 @@
<script setup lang="ts">
import type { Exercise, ExerciseMatchData } from "utils/types"
import { shuffle } from "../composables/useShuffle"
const props = defineProps<{ exercise: Exercise; lang?: string }>()
const data = computed(() => props.exercise.data as ExerciseMatchData)
const PALETTE = [
"#2080f0",
"#18a058",
"#f0a020",
"#d03050",
"#8a2be2",
"#0891b2",
"#db2777",
"#65a30d",
]
const rightOrder = ref<number[]>([]) // 显示顺序里的 right 原始下标
const pairs = ref<(number | null)[]>([]) // pairs[leftIdx] = 配对的 right 原始下标
const selectedLeft = ref<number | null>(null)
const submitted = ref(false)
function init() {
const n = data.value.right.length
rightOrder.value = shuffle(Array.from({ length: n }, (_, i) => i))
pairs.value = Array(data.value.left.length).fill(null)
selectedLeft.value = null
submitted.value = false
}
onMounted(init)
watch(() => props.exercise.id, init)
const allPaired = computed(() => pairs.value.every((p) => p !== null))
const allCorrect = computed(() =>
pairs.value.every((p, i) => p === data.value.answer[i]),
)
const locked = computed(() => submitted.value && allCorrect.value)
function leftOf(rightIdx: number): number {
return pairs.value.findIndex((p) => p === rightIdx)
}
function onLeftClick(i: number) {
if (locked.value) return
submitted.value = false
if (pairs.value[i] !== null) {
pairs.value[i] = null
selectedLeft.value = i
return
}
selectedLeft.value = selectedLeft.value === i ? null : i
}
function onRightClick(rightIdx: number) {
if (locked.value) return
submitted.value = false
if (selectedLeft.value === null) {
const l = leftOf(rightIdx)
if (l !== -1) pairs.value[l] = null
return
}
const prev = leftOf(rightIdx)
if (prev !== -1) pairs.value[prev] = null
pairs.value[selectedLeft.value] = rightIdx
selectedLeft.value = null
}
function submit() {
submitted.value = true
}
function reset() {
init()
}
function leftColor(i: number): string {
if (submitted.value) {
if (pairs.value[i] === null) return "#d03050"
return pairs.value[i] === data.value.answer[i] ? "#18a058" : "#d03050"
}
if (selectedLeft.value === i) return "#2080f0"
if (pairs.value[i] !== null) return PALETTE[i % PALETTE.length]
return "var(--n-border-color)"
}
function rightColor(rightIdx: number): string {
const l = leftOf(rightIdx)
if (submitted.value) {
if (l === -1) return "var(--n-border-color)"
return pairs.value[l] === data.value.answer[l] ? "#18a058" : "#d03050"
}
if (l === -1) return "var(--n-border-color)"
return PALETTE[l % PALETTE.length]
}
function itemStyle(color: string, selected: boolean): Record<string, string> {
const plain = color === "var(--n-border-color)"
return {
display: "flex",
alignItems: "center",
gap: "8px",
padding: "10px 12px",
borderRadius: "6px",
border: `${selected ? "2px" : "1.5px"} solid ${color}`,
background: plain ? "transparent" : color + "14",
cursor: locked.value ? "default" : "pointer",
userSelect: "none",
fontSize: "15px",
}
}
function dotStyle(color: string): Record<string, string> {
return {
width: "10px",
height: "10px",
borderRadius: "50%",
background: color,
flexShrink: "0",
}
}
</script>
<template>
<n-card style="margin: 16px 0; border: 1.5px solid var(--n-border-color)">
<template #header>
<n-tag type="primary" :bordered="false">练一练 · 连线匹配</n-tag>
</template>
<p style="font-weight: 500; font-size: 16px; margin-bottom: 8px">
{{ data.question }}
</p>
<p style="color: var(--n-text-color-3); font-size: 13px; margin: 0 0 12px">
先点左边一项,再点右边一项即可连线;点击已连线的项可取消。
</p>
<div style="display: flex; gap: 24px; align-items: flex-start">
<n-space vertical :size="8" style="flex: 1">
<div
v-for="(item, i) in data.left"
:key="'l' + i"
:style="itemStyle(leftColor(i), selectedLeft === i)"
@click="onLeftClick(i)"
>
<span
v-if="pairs[i] !== null && !submitted"
:style="dotStyle(PALETTE[i % PALETTE.length])"
/>
<span>{{ item }}</span>
</div>
</n-space>
<n-space vertical :size="8" style="flex: 1">
<div
v-for="rightIdx in rightOrder"
:key="'r' + rightIdx"
:style="itemStyle(rightColor(rightIdx), false)"
@click="onRightClick(rightIdx)"
>
<span
v-if="leftOf(rightIdx) !== -1 && !submitted"
:style="dotStyle(PALETTE[leftOf(rightIdx) % PALETTE.length])"
/>
<span>{{ data.right[rightIdx] }}</span>
</div>
</n-space>
</div>
<n-alert
v-if="submitted"
:type="allCorrect ? 'success' : 'error'"
:title="allCorrect ? '全部匹配正确!' : '有匹配错误,红色项需要重新连线'"
style="margin-top: 12px"
/>
<n-space style="margin-top: 12px" :size="8">
<n-button type="primary" :disabled="!allPaired || locked" @click="submit">
提交
</n-button>
<n-button @click="reset">重置</n-button>
</n-space>
</n-card>
</template>