Compare commits
5
Commits
06267fb0ff
...
951a6e0826
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
951a6e0826 | ||
|
|
4c9617caf1 | ||
|
|
3fb23dc3f3 | ||
|
|
6ad9be2b30 | ||
|
|
726906e3f6 |
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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()
|
||||
// 留在需要登录或特定角色的页面上没有意义
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
+542
-556
File diff suppressed because it is too large.
Load diff
+218
-311
@@ -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>
|
||||
@@ -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"),
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
@@ -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() : ""
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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([])
|
||||
|
||||
Reference in new issue
Block a user