Files
OJ2/apps/web/src/admin/problem/components/CaseTable.vue
T
xuyueandClaude Opus 5.5 02e2fa7d9b feat(后台): 手机上也能用了——菜单收进左边抽屉,列表一行一张卡,不用再横着滑
- 侧栏手机上不再占一列图标,顶栏左边 ☰ 拉出整张菜单,按返回键先关菜单
- 题目、比赛、用户、公告、题单列表手机上一项一张卡:名字一行、小字一行、按钮一行,
  「学生看得到」开关单独一列靠右
- 卡点分析、AI 报告手机上点开一项整屏看,返回键关掉;年度趋势、自学情况、首页改成上下排
- 出题页、教程编辑、题单编辑、标签、设置手机上排成一列;测试数据「删掉」手机上常驻
- 弹框一律收进屏幕宽度

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 11:21:58 +08:00

298 lines
6.9 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { useThemeVars } from "naive-ui"
import { useBreakpoints } from "shared/composables/breakpoints"
import { caseStatus, type AnswerSource, type CaseRow } from "./caseRows"
/**
* 测试数据一组一行:输入 | 输出 | 当例子(设计稿「编程题出题页」B)。
* 有标准答案时输出是跑出来的、只读;没有时(老题)输出手写。
*/
const rows = defineModel<CaseRow[]>("rows", { required: true })
const props = defineProps<{
source: AnswerSource | null
/** 只有例子这一列表的时候(测试数据太大、单独写例子)不显示「当例子」 */
examplesOnly?: boolean
}>()
const theme = useThemeVars()
// 手机上「当例子」那一列的表头藏了,勾选框自己带上字
const { isMobile } = useBreakpoints()
/** 输出最多先露这么多行,长了点开看全 */
const CLIP_LINES = 6
const expanded = ref(new Set<number>())
function lines(text: string) {
return text.split("\n")
}
function clipped(row: CaseRow) {
const all = lines(row.output)
if (expanded.value.has(row.key) || all.length <= CLIP_LINES + 1)
return { text: row.output, rest: 0 }
return { text: all.slice(0, CLIP_LINES).join("\n"), rest: all.length - CLIP_LINES }
}
function toggle(key: number) {
const next = new Set(expanded.value)
if (next.has(key)) next.delete(key)
else next.add(key)
expanded.value = next
}
function remove(index: number) {
rows.value.splice(index, 1)
}
const status = (row: CaseRow) => caseStatus(row, props.source)
</script>
<template>
<div class="table" :class="{ examplesOnly }">
<div class="row head">
<span></span>
<span>输入</span>
<span>{{ source ? "输出(答案跑出来的)" : "输出(手写)" }}</span>
<span v-if="!examplesOnly">当例子</span>
<span></span>
<span></span>
</div>
<div
v-for="(row, index) in rows"
:key="row.key"
class="row"
:class="{ bad: status(row) === 'error', changed: status(row) === 'changed' }"
>
<b class="no">{{ index + 1 }}</b>
<n-input
v-model:value="row.input"
type="textarea"
class="mono"
size="small"
:autosize="{ minRows: 1, maxRows: 8 }"
placeholder="(没有输入)"
:aria-label="`第 ${index + 1} 组输入`"
/>
<n-input
v-if="!source"
v-model:value="row.output"
type="textarea"
class="mono"
size="small"
:autosize="{ minRows: 1, maxRows: 8 }"
:aria-label="`第 ${index + 1} 组输出`"
/>
<div v-else class="outCell">
<div v-if="status(row) === 'error'" class="cell errorCell">{{ row.error }}</div>
<template v-else>
<pre class="cell" :class="{ stale: status(row) === 'running' }">{{
clipped(row).text || (status(row) === "running" ? "在跑……" : "(没有输出)")
}}</pre>
<button v-if="clipped(row).rest" type="button" class="more" @click="toggle(row.key)">
⋯ 还有 {{ clipped(row).rest }} 行
</button>
<button
v-else-if="expanded.has(row.key)"
type="button"
class="more"
@click="toggle(row.key)"
>
收起
</button>
<div v-if="status(row) === 'changed'" class="was">
变了 · 原来存的是
<del>{{
lines(row.saved ?? "")
.slice(0, 3)
.join(" ⏎ ") || "(空)"
}}</del>
</div>
</template>
</div>
<!-- 桌面上 display: contents,三样各占一格;手机上折到输入输出下面排成一行 -->
<div class="tools">
<n-checkbox
v-if="!examplesOnly"
v-model:checked="row.example"
class="example"
:aria-label="`第 ${index + 1} 组当例子`"
:label="isMobile ? '当例子' : undefined"
/>
<span class="mark" :class="status(row)">
<template v-if="status(row) === 'ok'">✓</template>
<template v-else-if="status(row) === 'error'">✗</template>
<template v-else-if="status(row) === 'changed'">●</template>
</span>
<n-button
quaternary
size="tiny"
class="remove"
:aria-label="`删掉第 ${index + 1} 组`"
@click="remove(index)"
>
删掉
</n-button>
</div>
</div>
</div>
</template>
<style scoped>
.table {
display: flex;
flex-direction: column;
}
.row {
display: grid;
grid-template-columns: 22px minmax(0, 1fr) minmax(0, 1fr) 44px 16px 40px;
gap: 8px;
align-items: start;
padding: 7px 4px;
border-top: 1px solid v-bind("theme.dividerColor");
font-size: 12px;
border-radius: 4px;
}
.examplesOnly .row {
grid-template-columns: 22px minmax(0, 1fr) minmax(0, 1fr) 16px 40px;
}
.row.head {
border-top: 0;
padding-top: 2px;
padding-bottom: 2px;
color: v-bind("theme.textColor3");
}
.row.changed {
background-color: rgba(240, 160, 32, 0.08);
}
.no {
line-height: 26px;
text-align: right;
}
.mono :deep(textarea),
.cell {
font-family: ui-monospace, "Cascadia Mono", Consolas, "Microsoft YaHei", monospace;
font-size: 12px;
line-height: 1.5;
}
.outCell {
min-width: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
}
.cell {
margin: 0;
width: 100%;
box-sizing: border-box;
min-height: 26px;
padding: 3px 7px;
border-radius: 3px;
border: 1px solid v-bind("theme.dividerColor");
background-color: rgba(128, 128, 128, 0.05);
white-space: pre;
overflow-x: auto;
}
.cell.stale {
opacity: 0.5;
}
.errorCell {
white-space: pre-wrap;
word-break: break-all;
color: v-bind("theme.errorColor");
border-color: v-bind("theme.errorColorSuppl");
background-color: rgba(208, 48, 80, 0.06);
}
.more {
border: 0;
padding: 0;
background: none;
font: inherit;
color: v-bind("theme.textColor3");
cursor: pointer;
}
.was {
color: v-bind("theme.warningColorPressed");
}
.was del {
color: v-bind("theme.textColor3");
}
.example {
margin-top: 5px;
}
.mark {
line-height: 26px;
font-weight: 700;
}
.mark.ok {
color: v-bind("theme.successColor");
}
.mark.error {
color: v-bind("theme.errorColor");
}
.mark.changed {
color: v-bind("theme.warningColor");
}
.remove {
margin-top: 1px;
opacity: 0;
}
.row:hover .remove,
.remove:focus-visible {
opacity: 1;
}
.tools {
display: contents;
}
/* 手机:一组占两行 —— 编号 / 输入 / 输出,下面一行 当例子 ✓ 删掉(没有悬停,删掉常显) */
@media (max-width: 768px) {
.row,
.examplesOnly .row {
grid-template-columns: 18px minmax(0, 1fr) minmax(0, 1fr);
gap: 6px;
}
.row.head > :nth-child(n + 4) {
display: none;
}
.tools {
grid-column: 2 / -1;
display: flex;
align-items: center;
gap: 10px;
}
.example {
margin-top: 0;
}
.remove {
margin-left: auto;
opacity: 1;
}
}
</style>