Compare commits

...
5 Commits
Author SHA1 Message Date
xuyueandClaude Opus 5.5 951a6e0826 feat: 重新设计作品展管理
Deploy / deploy (build, debian, 22) (push) Canceled after 0s
- 作品改成缩略图卡片:手动排序时用 ← → 调整顺序,有新提交的显示“换成最新的”,
  点缩略图在抽屉里打开批改面板,撤下用页面内确认
- 奖项列表可以上下调整顺序(不用再填排序数字);设置有改动才能保存;删除奖项说明会撤下几件作品
- 去掉按提交 ID 添加作品(前端、类型和 /manage/submissions 接口),改为提示在批改面板里“加入作品展”
- 管理接口返回作品代码,画缩略图用

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 12:02:53 +08:00
xuyueandClaude Opus 5.5 4c9617caf1 feat: 重新设计作品展,点开能看大图、获奖和提示词
- 点作品不再跳到只有网页的全屏页,而是弹出大图:作者、题目、所获奖项、得分、浏览次数,
  有提示词的能看“是怎么问出来的”(作品展接口),← → 在同一奖项里切换
- 卡片缩略图改用 buildPreviewDocument,题目素材能加载;未评分的不显示星标
- 顶部加奖项跳转;标题和导航统一叫“作品展”
- 作品展接口返回 task_type(拼素材地址要用)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 11:59:56 +08:00
xuyueandClaude Opus 5.5 3fb23dc3f3 feat: 重新设计提示词链弹框,批改面板也能逐轮看网页
- 左边每轮提问:来源(AI 对话 / 手改 / 外部 AI)、等级写成“L3 应用”,标出交的这一版和没有网页的轮次;
  右边是这一轮生成的网页,↑ ↓ 切换
- 预览改用 buildPreviewDocument:题目素材能加载,暗色下也保持白底
- 批改面板加“逐轮看网页”;弹框开着时接管按键,批改页的 Esc / J / K 和面板的 1–5 / P 不会被触发
- 查看器里不再能删除对话轮次(学生在做题页的对话和历史里删)
- 预留作品展的接口参数(fromShowcase)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 11:54:44 +08:00
xuyueandClaude Opus 5.5 6ad9be2b30 feat: “我的提交”和“全部提交”拆成两个页面
- 学生的“我的提交”(MySubmissions):左边按教程 / 挑战列出交过的任务,右边看每一次交的版本,
  标出计入成绩的那一版(分数最高的一次);可以看提示词、放回编辑器、删除这一版
- 超管的“全部提交”:按学生、类型、分区筛选,按时间或得分排序;点一行在右边打开批改面板,
  批改面板加了超管专用的“删除这一版”
- 删掉只给旧提交页用的展开子表、看代码弹框、任务标题组件和全局 submission store,
  编辑器预览里随之不用的打分、看代码、复制链接一起去掉

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 11:46:55 +08:00
xuyueandClaude Opus 5.5 726906e3f6 feat: 重新设计成绩册
左右固定列从 11 列减到 6 列,1366 宽一屏能看到全部任务;任务按教程 / 挑战分组,
表头显示题目名和交的人数;单元格只区分缺交、未评和分数;考核等级和分数合成一列,
教程 / 挑战合计和平均放到总分的悬停提示里;名次、考核、总分、已交可以排序;
加“怎么算的”说明;默认打开批改页当前的班级。计算规则和 CSV 不变。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 11:42:21 +08:00
19 changed files with 2377 additions and 1891 deletions

No files matched your search

-10
View File
@@ -17,7 +17,6 @@ import type {
AwardItemManageOut,
GradebookOut,
GradebookQuery,
ShowcaseSubmissionLookupOut,
ShowcaseDetail,
PromptRound,
RandomRatingItem,
@@ -519,15 +518,6 @@ export const Showcase = {
return res.data
},
async findSubmissionForAward(
submissionId: string,
): Promise<ShowcaseSubmissionLookupOut> {
const res = await http.get(
`/submission/showcase/manage/submissions/${submissionId}`,
)
return res.data
},
async addAwardItem(
id: number,
payload: AwardItemIn,
+13 -75
View File
@@ -2,9 +2,6 @@
<n-flex align="center" justify="space-between" class="title">
<div>
<slot name="title"><n-text class="titleText">预览</n-text></slot>
<n-text v-if="!!submission.id" depth="3">
({{ submission.view_count || 0 }})
</n-text>
</div>
<n-flex>
<n-tooltip>
@@ -19,26 +16,6 @@
</n-tooltip>
<n-button quaternary @click="download" :disabled="!showDL">下载</n-button>
<n-button quaternary @click="open">全屏</n-button>
<n-button quaternary v-if="props.clearable" @click="clear">清空</n-button>
<n-button
quaternary
v-if="props.showCodeButton"
@click="emits('showCode')"
>
代码
</n-button>
<n-button quaternary v-if="props.submissionId" @click="copyLink">
链接
</n-button>
<n-flex v-if="!!submission.id" align="center">
<n-button quaternary @click="emits('showCode')">代码</n-button>
<n-popover v-if="submission.my_score === 0">
<template #trigger>
<n-button secondary type="primary">打分</n-button>
</template>
<n-rate :size="30" @update:value="updateScore" />
</n-popover>
</n-flex>
</n-flex>
</n-flex>
<div class="iframe-wrapper" :style="iframeWrapperStyle">
@@ -54,12 +31,7 @@
<script lang="ts" setup>
import { watchDebounced } from "@vueuse/core"
import { computed, onMounted, onUnmounted, ref, useTemplateRef } from "vue"
import { useRouter } from "vue-router"
import { Submission } from "../../api"
import { submission } from "../../store/submission"
import { useMessage } from "naive-ui"
import { Icon } from "../../icons"
import copy from "copy-text-to-clipboard"
import {
buildPreviewDocument,
PREVIEW_SANDBOX,
@@ -75,15 +47,11 @@ interface Props {
css: string
js: string
assetBaseUrl?: string
submissionId?: string
showCodeButton?: boolean
clearable?: boolean
// 自己的编辑器传当前任务 id:代码里的 localStorage 按“用户 + 任务”存下来,刷新后还在
storageTaskId?: number
}
const props = defineProps<Props>()
const emits = defineEmits(["afterScore", "showCode", "clear"])
type Layout = "desktop" | "mobile" | "tablet"
const layouts: Layout[] = ["desktop", "mobile", "tablet"]
@@ -121,9 +89,6 @@ function cycleLayout() {
layoutIndex.value = (layoutIndex.value + 1) % layouts.length
}
const message = useMessage()
const router = useRouter()
const showDL = computed(() => props.html || props.css || props.js)
const previewContent = ref("")
@@ -162,48 +127,21 @@ function download() {
}
function open() {
if (props.submissionId) {
const data = router.resolve({
name: "submission",
params: { id: props.submissionId },
})
window.open(data.href, "_blank")
} else {
const newTab = window.open("about:blank", "_blank")
if (!newTab) return
// 新标签页和本站同源,代码要放进带 sandbox 的 iframe 里跑
const doc = newTab.document
doc.open()
doc.write(
`<!DOCTYPE html><title>预览</title><style>html,body,iframe{margin:0;width:100%;height:100%;border:0;display:block}</style><iframe sandbox="${PREVIEW_SANDBOX}"></iframe>`,
)
doc.close()
const tabFrame = doc.querySelector("iframe")!
// 全屏页里的写入也存到同一个地方
listenPreviewStorage(newTab, () => tabFrame, storageScope)
tabFrame.srcdoc = getContent()
}
const newTab = window.open("about:blank", "_blank")
if (!newTab) return
// 新标签页和本站同源,代码要放进带 sandbox 的 iframe 里跑
const doc = newTab.document
doc.open()
doc.write(
`<!DOCTYPE html><title>预览</title><style>html,body,iframe{margin:0;width:100%;height:100%;border:0;display:block}</style><iframe sandbox="${PREVIEW_SANDBOX}"></iframe>`,
)
doc.close()
const tabFrame = doc.querySelector("iframe")!
// 全屏页里的写入也存到同一个地方
listenPreviewStorage(newTab, () => tabFrame, storageScope)
tabFrame.srcdoc = getContent()
}
function clear() {
emits("clear")
}
function copyLink() {
copy(`${document.location.origin}/submission/${props.submissionId}`)
message.success("该提交的链接已复制")
}
async function updateScore(score: number) {
try {
const res = await Submission.updateScore(submission.value.id, score)
message.success(res.message)
submission.value.my_score = score
emits("afterScore")
} catch (err: any) {
message.error(err.response.data.detail)
}
}
watchDebounced(
() => [props.html, props.css, props.js, props.assetBaseUrl],
+1 -5
View File
@@ -121,11 +121,7 @@ const userMenu = [
async function clickUserMenu(key: string) {
if (key === "submissions") {
router.push({
name: "submissions",
params: { page: 1 },
query: { username: user.username },
})
router.push({ name: "submissions" })
} else if (key === "logout") {
await logout()
// 留在需要登录或特定角色的页面上没有意义
+51 -3
View File
@@ -111,16 +111,27 @@
</n-dropdown>
</n-flex>
<div class="muted hint">P 键:加入 / 取消课上展示</div>
<n-popconfirm v-if="canDelete" @positive-click="remove">
<template #trigger>
<n-button size="small" quaternary type="error" class="delete">删除这一版</n-button>
</template>
删除后找不回来,评分和作品展里的这一版也会一起删掉;它对应的那轮 AI 对话也会删掉。
</n-popconfirm>
</section>
<section>
<h4>提示词 <span class="muted" v-if="chain.length">· {{ chain.length }} 轮</span></h4>
<n-flex justify="space-between" align="center" class="chain-head">
<h4>提示词 <span class="muted" v-if="chain.length">· {{ chain.length }} 轮</span></h4>
<n-button v-if="chain.length" size="tiny" quaternary @click="showChain = true">
逐轮看网页
</n-button>
</n-flex>
<div v-if="!chain.length" class="muted">这份提交没有关联的提示词</div>
<ol class="chain">
<li v-for="(r, i) in chain" :key="i">
<n-flex :size="6" align="center">
<span class="src">{{ sourceLabel(r.source) }}</span>
<span v-if="r.prompt_level" class="level">L{{ r.prompt_level }}</span>
<span v-if="r.prompt_level" class="level">{{ levelLabel(r.prompt_level) }}</span>
</n-flex>
<div class="q">{{ r.question }}</div>
</li>
@@ -129,6 +140,13 @@
</div>
</div>
</n-spin>
<ChainModal
v-if="sub"
v-model:show="showChain"
:submission-id="sub.id"
:title="`${sub.username} · ${sub.task_title}`"
:asset-base-url="`/media/tasks/${sub.task_type}/${sub.task_display}/`"
/>
</div>
</template>
<script lang="ts" setup>
@@ -137,7 +155,8 @@ import { useMessage } from "naive-ui"
import { Icon } from "../../icons"
import { Showcase, Submission } from "../../api"
import { buildPreviewDocument, PREVIEW_SANDBOX } from "../../utils/previewDocument"
import { parseTime, sourceLabel } from "../../utils/helper"
import { levelLabel, parseTime, sourceLabel } from "../../utils/helper"
import ChainModal from "../submissions/ChainModal.vue"
import type {
AwardManageOut,
FlagType,
@@ -155,6 +174,8 @@ const props = defineProps<{
canWiden?: boolean
// 作品墙里用:能不能切到上一个 / 下一个学生
nav?: { prev: boolean; next: boolean }
// 全部提交页(超管):能删除这一版
canDelete?: boolean
}>()
const emit = defineEmits<{
prev: []
@@ -163,6 +184,7 @@ const emit = defineEmits<{
"toggle-wide": []
// 让作品墙同步更新这张卡片
changed: [patch: { id: string; my_score?: number; score?: number; flag?: FlagType }]
deleted: [id: string]
}>()
const message = useMessage()
@@ -172,6 +194,7 @@ const sub = ref<SubmissionAll | null>(null)
const versions = ref<SubmissionOut[]>([])
const chain = ref<PromptRound[]>([])
const rating = ref(false)
const showChain = ref(false)
let awardsCache: Promise<AwardManageOut[]> | null = null
const awards = ref<AwardManageOut[]>([])
@@ -255,6 +278,19 @@ async function addToAward(awardId: number) {
}
}
async function remove() {
if (!sub.value) return
const id = sub.value.id
try {
await Submission.delete(id)
} catch (err: any) {
message.error(err.response?.data?.detail ?? "删除失败")
return
}
message.success("已删除")
emit("deleted", id)
}
function openFull() {
if (sub.value) window.open(`/submission/${sub.value.id}`, "_blank")
}
@@ -397,6 +433,18 @@ h4 {
font-size: 13px;
}
.chain-head {
margin-bottom: 8px;
}
.chain-head h4 {
margin: 0;
}
.delete {
margin-top: 8px;
}
.rates {
display: flex;
gap: 6px;
+318 -325
View File
@@ -2,232 +2,81 @@
<n-modal
:show="show"
preset="card"
style="width: 90vw; max-width: 1400px"
@update:show="$emit('update:show', $event)"
style="width: 92vw; max-width: 1280px"
:bordered="false"
@update:show="emit('update:show', $event)"
>
<template #header>
<n-flex justify="start" align="center">
<span>提示词</span>
<n-tooltip placement="bottom-start">
<template #trigger>
<Icon icon="lucide:info" :width="20" style="color: var(--wp-faint)" />
</template>
<div style="line-height: 2">
<div>
<span :style="{ color: levelColors[1], fontWeight: 'bold' }">
L1
</span>
— 记忆:复述或识别知识点
</div>
<div>
<span :style="{ color: levelColors[2], fontWeight: 'bold' }">
L2
</span>
— 理解:用自己的话解释概念
</div>
<div>
<span :style="{ color: levelColors[3], fontWeight: 'bold' }">
L3
</span>
— 应用:将知识用于新情境
</div>
<div>
<span :style="{ color: levelColors[4], fontWeight: 'bold' }">
L4
</span>
— 分析:拆解结构、找出规律
</div>
<div>
<span :style="{ color: levelColors[5], fontWeight: 'bold' }">
L5
</span>
— 评价:基于标准作出判断
</div>
<div>
<span :style="{ color: levelColors[6], fontWeight: 'bold' }">
L6
</span>
— 创造:整合信息产出新成果
</div>
</div>
</n-tooltip>
</n-flex>
<div class="head">
<b>提示词</b>
<span v-if="rounds.length" class="muted">{{ rounds.length }} 轮</span>
<span v-if="title" class="muted title">{{ title }}</span>
</div>
</template>
<n-spin :show="loading">
<n-empty
v-if="!loading && rounds.length === 0"
description="暂无对话记录"
/>
<div
v-else
style="
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
height: 75vh;
"
>
<div
style="
overflow-y: auto;
padding-right: 8px;
border-right: 1px solid var(--wp-line);
display: flex;
flex-direction: column;
gap: 8px;
"
>
<div
v-for="(round, index) in rounds"
:key="index"
style="
display: flex;
gap: 10px;
align-items: flex-start;
cursor: pointer;
"
@click="selectedRound = index"
>
<div
:style="{
flexShrink: 0,
width: '22px',
height: '22px',
borderRadius: '50%',
background: selectedRound === index ? 'var(--wp-css)' : 'var(--wp-css-soft)',
color: 'var(--wp-surface)',
fontSize: '12px',
fontWeight: 'bold',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginTop: '2px',
transition: 'background 0.2s',
}"
>
{{ index + 1 }}
</div>
<div
:style="{
flex: 1,
padding: '10px 14px',
borderRadius: '8px',
background: selectedRound === index ? 'var(--wp-css-soft)' : 'var(--wp-paper)',
border:
selectedRound === index
? '1px solid var(--wp-css)'
: '1px solid var(--wp-line)',
fontSize: '13px',
lineHeight: '1.6',
transition: 'all 0.2s',
}"
>
<div
style="
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 8px;
"
>
<div
class="prompt-markdown markdown-body"
:class="{
'is-collapsed':
isPromptLong(round.question) && !isExpanded(index),
}"
v-html="renderMarkdown(round.question)"
></div>
<div
style="
display: flex;
flex-direction: row;
align-items: center;
gap: 4px;
flex-shrink: 0;
"
>
<span
v-if="round.source"
:style="{
fontSize: '10px',
padding: '1px 5px',
borderRadius: '4px',
background:
round.source === 'conversation' ? 'var(--wp-css-soft)' : 'var(--wp-line)',
color:
round.source === 'conversation' ? 'var(--wp-css)' : 'var(--wp-muted)',
fontWeight: 500,
whiteSpace: 'nowrap',
}"
>{{ sourceLabel(round.source) }}</span
>
<span
v-if="round.prompt_level"
:style="{
fontSize: '11px',
fontWeight: 'bold',
color: levelColors[round.prompt_level],
}"
>L{{ round.prompt_level }}</span
>
<n-button
v-if="isPromptLong(round.question)"
text
size="tiny"
type="primary"
@click.stop="toggleExpanded(index)"
>
<Icon
:icon="
isExpanded(index)
? 'lucide:chevron-up'
: 'lucide:chevron-down'
"
:width="12"
/>
{{ isExpanded(index) ? "收起" : "展开" }}
</n-button>
<n-popconfirm
v-if="round.assistantMsgId && canDelete"
:show-icon="false"
@positive-click="deleteRound(index)"
>
<template #trigger>
<n-button
text
size="tiny"
type="error"
style="margin-left: 2px"
@click.stop
>
<Icon icon="lucide:trash-2" :width="12" />
</n-button>
</template>
确定删除这一轮?
</n-popconfirm>
</div>
</div>
</div>
<template #header-extra>
<n-popover trigger="click" placement="bottom-end">
<template #trigger>
<n-button size="small" quaternary style="margin-right: 8px">
<template #icon><Icon icon="lucide:info" /></template>
L1–L6 是什么
</n-button>
</template>
<div class="levels">
<div class="levels-note">按布鲁姆认知层级给每次提问打的标签,越高越需要思考:</div>
<div v-for="(l, n) in PROMPT_LEVELS" :key="n">
<b>L{{ n }} {{ l.name }}</b>:{{ l.desc }}
</div>
</div>
<div style="display: flex; flex-direction: column; gap: 8px">
<div style="font-weight: bold; font-size: 13px; color: var(--wp-ink)">
第 {{ selectedRound + 1 }} 轮网页
</n-popover>
</template>
<n-spin :show="loading">
<n-empty
v-if="!loading && !rounds.length"
description="这份提交没有关联的提示词"
style="padding: 80px 0"
/>
<div v-else class="body">
<ol class="rounds">
<li
v-for="(r, i) in rounds"
:key="i"
:class="['round', { on: i === selected }]"
@click="selected = i"
>
<div class="round-head">
<span class="num">{{ i + 1 }}</span>
<span :class="['src', `src-${r.source || 'conversation'}`]">
{{ sourceLabel(r.source) }}
</span>
<span v-if="r.prompt_level" class="level">{{ levelLabel(r.prompt_level) }}</span>
<span v-if="i === rounds.length - 1" class="final">交的这一版</span>
<span v-else-if="!r.html" class="muted no-code">没有网页</span>
</div>
<div
:class="['q', 'markdown-body', { clamp: isLong(r.question) && !expanded.has(i) }]"
v-html="renderMarkdown(r.question)"
></div>
<button v-if="isLong(r.question)" class="more" @click.stop="toggle(i)">
{{ expanded.has(i) ? "收起" : "展开全部" }}
</button>
</li>
</ol>
<div class="stage">
<div class="stage-head">
<b>第 {{ selected + 1 }} 轮生成的网页</b>
<span class="muted">↑ ↓ 切换</span>
</div>
<div class="frame">
<iframe
v-if="doc"
:key="selected"
:sandbox="PREVIEW_SANDBOX"
:srcdoc="doc"
></iframe>
<n-empty v-else description="这一轮没有生成网页" style="margin: auto" />
</div>
<iframe
v-if="selectedPageHtml"
:srcdoc="selectedPageHtml"
:key="selectedRound"
sandbox="allow-scripts"
style="
flex: 1;
border: 1px solid var(--wp-line);
border-radius: 6px;
background: var(--wp-surface);
"
/>
<n-empty v-else description="该轮无网页代码" style="margin: auto" />
</div>
</div>
</n-spin>
@@ -235,157 +84,301 @@
</template>
<script setup lang="ts">
import { computed, ref, watch } from "vue"
import { NPopconfirm, NButton, useMessage } from "naive-ui"
import { computed, nextTick, onUnmounted, ref, watch } from "vue"
import { Icon } from "../../icons"
import { Showcase, Submission } from "../../api"
import { renderMarkdown } from "../../utils/markdown"
import { sourceLabel } from "../../utils/helper"
import { storageShim } from "../../utils/previewStorage"
import { Prompt, Submission } from "../../api"
import { levelLabel, PROMPT_LEVELS, sourceLabel } from "../../utils/helper"
import { buildPreviewDocument, PREVIEW_SANDBOX } from "../../utils/previewDocument"
import type { PromptRound } from "../../utils/type"
import { user, roleSuper } from "../../store/user"
const message = useMessage()
// 一份提交是怎么一轮一轮问出来的:左边每轮的提问,右边这一轮生成的网页
const props = defineProps<{
show: boolean
submissionId: string
username?: string
// 例如“张三 · 个人名片”
title?: string
// 题目素材的地址,网页里的图片要用
assetBaseUrl?: string
// 作品展里的作品走作品展的接口(不用是老师或作者本人)
fromShowcase?: boolean
}>()
const canDelete = computed(
() =>
roleSuper.value || (!!props.username && props.username === user.username),
)
defineEmits<{ "update:show": [value: boolean] }>()
const emit = defineEmits<{ "update:show": [value: boolean] }>()
const loading = ref(false)
const selectedRound = ref(0)
const expandedRounds = ref<Set<number>>(new Set())
type ChainRound = Omit<PromptRound, "source"> & {
source: string | null
assistantMsgId: number | null
}
const rounds = ref<ChainRound[]>([])
const rounds = ref<PromptRound[]>([])
const selected = ref(0)
const expanded = ref(new Set<number>())
async function deleteRound(index: number) {
const round = rounds.value[index]
if (!round.assistantMsgId) return
try {
await Prompt.deleteMessagePair(round.assistantMsgId)
} catch (error: any) {
message.error(error.response?.data?.detail ?? "删除失败,请重试")
return
}
await loadMessages()
if (selectedRound.value >= rounds.value.length) {
selectedRound.value = Math.max(0, rounds.value.length - 1)
}
}
const levelColors: Record<number, string> = {
1: "var(--wp-faint)",
2: "var(--wp-brand)",
3: "var(--wp-css)",
4: "#c47ab8",
5: "var(--wp-warn)",
6: "var(--wp-bad)",
}
const selectedPageHtml = computed(() => {
const round = rounds.value[selectedRound.value]
if (!round?.html) return null
const style = round.css ? `<style>${round.css}</style>` : ""
const script = round.js ? `<script>${round.js}<\/script>` : ""
return `<!DOCTYPE html><html><head><meta charset="utf-8">${storageShim()}${style}</head><body>${round.html}${script}</body></html>`
const doc = computed(() => {
const r = rounds.value[selected.value]
if (!r?.html) return ""
return buildPreviewDocument({
html: r.html,
css: r.css ?? "",
js: r.js ?? "",
assetBaseUrl: props.assetBaseUrl,
})
})
function isPromptLong(text: string): boolean {
return text.length > 220 || text.split(/\r?\n/).length > 4
function isLong(text: string) {
return text.length > 200 || text.split(/\r?\n/).length > 4
}
function isExpanded(index: number): boolean {
return expandedRounds.value.has(index)
function toggle(i: number) {
const next = new Set(expanded.value)
if (!next.delete(i)) next.add(i)
expanded.value = next
}
function toggleExpanded(index: number) {
const next = new Set(expandedRounds.value)
if (next.has(index)) {
next.delete(index)
} else {
next.add(index)
}
expandedRounds.value = next
}
async function loadMessages() {
if (!props.submissionId) return
let seq = 0
async function load() {
const s = ++seq
loading.value = true
rounds.value = []
selectedRound.value = 0
expandedRounds.value = new Set()
expanded.value = new Set()
try {
const data = await Submission.getPromptChain(props.submissionId)
rounds.value = data.map((round) => ({
...round,
source: round.source ?? null,
assistantMsgId: round.assistant_msg_id ?? null,
}))
const last = rounds.value.length - 1
if (last >= 0) selectedRound.value = last
const data = props.fromShowcase
? await Showcase.getPromptChain(props.submissionId)
: await Submission.getPromptChain(props.submissionId)
if (s !== seq) return
rounds.value = data
// 默认看交的那一版
selected.value = Math.max(0, data.length - 1)
await nextTick()
document.querySelector(".rounds .round.on")?.scrollIntoView({ block: "nearest" })
} catch {
rounds.value = []
if (s === seq) rounds.value = []
} finally {
loading.value = false
if (s === seq) loading.value = false
}
}
// 在捕获阶段接管按键:弹框开着时,批改页的 Esc / J / K、批改面板的 1–5 / P 都不能触发
function onKey(e: KeyboardEvent) {
if (!props.show) return
e.stopPropagation()
if (e.key === "Escape") {
emit("update:show", false)
return
}
const step = e.key === "ArrowDown" ? 1 : e.key === "ArrowUp" ? -1 : 0
if (!step || !rounds.value.length) return
e.preventDefault()
selected.value = Math.min(rounds.value.length - 1, Math.max(0, selected.value + step))
nextTick(() =>
document.querySelector(".rounds .round.on")?.scrollIntoView({ block: "nearest" }),
)
}
watch(
() => [props.show, props.submissionId] as const,
([visible]) => {
if (visible) loadMessages()
if (visible) {
load()
window.addEventListener("keydown", onKey, true)
} else {
window.removeEventListener("keydown", onKey, true)
}
},
{ immediate: true },
)
onUnmounted(() => window.removeEventListener("keydown", onKey, true))
</script>
<style scoped>
.prompt-markdown {
flex: 1;
.head {
display: flex;
align-items: baseline;
gap: 10px;
min-width: 0;
background: transparent;
color: inherit;
font-size: 13px;
line-height: 1.6;
}
.prompt-markdown.is-collapsed {
position: relative;
max-height: 126px;
.title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.prompt-markdown :deep(p),
.prompt-markdown :deep(ul),
.prompt-markdown :deep(ol),
.prompt-markdown :deep(blockquote),
.prompt-markdown :deep(pre) {
margin-top: 0;
margin-bottom: 6px;
.muted {
font-size: 12px;
font-weight: 400;
color: var(--wp-muted);
}
.prompt-markdown :deep(:last-child) {
margin-bottom: 0;
.levels {
font-size: 13px;
line-height: 1.9;
}
.prompt-markdown :deep(pre) {
padding: 8px;
overflow-x: auto;
.levels-note {
color: var(--wp-muted);
}
.body {
display: grid;
grid-template-columns: minmax(300px, 2fr) 3fr;
gap: 16px;
height: 72vh;
}
.rounds {
margin: 0;
padding: 0 6px 0 0;
list-style: none;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 6px;
}
.round {
padding: 8px 10px;
border: 1px solid var(--wp-line);
border-radius: 8px;
background: var(--wp-surface);
cursor: pointer;
}
.round:hover {
border-color: var(--wp-faint);
}
.round.on {
border-color: var(--wp-brand);
background: var(--wp-brand-soft);
}
.round-head {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
font-size: 12px;
}
.prompt-markdown :deep(code) {
white-space: pre-wrap;
.num {
min-width: 18px;
font: 600 12px var(--wp-font-mono);
color: var(--wp-muted);
}
.round.on .num {
color: var(--wp-brand);
}
.src,
.level,
.final {
padding: 0 6px;
border-radius: 4px;
line-height: 18px;
white-space: nowrap;
}
.src {
color: var(--wp-css);
background: var(--wp-css-soft);
}
.src-edit {
color: var(--wp-js);
background: var(--wp-js-soft);
}
.src-manual {
color: var(--wp-html);
background: var(--wp-html-soft);
}
.level {
color: var(--wp-ink);
background: var(--wp-paper);
border: 1px solid var(--wp-line);
}
.final {
margin-left: auto;
color: var(--wp-brand);
font-weight: 600;
}
.no-code {
margin-left: auto;
}
.q {
background: transparent;
color: var(--wp-ink);
font-size: 13px;
line-height: 1.6;
word-break: break-word;
}
.q.clamp {
max-height: 84px;
overflow: hidden;
-webkit-mask-image: linear-gradient(#000 55%, transparent);
mask-image: linear-gradient(#000 55%, transparent);
}
.q :deep(p),
.q :deep(ul),
.q :deep(ol),
.q :deep(pre),
.q :deep(blockquote) {
margin: 0 0 6px;
}
.q :deep(:last-child) {
margin-bottom: 0;
}
.q :deep(pre) {
padding: 6px 8px;
font-size: 12px;
}
.q :deep(code) {
white-space: pre-wrap;
}
.more {
margin-top: 4px;
padding: 0;
border: 0;
background: transparent;
color: var(--wp-brand);
font: inherit;
font-size: 12px;
cursor: pointer;
}
.stage {
min-width: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.stage-head {
display: flex;
align-items: baseline;
justify-content: space-between;
font-size: 13px;
}
.frame {
flex: 1;
min-height: 0;
display: flex;
border: 1px solid var(--wp-line);
border-radius: 8px;
overflow: hidden;
}
.frame iframe {
flex: 1;
border: 0;
}
</style>
@@ -1,42 +0,0 @@
<template>
<n-modal
preset="card"
:show="show"
style="max-width: 60%"
@update:show="$emit('update:show', $event)"
>
<template #header>
<n-flex align="center">
<span>前端代码</span>
<n-button tertiary @click="$emit('copy-to-editor')"
>复制到编辑框</n-button
>
</n-flex>
</template>
<n-tabs animated type="segment">
<n-tab-pane name="html" tab="html">
<n-code :code="html" language="html" word-wrap />
</n-tab-pane>
<n-tab-pane name="css" tab="css">
<n-code :code="css" language="css" word-wrap />
</n-tab-pane>
<n-tab-pane v-if="!!js" name="js" tab="js">
<n-code :code="js" language="js" word-wrap />
</n-tab-pane>
</n-tabs>
</n-modal>
</template>
<script setup lang="ts">
defineProps<{
show: boolean
html: string
css: string
js: string
}>()
defineEmits<{
"update:show": [value: boolean]
"copy-to-editor": []
}>()
</script>
@@ -1,126 +0,0 @@
<template>
<n-spin v-if="loading" size="small" style="padding: 12px" />
<n-data-table
v-else-if="items"
:columns="subColumns"
:data="items"
size="small"
striped
:row-key="(r: SubmissionOut) => r.id"
:row-props="rowProps"
:row-class-name="rowClassName"
/>
</template>
<script setup lang="ts">
import { computed, h } from "vue"
import {
NButton,
NDataTable,
NPopconfirm,
NSpin,
type DataTableColumn,
} from "naive-ui"
import type { SubmissionOut } from "../../utils/type"
import { TASK_TYPE } from "../../utils/const"
import { parseTime } from "../../utils/helper"
import { user, roleSuper } from "../../store/user"
import { submission } from "../../store/submission"
const props = defineProps<{
row: SubmissionOut
items: SubmissionOut[] | undefined
loading: boolean
}>()
const emit = defineEmits<{
select: [id: string]
delete: [row: SubmissionOut, parentId: string]
"show-chain": [submissionId: string, username: string]
}>()
const isChallenge = computed(() => props.row.task_type === TASK_TYPE.Challenge)
function rowProps(r: SubmissionOut) {
return {
style: { cursor: "pointer" },
onClick: () => emit("select", r.id),
}
}
function rowClassName(r: SubmissionOut) {
return submission.value.id === r.id ? "row-active" : ""
}
const subColumns = computed((): DataTableColumn<SubmissionOut>[] => [
{
title: "时间",
key: "created",
width: 160,
render: (r) => parseTime(r.created, "YYYY-MM-DD HH:mm:ss"),
},
{
title: "得分",
key: "score",
width: 80,
render: (r) => {
const myScore = r.my_score > 0 ? String(r.my_score) : "-"
const avgScore = r.score > 0 ? r.score.toFixed(2) : "-"
return h(
"div",
{ style: { display: "flex", gap: "6px", alignItems: "baseline" } },
[
h("span", avgScore),
h("span", { style: { fontSize: "11px", color: "var(--wp-faint)" } }, myScore),
],
)
},
},
{
title: "操作",
key: "actions",
width: isChallenge.value ? 110 : 60,
render: (r: SubmissionOut) =>
h("div", { style: { display: "flex", gap: "8px" } }, [
...(isChallenge.value
? [
h(
NButton,
{
text: true,
type: "primary",
onClick: (e: Event) => {
e.stopPropagation()
emit("show-chain", r.id, r.username)
},
},
() => "查看",
),
]
: []),
...(r.username === user.username || roleSuper.value
? [
h(
NPopconfirm,
{ onPositiveClick: () => emit("delete", r, props.row.id) },
{
trigger: () =>
h(
NButton,
{
text: true,
type: "error",
size: "small",
onClick: (e: Event) => e.stopPropagation(),
},
() => "删除",
),
default: () => "确定删除这次提交?",
},
),
]
: []),
]),
} as DataTableColumn<SubmissionOut>,
])
</script>
@@ -1,37 +0,0 @@
<template>
<n-flex>
<n-tag
size="small"
:bordered="false"
type="primary"
v-if="props.submission.task_type === 'tutorial'"
>
教程
</n-tag>
<n-tag
:bordered="false"
size="small"
type="error"
v-if="props.submission.task_type === 'challenge'"
>
挑战
</n-tag>
<n-button text @click="open">{{ props.submission.task_title }}</n-button>
</n-flex>
</template>
<script setup lang="ts">
import { useRouter } from "vue-router"
import type { SubmissionOut } from "../../utils/type"
import { goHome } from "../../utils/helper"
interface Props {
submission: SubmissionOut
}
const props = defineProps<Props>()
const router = useRouter()
function open() {
goHome(router, props.submission.task_type, props.submission.task_display)
}
</script>
+379 -247
View File
@@ -1,95 +1,116 @@
<template>
<n-flex
vertical
:size="12"
style="height: 100%; min-width: 0; box-sizing: border-box; padding: 10px 10px 10px 0; overflow: hidden;"
>
<n-flex class="toolbar" align="center" justify="space-between" style="flex-shrink: 0;">
<n-flex align="center" :size="8" wrap style="min-width: 0;">
<n-select
v-model:value="query.classname"
class="class-select"
:options="classOptions"
placeholder="班级"
:loading="classesLoading"
/>
<n-select
v-model:value="query.task_type"
class="type-select"
:options="taskTypeOptions"
/>
<n-input
v-model:value="query.username"
class="search-input"
clearable
placeholder="学生搜索"
/>
<n-switch v-model:value="query.include_all_tasks">
<template #checked>全部有提交任务</template>
<template #unchecked>只看计入任务</template>
</n-switch>
</n-flex>
<n-flex align="center" :size="8">
<n-button
secondary
title="刷新"
:disabled="!query.classname"
:loading="loading"
@click="loadGradebook"
>
<template #icon>
<Icon icon="lucide:refresh-cw" :width="15" />
</template>
</n-button>
<n-button
type="primary"
secondary
:disabled="!query.classname || !gradebook"
:loading="exporting"
@click="exportCsv"
>
<template #icon>
<Icon icon="lucide:download" :width="15" />
</template>
导出 CSV
</n-button>
</n-flex>
</n-flex>
<div class="gradebook">
<div class="toolbar">
<n-select
v-model:value="query.classname"
class="class-select"
:options="classOptions"
placeholder="班级"
size="small"
:loading="classesLoading"
/>
<n-radio-group v-model:value="query.task_type" size="small">
<n-radio-button value="">全部</n-radio-button>
<n-radio-button value="tutorial">教程</n-radio-button>
<n-radio-button value="challenge">挑战</n-radio-button>
</n-radio-group>
<n-input
v-model:value="query.username"
class="search-input"
size="small"
clearable
placeholder="搜索学生"
>
<template #prefix><Icon icon="lucide:search" :width="14" /></template>
</n-input>
<n-checkbox v-model:checked="query.include_all_tasks" size="small">
显示不计入的任务
</n-checkbox>
<span class="spacer"></span>
<span v-if="gradebook" class="summary">
{{ gradebook.student_count }} 人 · 计入 {{ gradebook.included_task_count }} 个任务
</span>
<n-popover trigger="click" placement="bottom-end" style="max-width: 340px">
<template #trigger>
<n-button size="small" quaternary>
<template #icon><Icon icon="lucide:info" /></template>
怎么算的
</n-button>
</template>
<div class="rules">
<p>
<b>计入的任务</b>:班里至少一半人交过的任务才计入(现在是
{{ gradebook?.coverage_threshold_count ?? "—" }} 人),灰色的列不计入。
</p>
<p><b>每格分数</b>:这个学生在这个任务里分数最高的一次提交,点开可以批改。</p>
<p>
<b>总分 / 平时等级</b>:计入任务的分数相加后排名,A 30%、B 40%、C 20%、D 5%、E 5%。
</p>
<p>
<b>考核</b>:计入任务里最高三次的平均分另外排名,A 10%、B 20%、C 40%、D 20%、E 10%;
分数在等级的分数段内按名次铺开(A 92–99、B 84–91、C 76–83、D 68–75、E 60–67)。
</p>
</div>
</n-popover>
<n-tooltip>
<template #trigger>
<n-button
size="small"
quaternary
:disabled="!query.classname"
:loading="loading"
@click="loadGradebook"
>
<template #icon><Icon icon="lucide:refresh-cw" /></template>
</n-button>
</template>
刷新
</n-tooltip>
<n-button
size="small"
secondary
type="primary"
:disabled="!gradebook"
:loading="exporting"
@click="exportCsv"
>
<template #icon><Icon icon="lucide:download" /></template>
导出 CSV
</n-button>
</div>
<n-alert v-if="loadError" type="error" closable @close="loadError = ''">
{{ loadError }}
</n-alert>
<n-flex v-if="gradebook" align="center" :size="8" style="flex-shrink: 0;">
<n-tag size="small">学生 {{ gradebook.student_count }}</n-tag>
<n-tag size="small">任务 {{ gradebook.task_count }}</n-tag>
<n-tag size="small" type="success">
计入 {{ gradebook.included_task_count }}
</n-tag>
<n-tag size="small">
覆盖门槛 {{ gradebook.coverage_threshold_count }} 人
</n-tag>
</n-flex>
<n-data-table
class="table gradebook-table"
size="small"
striped
flex-height
:single-line="false"
:loading="loading"
:columns="columns"
:data="rows"
:row-key="(row: GradebookRow) => row.user_id"
:scroll-x="scrollX"
style="flex: 1; min-height: 0;"
/>
</n-flex>
</div>
<n-drawer
:show="!!reviewId"
width="min(1180px, 92vw)"
placement="right"
@update:show="(v: boolean) => !v && (reviewId = null)"
>
<ReviewPanel v-if="reviewId" :submission-id="reviewId" wide @close="reviewId = null" />
<ReviewPanel
v-if="reviewId"
:submission-id="reviewId"
wide
@close="reviewId = null"
@changed="loadGradebook"
/>
</n-drawer>
</template>
@@ -98,13 +119,7 @@ import ReviewPanel from "../components/review/ReviewPanel.vue"
import { computed, h, onMounted, reactive, ref, watch } from "vue"
import { Icon } from "../icons"
import { watchDebounced } from "@vueuse/core"
import {
NButton,
NTag,
NText,
useMessage,
type DataTableColumn,
} from "naive-ui"
import { useMessage, type DataTableColumn } from "naive-ui"
import { Account, Gradebook } from "../api"
import { displayGradebookStudentName } from "../utils/gradebook"
import type {
@@ -113,9 +128,11 @@ import type {
GradebookQuery,
GradebookRow,
GradebookTask,
GradebookTaskType,
} from "../utils/type"
// 和批改页共用“当前班级”(那边的“全部班级”在这里不适用)
const CLASS_KEY = "review-class"
const message = useMessage()
const classesLoading = ref(false)
const loading = ref(false)
@@ -131,28 +148,19 @@ const query = reactive<GradebookQuery>({
include_all_tasks: false,
})
const taskTypeOptions: { label: string; value: GradebookTaskType | "" }[] = [
{ label: "全部", value: "" },
{ label: "教程", value: "tutorial" },
{ label: "挑战", value: "challenge" },
]
const classOptions = computed(() =>
classes.value.map((classname) => ({ label: classname, value: classname })),
classes.value.map((classname) => ({ label: `${classname} 班`, value: classname })),
)
const rows = computed(() => gradebook.value?.rows ?? [])
const scrollX = computed(() => 1040 + (gradebook.value?.tasks.length ?? 0) * 96)
const TASK_WIDTH = 66
const scrollX = computed(() => 380 + (gradebook.value?.tasks.length ?? 0) * TASK_WIDTH)
function formatScore(value: number | null) {
if (value === null) return "-"
if (value === null) return "—"
return Number.isInteger(value) ? String(value) : value.toFixed(2)
}
function taskTitle(task: GradebookTask) {
const typeLabel = task.task_type === "tutorial" ? "教程" : "挑战"
return `${typeLabel}${task.display}`
}
// 点分数:在右侧打开和批改页一样的详情,能直接打分、看提示词
const reviewId = ref<string | null>(null)
function openSubmission(cell: GradebookCell) {
@@ -160,152 +168,135 @@ function openSubmission(cell: GradebookCell) {
reviewId.value = cell.submission_id
}
function gradeTagType(grade: string) {
if (grade === "A") return "success"
if (grade === "B") return "info"
if (grade === "C") return "default"
return "warning"
function renderGrade(grade: string, extra?: string | number) {
return h("span", { class: ["grade", `g-${grade}`] }, [
grade,
extra === undefined ? null : h("small", String(extra)),
])
}
function renderTaskHeader(task: GradebookTask) {
return h("div", { class: ["task-header", { muted: !task.included }] }, [
h("div", { class: "task-title", title: task.title }, taskTitle(task)),
h("div", { class: "task-meta" }, [
h("span", `${Math.round(task.coverage * 100)}%`),
task.included
? null
: h(NTag, { size: "tiny", round: false }, { default: () => "未计入" }),
]),
])
const pct = Math.round(task.coverage * 100)
const studentCount = gradebook.value?.student_count ?? 0
return h(
"div",
{
class: ["task-head", { excluded: !task.included }],
title: `${task.display}. ${task.title}\n交了 ${task.submitted_count} / ${studentCount} 人(${pct}%)${task.included ? "" : ",不计入"}`,
},
[
h("div", { class: "task-name" }, task.title),
h("div", { class: "task-cov" }, [
h("span", { class: "cov-bar" }, [h("span", { style: { width: pct + "%" } })]),
]),
],
)
}
function renderScore(row: GradebookRow, task: GradebookTask) {
const cell = row.scores[task.id]
if (!cell || !cell.submitted) {
return h(
NText,
{ class: "missing-cell", type: "error" },
{ default: () => "缺交" },
)
}
if (!cell?.submitted)
return h("span", { class: ["cell", "missing", { excluded: !task.included }] }, "缺")
return h(
NButton,
"button",
{
text: true,
type: task.included ? "primary" : "default",
class: ["score-link", { muted: !task.included }],
onClick: (event: MouseEvent) => {
event.stopPropagation()
openSubmission(cell)
},
class: ["cell", "score", { unrated: cell.score === 0, excluded: !task.included }],
title: cell.score === 0 ? "交了,还没人评分" : `${formatScore(cell.score)},点开批改`,
onClick: () => openSubmission(cell),
},
{ default: () => formatScore(cell.score) },
cell.score === 0 ? "未评" : cell.score.toFixed(1),
)
}
function renderMissingCount(value: number) {
if (value <= 0) return "0"
return h(NText, { type: "error" }, { default: () => String(value) })
function taskColumn(task: GradebookTask): DataTableColumn<GradebookRow> {
return {
title: () => renderTaskHeader(task),
key: `task-${task.id}`,
width: TASK_WIDTH,
align: "center",
className: task.included ? "" : "excluded-col",
render: (row) => renderScore(row, task),
}
}
const columns = computed<DataTableColumn<GradebookRow>[]>(() => {
const tasks = gradebook.value?.tasks ?? []
const groups = (["tutorial", "challenge"] as const)
.map((type) => ({ type, items: tasks.filter((t) => t.task_type === type) }))
.filter((g) => g.items.length)
return [
{
title: "排名",
title: "#",
key: "rank",
width: 60,
width: 40,
fixed: "left",
},
{
title: "平时等级",
key: "grade",
width: 80,
fixed: "left",
render: (row) =>
h(
NTag,
{ size: "small", type: gradeTagType(row.grade) },
{ default: () => row.grade },
),
},
{
title: "考核等级",
key: "assessment_grade",
width: 80,
fixed: "left",
render: (row) =>
h(
NTag,
{ size: "small", type: gradeTagType(row.assessment_grade) },
{ default: () => row.assessment_grade },
),
},
{
title: "考核分数",
key: "assessment_score",
width: 80,
fixed: "left",
render: (row) => row.assessment_score,
align: "right",
sorter: (a, b) => a.rank - b.rank,
render: (row) => h("span", { class: "rank" }, row.rank),
},
{
title: "学生",
key: "username",
width: 80,
width: 92,
fixed: "left",
render: (row) => {
const studentName = displayGradebookStudentName(row)
return h(NText, { title: studentName }, { default: () => studentName })
},
ellipsis: { tooltip: true },
render: (row) => displayGradebookStudentName(row),
},
...tasks.map((task) => ({
title: () => renderTaskHeader(task),
key: `task-${task.id}`,
width: 96,
{
title: "平时",
key: "grade",
width: 52,
fixed: "left",
align: "center",
render: (row) => renderGrade(row.grade),
},
{
title: "考核",
key: "assessment_score",
width: 72,
fixed: "left",
sorter: (a, b) => a.assessment_score - b.assessment_score,
render: (row) => renderGrade(row.assessment_grade, row.assessment_score),
},
...groups.map((g) => ({
title: g.type === "tutorial" ? "教程" : "挑战",
key: `group-${g.type}`,
align: "center" as const,
className: task.included ? "" : "excluded-task-column",
render: (row: GradebookRow) => renderScore(row, task),
children: g.items.map(taskColumn),
})),
{
title: "教程合计",
key: "tutorial_total",
width: 92,
fixed: "right",
render: (row) => formatScore(row.tutorial_total),
},
{
title: "挑战合计",
key: "challenge_total",
width: 92,
fixed: "right",
render: (row) => formatScore(row.challenge_total),
},
{
title: "总分",
key: "total_score",
width: 82,
width: 72,
fixed: "right",
render: (row) => formatScore(row.total_score),
},
{
title: "平均",
key: "average_score",
width: 82,
fixed: "right",
render: (row) => formatScore(row.average_score),
align: "right",
sorter: (a, b) => a.total_score - b.total_score,
render: (row) =>
h(
"span",
{
class: "total",
title: `教程合计 ${formatScore(row.tutorial_total)}\n挑战合计 ${formatScore(row.challenge_total)}\n平均 ${formatScore(row.average_score)}`,
},
formatScore(row.total_score),
),
},
{
title: "已交",
key: "submitted_task_count",
width: 70,
width: 64,
fixed: "right",
},
{
title: "缺交",
key: "missing_task_count",
width: 70,
fixed: "right",
render: (row) => renderMissingCount(row.missing_task_count),
align: "right",
sorter: (a, b) => a.missing_task_count - b.missing_task_count,
render: (row) =>
h(
"span",
{
class: ["done", { short: row.missing_task_count > 0 }],
title: row.missing_task_count ? `缺交 ${row.missing_task_count} 个` : "都交了",
},
`${row.submitted_task_count}/${row.submitted_task_count + row.missing_task_count}`,
),
},
]
})
@@ -315,7 +306,11 @@ async function loadClasses() {
try {
classes.value = await Account.listClasses()
if (!query.classname && classes.value.length > 0) {
query.classname = classes.value[0]
let saved: string | null = null
try {
saved = localStorage.getItem(CLASS_KEY)
} catch {}
query.classname = saved && classes.value.includes(saved) ? saved : classes.value[0]!
}
} finally {
classesLoading.value = false
@@ -359,6 +354,16 @@ async function exportCsv() {
}
}
watch(
() => query.classname,
(c) => {
if (!c) return
try {
localStorage.setItem(CLASS_KEY, c)
} catch {}
},
)
watch(
() => [query.classname, query.task_type, query.include_all_tasks],
() => loadGradebook(),
@@ -379,67 +384,194 @@ onMounted(async () => {
</script>
<style scoped>
.class-select {
width: 150px;
.gradebook {
height: 100%;
min-width: 0;
display: flex;
flex-direction: column;
gap: 10px;
padding: 10px 12px;
box-sizing: border-box;
overflow: hidden;
}
.type-select {
width: 112px;
.toolbar {
flex: none;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 10px;
}
.class-select {
width: 120px;
}
.search-input {
width: 160px;
width: 150px;
}
.task-header {
min-width: 0;
line-height: 1.2;
.spacer {
flex: 1;
}
.task-header.muted {
color: var(--wp-muted);
}
.task-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 600;
}
.task-meta {
display: flex;
min-height: 18px;
align-items: center;
justify-content: center;
gap: 4px;
margin-top: 2px;
color: var(--wp-faint);
font-size: 11px;
}
.missing-cell {
color: var(--wp-bad);
.summary {
font-size: 12px;
}
.score-link.muted {
color: var(--wp-muted);
}
:deep(.excluded-task-column) {
.rules {
font-size: 13px;
line-height: 1.6;
}
.rules p {
margin: 0 0 6px;
}
.rules p:last-child {
margin: 0;
}
.table {
flex: 1;
min-height: 0;
}
.table :deep(.n-data-table-td) {
padding: 4px 8px;
}
.table :deep(.n-data-table-th) {
padding: 6px 8px;
}
</style>
<style>
/* 单元格是 render 函数画的,拿不到 scoped 属性,用表格的类名限定 */
.gradebook-table .excluded-col {
background: var(--wp-paper);
}
@media (max-width: 720px) {
.toolbar {
align-items: flex-start;
}
.gradebook-table .rank {
font: 12px var(--wp-font-mono);
color: var(--wp-faint);
}
.class-select,
.type-select,
.search-input {
width: 100%;
}
.gradebook-table .grade {
font-weight: 700;
}
.gradebook-table .grade small {
margin-left: 6px;
font: 12px var(--wp-font-mono);
color: var(--wp-muted);
}
.gradebook-table .g-A {
color: var(--wp-brand);
}
.gradebook-table .g-B {
color: var(--wp-css);
}
.gradebook-table .g-C {
color: var(--wp-muted);
}
.gradebook-table .g-D {
color: var(--wp-warn);
}
.gradebook-table .g-E {
color: var(--wp-bad);
}
.gradebook-table .task-head {
line-height: 1.3;
font-weight: 400;
}
.gradebook-table .task-name {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
font-size: 12px;
color: var(--wp-ink);
word-break: break-all;
}
.gradebook-table .task-head.excluded .task-name {
color: var(--wp-faint);
}
.gradebook-table .task-cov {
margin-top: 4px;
}
.gradebook-table .cov-bar {
display: block;
height: 3px;
border-radius: 2px;
background: var(--wp-line);
overflow: hidden;
}
.gradebook-table .cov-bar span {
display: block;
height: 100%;
background: var(--wp-brand);
}
.gradebook-table .task-head.excluded .cov-bar span {
background: var(--wp-faint);
}
.gradebook-table .cell {
font: 13px var(--wp-font-mono);
}
.gradebook-table .cell.missing {
font: 12px var(--wp-font-sans);
color: var(--wp-bad);
}
.gradebook-table .score {
padding: 2px 6px;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--wp-ink);
cursor: pointer;
}
.gradebook-table .score:hover {
background: var(--wp-brand-soft);
}
.gradebook-table .score.unrated {
font: 12px var(--wp-font-sans);
color: var(--wp-faint);
}
.gradebook-table .missing.excluded,
.gradebook-table .score.excluded {
color: var(--wp-faint);
}
.gradebook-table .total {
font: 600 13px var(--wp-font-mono);
cursor: default;
}
.gradebook-table .done {
font: 13px var(--wp-font-mono);
color: var(--wp-muted);
}
.gradebook-table .done.short {
color: var(--wp-bad);
}
</style>
+442
View File
@@ -0,0 +1,442 @@
<template>
<div class="mine">
<aside class="tasks">
<div class="tasks-head">
<b>我的提交</b>
<span class="muted">{{ tasks.length }} 个任务</span>
</div>
<n-spin :show="loadingList" class="task-list">
<template v-for="g in groups" :key="g.label">
<div class="group-label">{{ g.label }}</div>
<button
v-for="t in g.items"
:key="t.task_id"
:class="['task', { on: t.task_id === taskId }]"
@click="taskId = t.task_id"
>
<span class="task-title">{{ t.task_display }}. {{ t.task_title }}</span>
<span class="task-meta">
{{ t.submit_count }} 次 · {{ parseTime(t.created, "MM-DD") }}
</span>
</button>
</template>
<n-empty
v-if="!loadingList && !tasks.length"
description="还没有交过作品"
style="margin-top: 60px"
/>
</n-spin>
</aside>
<main class="detail">
<template v-if="task">
<div class="detail-head">
<div class="title">
<span class="kind">{{ task.task_type === TASK_TYPE.Tutorial ? "教程" : "挑战" }}</span>
<b>{{ task.task_display }}. {{ task.task_title }}</b>
</div>
<n-button size="small" quaternary icon-placement="right" @click="openTask">
打开这道题
<template #icon><Icon icon="lucide:arrow-right" /></template>
</n-button>
</div>
<n-spin :show="loadingVersions" class="stage">
<div v-if="versions.length" class="versions">
<button
v-for="(v, i) in versions"
:key="v.id"
:class="['ver', { on: v.id === currentId }]"
@click="currentId = v.id"
>
第 {{ versions.length - i }} 次
<span class="ver-score">{{ v.score ? v.score.toFixed(1) : "未评" }}</span>
<span v-if="v.id === bestId && versions.length > 1" class="best">计入成绩</span>
</button>
</div>
<div class="frame">
<iframe
v-if="current"
:key="current.id"
:sandbox="PREVIEW_SANDBOX"
:srcdoc="doc"
></iframe>
</div>
<div v-if="current" class="meta">
<span class="muted">
{{ parseTime(current.created, "YYYY-MM-DD HH:mm") }} 提交 ·
{{ current.score ? `得分 ${current.score.toFixed(2)}` : "还没有人评分" }}
<template v-if="versions.length > 1"> · 成绩按分数最高的一次算</template>
</span>
<n-flex :size="4" :wrap="false">
<n-button
v-if="task.task_type === TASK_TYPE.Challenge"
size="small"
quaternary
@click="showChain = true"
>
提示词
</n-button>
<n-popconfirm @positive-click="copyToEditor">
<template #trigger>
<n-button size="small" quaternary>放回编辑器</n-button>
</template>
把这一版的代码放进这道题的编辑器,会替换编辑器里现在的代码。
</n-popconfirm>
<n-popconfirm @positive-click="remove">
<template #trigger>
<n-button size="small" quaternary type="error">删除这一版</n-button>
</template>
删除后找不回来;它对应的那轮 AI 对话也会一起删掉。<br />
已经有人评过分的不能删。
</n-popconfirm>
</n-flex>
</div>
</n-spin>
</template>
<n-empty v-else-if="tasks.length" description="选一个任务" style="margin-top: 120px" />
</main>
</div>
<ChainModal
v-if="current"
v-model:show="showChain"
:submission-id="current.id"
:title="`${current.task_display}. ${current.task_title}`"
:asset-base-url="`/media/tasks/${current.task_type}/${current.task_display}/`"
/>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue"
import { useRoute, useRouter } from "vue-router"
import { useMessage } from "naive-ui"
import { Icon } from "../icons"
import { Submission } from "../api"
import ChainModal from "../components/submissions/ChainModal.vue"
import { saveTaskCode } from "../composables/useTaskCode"
import { TASK_TYPE } from "../utils/const"
import { goHome, parseTime } from "../utils/helper"
import { buildPreviewDocument, PREVIEW_SANDBOX } from "../utils/previewDocument"
import type { SubmissionOut } from "../utils/type"
// 学生看自己交过的作品:左边按任务列出,右边看每一次交的版本
type Version = SubmissionOut & { html: string | null; css: string | null; js: string | null }
const route = useRoute()
const router = useRouter()
const message = useMessage()
const tasks = ref<SubmissionOut[]>([])
const loadingList = ref(false)
const taskId = ref<number | null>(route.query.task ? Number(route.query.task) : null)
const versions = ref<Version[]>([])
const loadingVersions = ref(false)
const currentId = ref<string | null>(null)
const showChain = ref(false)
const task = computed(() => tasks.value.find((t) => t.task_id === taskId.value) ?? null)
const current = computed(() => versions.value.find((v) => v.id === currentId.value) ?? null)
const groups = computed(() =>
[
{ label: "教程", type: TASK_TYPE.Tutorial },
{ label: "挑战", type: TASK_TYPE.Challenge },
]
.map((g) => ({
label: g.label,
items: tasks.value
.filter((t) => t.task_type === g.type)
.sort((a, b) => a.task_display - b.task_display),
}))
.filter((g) => g.items.length),
)
// 和成绩册一样:分数最高的一次计入成绩,同分取最新
const bestId = computed(() => {
let best: Version | null = null
for (const v of versions.value) if (v.score > 0 && (!best || v.score > best.score)) best = v
return best?.id ?? null
})
const doc = computed(() =>
current.value
? buildPreviewDocument({
html: current.value.html ?? "",
css: current.value.css ?? "",
js: current.value.js ?? "",
assetBaseUrl: `/media/tasks/${current.value.task_type}/${current.value.task_display}/`,
})
: "",
)
async function loadTasks() {
loadingList.value = true
try {
const all: SubmissionOut[] = []
for (let page = 1; ; page++) {
const res = await Submission.list({ page, page_size: 100, grouped: true })
all.push(...res.items)
if (all.length >= res.count || !res.items.length) break
}
tasks.value = all
if (!task.value) taskId.value = groups.value[0]?.items[0]?.task_id ?? null
} finally {
loadingList.value = false
}
}
let seq = 0
async function loadVersions() {
const s = ++seq
const t = task.value
if (!t) {
versions.value = []
return
}
loadingVersions.value = true
try {
const list = (await Submission.listByUserTask(t.userid, t.task_id)) as Version[]
if (s !== seq) return
versions.value = list
if (!list.some((v) => v.id === currentId.value)) currentId.value = list[0]?.id ?? null
} finally {
if (s === seq) loadingVersions.value = false
}
}
watch(taskId, (id) => {
router.replace({ query: id ? { task: String(id) } : {} })
loadVersions()
})
function openTask() {
if (task.value) goHome(router, task.value.task_type, task.value.task_display)
}
function copyToEditor() {
const v = current.value
if (!v) return
// 教程和挑战的代码都按任务各存一份,打开那个任务时读到
saveTaskCode(v.task_id, { html: v.html ?? "", css: v.css ?? "", js: v.js ?? "" })
goHome(router, v.task_type, v.task_display)
}
async function remove() {
const v = current.value
if (!v) return
try {
await Submission.delete(v.id)
} catch (err: any) {
message.error(err.response?.data?.detail ?? "删除失败,请重试")
return
}
message.success("已删除")
if (versions.value.length > 1) {
await loadVersions()
const t = tasks.value.find((x) => x.task_id === v.task_id)
if (t) t.submit_count = versions.value.length
} else {
// 这道题一次都不剩了
tasks.value = tasks.value.filter((x) => x.task_id !== v.task_id)
taskId.value = groups.value[0]?.items[0]?.task_id ?? null
}
}
onMounted(async () => {
await loadTasks()
loadVersions()
})
</script>
<style scoped>
.mine {
height: 100%;
display: flex;
min-width: 0;
}
.tasks {
width: 260px;
flex: none;
display: flex;
flex-direction: column;
border-right: 1px solid var(--wp-line);
background: var(--wp-surface);
min-height: 0;
}
.tasks-head {
display: flex;
align-items: baseline;
justify-content: space-between;
padding: 12px 14px 6px;
}
.task-list {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 0 8px 10px;
}
.group-label {
padding: 10px 6px 4px;
font-size: 12px;
color: var(--wp-faint);
}
.task {
width: 100%;
display: flex;
flex-direction: column;
gap: 2px;
padding: 6px 8px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--wp-ink);
font: inherit;
font-size: 13px;
text-align: left;
cursor: pointer;
}
.task:hover {
background: var(--wp-paper);
}
.task.on {
background: var(--wp-brand-soft);
color: var(--wp-brand);
}
.task-title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.task-meta {
font-size: 12px;
color: var(--wp-faint);
}
.muted {
font-size: 12px;
color: var(--wp-muted);
}
.detail {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
padding: 10px 14px 12px;
gap: 10px;
}
.detail-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.title {
min-width: 0;
display: flex;
align-items: baseline;
gap: 8px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.kind {
font-size: 12px;
color: var(--wp-muted);
}
.stage {
flex: 1;
min-height: 0;
}
.stage :deep(.n-spin-content) {
height: 100%;
display: flex;
flex-direction: column;
gap: 8px;
}
.versions {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.ver {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 10px;
border: 1px solid var(--wp-line);
border-radius: 6px;
background: var(--wp-surface);
color: var(--wp-ink);
font: inherit;
font-size: 13px;
cursor: pointer;
}
.ver:hover {
border-color: var(--wp-brand);
}
.ver.on {
border-color: var(--wp-brand);
background: var(--wp-brand-soft);
color: var(--wp-brand);
}
.ver-score {
font: 12px var(--wp-font-mono);
color: var(--wp-muted);
}
.best {
padding: 0 5px;
border-radius: 3px;
font-size: 11px;
line-height: 18px;
color: var(--wp-brand);
background: var(--wp-brand-soft);
}
.ver.on .best {
background: var(--wp-surface);
}
.frame {
flex: 1;
min-height: 0;
border: 1px solid var(--wp-line);
border-radius: 8px;
overflow: hidden;
}
.frame iframe {
width: 100%;
height: 100%;
border: 0;
display: block;
}
.meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
</style>
+379 -91
View File
@@ -1,167 +1,455 @@
<template>
<main class="showcase">
<n-flex justify="space-between" align="flex-end" style="margin-bottom: 32px;">
<header class="page-head">
<div>
<n-h2 style="margin: 0 0 4px;">创意工坊</n-h2>
<n-text depth="3">优秀作品展示</n-text>
<h2>作品展</h2>
<p class="muted">老师从大家交的作品里挑出来的,点开能看大图和它是怎么问出来的</p>
</div>
</n-flex>
<nav v-if="awards.length > 1" class="jump">
<a v-for="a in awards" :key="a.id" :href="`#award-${a.id}`" @click.prevent="jump(a.id)">
{{ a.name }}
</a>
</nav>
</header>
<n-spin :show="loading">
<n-empty
v-if="!loading && awards.length === 0"
description="暂无展示作品"
style="margin-top: 72px;"
v-if="!loading && !awards.length"
description="还没有展出的作品"
style="margin-top: 72px"
/>
<section
v-for="section in awards"
:key="section.id"
style="margin-bottom: 48px;"
>
<n-flex vertical :size="4" style="margin-bottom: 16px;">
<n-h3 style="margin: 0;">{{ section.name }}</n-h3>
<n-text v-if="section.description" depth="3" style="font-size: 13px;">
{{ section.description }}
</n-text>
</n-flex>
<section v-for="(section, si) in awards" :id="`award-${section.id}`" :key="section.id">
<div class="section-head">
<h3>{{ section.name }}</h3>
<span class="muted">{{ section.items.length }} 件</span>
</div>
<p v-if="section.description" class="desc">{{ section.description }}</p>
<div class="card-grid">
<n-card
v-for="item in section.items"
<div class="grid">
<button
v-for="(item, ii) in section.items"
:key="item.submission_id"
class="work-card"
content-style="padding: 0;"
hoverable
@click="openDetail(item)"
class="card"
@click="open(si, ii)"
>
<div class="card-preview">
<div class="thumb">
<iframe
:srcdoc="buildSrcdoc(item)"
sandbox="allow-scripts"
:srcdoc="docOf(item)"
:sandbox="PREVIEW_SANDBOX"
loading="lazy"
scrolling="no"
class="preview-iframe"
/>
<div class="preview-overlay" />
tabindex="-1"
></iframe>
</div>
<div class="card-info">
<n-flex justify="space-between" align="center" :wrap="false">
<n-ellipsis style="font-size: 13px; font-weight: 600; min-width: 0; flex: 1;">
{{ item.username }}
</n-ellipsis>
<n-flex align="center" :wrap="false" :size="8" style="flex-shrink: 0;">
<n-flex align="center" :size="3">
<Icon icon="lucide:star" :width="13" />
<n-text style="font-size: 12px; color: var(--wp-muted);">
{{ item.score.toFixed(1) }}
</n-text>
</n-flex>
<n-flex align="center" :size="3">
<Icon icon="lucide:eye" :width="13" />
<n-text style="font-size: 12px; color: var(--wp-muted);">
{{ item.view_count }}
</n-text>
</n-flex>
</n-flex>
</n-flex>
<n-ellipsis
style="display: block; margin-top: 4px; font-size: 12px; line-height: 1.4; color: var(--wp-muted);"
>
{{ item.task_title }}
</n-ellipsis>
<div class="cap">
<div class="who">
<b>{{ item.username }}</b>
<span v-if="item.score" class="score">
<Icon icon="lucide:star" :width="12" />{{ item.score.toFixed(1) }}
</span>
</div>
<div class="task">{{ item.task_display }}. {{ item.task_title }}</div>
</div>
</n-card>
</button>
</div>
</section>
</n-spin>
</main>
<n-modal
:show="!!current"
preset="card"
style="width: 94vw; max-width: 1280px"
:bordered="false"
@update:show="(v: boolean) => !v && (viewing = null)"
>
<template #header>
<div class="viewer-head">
<b>{{ current?.username }}</b>
<span class="muted">{{ current?.task_display }}. {{ current?.task_title }}</span>
</div>
</template>
<template #header-extra>
<n-flex v-if="viewing" align="center" :size="4" style="margin-right: 8px">
<span class="muted">
{{ viewing.item + 1 }} / {{ awards[viewing.section]?.items.length }}
</span>
<n-button size="small" quaternary :disabled="viewing.item === 0" @click="step(-1)">
<template #icon><Icon icon="lucide:arrow-left" /></template>
</n-button>
<n-button
size="small"
quaternary
:disabled="viewing.item >= (awards[viewing.section]?.items.length ?? 0) - 1"
@click="step(1)"
>
<template #icon><Icon icon="lucide:arrow-right" /></template>
</n-button>
</n-flex>
</template>
<div v-if="current" class="viewer">
<div class="big">
<iframe :key="current.submission_id" :srcdoc="docOf(current)" :sandbox="PREVIEW_SANDBOX"></iframe>
</div>
<aside class="info">
<div class="field">
<div class="label">获奖</div>
<div class="awards">
<span v-for="name in awardsOf(current.submission_id)" :key="name" class="award">
<Icon icon="lucide:award" :width="13" />{{ name }}
</span>
</div>
</div>
<div class="field">
<div class="label">得分</div>
<div v-if="current.score" class="mono">{{ current.score.toFixed(2) }}</div>
<div v-else class="muted">还没有评分</div>
</div>
<div class="field">
<div class="label">看过的次数</div>
<div class="mono">{{ current.view_count }}</div>
</div>
<n-flex vertical :size="8" class="actions">
<n-button v-if="current.has_prompt_chain" secondary @click="showChain = true">
<template #icon><Icon icon="lucide:sparkles" /></template>
看看是怎么问出来的
</n-button>
<n-button quaternary @click="openFull">
<template #icon><Icon icon="lucide:maximize-2" /></template>
全屏打开
</n-button>
</n-flex>
<div class="muted hint">← → 切换,Esc 关闭</div>
</aside>
</div>
</n-modal>
<ChainModal
v-if="current"
v-model:show="showChain"
:submission-id="current.submission_id"
:title="`${current.username} · ${current.task_title}`"
:asset-base-url="assetBase(current)"
from-showcase
/>
</template>
<script setup lang="ts">
import { storageShim } from "../utils/previewStorage"
import { onMounted, ref } from "vue"
import { computed, onMounted, onUnmounted, ref, watch } from "vue"
import { useRouter } from "vue-router"
import { Icon } from "../icons"
import { Showcase } from "../api"
import { Showcase, Submission } from "../api"
import ChainModal from "../components/submissions/ChainModal.vue"
import { buildPreviewDocument, PREVIEW_SANDBOX } from "../utils/previewDocument"
import type { AwardSection, ShowcaseItem } from "../utils/type"
const router = useRouter()
const loading = ref(true)
const awards = ref<AwardSection[]>([])
// 正在看第几个奖项的第几件
const viewing = ref<{ section: number; item: number } | null>(null)
const showChain = ref(false)
function buildSrcdoc(item: ShowcaseItem): string {
const css = item.css ? `<style>${item.css}</style>` : ""
const js = item.js ? `<script>${item.js}<\/script>` : ""
return `<!DOCTYPE html><html><head><meta charset="utf-8">${storageShim()}<meta name="viewport" content="width=device-width, initial-scale=1.0"><link rel="stylesheet" href="/normalize.min.css" />${css}</head><body>${item.html ?? ""}${js}</body></html>`
const current = computed(() =>
viewing.value ? (awards.value[viewing.value.section]?.items[viewing.value.item] ?? null) : null,
)
const assetBase = (item: ShowcaseItem) => `/media/tasks/${item.task_type}/${item.task_display}/`
function docOf(item: ShowcaseItem) {
return buildPreviewDocument({
html: item.html ?? "",
css: item.css ?? "",
js: item.js ?? "",
assetBaseUrl: assetBase(item),
})
}
function openDetail(item: ShowcaseItem) {
const { href } = router.resolve({
name: "submission",
params: { id: item.submission_id },
})
// 一件作品可能同时在几个奖项里
function awardsOf(submissionId: string) {
return awards.value
.filter((a) => a.items.some((i) => i.submission_id === submissionId))
.map((a) => a.name)
}
function jump(id: number) {
document.getElementById(`award-${id}`)?.scrollIntoView({ behavior: "smooth", block: "start" })
}
function open(section: number, item: number) {
viewing.value = { section, item }
}
function step(d: number) {
const v = viewing.value
if (!v) return
const n = awards.value[v.section]?.items.length ?? 0
const item = v.item + d
if (item >= 0 && item < n) viewing.value = { section: v.section, item }
}
// 每打开一件记一次浏览
const counted = new Set<string>()
watch(current, (item) => {
if (!item || counted.has(item.submission_id)) return
counted.add(item.submission_id)
Submission.incrementView(item.submission_id)
.then(() => item.view_count++)
.catch(() => {})
})
function openFull() {
if (!current.value) return
const { href } = router.resolve({ name: "submission", params: { id: current.value.submission_id } })
window.open(href, "_blank")
}
async function init() {
function onKey(e: KeyboardEvent) {
if (!viewing.value || showChain.value) return
if (e.key === "ArrowLeft") step(-1)
else if (e.key === "ArrowRight") step(1)
}
onMounted(async () => {
window.addEventListener("keydown", onKey)
try {
awards.value = await Showcase.list()
} finally {
loading.value = false
}
}
onMounted(init)
})
onUnmounted(() => window.removeEventListener("keydown", onKey))
</script>
<style scoped>
.showcase {
max-width: 1180px;
margin: 0 auto;
padding: 32px 20px 48px;
padding: 28px 20px 48px;
}
.card-grid {
.page-head {
display: flex;
align-items: flex-end;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px 24px;
margin-bottom: 24px;
}
h2 {
margin: 0 0 4px;
font-size: 22px;
}
.muted {
font-size: 12px;
font-weight: 400;
color: var(--wp-muted);
}
.page-head .muted {
margin: 0;
font-size: 13px;
}
.jump {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.jump a {
padding: 3px 10px;
border: 1px solid var(--wp-line);
border-radius: 999px;
font-size: 13px;
color: var(--wp-ink);
text-decoration: none;
background: var(--wp-surface);
}
.jump a:hover {
border-color: var(--wp-brand);
color: var(--wp-brand);
}
section {
margin-bottom: 40px;
scroll-margin-top: 16px;
}
.section-head {
display: flex;
align-items: baseline;
gap: 10px;
}
h3 {
margin: 0;
font-size: 17px;
}
.desc {
margin: 4px 0 0;
font-size: 13px;
color: var(--wp-muted);
}
.grid {
margin-top: 14px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 16px;
}
.work-card {
.card {
padding: 0;
border: 1px solid var(--wp-line);
border-radius: 10px;
overflow: hidden;
background: var(--wp-surface);
color: var(--wp-ink);
font: inherit;
text-align: left;
cursor: pointer;
transition: transform 0.2s ease;
transition:
border-color 0.15s,
transform 0.15s;
}
.work-card:hover {
.card:hover {
border-color: var(--wp-brand);
transform: translateY(-2px);
}
.card-preview {
.thumb {
position: relative;
height: 160px;
aspect-ratio: 16 / 10;
overflow: hidden;
background: var(--wp-paper);
border-bottom: 1px solid var(--wp-line);
}
.preview-iframe {
/* 按 2 倍大小渲染再缩小 */
.thumb iframe {
position: absolute;
top: 0;
left: 0;
width: 200%;
height: 200%;
border: none;
border: 0;
transform: scale(0.5);
transform-origin: top left;
transform-origin: 0 0;
pointer-events: none;
}
.preview-overlay {
position: absolute;
inset: 0;
.cap {
padding: 10px 12px 12px;
}
.card-info {
min-height: 72px;
padding: 10px 12px;
border-top: 1px solid var(--wp-line);
.who {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
font-size: 13px;
}
.who b {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.score {
flex: none;
display: inline-flex;
align-items: center;
gap: 3px;
font: 12px var(--wp-font-mono);
color: var(--wp-warn);
}
.task {
margin-top: 4px;
font-size: 12px;
color: var(--wp-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.viewer-head {
display: flex;
align-items: baseline;
gap: 10px;
min-width: 0;
}
.viewer {
display: grid;
grid-template-columns: 1fr 230px;
gap: 16px;
height: 72vh;
}
.big {
display: flex;
border: 1px solid var(--wp-line);
border-radius: 8px;
overflow: hidden;
}
.big iframe {
flex: 1;
border: 0;
}
.info {
display: flex;
flex-direction: column;
gap: 14px;
}
.label {
margin-bottom: 4px;
font-size: 12px;
color: var(--wp-muted);
}
.awards {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.award {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 4px;
font-size: 13px;
color: var(--wp-warn);
background: var(--wp-warn-soft);
}
.mono {
font: 15px var(--wp-font-mono);
}
.actions {
margin-top: 4px;
}
.actions :deep(.n-button) {
justify-content: flex-start;
}
.hint {
margin-top: auto;
}
</style>
File diff suppressed because it is too large. Load diff
+218 -311
View File
@@ -1,335 +1,217 @@
<template>
<n-split
class="container"
direction="horizontal"
:default-size="0.5"
:min="0.2"
:max="0.8"
>
<template #1>
<n-flex
vertical
style="height: 100%; padding-right: 10px; overflow: hidden"
>
<n-flex align="center" justify="end">
<n-select
v-model:value="query.zone"
style="width: 110px"
clearable
placeholder="得分分区"
:options="zoneOptions"
/>
<n-input
v-if="isAdmin"
style="width: 120px"
v-model:value="query.username"
placeholder="搜索学生"
clearable
/>
<n-pagination
v-model:page="query.page"
:page-size="10"
:item-count="count"
simple
/>
<n-button
secondary
style="padding: 0 10px"
title="刷新"
@click="init"
>
<Icon :width="16" icon="lucide:refresh-cw" />
<div class="subs">
<main class="list">
<div class="toolbar">
<n-input
v-model:value="query.username"
size="small"
clearable
placeholder="搜索学生"
style="width: 150px"
>
<template #prefix><Icon icon="lucide:search" :width="14" /></template>
</n-input>
<n-radio-group v-model:value="query.task_type" size="small">
<n-radio-button value="">全部</n-radio-button>
<n-radio-button value="tutorial">教程</n-radio-button>
<n-radio-button value="challenge">挑战</n-radio-button>
</n-radio-group>
<n-select
v-model:value="query.zone"
size="small"
clearable
placeholder="得分分区"
:options="zoneOptions"
style="width: 110px"
/>
<n-select
v-model:value="query.ordering"
size="small"
:options="orderOptions"
style="width: 130px"
/>
<n-checkbox v-model:checked="query.grouped" size="small">每人每题只看最新一次</n-checkbox>
<span class="spacer"></span>
<span class="muted">共 {{ count }} 条</span>
<n-tooltip>
<template #trigger>
<n-button size="small" quaternary :loading="loading" @click="load">
<template #icon><Icon icon="lucide:refresh-cw" /></template>
</n-button>
</n-flex>
<n-data-table
flex-height
striped
:columns="columns"
:data="data"
:row-key="(row: SubmissionOut) => row.id"
:expanded-row-keys="expandedKeys"
@update:expanded-row-keys="handleExpand"
:row-props="rowProps"
:row-class-name="rowClassName"
style="flex: 1; min-height: 0"
</template>
刷新
</n-tooltip>
</div>
<n-data-table
class="table"
size="small"
flex-height
:loading="loading"
:columns="columns"
:data="data"
:row-key="(row: SubmissionOut) => row.id"
:row-props="rowProps"
:row-class-name="(row: SubmissionOut) => (row.id === selectedId ? 'row-active' : '')"
/>
<n-flex justify="end">
<n-pagination
v-model:page="query.page"
:page-size="PAGE_SIZE"
:item-count="count"
size="small"
/>
</n-flex>
</template>
<template #2>
<div style="height: 100%; padding-left: 10px">
<Preview
v-if="submission.id"
:html="html"
:css="css"
:js="js"
:submission-id="submission.id"
@after-score="afterScore"
@show-code="codeModal = true"
/>
</div>
</template>
</n-split>
</main>
<CodeModal
v-model:show="codeModal"
:html="html"
:css="css"
:js="js"
@copy-to-editor="copyToEditor"
/>
<ChainModal
v-model:show="chainModal"
:submission-id="chainSubmissionId"
:username="chainUsername"
/>
<aside v-if="selectedId" class="detail">
<ReviewPanel
:submission-id="selectedId"
can-delete
@close="selectedId = null"
@changed="applyPatch"
@deleted="onDeleted"
/>
</aside>
</div>
</template>
<script setup lang="ts">
import { computed, h, onMounted, onUnmounted, reactive, ref, watch } from "vue"
import { NButton, NDataTable, NTag, useMessage, type DataTableColumn } from "naive-ui"
import { h, onMounted, reactive, ref, watch } from "vue"
import { useRoute, useRouter } from "vue-router"
import { watchDebounced } from "@vueuse/core"
import type { DataTableColumn } from "naive-ui"
import { Icon } from "../icons"
import { Submission } from "../api"
import type { SubmissionOut } from "../utils/type"
import { parseTime } from "../utils/helper"
import { goHome } from "../utils/helper"
import { saveTaskCode } from "../composables/useTaskCode"
import ReviewPanel from "../components/review/ReviewPanel.vue"
import { TASK_TYPE } from "../utils/const"
import { watchDebounced } from "@vueuse/core"
import { useRouter, useRoute } from "vue-router"
import { parseTime } from "../utils/helper"
import type { FlagType, SubmissionOut } from "../utils/type"
import Preview from "../components/editor/Preview.vue"
import TaskTitle from "../components/submissions/TaskTitle.vue"
import CodeModal from "../components/submissions/CodeModal.vue"
import ChainModal from "../components/submissions/ChainModal.vue"
import ExpandedSubTable from "../components/submissions/ExpandedSubTable.vue"
import { submission } from "../store/submission"
import { roleAdmin, roleSuper } from "../store/user"
// 全站提交列表,留给超管查某个学生、处理有问题的提交;平时批改用“批改”页
const PAGE_SIZE = 20
const route = useRoute()
const router = useRouter()
const message = useMessage()
// 列表数据
const data = ref<SubmissionOut[]>([])
const count = ref(0)
const loading = ref(false)
const selectedId = ref<string | null>(null)
const query = reactive({
page: Number(route.params.page),
username: (Array.isArray(route.query.username)
? ""
: (route.query.username ?? "")) as string,
page: Number(route.params.page) || 1,
username: typeof route.query.username === "string" ? route.query.username : "",
task_type: "" as "" | "tutorial" | "challenge",
zone: null as string | null,
ordering: "-created",
grouped: true,
})
// 当前选中提交的代码
const html = computed(() => submission.value.html)
const css = computed(() => submission.value.css)
const js = computed(() => submission.value.js)
// Modal 状态
const codeModal = ref(false)
const chainModal = ref(false)
const chainSubmissionId = ref<string>("")
const chainUsername = ref<string>("")
// 展开行
const expandedKeys = ref<string[]>([])
const expandedData = reactive(new Map<string, SubmissionOut[]>())
const expandedLoading = reactive(new Set<string>())
// 管理员判断
const isAdmin = computed(() => roleAdmin.value || roleSuper.value)
// 得分分区(按得分高低和评分人数分的三档,见 server/src/services/score.ts)
const zoneOptions = [
{ label: "优秀", value: "featured" },
{ label: "待定", value: "pending" },
{ label: "待提高", value: "low" },
const ZONES: Record<string, string> = { featured: "优秀", pending: "待定", low: "待提高" }
const zoneOptions = Object.entries(ZONES).map(([value, label]) => ({ label, value }))
const orderOptions = [
{ label: "最新的在前", value: "-created" },
{ label: "得分从高到低", value: "-score" },
{ label: "得分从低到高", value: "score" },
]
function showChain(submissionId: string, username: string) {
chainSubmissionId.value = submissionId
chainUsername.value = username
chainModal.value = true
}
// 表格列定义
const columns: DataTableColumn<SubmissionOut>[] = [
{
type: "expand",
expandable: () => true,
renderExpand: (row) =>
h(ExpandedSubTable, {
row,
items: expandedData.get(row.id),
loading: expandedLoading.has(row.id),
onSelect: (id) => getSubmissionByID(id),
onDelete: (r, parentId) => handleDelete(r, parentId),
"onShow-chain": (submissionId, username) =>
showChain(submissionId, username),
}),
},
{
title: "分区",
key: "zone",
width: 64,
render: (row) => {
const map: Record<
string,
{ label: string; type: "success" | "default" | "warning" }
> = {
featured: { label: "优秀", type: "success" },
pending: { label: "待定", type: "default" },
low: { label: "待提高", type: "warning" },
}
if (!row.zone || !map[row.zone]) return null
const { label, type } = map[row.zone]
return h(NTag, { size: "small", round: true, type }, () => label)
},
},
{
title: "时间",
key: "created",
width: 110,
render: (row) => parseTime(row.created, "YYYY-MM-DD HH:mm:ss"),
},
{
title: "提交者",
key: "user",
width: 80,
render: (row) => row.username,
width: 100,
render: (row) => h("span", { class: "mono muted" }, parseTime(row.created, "MM-DD HH:mm")),
},
{ title: "学生", key: "username", width: 120, ellipsis: { tooltip: true } },
{
title: "任务",
key: "task_title",
render: (row) => h(TaskTitle, { submission: row }),
ellipsis: { tooltip: true },
render: (row) => [
h("span", { class: "muted" }, row.task_type === TASK_TYPE.Tutorial ? "教程 " : "挑战 "),
`${row.task_display}. ${row.task_title}`,
],
},
{
title: "得分",
key: "score",
width: 70,
render: (row) => {
const myScore = row.my_score > 0 ? String(row.my_score) : "-"
const avgScore = row.score > 0 ? row.score.toFixed(2) : "-"
return h(
"div",
{ style: { display: "flex", gap: "6px", alignItems: "baseline" } },
[
h("span", avgScore),
h("span", { style: { fontSize: "11px", color: "var(--wp-faint)" } }, myScore),
],
)
},
align: "right",
render: (row) =>
row.score > 0
? h("span", { class: "mono" }, row.score.toFixed(2))
: h("span", { class: "muted" }, "未评"),
},
{
title: "次数",
title: "分区",
key: "zone",
width: 70,
render: (row) => (row.zone ? h("span", { class: `zone z-${row.zone}` }, ZONES[row.zone]) : null),
},
{
title: "共交",
key: "submit_count",
width: 60,
render: (row) => row.submit_count || "-",
},
{
title: "浏览",
key: "view_count",
width: 60,
render: (row) => row.view_count || "-",
align: "right",
render: (row) => h("span", { class: "mono muted" }, `${row.submit_count} 次`),
},
]
async function handleExpand(keys: (string | number)[]) {
const strKeys = keys.map(String)
const newKey = strKeys.find((k) => !expandedKeys.value.includes(k))
expandedKeys.value = strKeys
if (newKey) {
const row = data.value.find((d) => d.id === newKey)
if (row && !expandedData.has(newKey)) {
expandedLoading.add(newKey)
try {
const items = await Submission.listByUserTask(row.userid, row.task_id)
expandedData.set(newKey, items)
} finally {
expandedLoading.delete(newKey)
}
}
}
}
async function handleDelete(row: SubmissionOut, parentId: string) {
try {
await Submission.delete(row.id)
} catch (error: any) {
message.error(error.response?.data?.detail ?? "删除失败,请重试")
return
}
const items = expandedData.get(parentId)
if (items)
expandedData.set(
parentId,
items.filter((d) => d.id !== row.id),
)
if (submission.value.id === row.id) submission.value.id = ""
const res = await Submission.list(query)
data.value = res.items
count.value = res.count
}
function rowProps(row: SubmissionOut) {
return {
style: { cursor: "pointer" },
onClick: () => {
getSubmissionByID(row.id)
handleExpand(
expandedKeys.value.includes(row.id)
? expandedKeys.value.filter((k) => k !== row.id)
: [...expandedKeys.value, row.id],
)
},
onClick: () => (selectedId.value = row.id),
}
}
function rowClassName(row: SubmissionOut) {
return submission.value.id === row.id ? "row-active" : ""
}
// 快速翻页时只用最后一次请求的结果
let initSeq = 0
let seq = 0
async function init() {
const seq = ++initSeq
expandedKeys.value = []
expandedData.clear()
const res = await Submission.list(query)
if (seq !== initSeq) return
data.value = res.items
count.value = res.count
async function load() {
const s = ++seq
loading.value = true
try {
const res = await Submission.list({
page: query.page,
page_size: PAGE_SIZE,
username: query.username || undefined,
task_type: query.task_type || undefined,
zone: query.zone,
ordering: query.ordering,
grouped: query.grouped,
})
if (s !== seq) return
data.value = res.items
count.value = res.count
} finally {
if (s === seq) loading.value = false
}
}
let detailSeq = 0
async function getSubmissionByID(id: string) {
const seq = ++detailSeq
const res = await Submission.get(id)
if (seq === detailSeq) submission.value = res
function applyPatch(patch: { id: string; my_score?: number; score?: number; flag?: FlagType }) {
const row = data.value.find((d) => d.id === patch.id)
if (row) Object.assign(row, patch)
}
function afterScore() {
data.value = data.value.map((d) => {
if (d.id === submission.value.id) d.my_score = submission.value.my_score
return d
})
function onDeleted() {
selectedId.value = null
load()
}
function copyToEditor() {
// 教程和挑战的代码都按任务各存一份,打开那个任务时读到
saveTaskCode(submission.value.task_id, { html: html.value, css: css.value, js: js.value })
goHome(router, submission.value.task_type, submission.value.task_display)
// 换了筛选条件就回到第一页
function reset() {
if (query.page !== 1) query.page = 1
else load()
}
watch(
() => query.page,
(v) => {
init()
router.push({ params: { page: v } })
(page) => {
load()
router.push({ params: { page } })
},
)
// 浏览器前进 / 后退时,页码跟着地址栏走
@@ -340,53 +222,78 @@ watch(
if (page !== query.page) query.page = page
},
)
watchDebounced(
() => query.username,
() => {
if (query.page !== 1) query.page = 1
else init()
},
{ debounce: 500, maxWait: 1000 },
)
watch(
() => query.zone,
() => {
if (query.page !== 1) query.page = 1
else init()
},
)
watch(() => [query.task_type, query.zone, query.ordering, query.grouped], reset)
watchDebounced(() => query.username, reset, { debounce: 500, maxWait: 1000 })
onMounted(init)
onUnmounted(() => {
submission.value = {
id: "",
userid: 0,
username: "",
task_id: 0,
task_display: 0,
task_title: "",
task_type: TASK_TYPE.Tutorial,
score: 0,
my_score: 0,
html: "",
css: "",
js: "",
submit_count: 0,
view_count: 0,
created: new Date(),
modified: new Date(),
}
})
onMounted(load)
</script>
<style scoped>
.container {
padding: 10px;
box-sizing: border-box;
width: 100%;
.subs {
height: 100%;
display: flex;
min-width: 0;
}
:deep(.row-active td) {
background-color: var(--wp-brand-soft) !important;
.list {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 10px;
padding: 10px 12px;
}
.toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 10px;
}
.spacer {
flex: 1;
}
.table {
flex: 1;
min-height: 0;
}
.table :deep(.row-active td) {
background: var(--wp-brand-soft) !important;
}
.table :deep(.mono) {
font-family: var(--wp-font-mono);
}
.muted,
.table :deep(.muted) {
font-size: 12px;
color: var(--wp-muted);
}
.table :deep(.zone) {
font-size: 12px;
}
.table :deep(.z-featured) {
color: var(--wp-brand);
}
.table :deep(.z-low) {
color: var(--wp-warn);
}
.table :deep(.z-pending) {
color: var(--wp-muted);
}
.detail {
width: 440px;
flex: none;
border-left: 1px solid var(--wp-line);
min-height: 0;
}
</style>
+4 -3
View File
@@ -44,9 +44,10 @@ const routes: RouteRecordRaw[] = [
meta: { auth: true },
},
{
path: "/submissions/:page",
// 学生(和老师自己)的“我的提交”;:page 是旧链接留下的,不用了
path: "/submissions/:page?",
name: "submissions",
component: () => import("./pages/Submissions.vue"),
component: () => import("./pages/MySubmissions.vue"),
meta: { auth: true },
},
{
@@ -116,7 +117,7 @@ const routes: RouteRecordRaw[] = [
component: () => import("./pages/Review.vue"),
},
{
// 和顶层的 /submissions 是同一个页面,放在教学侧栏里打开,侧栏不会消失
// 全站提交列表(超管),和学生的“我的提交”是两个页面
path: "submissions/:page",
name: "teach-submissions",
component: () => import("./pages/Submissions.vue"),
-22
View File
@@ -1,22 +0,0 @@
import { ref } from "vue"
import type { SubmissionAll } from "../utils/type"
import { TASK_TYPE } from "../utils/const"
export const submission = ref<SubmissionAll>({
id: "",
userid: 0,
username: "",
task_id: 0,
task_display: 0, // 这是序号
task_title: "",
task_type: TASK_TYPE.Tutorial,
score: 0.0,
my_score: 0,
submit_count: 0,
view_count: 0,
html: "",
css: "",
js: "",
created: new Date(),
modified: new Date(),
})
+14
View File
@@ -23,3 +23,17 @@ export function sourceLabel(source: string | null | undefined) {
if (source === "manual") return "外部 AI"
return "AI 对话"
}
// 提问的 Bloom 等级(services/classifier.ts 后台打的标签)
export const PROMPT_LEVELS: Record<number, { name: string; desc: string }> = {
1: { name: "记忆", desc: "复述或识别知识点" },
2: { name: "理解", desc: "用自己的话解释概念" },
3: { name: "应用", desc: "把知识用到新情境" },
4: { name: "分析", desc: "拆解结构、找出规律" },
5: { name: "评价", desc: "按标准作出判断" },
6: { name: "创造", desc: "整合信息做出新东西" },
}
export function levelLabel(level: number | null | undefined) {
return level ? `L${level} ${PROMPT_LEVELS[level]?.name ?? ""}`.trim() : ""
}
+6 -10
View File
@@ -261,6 +261,7 @@ export interface ShowcaseItem {
username: string
task_title: string
task_display: number
task_type: TASK_TYPE
score: number
view_count: number
html: string | null
@@ -301,28 +302,22 @@ export interface AwardItemUpdateIn {
sort_order: number
}
export interface ShowcaseSubmissionLookupOut {
submission_id: string
username: string
task_title: string
task_display: number
score: number
view_count: number
has_prompt_chain: boolean
}
export interface AwardItemManageOut {
id: number
submission_id: string
username: string
task_title: string
task_display: number
task_type: TASK_TYPE
score: number
view_count: number
sort_order: number
awarded_at: string
has_prompt_chain: boolean
is_stale: boolean
html: string | null
css: string | null
js: string | null
}
export interface ShowcaseDetail {
@@ -330,6 +325,7 @@ export interface ShowcaseDetail {
username: string
task_title: string
task_display: number
task_type: TASK_TYPE
score: number
view_count: number
html: string | null
+9 -27
View File
@@ -20,7 +20,7 @@ const ORDER_BY: Record<string, string> = {
const HAS_CHAIN = `EXISTS (SELECT 1 FROM prompt_message m WHERE m.submission_id = s.id) AS has_prompt_chain`
const ITEM_SELECT = `SELECT sa.id, sa.submission_id, sa.sort_order, sa.awarded_at, sa.is_stale,
u.username, t.title AS task_title, t.display AS task_display,
u.username, t.title AS task_title, t.display AS task_display, t.task_type,
s.score, s.view_count, s.html, s.css, s.js, ${HAS_CHAIN}
FROM submission_submissionaward sa
JOIN submission_submission s ON s.id = sa.submission_id
@@ -34,12 +34,17 @@ function itemManageOut(r: any) {
username: r.username,
task_title: r.task_title,
task_display: r.task_display,
task_type: r.task_type,
score: r.score,
view_count: r.view_count,
sort_order: r.sort_order,
awarded_at: iso(r.awarded_at),
has_prompt_chain: bool(r.has_prompt_chain),
is_stale: bool(r.is_stale),
// 管理页的缩略图要用
html: r.html,
css: r.css,
js: r.js,
}
}
@@ -104,6 +109,7 @@ router.get("/", (c) => {
username: r.username,
task_title: r.task_title,
task_display: r.task_display,
task_type: r.task_type,
score: r.score,
view_count: r.view_count,
html: r.html,
@@ -162,31 +168,6 @@ router.delete("/manage/awards/:award_id", (c) => {
return c.json({ message: "删除成功" })
})
router.get("/manage/submissions/:submission_id", (c) => {
requireAdmin(c)
const id = uuidParam(c, "submission_id")
const s = orNotFound(
get(
`SELECT s.id, u.username, t.title AS task_title, t.display AS task_display,
s.score, s.view_count, ${HAS_CHAIN}
FROM submission_submission s
JOIN account_user u ON u.id = s.user_id
JOIN task_task t ON t.id = s.task_id
WHERE s.id = $id`,
{ id },
),
)
return c.json({
submission_id: uuidOut(s.id),
username: s.username,
task_title: s.task_title,
task_display: s.task_display,
score: s.score,
view_count: s.view_count,
has_prompt_chain: bool(s.has_prompt_chain),
})
})
router.get("/manage/awards/:award_id/items", (c) => {
requireAdmin(c)
const award = orNotFound(
@@ -301,7 +282,7 @@ router.get("/:submission_id", (c) => {
assertAwarded(id)
const s = orNotFound(
get(
`SELECT s.id, u.username, t.title AS task_title, t.display AS task_display,
`SELECT s.id, u.username, t.title AS task_title, t.display AS task_display, t.task_type,
s.score, s.view_count, s.html, s.css, s.js, ${HAS_CHAIN}
FROM submission_submission s
JOIN account_user u ON u.id = s.user_id
@@ -320,6 +301,7 @@ router.get("/:submission_id", (c) => {
username: s.username,
task_title: s.task_title,
task_display: s.task_display,
task_type: s.task_type,
score: s.score,
view_count: s.view_count,
html: s.html,
+1 -1
View File
@@ -415,7 +415,7 @@ describe("作品展", () => {
const author1 = await client("author")
expect((await author1("GET", "/api/submission/showcase/")).json).toHaveLength(1)
expect((await author1("GET", `/api/submission/showcase/${uuid(sub)}/`)).json.awards).toEqual(["最佳"])
expect((await author1("GET", `/api/submission/showcase/${uuid(sub)}/`)).json).toMatchObject({ awards: ["最佳"], task_type: "challenge" })
await adm("PUT", `/api/submission/showcase/manage/awards/${award.id}`, { name: "最佳", is_active: false })
expect((await author1("GET", "/api/submission/showcase/")).json).toEqual([])