refactor(课堂任务): 「课堂看板」改名「课堂任务」,地址换成 /task;班级 PK 换成 /pk
- 界面上的「课堂看板」「课堂作业」统一叫「课堂任务」,顶栏、老师首页、后台菜单一起改 - 前端 oj/classroom → oj/task、oj/class → oj/pk,两个名字不再只差几个字母却毫不相干;旧地址不留跳转 - 后端 routes/classroom.ts 拆开:课堂任务的接口进 routes/task.ts(/task/board|lesson|today), 首页的「上次来」「班里在做」进 routes/home.ts;共用的取题口径挪进 services/lesson.ts - 契约 classroom.ts 同样拆成 task.ts / home.ts,ClassBoard* 改叫 TaskBoard* Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
46d3fe2dbe
commit
29c31640b5
82 files changed
+798
-766
No files matched your search
@@ -0,0 +1,134 @@
|
||||
import type { LessonMode } from '@oj2/contract'
|
||||
import type { Tone } from 'shared/composables/tone'
|
||||
import { parseTime } from 'utils/functions'
|
||||
import type { TaskBoardCell, TaskBoardStudent } from 'utils/types'
|
||||
|
||||
/**
|
||||
* 课堂任务的格子:一个学生 × 一道题。显示什么字、什么颜色、悬停说什么,都是纯函数,
|
||||
* 和 errorKind.ts 一样单放一个文件(任务页组件只管排版和交互)。
|
||||
*
|
||||
* `mode` 是这节课做什么(契约 lessonModeSchema),推断出来的课为 null。先画再写的格子是一条进度:
|
||||
* 在画 → 图画对了(空心绿「图 ✓」)→ 在写 → 两样都对(实心绿 ✓)。先写了代码、还没画图的写「缺图」
|
||||
*/
|
||||
|
||||
/** 同一道题今天交了这么多次还没过,算卡住 */
|
||||
export const STUCK_ATTEMPTS = 3
|
||||
|
||||
/**
|
||||
* 没做完的格子卡在哪一步:draw = 在画流程图、code = 在写代码、nodraw = 代码对了但还没画图。
|
||||
* 只画流程图的课一律是 draw;写代码的课和推断出来的课一律是 code
|
||||
*/
|
||||
export type CellStage = 'draw' | 'code' | 'nodraw'
|
||||
|
||||
export function cellStage(cell: TaskBoardCell, mode: LessonMode | null): CellStage {
|
||||
if (mode === 'flowchart') return 'draw'
|
||||
if (mode !== 'both' || !cell.flowchart) return 'code'
|
||||
if (cell.flowchart.status === 'tried') return 'draw'
|
||||
if (cell.flowchart.status === 'none' && cell.codeStatus === 'accepted') return 'nodraw'
|
||||
return 'code'
|
||||
}
|
||||
|
||||
/** 今天在卡着的那一步上交了几次:先画再写的课里,画了三张才画对的人写代码时不该算卡住 */
|
||||
export function stageAttempts(cell: TaskBoardCell, mode: LessonMode | null) {
|
||||
if (mode !== 'both') return cell.attempts
|
||||
const stage = cellStage(cell, mode)
|
||||
return stage === 'draw' ? cell.flowchartAttempts : stage === 'code' ? cell.codeAttempts : 0
|
||||
}
|
||||
|
||||
/** 卡住了:没做完,而且在卡着的那一步上今天交了 STUCK_ATTEMPTS 次以上 */
|
||||
export function isStuck(cell: TaskBoardCell, mode: LessonMode | null) {
|
||||
return cell.status === 'tried' && stageAttempts(cell, mode) >= STUCK_ATTEMPTS
|
||||
}
|
||||
|
||||
/** 先画再写:图画对了、代码还没交过 —— 空心绿的「图 ✓」 */
|
||||
function halfDone(cell: TaskBoardCell, mode: LessonMode | null) {
|
||||
return mode === 'both' && cell.status === 'tried' && cell.flowchart?.status === 'accepted' && cell.codeStatus === 'none'
|
||||
}
|
||||
|
||||
/** 今天之前就通过了的,颜色淡一档。`day` 是任务页的「今天」(东八区日期) */
|
||||
export function acceptedToday(cell: TaskBoardCell, day: string | undefined) {
|
||||
return !!cell.acceptedAt && parseTime(cell.acceptedAt, 'YYYY-MM-DD') === day
|
||||
}
|
||||
|
||||
/** 最后一张图的那句:评分中 / 评失败 / 走对几组 */
|
||||
function drawText(cell: TaskBoardCell, prefix: string) {
|
||||
const latest = cell.flowchart?.latest
|
||||
if (!latest) return '✗'
|
||||
if (latest.status === 0 || latest.status === 1) return '评分中'
|
||||
if (latest.status === 3) return '评失败'
|
||||
if (latest.passed !== null && latest.total) return `${prefix}${latest.passed}/${latest.total}`
|
||||
return '✗'
|
||||
}
|
||||
|
||||
/** 做对了的写第几次做对:「✓ 9」是今天第 9 次才对,一次就对的、以前就对了的还是「✓」 */
|
||||
export function cellText(cell: TaskBoardCell, mode: LessonMode | null = null) {
|
||||
if (cell.status === 'accepted') return (cell.acceptedTry ?? 1) > 1 ? `✓ ${cell.acceptedTry}` : '✓'
|
||||
if (cell.status === 'tried') {
|
||||
const stage = cellStage(cell, mode)
|
||||
if (stage === 'draw') return drawText(cell, mode === 'both' ? '图 ' : '')
|
||||
if (stage === 'nodraw') return '缺图'
|
||||
if (halfDone(cell, mode)) return '图 ✓'
|
||||
const attempts = mode === 'both' ? cell.codeAttempts : cell.attempts
|
||||
return attempts ? `✗ ${attempts}` : '✗'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export function cellStyle(
|
||||
cell: TaskBoardCell,
|
||||
day: string | undefined,
|
||||
colors: {
|
||||
tone: (kind: Tone) => { color: string; background: string }
|
||||
errorColor: string
|
||||
actionColor: string
|
||||
successColor: string
|
||||
},
|
||||
mode: LessonMode | null = null,
|
||||
) {
|
||||
if (cell.status === 'accepted') {
|
||||
const t = colors.tone('success')
|
||||
return acceptedToday(cell, day) ? { background: t.background, color: t.color } : { background: t.background, color: t.color, opacity: 0.5 }
|
||||
}
|
||||
if (cell.status === 'tried') {
|
||||
if (halfDone(cell, mode)) {
|
||||
return { background: 'transparent', color: colors.tone('success').color, boxShadow: `inset 0 0 0 1.5px ${colors.successColor}` }
|
||||
}
|
||||
if (isStuck(cell, mode)) {
|
||||
return { background: colors.errorColor, color: '#ffffff' }
|
||||
}
|
||||
const t = colors.tone('warning')
|
||||
return { background: t.background, color: t.color }
|
||||
}
|
||||
return { background: colors.actionColor }
|
||||
}
|
||||
|
||||
export function cellTitle(cell: TaskBoardCell, student: TaskBoardStudent, day: string | undefined, mode: LessonMode | null = null) {
|
||||
if (cell.status === 'accepted') {
|
||||
const when = acceptedToday(cell, day) ? `${parseTime(cell.acceptedAt!, 'HH:mm')} 做对` : '以前就做对了'
|
||||
if (cell.challenge === 'done') return `${when},挑战也做到了`
|
||||
if (cell.challenge === 'doing') return `${when},在挑战`
|
||||
return when
|
||||
}
|
||||
if (cell.status === 'tried') {
|
||||
const stage = cellStage(cell, mode)
|
||||
if (stage === 'draw') {
|
||||
const latest = cell.flowchart?.latest
|
||||
if (latest?.status === 0 || latest?.status === 1) return 'AI 正在评这张图'
|
||||
return latest ? `讲讲 ${student.username} 这张图哪里画错了` : '还没画对'
|
||||
}
|
||||
if (stage === 'nodraw') return '代码做对了,流程图还没画'
|
||||
if (halfDone(cell, mode)) return '流程图画对了,代码还没交'
|
||||
if (cell.latest) return `讲讲 ${student.username} 这道题的错`
|
||||
if (mode === 'both' && cell.flowchart?.status === 'accepted') return '流程图画对了,代码以前交过、没做对'
|
||||
return cell.attempts ? `今天画了 ${cell.flowchartAttempts} 张流程图` : '以前交过,没做对'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/** 点开做对的格子,方块串旁边那句 */
|
||||
export function trailSummary(cell: TaskBoardCell, day: string | undefined) {
|
||||
const total = cell.trail.length
|
||||
if (cell.acceptedTry === 1) return total === 1 ? '一次就做对' : `交了 ${total} 次,第 1 次就做对`
|
||||
if (cell.acceptedTry) return `交了 ${total} 次,第 ${cell.acceptedTry} 次做对`
|
||||
return acceptedToday(cell, day) ? `今天交了 ${total} 次代码` : `以前就做对了,今天又交了 ${total} 次`
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* 弹层顶上那排人里的一个。讲代码给 submissionId;要画流程图的课里卡在画图上的,给他最后
|
||||
* 那张图的 flowchartId —— 下面就换成图和 AI 的点评
|
||||
*/
|
||||
export type ExplainPerson = {
|
||||
userId: number
|
||||
username: string
|
||||
/** 错法的短名(errorKind),挂在名字后面 */
|
||||
label: string
|
||||
} & ({ submissionId: string; flowchartId?: undefined } | { flowchartId: string; submissionId?: undefined })
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { onKeyStroke } from '@vueuse/core'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import SubmissionView from 'oj/submission/components/SubmissionView.vue'
|
||||
import UserName from 'shared/components/UserName.vue'
|
||||
import { useBackClose } from 'shared/composables/backClose'
|
||||
import { useBreakpoints } from 'shared/composables/breakpoints'
|
||||
import type { TaskBoardProblem } from 'utils/types'
|
||||
import FlowchartExplain from './FlowchartExplain.vue'
|
||||
import ProblemBrief from './ProblemBrief.vue'
|
||||
|
||||
/**
|
||||
* 课堂任务「讲错误」(设计稿 BdOpen):点一个红格子或错法堆里的名字打开。老师在讲台上
|
||||
* 一个个讲这道题错着的人,所以顶上是这几个人,← → 换人;下面和独立的提交详情页同一块
|
||||
* (代码占满宽、为什么错在后),只是不出名字那一行、点「第几次」就地换。
|
||||
*
|
||||
* 「题目」按钮:演示错误时题目和代码要并排(用户要的),点了左边出题目,再点收起。
|
||||
* 平时收着,代码照样占满宽。开没开记在这台电脑上,讲下一个人、下一道题还是那样
|
||||
*
|
||||
* 手机上(老师拿手机看任务页):顶上拆成三行 —— 题目和关闭、一排人(横着滑)、两个按钮;
|
||||
* 没有键盘所以不写「← → 换人」;题目展开时叠在代码上面,不并排
|
||||
*/
|
||||
const props = defineProps<{
|
||||
problem: TaskBoardProblem | null
|
||||
people: ExplainPerson[]
|
||||
}>()
|
||||
const show = defineModel<boolean>('show', { required: true })
|
||||
const index = defineModel<number>('index', { required: true })
|
||||
|
||||
const theme = useThemeVars()
|
||||
const { isMobile } = useBreakpoints()
|
||||
// 手机上是整屏的:按返回键关掉它回到任务页,不是退出任务页
|
||||
useBackClose(show, isMobile)
|
||||
/**
|
||||
* 颜色走内联的 CSS 变量,不用 <style> 里的 v-bind():n-modal 把内容挂到 body 下面,
|
||||
* v-bind 的变量挂在组件根上,传不过去(弹层底色整个透明)
|
||||
*/
|
||||
const vars = computed(() => ({
|
||||
'--card': theme.value.cardColor,
|
||||
'--text1': theme.value.textColor1,
|
||||
'--text2': theme.value.textColor2,
|
||||
'--text3': theme.value.textColor3,
|
||||
'--divider': theme.value.dividerColor,
|
||||
'--border': theme.value.borderColor,
|
||||
'--success': theme.value.successColor,
|
||||
'--hover': theme.value.hoverColor,
|
||||
}))
|
||||
const current = computed(() => props.people[index.value] ?? null)
|
||||
const showProblem = useStorage('oj_explain_show_problem', false)
|
||||
|
||||
/** 一排人横着滑的时候,换到的人要滑进来(← → 换人、或者点开的就是排在后面的) */
|
||||
const peopleRef = ref<HTMLElement | null>(null)
|
||||
watch([index, show], async () => {
|
||||
if (!show.value) return
|
||||
await nextTick()
|
||||
peopleRef.value?.querySelector<HTMLElement>('.person.on')?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
|
||||
})
|
||||
|
||||
function step(delta: -1 | 1) {
|
||||
const next = index.value + delta
|
||||
if (next >= 0 && next < props.people.length) index.value = next
|
||||
}
|
||||
|
||||
onKeyStroke(['ArrowLeft', 'ArrowRight'], (event) => {
|
||||
if (!show.value || props.people.length < 2) return
|
||||
if (event.altKey || event.ctrlKey || event.metaKey) return
|
||||
event.preventDefault()
|
||||
step(event.key === 'ArrowLeft' ? -1 : 1)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-modal v-model:show="show" :auto-focus="false">
|
||||
<!-- 无 preset 的 n-modal 直接子节点要是普通元素 -->
|
||||
<div class="sheet" :class="{ narrow: isMobile }" :style="vars" role="dialog" :aria-label="problem ? `讲 ${problem.problemDisplayId} 的错误` : '讲错误'">
|
||||
<div class="top">
|
||||
<span v-if="problem" class="problem">
|
||||
<b>{{ problem.problemDisplayId }}</b> {{ problem.title }}
|
||||
<template v-if="people.length > 1"> · 错着的</template>
|
||||
</span>
|
||||
<div ref="peopleRef" class="people">
|
||||
<button v-for="(person, i) in people" :key="person.userId" type="button" class="person" :class="{ on: i === index }" @click="index = i">
|
||||
<UserName :username="person.username" />
|
||||
<span class="kind">{{ person.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<span v-if="people.length > 1 && !isMobile" class="keys"><kbd>←</kbd><kbd>→</kbd> 换人</span>
|
||||
<div class="spacer"></div>
|
||||
<div class="buttons">
|
||||
<n-button
|
||||
v-if="problem"
|
||||
:size="isMobile ? 'small' : 'medium'"
|
||||
:type="showProblem ? 'primary' : 'default'"
|
||||
:secondary="showProblem"
|
||||
@click="showProblem = !showProblem"
|
||||
>
|
||||
题目
|
||||
</n-button>
|
||||
<n-button v-if="current?.submissionId" :size="isMobile ? 'small' : 'medium'" tag="a" :href="'/submission/' + current.submissionId" target="_blank">
|
||||
在新页面打开
|
||||
</n-button>
|
||||
</div>
|
||||
<n-button quaternary class="close" aria-label="关闭" @click="show = false">
|
||||
<template #icon><Icon icon="ph:x-bold" /></template>
|
||||
</n-button>
|
||||
</div>
|
||||
<div class="body" :class="{ split: showProblem && problem }">
|
||||
<aside v-if="showProblem && problem" class="statement">
|
||||
<ProblemBrief :display-id="problem.problemDisplayId" />
|
||||
</aside>
|
||||
<div class="main">
|
||||
<FlowchartExplain v-if="current?.flowchartId" :id="current.flowchartId" />
|
||||
<SubmissionView v-else-if="current?.submissionId" :id="current.submissionId" embedded />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sheet {
|
||||
width: min(1240px, calc(100vw - 48px));
|
||||
max-height: calc(100vh - 48px);
|
||||
overflow: auto;
|
||||
box-sizing: border-box;
|
||||
padding: 18px 24px 24px;
|
||||
border-radius: 10px;
|
||||
background: var(--card);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.problem {
|
||||
flex-shrink: 0;
|
||||
font-size: 14px;
|
||||
color: var(--text3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.problem b {
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.people {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.person {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 32px;
|
||||
padding: 0 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--divider);
|
||||
background: var(--card);
|
||||
color: var(--text1);
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.person.on {
|
||||
border-color: var(--success);
|
||||
background: var(--hover);
|
||||
}
|
||||
|
||||
.kind {
|
||||
font-size: 12px;
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
.keys {
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
color: var(--text3);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
kbd {
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 4px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
background: var(--card);
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.body.split {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(300px, 2fr) minmax(0, 3fr);
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
/* 题目自己滚:讲到代码后半段时题目还在旁边 */
|
||||
.statement {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
max-height: calc(100vh - 140px);
|
||||
overflow: auto;
|
||||
padding-right: 8px;
|
||||
color: var(--text1);
|
||||
}
|
||||
|
||||
.main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* ---------- 手机 ---------- */
|
||||
|
||||
.sheet.narrow {
|
||||
width: calc(100vw - 16px);
|
||||
/* 全站给 .n-modal 的「左右各留 16px」在这里不要 */
|
||||
max-width: none;
|
||||
max-height: calc(100vh - 16px);
|
||||
max-height: calc(100dvh - 16px);
|
||||
padding: 12px 14px 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* 第一行题目 + 关闭,第二行一排人(横着滑),第三行两个按钮 */
|
||||
.narrow .top {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.narrow .problem {
|
||||
grid-row: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.narrow .close {
|
||||
grid-row: 1;
|
||||
grid-column: 2;
|
||||
margin-right: -6px;
|
||||
}
|
||||
|
||||
.narrow .people {
|
||||
grid-column: 1 / -1;
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.narrow .person {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.narrow .spacer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.narrow .buttons {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
/* 题目叠在代码上面,自己滚,别把代码顶出一屏 */
|
||||
.narrow .body.split {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.narrow .statement {
|
||||
position: static;
|
||||
max-height: 40vh;
|
||||
padding: 0 0 12px;
|
||||
border-bottom: 1px solid var(--divider);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,159 @@
|
||||
<script setup lang="ts">
|
||||
import { getFlowchartSubmission } from 'oj/api'
|
||||
import FlowchartNote from 'shared/components/FlowchartNote.vue'
|
||||
import { useMermaid } from 'shared/composables/useMermaid'
|
||||
import { useTone } from 'shared/composables/tone'
|
||||
import { caseReport, fitFlowchartGraph, flowchartVerdict, markFlowchartFocus } from 'utils/flowchart'
|
||||
import type { FlowchartSubmission } from 'utils/types'
|
||||
|
||||
/**
|
||||
* 课堂任务「讲错误」弹层里的一张流程图:要画流程图的课,点卡在画图上的人打开它。
|
||||
* 左边是他最后画的那张(要改的框标红),右边是 AI 写的哪里错、可以怎么改 —— 和提交列表右栏
|
||||
* (FlowchartPane)、题目页结果页签同一套点评(FlowchartNote)。
|
||||
*
|
||||
* 挂在 n-modal 里(内容在 body 下),<style> 的 v-bind() 传不进来:颜色走外层弹层给的
|
||||
* CSS 变量(--card、--divider…),标红的颜色走根节点的内联变量
|
||||
*/
|
||||
const props = defineProps<{ id: string }>()
|
||||
|
||||
const tone = useTone()
|
||||
const { renderError, renderFlowchart } = useMermaid()
|
||||
const detail = ref<FlowchartSubmission | null>(null)
|
||||
const failed = ref(false)
|
||||
const graph = useTemplateRef<HTMLElement>('graph')
|
||||
const openCase = ref<number | null>(null)
|
||||
const casesOpen = ref(false)
|
||||
|
||||
const report = computed(() => caseReport(detail.value?.aiCriteriaDetails))
|
||||
const vars = computed(() => ({
|
||||
'--focus-fill': tone('error').background,
|
||||
'--focus-stroke': tone('error').solid,
|
||||
}))
|
||||
|
||||
let seq = 0
|
||||
async function load() {
|
||||
const mine = ++seq
|
||||
failed.value = false
|
||||
openCase.value = null
|
||||
casesOpen.value = false
|
||||
try {
|
||||
const res = await getFlowchartSubmission(props.id)
|
||||
if (mine !== seq) return
|
||||
detail.value = res
|
||||
await nextTick()
|
||||
await renderFlowchart(graph.value, res.mermaidCode)
|
||||
fitFlowchartGraph(graph.value)
|
||||
markFlowchartFocus(graph.value, report.value)
|
||||
} catch {
|
||||
if (mine !== seq) return
|
||||
detail.value = null
|
||||
failed.value = true
|
||||
}
|
||||
}
|
||||
watch(() => props.id, load, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="explain-flow" :style="vars">
|
||||
<div class="graph-box">
|
||||
<span v-if="renderError" class="muted">这张流程图画不出来,可能有框没连上</span>
|
||||
<div v-show="!renderError" ref="graph" class="graph"></div>
|
||||
<span v-if="failed" class="muted">没拉下来,<a href="#" @click.prevent="load">再试一次</a></span>
|
||||
</div>
|
||||
<div v-if="detail" class="note-box">
|
||||
<template v-if="report">
|
||||
<b class="headline">{{ flowchartVerdict(report, detail.aiGrade) }}</b>
|
||||
<FlowchartNote
|
||||
v-model:selected="openCase"
|
||||
v-model:open="casesOpen"
|
||||
:report="report"
|
||||
:feedback="detail.aiFeedback"
|
||||
:suggestions="detail.aiSuggestions"
|
||||
:previous="detail.previousCases"
|
||||
/>
|
||||
</template>
|
||||
<!-- 2026-10 之前的老评法没有走例子的报告,只有一段评语 -->
|
||||
<template v-else>
|
||||
<b class="headline">{{ detail.aiGrade ? `${detail.aiGrade} 级` : '还没画对' }}</b>
|
||||
<div v-if="detail.aiFeedback" class="plain">{{ detail.aiFeedback }}</div>
|
||||
<div v-if="detail.aiSuggestions" class="plain">{{ detail.aiSuggestions }}</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.explain-flow {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
/* 图自己画了一层底框,外面不再套边框;高度压在一屏以内,讲的时候不用上下滚 */
|
||||
.graph-box {
|
||||
flex: 0 0 60%;
|
||||
min-width: 0;
|
||||
height: min(620px, calc(100vh - 190px));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.graph {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.note-box {
|
||||
border: 1px solid var(--divider);
|
||||
border-radius: 8px;
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
.graph :deep(svg) {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.graph :deep(.pele-node.oj-focus > :not(text)) {
|
||||
fill: var(--focus-fill) !important;
|
||||
stroke: var(--focus-stroke) !important;
|
||||
stroke-width: 3px !important;
|
||||
}
|
||||
|
||||
.note-box {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.headline {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.plain {
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.explain-flow {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.graph-box {
|
||||
flex: none;
|
||||
height: 360px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,113 @@
|
||||
<script setup lang="ts">
|
||||
import type { TaskBoardStudent } from 'utils/types'
|
||||
|
||||
/**
|
||||
* 任务页的「请假」框:勾今天不在的人。规则在任务页那边(只有今天还没交过的人能算请假、
|
||||
* 交了题或举手就自动算到,见 board.vue 的 canLeave),这里只管勾。
|
||||
*/
|
||||
const props = defineProps<{
|
||||
/** 能勾的人:今天还没交过的(刚上课时就是全班),按名字排好 */
|
||||
candidates: TaskBoardStudent[]
|
||||
/** 打开时已经标着请假的 */
|
||||
current: string[]
|
||||
}>()
|
||||
const emit = defineEmits<{ save: [usernames: string[]] }>()
|
||||
const show = defineModel<boolean>('show', { required: true })
|
||||
|
||||
const selected = ref<string[]>([])
|
||||
watch(show, (open) => {
|
||||
if (open) selected.value = [...props.current]
|
||||
})
|
||||
|
||||
/**
|
||||
* 今天一次都没上过线。机房有两人共用一台电脑的,同桌只有一个登着,所以「谁没来」
|
||||
* 看今天上没上过线,不看此刻在不在线(见契约 seenAt)
|
||||
*/
|
||||
const unseen = (student: TaskBoardStudent | undefined) => !!student && !student.seenAt
|
||||
|
||||
/** 今天没上过线的分在前面一堆:请假的人基本在这里面,剩下的是和同桌共用电脑的 */
|
||||
const sections = computed(() =>
|
||||
[
|
||||
{ label: '今天没上线', students: props.candidates.filter(unseen) },
|
||||
{
|
||||
label: '今天上过线',
|
||||
students: props.candidates.filter((student) => !unseen(student)),
|
||||
},
|
||||
].filter((section) => section.students.length),
|
||||
)
|
||||
|
||||
function selectAll(list: TaskBoardStudent[]) {
|
||||
selected.value = [...new Set([...selected.value, ...list.map((student) => student.username)])]
|
||||
}
|
||||
|
||||
function save() {
|
||||
emit('save', selected.value)
|
||||
show.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-modal
|
||||
v-model:show="show"
|
||||
preset="card"
|
||||
:title="candidates.length ? `今天还没交过的 ${candidates.length} 人里,谁请假了?` : '今天全班都交过题了'"
|
||||
style="width: 640px; max-width: calc(100vw - 32px)"
|
||||
>
|
||||
<n-checkbox-group v-if="candidates.length" v-model:value="selected">
|
||||
<div v-for="section in sections" :key="section.label" class="leave-section">
|
||||
<div v-if="sections.length > 1 || unseen(section.students[0])" class="leave-head">
|
||||
{{ section.label }} {{ section.students.length }} 人
|
||||
<n-button
|
||||
v-if="unseen(section.students[0])"
|
||||
text
|
||||
type="primary"
|
||||
:disabled="section.students.every((student) => selected.includes(student.username))"
|
||||
@click="selectAll(section.students)"
|
||||
>
|
||||
全勾上
|
||||
</n-button>
|
||||
</div>
|
||||
<div class="leave-grid">
|
||||
<n-checkbox v-for="student in section.students" :key="student.userId" :value="student.username" :label="student.username" class="leave-option" />
|
||||
</div>
|
||||
</div>
|
||||
</n-checkbox-group>
|
||||
<n-text v-else depth="3">请假的人一定是没交过题的,现在没有人可以标。</n-text>
|
||||
<template #footer>
|
||||
<n-flex align="center" :wrap="false">
|
||||
<n-text depth="3" class="meta" style="flex-grow: 1"> 已选 {{ selected.length }} 人 · 只记在这台电脑上,今天有效 </n-text>
|
||||
<n-button :disabled="!selected.length" @click="selected = []">全部取消</n-button>
|
||||
<n-button @click="show = false">取消</n-button>
|
||||
<n-button type="primary" @click="save">确定</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.leave-section + .leave-section {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.leave-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.leave-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: 10px 8px;
|
||||
}
|
||||
|
||||
.leave-option {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: var(--oj-fs-sec);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,164 @@
|
||||
<script setup lang="ts">
|
||||
import { LESSON_MODE_LABEL, lessonNeeds, type LessonLanguage, type LessonMode } from '@oj2/contract'
|
||||
import { setClassLesson } from 'oj/api'
|
||||
import { errorMessage } from 'utils/api'
|
||||
import { LANGUAGE_SHOW_VALUE } from 'utils/constants'
|
||||
import type { TaskBoard } from 'utils/types'
|
||||
import { Icon } from '@iconify/vue'
|
||||
import ProblemPicker from './ProblemPicker.vue'
|
||||
|
||||
/**
|
||||
* 任务页上布置「这节课的题」:题号是上课时当场填的。两种样子 ——
|
||||
* - `inline`:顶上一行里点「改 / 新的一节 / 布置」展开的那一小条;
|
||||
* - `start`:刚上课还没有题时,这页最大的那个框。
|
||||
*
|
||||
* 一个班一天可以上几节内容不同的课:`lessonId` 是要改的那一节,null = 今天新开一节。
|
||||
* `carry`:新开的这一节带上这一节的题、改成写代码 —— 先布置画流程图、再让全班写代码时用
|
||||
*/
|
||||
const props = defineProps<{
|
||||
board: TaskBoard
|
||||
className: string
|
||||
variant: 'inline' | 'start'
|
||||
lessonId: number | null
|
||||
carry?: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{ saved: []; cancel: [] }>()
|
||||
const message = useMessage()
|
||||
|
||||
/**
|
||||
* 这份作业用什么语言做,跟着这一次布置走(用户定的:不是给班级定死的设置)。默认是这个班
|
||||
* 上一次布置时选的;选了之后任务页和学生那边的「做完」都只认这个语言交对的
|
||||
*/
|
||||
const lessonLanguage = ref<LessonLanguage>('Python')
|
||||
const LESSON_LANGUAGES: LessonLanguage[] = ['Python', 'C', 'C++', 'SQL']
|
||||
const languageOptions = LESSON_LANGUAGES.map((value) => ({
|
||||
label: LANGUAGE_SHOW_VALUE[value],
|
||||
value,
|
||||
}))
|
||||
/**
|
||||
* 这节课做什么:写代码 / 先画再写 / 画流程图。画流程图不用选语言(下拉藏起来,存成 null),
|
||||
* 要画的两种只能布置打开了画流程图的题(后端会点名拒掉)
|
||||
*/
|
||||
const lessonMode = ref<LessonMode>('code')
|
||||
const modeOptions = (['code', 'both', 'flowchart'] as const).map((value) => ({ label: LESSON_MODE_LABEL[value], value }))
|
||||
const needsCode = computed(() => lessonNeeds(lessonMode.value).code)
|
||||
const lessonInput = ref('')
|
||||
const saving = ref(false)
|
||||
|
||||
/** 改的是老师布置的这一节:框里是它的题;新开一节是空的 */
|
||||
const editingPlanned = computed(() => props.lessonId !== null && props.lessonId === props.board.lessonId)
|
||||
|
||||
function resetLessonInput() {
|
||||
const keep = editingPlanned.value || props.carry
|
||||
lessonInput.value = keep ? props.board.problems.map((problem) => problem.problemDisplayId).join(' ') : ''
|
||||
lessonLanguage.value = (editingPlanned.value ? props.board.language : null) ?? props.board.lastLanguage ?? 'Python'
|
||||
lessonMode.value = props.carry ? 'code' : editingPlanned.value ? (props.board.mode ?? 'code') : 'code'
|
||||
}
|
||||
|
||||
resetLessonInput()
|
||||
// 推断出来的题也带进框里,老师多半只是确认一下
|
||||
if (props.variant === 'inline' && props.board.source === 'inferred' && !lessonInput.value && props.board.problems.length) {
|
||||
lessonInput.value = props.board.problems.map((problem) => problem.problemDisplayId).join(' ')
|
||||
}
|
||||
// 换了班、换了一节要把输入框换成那一节的题、布置时选的语言(15 秒一次的刷新不动它们)。
|
||||
// 必须写成几个单值来源:一个 getter 返回数组的话,每次刷新整个 board 换新,数组也是新的,
|
||||
// 回调次次都跑,老师打到一半的题号每 15 秒被清一次
|
||||
watch([() => props.board.className, () => props.board.source, () => props.lessonId, () => props.carry], resetLessonInput)
|
||||
|
||||
function inputIds() {
|
||||
return lessonInput.value.split(/[\s,,、;;]+/).filter(Boolean)
|
||||
}
|
||||
|
||||
async function saveLesson() {
|
||||
const ids = inputIds()
|
||||
saving.value = true
|
||||
try {
|
||||
await setClassLesson(props.className, ids, ids.length && needsCode.value ? lessonLanguage.value : null, lessonMode.value, props.lessonId)
|
||||
message.success(ids.length ? '已布置,学生首页会显示这几道题' : '已清掉')
|
||||
picking.value = false
|
||||
emit('saved')
|
||||
} catch (error) {
|
||||
message.error(errorMessage(error))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 选题弹框:不用记题号,按知识点挑。框里已经敲了的题号带进去,题名能对上的带上题名 ----
|
||||
const picking = ref(false)
|
||||
const pickerInitial = ref<{ id: string; title?: string }[]>([])
|
||||
function openPicker() {
|
||||
const titles = new Map(props.board.problems.map((p) => [p.problemDisplayId.toLowerCase(), p.title]))
|
||||
pickerInitial.value = inputIds().map((id) => ({ id, title: titles.get(id.toLowerCase()) }))
|
||||
picking.value = true
|
||||
}
|
||||
function pickerConfirm(ids: string[]) {
|
||||
lessonInput.value = ids.join(' ')
|
||||
saveLesson()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="variant === 'inline'">
|
||||
<n-select v-model:value="lessonMode" :options="modeOptions" aria-label="这节课做什么" class="mode-select" />
|
||||
<n-select v-if="needsCode" v-model:value="lessonLanguage" :options="languageOptions" aria-label="用什么语言写" class="lang-select" />
|
||||
<n-input v-model:value="lessonInput" placeholder="这节课的题号,用空格隔开" clearable autofocus @keyup.enter="saveLesson" />
|
||||
<n-button @click="openPicker">
|
||||
<template #icon><Icon icon="ph:list-checks" /></template>
|
||||
选题
|
||||
</n-button>
|
||||
<n-button type="primary" :loading="saving" @click="saveLesson">布置</n-button>
|
||||
<n-button @click="emit('cancel')">取消</n-button>
|
||||
<!-- 弹层挂在 body 下,放在根节点里面:两个根会让任务页传进来的 class 落空 -->
|
||||
<ProblemPicker
|
||||
v-model:show="picking"
|
||||
v-model:language="lessonLanguage"
|
||||
:initial="pickerInitial"
|
||||
:saving="saving"
|
||||
@confirm="pickerConfirm"
|
||||
v-model:lesson-mode="lessonMode"
|
||||
/>
|
||||
</div>
|
||||
<div v-else>
|
||||
<n-select v-model:value="lessonMode" :options="modeOptions" aria-label="这节课做什么" size="large" class="mode-select" />
|
||||
<n-select v-if="needsCode" v-model:value="lessonLanguage" :options="languageOptions" aria-label="用什么语言写" size="large" class="lang-select" />
|
||||
<n-input v-model:value="lessonInput" size="large" placeholder="输入题号,用空格隔开" class="start-input" @keyup.enter="saveLesson" />
|
||||
<n-button size="large" @click="openPicker">
|
||||
<template #icon><Icon icon="ph:list-checks" /></template>
|
||||
选题
|
||||
</n-button>
|
||||
<n-button type="primary" size="large" :loading="saving" @click="saveLesson">布置</n-button>
|
||||
<ProblemPicker
|
||||
v-model:show="picking"
|
||||
v-model:language="lessonLanguage"
|
||||
:initial="pickerInitial"
|
||||
:saving="saving"
|
||||
@confirm="pickerConfirm"
|
||||
v-model:lesson-mode="lessonMode"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.lang-select {
|
||||
width: 110px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mode-select {
|
||||
width: 116px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.start-input {
|
||||
flex: 1 1 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.start-input {
|
||||
flex-basis: 100%;
|
||||
order: -1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,124 @@
|
||||
<script setup lang="ts">
|
||||
import { isSqlProblem } from '@oj2/contract'
|
||||
import { getProblem } from 'oj/api'
|
||||
import { errorMessage } from 'utils/api'
|
||||
import type { ProblemDetail } from 'utils/types'
|
||||
|
||||
// Markdown 连着整套代码高亮(两百多 KB),这里要等点开 / AI 写完才用得上,别让它拖慢页面
|
||||
const Markdown = defineAsyncComponent(() => import('shared/components/Markdown.vue'))
|
||||
|
||||
/**
|
||||
* 「讲错误」弹层左边的题目(用户要的:演示学生的错误时题目和代码要并排)。
|
||||
* 只摆讲题用得上的:描述、输入、输出、例子 —— 题目页的状态标签、流程图、相似题都不要。
|
||||
*/
|
||||
const props = defineProps<{ displayId: string }>()
|
||||
|
||||
const problem = ref<ProblemDetail | null>(null)
|
||||
const failed = ref('')
|
||||
|
||||
// 同一道题 ← → 换人不重拉:弹层开着时一直是这道题
|
||||
const cache = new Map<string, ProblemDetail>()
|
||||
watch(
|
||||
() => props.displayId,
|
||||
async (id) => {
|
||||
failed.value = ''
|
||||
const hit = cache.get(id)
|
||||
if (hit) {
|
||||
problem.value = hit
|
||||
return
|
||||
}
|
||||
problem.value = null
|
||||
try {
|
||||
const data = await getProblem(id, '')
|
||||
cache.set(id, data)
|
||||
if (props.displayId === id) problem.value = data
|
||||
} catch (error) {
|
||||
failed.value = errorMessage(error)
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const isSQL = computed(() => !!problem.value && isSqlProblem(problem.value))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="brief">
|
||||
<n-text v-if="failed" type="error">{{ failed }}</n-text>
|
||||
<n-spin v-else-if="!problem" size="small" />
|
||||
<template v-else>
|
||||
<section class="block">
|
||||
<h3 class="title">描述</h3>
|
||||
<Markdown html :content="problem.description" />
|
||||
</section>
|
||||
<template v-if="!isSQL">
|
||||
<section v-if="problem.inputDescription" class="block">
|
||||
<h3 class="title">输入</h3>
|
||||
<Markdown html :content="problem.inputDescription" />
|
||||
</section>
|
||||
<section v-if="problem.outputDescription" class="block">
|
||||
<h3 class="title">输出</h3>
|
||||
<Markdown html :content="problem.outputDescription" />
|
||||
</section>
|
||||
<section v-if="problem.samples.length" class="block">
|
||||
<h3 class="title">例子</h3>
|
||||
<div v-for="(sample, i) in problem.samples" :key="i" class="sample">
|
||||
<div class="io">
|
||||
<span class="label">例子 {{ i + 1 }} 输入</span>
|
||||
<pre>{{ sample.input }}</pre>
|
||||
</div>
|
||||
<div class="io">
|
||||
<span class="label">输出</span>
|
||||
<pre>{{ sample.output }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.brief {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
font-size: 16px;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0 0 4px;
|
||||
padding-left: 8px;
|
||||
border-left: 3px solid var(--success);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.sample {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.io {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--divider);
|
||||
border-radius: 6px;
|
||||
padding: 4px 10px 6px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 12px;
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
font-family: Consolas, Monaco, monospace;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,544 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { LESSON_MODE_LABEL, lessonNeeds, type LessonLanguage, type LessonMode, type Tag } from '@oj2/contract'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import { getProblemList, getProblemTagList } from 'shared/api'
|
||||
import { useTone } from 'shared/composables/tone'
|
||||
import { LANGUAGE_SHOW_VALUE } from 'utils/constants'
|
||||
import type { ProblemRow } from 'utils/types'
|
||||
import ProblemTypeTag from 'oj/problem/components/ProblemTypeTag.vue'
|
||||
|
||||
/**
|
||||
* 任务页布置任务的「选题」弹框:左边按知识点分,右边勾题,底下按勾的先后排成这节课的顺序。
|
||||
* 只列这节课做得了的题(左栏题数也这样数):要写代码的只列能用这次作业的语言交的,
|
||||
* 要画流程图的只列打开了画流程图的。不用再一个个记题号。
|
||||
* 点「布置」直接存,和在输入框里敲题号回车是一回事
|
||||
*/
|
||||
const props = defineProps<{
|
||||
/** 打开时已经在框里的题(题号 + 知道的话带上题名) */
|
||||
initial: { id: string; title?: string }[]
|
||||
saving: boolean
|
||||
/** pick = 只把选好的题交回去(后台改一节时用),不直接布置 */
|
||||
mode?: 'assign' | 'pick'
|
||||
}>()
|
||||
const show = defineModel<boolean>('show', { required: true })
|
||||
const language = defineModel<LessonLanguage>('language', { required: true })
|
||||
/** 这节课做什么(写代码 / 画流程图 / 先画再写),和任务页上那个下拉是同一个 */
|
||||
const lessonMode = defineModel<LessonMode>('lessonMode', { required: true })
|
||||
const needs = computed(() => lessonNeeds(lessonMode.value))
|
||||
/** 只画流程图不看语言 */
|
||||
const languageFilter = computed(() => (needs.value.code ? language.value : ''))
|
||||
const emit = defineEmits<{ confirm: [ids: string[]] }>()
|
||||
|
||||
const theme = useThemeVars()
|
||||
const tone = useTone()
|
||||
|
||||
const LESSON_LANGUAGES: LessonLanguage[] = ['Python', 'C', 'C++', 'SQL']
|
||||
const LESSON_MODE_OPTIONS = (['code', 'both', 'flowchart'] as const).map((value) => ({ label: LESSON_MODE_LABEL[value], value }))
|
||||
const languageOptions = LESSON_LANGUAGES.map((value) => ({ label: LANGUAGE_SHOW_VALUE[value], value }))
|
||||
const SORTS = [
|
||||
{ label: '最新出的', value: '' },
|
||||
{ label: '做过的人最多', value: '-submission_number' },
|
||||
]
|
||||
const PAGE_SIZE = 100
|
||||
/** 老师挑的题,必会题是按课堂用得最多挑出来的,放在知识点前面 */
|
||||
const MUST_DO = '必会题'
|
||||
|
||||
const tags = ref<Tag[]>([])
|
||||
const tag = ref('')
|
||||
const keyword = ref('')
|
||||
const sort = ref('')
|
||||
const page = ref(1)
|
||||
const rows = ref<ProblemRow[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
|
||||
/** 勾的先后就是这节课的顺序 */
|
||||
const picked = ref<{ id: string; title?: string }[]>([])
|
||||
const pickedIndex = computed(() => new Map(picked.value.map((p, i) => [p.id.toLowerCase(), i])))
|
||||
|
||||
const knowledge = computed(() => tags.value.filter((t) => t.category === 'knowledge').sort((a, b) => b.problemCount - a.problemCount))
|
||||
const mustDo = computed(() => tags.value.find((t) => t.name === MUST_DO) ?? null)
|
||||
const allCount = ref(0)
|
||||
|
||||
async function loadTags() {
|
||||
const list = await getProblemTagList(languageFilter.value, needs.value.draw)
|
||||
tags.value = list
|
||||
// 换了语言,原来选的知识点在这个语言下可能一道都没有
|
||||
if (tag.value && !list.some((t) => t.name === tag.value)) tag.value = ''
|
||||
}
|
||||
|
||||
let seq = 0
|
||||
async function loadRows() {
|
||||
const mine = ++seq
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getProblemList((page.value - 1) * PAGE_SIZE, PAGE_SIZE, {
|
||||
tag: tag.value,
|
||||
keyword: keyword.value.trim(),
|
||||
sort: sort.value,
|
||||
language: languageFilter.value,
|
||||
...(needs.value.draw ? { flowchart: '1' } : {}),
|
||||
})
|
||||
if (mine !== seq) return
|
||||
rows.value = res.results
|
||||
total.value = res.total
|
||||
if (!tag.value && !keyword.value.trim()) allCount.value = res.total
|
||||
} finally {
|
||||
if (mine === seq) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const listRef = ref<HTMLElement | null>(null)
|
||||
function reload() {
|
||||
page.value = 1
|
||||
listRef.value?.scrollTo({ top: 0 })
|
||||
loadRows()
|
||||
}
|
||||
|
||||
watch(show, (open) => {
|
||||
if (!open) return
|
||||
picked.value = props.initial.map((p) => ({ ...p }))
|
||||
keyword.value = ''
|
||||
loadTags()
|
||||
reload()
|
||||
})
|
||||
watch([languageFilter, () => needs.value.draw], () => {
|
||||
if (!show.value) return
|
||||
loadTags()
|
||||
reload()
|
||||
})
|
||||
// 搜题号多半是记得那道题、不知道它在哪个知识点下,所以搜的是整个题库;点了知识点就不再搜
|
||||
function pickTag(name: string) {
|
||||
keyword.value = ''
|
||||
tag.value = name
|
||||
}
|
||||
watch(keyword, (text) => {
|
||||
if (text.trim()) tag.value = ''
|
||||
})
|
||||
watch([tag, sort], reload)
|
||||
watchDebounced(keyword, reload, { debounce: 300 })
|
||||
watch(page, () => {
|
||||
listRef.value?.scrollTo({ top: 0 })
|
||||
loadRows()
|
||||
})
|
||||
|
||||
function toggle(row: ProblemRow) {
|
||||
const key = row._id.toLowerCase()
|
||||
if (pickedIndex.value.has(key)) picked.value = picked.value.filter((p) => p.id.toLowerCase() !== key)
|
||||
else picked.value = [...picked.value, { id: row._id, title: row.title }]
|
||||
}
|
||||
|
||||
function remove(id: string) {
|
||||
picked.value = picked.value.filter((p) => p.id !== id)
|
||||
}
|
||||
|
||||
const emptyText = computed(() => {
|
||||
if (!needs.value.code) return '没有能画流程图的题'
|
||||
const text = `没有能用 ${LANGUAGE_SHOW_VALUE[language.value]} 做的题`
|
||||
return needs.value.draw ? `${text}(还要能画流程图)` : text
|
||||
})
|
||||
|
||||
const DIFFICULTY_TONE = { 简单: 'success', 中等: 'warning', 困难: 'error' } as const
|
||||
function difficultyStyle(row: ProblemRow) {
|
||||
if (!row.difficulty) return {}
|
||||
const t = tone(DIFFICULTY_TONE[row.difficulty])
|
||||
return { color: t.color, background: t.background }
|
||||
}
|
||||
|
||||
// n-modal 把内容挂到 body 下,<style> 里的 v-bind() 传不进去,颜色走根节点的内联变量
|
||||
const vars = computed(() => ({
|
||||
// 宽度也写在这里:n-modal 的 preset card 上 scoped 的 class 宽度不生效
|
||||
width: 'min(1040px, calc(100vw - 32px))',
|
||||
'--text2': theme.value.textColor2,
|
||||
'--text3': theme.value.textColor3,
|
||||
'--divider': theme.value.dividerColor,
|
||||
'--hover': theme.value.hoverColor,
|
||||
'--action': theme.value.actionColor,
|
||||
'--primary': theme.value.primaryColor,
|
||||
'--on-bg': tone('success').background,
|
||||
'--on-text': tone('success').color,
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-modal v-model:show="show" preset="card" :style="vars" :bordered="false" :auto-focus="false" title="选题">
|
||||
<template #header-extra>
|
||||
<div class="head-extra">
|
||||
<n-select v-model:value="lessonMode" :options="LESSON_MODE_OPTIONS" aria-label="这节课做什么" size="small" class="mode" />
|
||||
<template v-if="needs.code">
|
||||
<span class="muted">用</span>
|
||||
<n-select v-model:value="language" :options="languageOptions" aria-label="用什么语言写" size="small" class="lang" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="body">
|
||||
<nav class="tags">
|
||||
<button class="tag" :class="{ on: tag === '' && !keyword.trim() }" @click="pickTag('')">
|
||||
<span>全部</span><span class="count">{{ allCount || '' }}</span>
|
||||
</button>
|
||||
<button v-if="mustDo" class="tag" :class="{ on: tag === MUST_DO }" @click="pickTag(MUST_DO)">
|
||||
<span>{{ MUST_DO }}</span
|
||||
><span class="count">{{ mustDo.problemCount }}</span>
|
||||
</button>
|
||||
<div class="tag-head">知识点</div>
|
||||
<button v-for="t in knowledge" :key="t.id" class="tag" :class="{ on: tag === t.name }" @click="pickTag(t.name)">
|
||||
<span>{{ t.name }}</span
|
||||
><span class="count">{{ t.problemCount }}</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<section class="main">
|
||||
<div class="filters">
|
||||
<n-input v-model:value="keyword" placeholder="搜题号或题目名" clearable class="search">
|
||||
<template #prefix><Icon icon="ph:magnifying-glass" /></template>
|
||||
</n-input>
|
||||
<n-select v-model:value="sort" :options="SORTS" class="sort" />
|
||||
</div>
|
||||
|
||||
<div ref="listRef" class="list">
|
||||
<n-spin :show="loading" class="spin">
|
||||
<n-empty v-if="!loading && !rows.length" :description="emptyText" class="empty" />
|
||||
<button v-for="row in rows" :key="row.id" type="button" class="row" :class="{ on: pickedIndex.has(row._id.toLowerCase()) }" @click="toggle(row)">
|
||||
<span class="check">
|
||||
<template v-if="pickedIndex.has(row._id.toLowerCase())">{{ pickedIndex.get(row._id.toLowerCase())! + 1 }}</template>
|
||||
</span>
|
||||
<span class="pid">{{ row._id }}</span>
|
||||
<span class="title">
|
||||
<span class="title-text">{{ row.title }}</span>
|
||||
<ProblemTypeTag v-if="row.allowFlowchart" kind="flowchart" />
|
||||
<ProblemTypeTag v-if="row.hasAstRules" kind="ast" />
|
||||
</span>
|
||||
<span class="diff" :style="difficultyStyle(row)">{{ row.difficulty ?? '' }}</span>
|
||||
<span class="people">{{ row.solvedUsers }}/{{ row.triedUsers }}</span>
|
||||
</button>
|
||||
</n-spin>
|
||||
</div>
|
||||
<div v-if="total > PAGE_SIZE" class="pager">
|
||||
<n-pagination v-model:page="page" :page-size="PAGE_SIZE" :item-count="total" simple />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="foot">
|
||||
<div class="picked">
|
||||
<span v-if="!picked.length" class="muted">还没选题</span>
|
||||
<span v-for="(p, i) in picked" :key="p.id" class="pick">
|
||||
<b>{{ i + 1 }}</b>
|
||||
<span>{{ p.id }}</span>
|
||||
<span v-if="p.title" class="pick-title">{{ p.title }}</span>
|
||||
<button type="button" class="x" :aria-label="`去掉 ${p.id}`" @click="remove(p.id)"><Icon icon="ph:x-bold" /></button>
|
||||
</span>
|
||||
</div>
|
||||
<n-button @click="show = false">取消</n-button>
|
||||
<n-button
|
||||
type="primary"
|
||||
:disabled="!picked.length && (mode === 'pick' || !initial.length)"
|
||||
:loading="saving"
|
||||
@click="
|
||||
emit(
|
||||
'confirm',
|
||||
picked.map((p) => p.id),
|
||||
)
|
||||
"
|
||||
>
|
||||
<template v-if="mode === 'pick'">{{ picked.length ? `用这 ${picked.length} 道` : '选好了' }}</template>
|
||||
<template v-else>{{ picked.length ? `布置这 ${picked.length} 道` : initial.length ? '清掉' : '布置' }}</template>
|
||||
</n-button>
|
||||
</div>
|
||||
</template>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.head-extra {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.lang {
|
||||
width: 110px;
|
||||
}
|
||||
|
||||
.mode {
|
||||
width: 116px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
height: min(560px, calc(100vh - 260px));
|
||||
}
|
||||
|
||||
/* ---- 左栏:知识点 ---- */
|
||||
/* 全部 + 必会题 + 十几个知识点要整列放下、不出滚动条:36px 一行时差 40px,「总是显示滚动条」
|
||||
的电脑上冒出一根。32px 一行在 1366×768(框高 508px)上是 493px */
|
||||
.tags {
|
||||
flex: 0 0 168px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
padding-right: 12px;
|
||||
border-right: 1px solid var(--divider);
|
||||
}
|
||||
|
||||
.tag-head {
|
||||
margin: 8px 10px 2px;
|
||||
font-size: 13px;
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 32px;
|
||||
padding: 0 10px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-size: 15px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tag:hover {
|
||||
background: var(--hover);
|
||||
}
|
||||
|
||||
.tag.on {
|
||||
background: var(--on-bg);
|
||||
color: var(--on-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.count {
|
||||
font-size: 13px;
|
||||
color: var(--text3);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.tag.on .count {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* ---- 右边:题 ---- */
|
||||
.main {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.search {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.sort {
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
.list {
|
||||
flex: 1 1 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.spin {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 60px 0;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
padding: 0 10px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--divider);
|
||||
background: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-size: 15px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
background: var(--hover);
|
||||
}
|
||||
|
||||
.row.on {
|
||||
background: var(--on-bg);
|
||||
}
|
||||
|
||||
.check {
|
||||
flex: 0 0 22px;
|
||||
height: 22px;
|
||||
border: 1.5px solid var(--text3);
|
||||
border-radius: 5px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.row.on .check {
|
||||
border-color: var(--primary);
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
.pid {
|
||||
flex: 0 0 56px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.title-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.diff {
|
||||
flex: 0 0 auto;
|
||||
padding: 1px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.people {
|
||||
flex: 0 0 76px;
|
||||
text-align: right;
|
||||
font-size: 14px;
|
||||
color: var(--text3);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.pager {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* ---- 底下:选好的,按先后 ---- */
|
||||
.foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.picked {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.pick {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 30px;
|
||||
padding: 0 4px 0 10px;
|
||||
border-radius: 6px;
|
||||
background: var(--action);
|
||||
font-size: 14px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
max-width: 260px;
|
||||
}
|
||||
|
||||
.pick b {
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
.pick-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.x {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: none;
|
||||
color: var(--text3);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.x:hover {
|
||||
background: var(--hover);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.body {
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 280px);
|
||||
}
|
||||
|
||||
.tags {
|
||||
flex: 0 0 auto;
|
||||
flex-direction: row;
|
||||
overflow-x: auto;
|
||||
padding: 0 0 8px;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--divider);
|
||||
}
|
||||
|
||||
.tag-head {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.people {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.foot {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.picked {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,216 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import { useTone } from 'shared/composables/tone'
|
||||
import UserName from 'shared/components/UserName.vue'
|
||||
import { attemptTone, cachedSubmission } from 'oj/statistics/attempts'
|
||||
import { JUDGE_STATUS, LANGUAGE_FORMAT_VALUE } from 'utils/constants'
|
||||
import { parseTime } from 'utils/functions'
|
||||
import type { TaskBoardCell, TaskBoardProblem, Submission } from 'utils/types'
|
||||
|
||||
/**
|
||||
* 点开一个做对了的格子:他这道题今天交的每一次,一格一次、从早到晚,下面是停着的那一次的代码
|
||||
* (设计稿「课堂看板重设计」· 做对的格子也看得到轨迹)。
|
||||
*
|
||||
* 默认停在做对前最后错的那一次 —— 要讲的一般是「他最后是改了哪儿才对的」。
|
||||
* 停上去就换,不用点:老师在几格之间来回比;点也行(手机上没有悬停)
|
||||
*/
|
||||
const props = defineProps<{
|
||||
username: string
|
||||
problem: TaskBoardProblem
|
||||
cell: TaskBoardCell
|
||||
summary: string
|
||||
/** 「在提交列表看这 N 次」:和格子原来点进去的是同一个列表 */
|
||||
listHref: string
|
||||
}>()
|
||||
defineEmits<{ close: [] }>()
|
||||
|
||||
const tone = useTone()
|
||||
const theme = useThemeVars()
|
||||
|
||||
const picked = ref(props.cell.acceptedTry ? Math.max(props.cell.acceptedTry - 2, 0) : Math.max(props.cell.trail.length - 1, 0))
|
||||
const item = computed(() => props.cell.trail[picked.value] ?? props.cell.trail.at(-1))
|
||||
|
||||
const code = reactive<Record<string, Submission | 'loading' | 'failed'>>({})
|
||||
watch(
|
||||
() => item.value?.id,
|
||||
async (id) => {
|
||||
if (!id || (code[id] && code[id] !== 'failed')) return
|
||||
code[id] = 'loading'
|
||||
try {
|
||||
code[id] = await cachedSubmission(id)
|
||||
} catch {
|
||||
code[id] = 'failed'
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
const shown = computed(() => {
|
||||
const value = item.value && code[item.value.id]
|
||||
return typeof value === 'object' ? value : null
|
||||
})
|
||||
|
||||
const squares = computed(() =>
|
||||
props.cell.trail.map((row) => ({
|
||||
...row,
|
||||
color: tone(attemptTone(row.result)).solid,
|
||||
})),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="trail">
|
||||
<div class="head">
|
||||
<UserName :username="username" />
|
||||
<span class="problem">
|
||||
<b>{{ problem.problemDisplayId }}</b>
|
||||
<span class="muted">{{ problem.title }}</span>
|
||||
</span>
|
||||
<div class="spacer"></div>
|
||||
<n-button quaternary size="tiny" aria-label="收起" @click="$emit('close')">
|
||||
<template #icon><Icon icon="ph:x-bold" /></template>
|
||||
</n-button>
|
||||
</div>
|
||||
|
||||
<div class="line">
|
||||
<div class="squares">
|
||||
<button
|
||||
v-for="(row, index) in squares"
|
||||
:key="row.id"
|
||||
type="button"
|
||||
class="square"
|
||||
:class="{ on: index === picked }"
|
||||
:style="{ background: row.color }"
|
||||
:aria-label="`第 ${index + 1} 次:${JUDGE_STATUS[row.result]?.name ?? ''}`"
|
||||
@mouseenter="picked = index"
|
||||
@click="picked = index"
|
||||
></button>
|
||||
</div>
|
||||
<span class="summary">{{ summary }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="item" class="code">
|
||||
<div class="code-head">
|
||||
<b :style="{ color: tone(attemptTone(item.result)).color }">
|
||||
{{ JUDGE_STATUS[item.result]?.name }}
|
||||
</b>
|
||||
<span class="muted">{{ parseTime(item.createTime, 'HH:mm') }} · 第 {{ picked + 1 }} 次</span>
|
||||
</div>
|
||||
<n-code v-if="shown" class="code-body" :code="shown.code" :language="LANGUAGE_FORMAT_VALUE[shown.language]" show-line-numbers />
|
||||
<span v-else-if="code[item.id] === 'failed'" class="muted">代码没拉下来,点下面打开看</span>
|
||||
<span v-else class="muted">代码加载中…</span>
|
||||
</div>
|
||||
|
||||
<div class="links">
|
||||
<a v-if="item" :href="`/submission/${item.id}`" target="_blank">打开这次提交 ↗</a>
|
||||
<a :href="listHref" target="_blank">在提交列表看这 {{ cell.trail.length }} 次 ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.trail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
font-size: 15px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.problem {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.problem .muted {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.squares {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.square {
|
||||
flex: none;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.square.on {
|
||||
box-shadow:
|
||||
0 0 0 2px v-bind('theme.cardColor'),
|
||||
0 0 0 4px v-bind('theme.textColor1');
|
||||
}
|
||||
|
||||
.summary {
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: v-bind('theme.textColor2');
|
||||
}
|
||||
|
||||
.code {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
background: v-bind('theme.actionColor');
|
||||
}
|
||||
|
||||
.code-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.code-body {
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.links {
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.links a {
|
||||
color: v-bind('theme.primaryColor');
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.links a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user