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,
|
AwardItemManageOut,
|
||||||
GradebookOut,
|
GradebookOut,
|
||||||
GradebookQuery,
|
GradebookQuery,
|
||||||
ShowcaseSubmissionLookupOut,
|
|
||||||
ShowcaseDetail,
|
ShowcaseDetail,
|
||||||
PromptRound,
|
PromptRound,
|
||||||
RandomRatingItem,
|
RandomRatingItem,
|
||||||
@@ -519,15 +518,6 @@ export const Showcase = {
|
|||||||
return res.data
|
return res.data
|
||||||
},
|
},
|
||||||
|
|
||||||
async findSubmissionForAward(
|
|
||||||
submissionId: string,
|
|
||||||
): Promise<ShowcaseSubmissionLookupOut> {
|
|
||||||
const res = await http.get(
|
|
||||||
`/submission/showcase/manage/submissions/${submissionId}`,
|
|
||||||
)
|
|
||||||
return res.data
|
|
||||||
},
|
|
||||||
|
|
||||||
async addAwardItem(
|
async addAwardItem(
|
||||||
id: number,
|
id: number,
|
||||||
payload: AwardItemIn,
|
payload: AwardItemIn,
|
||||||
|
|||||||
@@ -2,9 +2,6 @@
|
|||||||
<n-flex align="center" justify="space-between" class="title">
|
<n-flex align="center" justify="space-between" class="title">
|
||||||
<div>
|
<div>
|
||||||
<slot name="title"><n-text class="titleText">预览</n-text></slot>
|
<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>
|
</div>
|
||||||
<n-flex>
|
<n-flex>
|
||||||
<n-tooltip>
|
<n-tooltip>
|
||||||
@@ -19,26 +16,6 @@
|
|||||||
</n-tooltip>
|
</n-tooltip>
|
||||||
<n-button quaternary @click="download" :disabled="!showDL">下载</n-button>
|
<n-button quaternary @click="download" :disabled="!showDL">下载</n-button>
|
||||||
<n-button quaternary @click="open">全屏</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>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
<div class="iframe-wrapper" :style="iframeWrapperStyle">
|
<div class="iframe-wrapper" :style="iframeWrapperStyle">
|
||||||
@@ -54,12 +31,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { watchDebounced } from "@vueuse/core"
|
import { watchDebounced } from "@vueuse/core"
|
||||||
import { computed, onMounted, onUnmounted, ref, useTemplateRef } from "vue"
|
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 { Icon } from "../../icons"
|
||||||
import copy from "copy-text-to-clipboard"
|
|
||||||
import {
|
import {
|
||||||
buildPreviewDocument,
|
buildPreviewDocument,
|
||||||
PREVIEW_SANDBOX,
|
PREVIEW_SANDBOX,
|
||||||
@@ -75,15 +47,11 @@ interface Props {
|
|||||||
css: string
|
css: string
|
||||||
js: string
|
js: string
|
||||||
assetBaseUrl?: string
|
assetBaseUrl?: string
|
||||||
submissionId?: string
|
|
||||||
showCodeButton?: boolean
|
|
||||||
clearable?: boolean
|
|
||||||
// 自己的编辑器传当前任务 id:代码里的 localStorage 按“用户 + 任务”存下来,刷新后还在
|
// 自己的编辑器传当前任务 id:代码里的 localStorage 按“用户 + 任务”存下来,刷新后还在
|
||||||
storageTaskId?: number
|
storageTaskId?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
const emits = defineEmits(["afterScore", "showCode", "clear"])
|
|
||||||
|
|
||||||
type Layout = "desktop" | "mobile" | "tablet"
|
type Layout = "desktop" | "mobile" | "tablet"
|
||||||
const layouts: Layout[] = ["desktop", "mobile", "tablet"]
|
const layouts: Layout[] = ["desktop", "mobile", "tablet"]
|
||||||
@@ -121,9 +89,6 @@ function cycleLayout() {
|
|||||||
layoutIndex.value = (layoutIndex.value + 1) % layouts.length
|
layoutIndex.value = (layoutIndex.value + 1) % layouts.length
|
||||||
}
|
}
|
||||||
|
|
||||||
const message = useMessage()
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
const showDL = computed(() => props.html || props.css || props.js)
|
const showDL = computed(() => props.html || props.css || props.js)
|
||||||
const previewContent = ref("")
|
const previewContent = ref("")
|
||||||
|
|
||||||
@@ -162,13 +127,6 @@ function download() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function open() {
|
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")
|
const newTab = window.open("about:blank", "_blank")
|
||||||
if (!newTab) return
|
if (!newTab) return
|
||||||
// 新标签页和本站同源,代码要放进带 sandbox 的 iframe 里跑
|
// 新标签页和本站同源,代码要放进带 sandbox 的 iframe 里跑
|
||||||
@@ -183,27 +141,7 @@ function open() {
|
|||||||
listenPreviewStorage(newTab, () => tabFrame, storageScope)
|
listenPreviewStorage(newTab, () => tabFrame, storageScope)
|
||||||
tabFrame.srcdoc = getContent()
|
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(
|
watchDebounced(
|
||||||
() => [props.html, props.css, props.js, props.assetBaseUrl],
|
() => [props.html, props.css, props.js, props.assetBaseUrl],
|
||||||
|
|||||||
@@ -121,11 +121,7 @@ const userMenu = [
|
|||||||
|
|
||||||
async function clickUserMenu(key: string) {
|
async function clickUserMenu(key: string) {
|
||||||
if (key === "submissions") {
|
if (key === "submissions") {
|
||||||
router.push({
|
router.push({ name: "submissions" })
|
||||||
name: "submissions",
|
|
||||||
params: { page: 1 },
|
|
||||||
query: { username: user.username },
|
|
||||||
})
|
|
||||||
} else if (key === "logout") {
|
} else if (key === "logout") {
|
||||||
await logout()
|
await logout()
|
||||||
// 留在需要登录或特定角色的页面上没有意义
|
// 留在需要登录或特定角色的页面上没有意义
|
||||||
|
|||||||
@@ -111,16 +111,27 @@
|
|||||||
</n-dropdown>
|
</n-dropdown>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
<div class="muted hint">P 键:加入 / 取消课上展示</div>
|
<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>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
|
<n-flex justify="space-between" align="center" class="chain-head">
|
||||||
<h4>提示词 <span class="muted" v-if="chain.length">· {{ chain.length }} 轮</span></h4>
|
<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>
|
<div v-if="!chain.length" class="muted">这份提交没有关联的提示词</div>
|
||||||
<ol class="chain">
|
<ol class="chain">
|
||||||
<li v-for="(r, i) in chain" :key="i">
|
<li v-for="(r, i) in chain" :key="i">
|
||||||
<n-flex :size="6" align="center">
|
<n-flex :size="6" align="center">
|
||||||
<span class="src">{{ sourceLabel(r.source) }}</span>
|
<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>
|
</n-flex>
|
||||||
<div class="q">{{ r.question }}</div>
|
<div class="q">{{ r.question }}</div>
|
||||||
</li>
|
</li>
|
||||||
@@ -129,6 +140,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</n-spin>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
@@ -137,7 +155,8 @@ import { useMessage } from "naive-ui"
|
|||||||
import { Icon } from "../../icons"
|
import { Icon } from "../../icons"
|
||||||
import { Showcase, Submission } from "../../api"
|
import { Showcase, Submission } from "../../api"
|
||||||
import { buildPreviewDocument, PREVIEW_SANDBOX } from "../../utils/previewDocument"
|
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 {
|
import type {
|
||||||
AwardManageOut,
|
AwardManageOut,
|
||||||
FlagType,
|
FlagType,
|
||||||
@@ -155,6 +174,8 @@ const props = defineProps<{
|
|||||||
canWiden?: boolean
|
canWiden?: boolean
|
||||||
// 作品墙里用:能不能切到上一个 / 下一个学生
|
// 作品墙里用:能不能切到上一个 / 下一个学生
|
||||||
nav?: { prev: boolean; next: boolean }
|
nav?: { prev: boolean; next: boolean }
|
||||||
|
// 全部提交页(超管):能删除这一版
|
||||||
|
canDelete?: boolean
|
||||||
}>()
|
}>()
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
prev: []
|
prev: []
|
||||||
@@ -163,6 +184,7 @@ const emit = defineEmits<{
|
|||||||
"toggle-wide": []
|
"toggle-wide": []
|
||||||
// 让作品墙同步更新这张卡片
|
// 让作品墙同步更新这张卡片
|
||||||
changed: [patch: { id: string; my_score?: number; score?: number; flag?: FlagType }]
|
changed: [patch: { id: string; my_score?: number; score?: number; flag?: FlagType }]
|
||||||
|
deleted: [id: string]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
@@ -172,6 +194,7 @@ const sub = ref<SubmissionAll | null>(null)
|
|||||||
const versions = ref<SubmissionOut[]>([])
|
const versions = ref<SubmissionOut[]>([])
|
||||||
const chain = ref<PromptRound[]>([])
|
const chain = ref<PromptRound[]>([])
|
||||||
const rating = ref(false)
|
const rating = ref(false)
|
||||||
|
const showChain = ref(false)
|
||||||
|
|
||||||
let awardsCache: Promise<AwardManageOut[]> | null = null
|
let awardsCache: Promise<AwardManageOut[]> | null = null
|
||||||
const awards = ref<AwardManageOut[]>([])
|
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() {
|
function openFull() {
|
||||||
if (sub.value) window.open(`/submission/${sub.value.id}`, "_blank")
|
if (sub.value) window.open(`/submission/${sub.value.id}`, "_blank")
|
||||||
}
|
}
|
||||||
@@ -397,6 +433,18 @@ h4 {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chain-head {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chain-head h4 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.rates {
|
.rates {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
|||||||
@@ -2,232 +2,81 @@
|
|||||||
<n-modal
|
<n-modal
|
||||||
:show="show"
|
:show="show"
|
||||||
preset="card"
|
preset="card"
|
||||||
style="width: 90vw; max-width: 1400px"
|
style="width: 92vw; max-width: 1280px"
|
||||||
@update:show="$emit('update:show', $event)"
|
:bordered="false"
|
||||||
|
@update:show="emit('update:show', $event)"
|
||||||
>
|
>
|
||||||
<template #header>
|
<template #header>
|
||||||
<n-flex justify="start" align="center">
|
<div class="head">
|
||||||
<span>提示词</span>
|
<b>提示词</b>
|
||||||
<n-tooltip placement="bottom-start">
|
<span v-if="rounds.length" class="muted">{{ rounds.length }} 轮</span>
|
||||||
|
<span v-if="title" class="muted title">{{ title }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #header-extra>
|
||||||
|
<n-popover trigger="click" placement="bottom-end">
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<Icon icon="lucide:info" :width="20" style="color: var(--wp-faint)" />
|
<n-button size="small" quaternary style="margin-right: 8px">
|
||||||
|
<template #icon><Icon icon="lucide:info" /></template>
|
||||||
|
L1–L6 是什么
|
||||||
|
</n-button>
|
||||||
</template>
|
</template>
|
||||||
<div style="line-height: 2">
|
<div class="levels">
|
||||||
<div>
|
<div class="levels-note">按布鲁姆认知层级给每次提问打的标签,越高越需要思考:</div>
|
||||||
<span :style="{ color: levelColors[1], fontWeight: 'bold' }">
|
<div v-for="(l, n) in PROMPT_LEVELS" :key="n">
|
||||||
L1
|
<b>L{{ n }} {{ l.name }}</b>:{{ l.desc }}
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
</n-tooltip>
|
</n-popover>
|
||||||
</n-flex>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<n-spin :show="loading">
|
<n-spin :show="loading">
|
||||||
<n-empty
|
<n-empty
|
||||||
v-if="!loading && rounds.length === 0"
|
v-if="!loading && !rounds.length"
|
||||||
description="暂无对话记录"
|
description="这份提交没有关联的提示词"
|
||||||
|
style="padding: 80px 0"
|
||||||
/>
|
/>
|
||||||
<div
|
<div v-else class="body">
|
||||||
v-else
|
<ol class="rounds">
|
||||||
style="
|
<li
|
||||||
display: grid;
|
v-for="(r, i) in rounds"
|
||||||
grid-template-columns: 1fr 1fr;
|
:key="i"
|
||||||
gap: 16px;
|
:class="['round', { on: i === selected }]"
|
||||||
height: 75vh;
|
@click="selected = i"
|
||||||
"
|
|
||||||
>
|
>
|
||||||
<div
|
<div class="round-head">
|
||||||
style="
|
<span class="num">{{ i + 1 }}</span>
|
||||||
overflow-y: auto;
|
<span :class="['src', `src-${r.source || 'conversation'}`]">
|
||||||
padding-right: 8px;
|
{{ sourceLabel(r.source) }}
|
||||||
border-right: 1px solid var(--wp-line);
|
</span>
|
||||||
display: flex;
|
<span v-if="r.prompt_level" class="level">{{ levelLabel(r.prompt_level) }}</span>
|
||||||
flex-direction: column;
|
<span v-if="i === rounds.length - 1" class="final">交的这一版</span>
|
||||||
gap: 8px;
|
<span v-else-if="!r.html" class="muted no-code">没有网页</span>
|
||||||
"
|
|
||||||
>
|
|
||||||
<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>
|
||||||
<div
|
<div
|
||||||
:style="{
|
:class="['q', 'markdown-body', { clamp: isLong(r.question) && !expanded.has(i) }]"
|
||||||
flex: 1,
|
v-html="renderMarkdown(r.question)"
|
||||||
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>
|
||||||
<div
|
<button v-if="isLong(r.question)" class="more" @click.stop="toggle(i)">
|
||||||
style="
|
{{ expanded.has(i) ? "收起" : "展开全部" }}
|
||||||
display: flex;
|
</button>
|
||||||
flex-direction: row;
|
</li>
|
||||||
align-items: center;
|
</ol>
|
||||||
gap: 4px;
|
|
||||||
flex-shrink: 0;
|
<div class="stage">
|
||||||
"
|
<div class="stage-head">
|
||||||
>
|
<b>第 {{ selected + 1 }} 轮生成的网页</b>
|
||||||
<span
|
<span class="muted">↑ ↓ 切换</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>
|
|
||||||
</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 }} 轮网页
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="frame">
|
||||||
<iframe
|
<iframe
|
||||||
v-if="selectedPageHtml"
|
v-if="doc"
|
||||||
:srcdoc="selectedPageHtml"
|
:key="selected"
|
||||||
:key="selectedRound"
|
:sandbox="PREVIEW_SANDBOX"
|
||||||
sandbox="allow-scripts"
|
:srcdoc="doc"
|
||||||
style="
|
></iframe>
|
||||||
flex: 1;
|
<n-empty v-else description="这一轮没有生成网页" style="margin: auto" />
|
||||||
border: 1px solid var(--wp-line);
|
</div>
|
||||||
border-radius: 6px;
|
|
||||||
background: var(--wp-surface);
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
<n-empty v-else description="该轮无网页代码" style="margin: auto" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</n-spin>
|
</n-spin>
|
||||||
@@ -235,157 +84,301 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from "vue"
|
import { computed, nextTick, onUnmounted, ref, watch } from "vue"
|
||||||
import { NPopconfirm, NButton, useMessage } from "naive-ui"
|
|
||||||
import { Icon } from "../../icons"
|
import { Icon } from "../../icons"
|
||||||
|
import { Showcase, Submission } from "../../api"
|
||||||
import { renderMarkdown } from "../../utils/markdown"
|
import { renderMarkdown } from "../../utils/markdown"
|
||||||
import { sourceLabel } from "../../utils/helper"
|
import { levelLabel, PROMPT_LEVELS, sourceLabel } from "../../utils/helper"
|
||||||
import { storageShim } from "../../utils/previewStorage"
|
import { buildPreviewDocument, PREVIEW_SANDBOX } from "../../utils/previewDocument"
|
||||||
import { Prompt, Submission } from "../../api"
|
|
||||||
import type { PromptRound } from "../../utils/type"
|
import type { PromptRound } from "../../utils/type"
|
||||||
import { user, roleSuper } from "../../store/user"
|
|
||||||
|
|
||||||
const message = useMessage()
|
|
||||||
|
|
||||||
|
// 一份提交是怎么一轮一轮问出来的:左边每轮的提问,右边这一轮生成的网页
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
show: boolean
|
show: boolean
|
||||||
submissionId: string
|
submissionId: string
|
||||||
username?: string
|
// 例如“张三 · 个人名片”
|
||||||
|
title?: string
|
||||||
|
// 题目素材的地址,网页里的图片要用
|
||||||
|
assetBaseUrl?: string
|
||||||
|
// 作品展里的作品走作品展的接口(不用是老师或作者本人)
|
||||||
|
fromShowcase?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
const emit = defineEmits<{ "update:show": [value: boolean] }>()
|
||||||
const canDelete = computed(
|
|
||||||
() =>
|
|
||||||
roleSuper.value || (!!props.username && props.username === user.username),
|
|
||||||
)
|
|
||||||
|
|
||||||
defineEmits<{ "update:show": [value: boolean] }>()
|
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const selectedRound = ref(0)
|
const rounds = ref<PromptRound[]>([])
|
||||||
const expandedRounds = ref<Set<number>>(new Set())
|
const selected = ref(0)
|
||||||
type ChainRound = Omit<PromptRound, "source"> & {
|
const expanded = ref(new Set<number>())
|
||||||
source: string | null
|
|
||||||
assistantMsgId: number | null
|
|
||||||
}
|
|
||||||
const rounds = ref<ChainRound[]>([])
|
|
||||||
|
|
||||||
async function deleteRound(index: number) {
|
const doc = computed(() => {
|
||||||
const round = rounds.value[index]
|
const r = rounds.value[selected.value]
|
||||||
if (!round.assistantMsgId) return
|
if (!r?.html) return ""
|
||||||
try {
|
return buildPreviewDocument({
|
||||||
await Prompt.deleteMessagePair(round.assistantMsgId)
|
html: r.html,
|
||||||
} catch (error: any) {
|
css: r.css ?? "",
|
||||||
message.error(error.response?.data?.detail ?? "删除失败,请重试")
|
js: r.js ?? "",
|
||||||
return
|
assetBaseUrl: props.assetBaseUrl,
|
||||||
}
|
})
|
||||||
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>`
|
|
||||||
})
|
})
|
||||||
|
|
||||||
function isPromptLong(text: string): boolean {
|
function isLong(text: string) {
|
||||||
return text.length > 220 || text.split(/\r?\n/).length > 4
|
return text.length > 200 || text.split(/\r?\n/).length > 4
|
||||||
}
|
}
|
||||||
|
|
||||||
function isExpanded(index: number): boolean {
|
function toggle(i: number) {
|
||||||
return expandedRounds.value.has(index)
|
const next = new Set(expanded.value)
|
||||||
|
if (!next.delete(i)) next.add(i)
|
||||||
|
expanded.value = next
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleExpanded(index: number) {
|
let seq = 0
|
||||||
const next = new Set(expandedRounds.value)
|
async function load() {
|
||||||
if (next.has(index)) {
|
const s = ++seq
|
||||||
next.delete(index)
|
|
||||||
} else {
|
|
||||||
next.add(index)
|
|
||||||
}
|
|
||||||
expandedRounds.value = next
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadMessages() {
|
|
||||||
if (!props.submissionId) return
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
rounds.value = []
|
rounds.value = []
|
||||||
selectedRound.value = 0
|
expanded.value = new Set()
|
||||||
expandedRounds.value = new Set()
|
|
||||||
try {
|
try {
|
||||||
const data = await Submission.getPromptChain(props.submissionId)
|
const data = props.fromShowcase
|
||||||
rounds.value = data.map((round) => ({
|
? await Showcase.getPromptChain(props.submissionId)
|
||||||
...round,
|
: await Submission.getPromptChain(props.submissionId)
|
||||||
source: round.source ?? null,
|
if (s !== seq) return
|
||||||
assistantMsgId: round.assistant_msg_id ?? null,
|
rounds.value = data
|
||||||
}))
|
// 默认看交的那一版
|
||||||
const last = rounds.value.length - 1
|
selected.value = Math.max(0, data.length - 1)
|
||||||
if (last >= 0) selectedRound.value = last
|
await nextTick()
|
||||||
|
document.querySelector(".rounds .round.on")?.scrollIntoView({ block: "nearest" })
|
||||||
} catch {
|
} catch {
|
||||||
rounds.value = []
|
if (s === seq) rounds.value = []
|
||||||
} finally {
|
} 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(
|
watch(
|
||||||
() => [props.show, props.submissionId] as const,
|
() => [props.show, props.submissionId] as const,
|
||||||
([visible]) => {
|
([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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.prompt-markdown {
|
.head {
|
||||||
flex: 1;
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
background: transparent;
|
|
||||||
color: inherit;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.prompt-markdown.is-collapsed {
|
.title {
|
||||||
position: relative;
|
white-space: nowrap;
|
||||||
max-height: 126px;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prompt-markdown :deep(p),
|
.muted {
|
||||||
.prompt-markdown :deep(ul),
|
font-size: 12px;
|
||||||
.prompt-markdown :deep(ol),
|
font-weight: 400;
|
||||||
.prompt-markdown :deep(blockquote),
|
color: var(--wp-muted);
|
||||||
.prompt-markdown :deep(pre) {
|
|
||||||
margin-top: 0;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.prompt-markdown :deep(:last-child) {
|
.levels {
|
||||||
margin-bottom: 0;
|
font-size: 13px;
|
||||||
|
line-height: 1.9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prompt-markdown :deep(pre) {
|
.levels-note {
|
||||||
padding: 8px;
|
color: var(--wp-muted);
|
||||||
overflow-x: auto;
|
}
|
||||||
|
|
||||||
|
.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;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prompt-markdown :deep(code) {
|
.num {
|
||||||
white-space: pre-wrap;
|
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;
|
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>
|
</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>
|
|
||||||
+352
-220
@@ -1,95 +1,116 @@
|
|||||||
<template>
|
<template>
|
||||||
<n-flex
|
<div class="gradebook">
|
||||||
vertical
|
<div class="toolbar">
|
||||||
: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
|
<n-select
|
||||||
v-model:value="query.classname"
|
v-model:value="query.classname"
|
||||||
class="class-select"
|
class="class-select"
|
||||||
:options="classOptions"
|
:options="classOptions"
|
||||||
placeholder="班级"
|
placeholder="班级"
|
||||||
|
size="small"
|
||||||
:loading="classesLoading"
|
:loading="classesLoading"
|
||||||
/>
|
/>
|
||||||
<n-select
|
<n-radio-group v-model:value="query.task_type" size="small">
|
||||||
v-model:value="query.task_type"
|
<n-radio-button value="">全部</n-radio-button>
|
||||||
class="type-select"
|
<n-radio-button value="tutorial">教程</n-radio-button>
|
||||||
:options="taskTypeOptions"
|
<n-radio-button value="challenge">挑战</n-radio-button>
|
||||||
/>
|
</n-radio-group>
|
||||||
<n-input
|
<n-input
|
||||||
v-model:value="query.username"
|
v-model:value="query.username"
|
||||||
class="search-input"
|
class="search-input"
|
||||||
|
size="small"
|
||||||
clearable
|
clearable
|
||||||
placeholder="学生搜索"
|
placeholder="搜索学生"
|
||||||
/>
|
>
|
||||||
<n-switch v-model:value="query.include_all_tasks">
|
<template #prefix><Icon icon="lucide:search" :width="14" /></template>
|
||||||
<template #checked>全部有提交任务</template>
|
</n-input>
|
||||||
<template #unchecked>只看计入任务</template>
|
<n-checkbox v-model:checked="query.include_all_tasks" size="small">
|
||||||
</n-switch>
|
显示不计入的任务
|
||||||
</n-flex>
|
</n-checkbox>
|
||||||
<n-flex align="center" :size="8">
|
|
||||||
|
<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
|
<n-button
|
||||||
secondary
|
size="small"
|
||||||
title="刷新"
|
quaternary
|
||||||
:disabled="!query.classname"
|
:disabled="!query.classname"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
@click="loadGradebook"
|
@click="loadGradebook"
|
||||||
>
|
>
|
||||||
<template #icon>
|
<template #icon><Icon icon="lucide:refresh-cw" /></template>
|
||||||
<Icon icon="lucide:refresh-cw" :width="15" />
|
|
||||||
</template>
|
|
||||||
</n-button>
|
</n-button>
|
||||||
|
</template>
|
||||||
|
刷新
|
||||||
|
</n-tooltip>
|
||||||
<n-button
|
<n-button
|
||||||
type="primary"
|
size="small"
|
||||||
secondary
|
secondary
|
||||||
:disabled="!query.classname || !gradebook"
|
type="primary"
|
||||||
|
:disabled="!gradebook"
|
||||||
:loading="exporting"
|
:loading="exporting"
|
||||||
@click="exportCsv"
|
@click="exportCsv"
|
||||||
>
|
>
|
||||||
<template #icon>
|
<template #icon><Icon icon="lucide:download" /></template>
|
||||||
<Icon icon="lucide:download" :width="15" />
|
|
||||||
</template>
|
|
||||||
导出 CSV
|
导出 CSV
|
||||||
</n-button>
|
</n-button>
|
||||||
</n-flex>
|
</div>
|
||||||
</n-flex>
|
|
||||||
|
|
||||||
<n-alert v-if="loadError" type="error" closable @close="loadError = ''">
|
<n-alert v-if="loadError" type="error" closable @close="loadError = ''">
|
||||||
{{ loadError }}
|
{{ loadError }}
|
||||||
</n-alert>
|
</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
|
<n-data-table
|
||||||
|
class="table gradebook-table"
|
||||||
size="small"
|
size="small"
|
||||||
striped
|
|
||||||
flex-height
|
flex-height
|
||||||
|
:single-line="false"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:data="rows"
|
:data="rows"
|
||||||
:row-key="(row: GradebookRow) => row.user_id"
|
:row-key="(row: GradebookRow) => row.user_id"
|
||||||
:scroll-x="scrollX"
|
:scroll-x="scrollX"
|
||||||
style="flex: 1; min-height: 0;"
|
|
||||||
/>
|
/>
|
||||||
</n-flex>
|
</div>
|
||||||
|
|
||||||
<n-drawer
|
<n-drawer
|
||||||
:show="!!reviewId"
|
:show="!!reviewId"
|
||||||
width="min(1180px, 92vw)"
|
width="min(1180px, 92vw)"
|
||||||
placement="right"
|
placement="right"
|
||||||
@update:show="(v: boolean) => !v && (reviewId = null)"
|
@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>
|
</n-drawer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -98,13 +119,7 @@ import ReviewPanel from "../components/review/ReviewPanel.vue"
|
|||||||
import { computed, h, onMounted, reactive, ref, watch } from "vue"
|
import { computed, h, onMounted, reactive, ref, watch } from "vue"
|
||||||
import { Icon } from "../icons"
|
import { Icon } from "../icons"
|
||||||
import { watchDebounced } from "@vueuse/core"
|
import { watchDebounced } from "@vueuse/core"
|
||||||
import {
|
import { useMessage, type DataTableColumn } from "naive-ui"
|
||||||
NButton,
|
|
||||||
NTag,
|
|
||||||
NText,
|
|
||||||
useMessage,
|
|
||||||
type DataTableColumn,
|
|
||||||
} from "naive-ui"
|
|
||||||
import { Account, Gradebook } from "../api"
|
import { Account, Gradebook } from "../api"
|
||||||
import { displayGradebookStudentName } from "../utils/gradebook"
|
import { displayGradebookStudentName } from "../utils/gradebook"
|
||||||
import type {
|
import type {
|
||||||
@@ -113,9 +128,11 @@ import type {
|
|||||||
GradebookQuery,
|
GradebookQuery,
|
||||||
GradebookRow,
|
GradebookRow,
|
||||||
GradebookTask,
|
GradebookTask,
|
||||||
GradebookTaskType,
|
|
||||||
} from "../utils/type"
|
} from "../utils/type"
|
||||||
|
|
||||||
|
// 和批改页共用“当前班级”(那边的“全部班级”在这里不适用)
|
||||||
|
const CLASS_KEY = "review-class"
|
||||||
|
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
const classesLoading = ref(false)
|
const classesLoading = ref(false)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -131,28 +148,19 @@ const query = reactive<GradebookQuery>({
|
|||||||
include_all_tasks: false,
|
include_all_tasks: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const taskTypeOptions: { label: string; value: GradebookTaskType | "" }[] = [
|
|
||||||
{ label: "全部", value: "" },
|
|
||||||
{ label: "教程", value: "tutorial" },
|
|
||||||
{ label: "挑战", value: "challenge" },
|
|
||||||
]
|
|
||||||
|
|
||||||
const classOptions = computed(() =>
|
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 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) {
|
function formatScore(value: number | null) {
|
||||||
if (value === null) return "-"
|
if (value === null) return "—"
|
||||||
return Number.isInteger(value) ? String(value) : value.toFixed(2)
|
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)
|
const reviewId = ref<string | null>(null)
|
||||||
function openSubmission(cell: GradebookCell) {
|
function openSubmission(cell: GradebookCell) {
|
||||||
@@ -160,152 +168,135 @@ function openSubmission(cell: GradebookCell) {
|
|||||||
reviewId.value = cell.submission_id
|
reviewId.value = cell.submission_id
|
||||||
}
|
}
|
||||||
|
|
||||||
function gradeTagType(grade: string) {
|
function renderGrade(grade: string, extra?: string | number) {
|
||||||
if (grade === "A") return "success"
|
return h("span", { class: ["grade", `g-${grade}`] }, [
|
||||||
if (grade === "B") return "info"
|
grade,
|
||||||
if (grade === "C") return "default"
|
extra === undefined ? null : h("small", String(extra)),
|
||||||
return "warning"
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderTaskHeader(task: GradebookTask) {
|
function renderTaskHeader(task: GradebookTask) {
|
||||||
return h("div", { class: ["task-header", { muted: !task.included }] }, [
|
const pct = Math.round(task.coverage * 100)
|
||||||
h("div", { class: "task-title", title: task.title }, taskTitle(task)),
|
const studentCount = gradebook.value?.student_count ?? 0
|
||||||
h("div", { class: "task-meta" }, [
|
return h(
|
||||||
h("span", `${Math.round(task.coverage * 100)}%`),
|
"div",
|
||||||
task.included
|
{
|
||||||
? null
|
class: ["task-head", { excluded: !task.included }],
|
||||||
: h(NTag, { size: "tiny", round: false }, { default: () => "未计入" }),
|
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) {
|
function renderScore(row: GradebookRow, task: GradebookTask) {
|
||||||
const cell = row.scores[task.id]
|
const cell = row.scores[task.id]
|
||||||
if (!cell || !cell.submitted) {
|
if (!cell?.submitted)
|
||||||
|
return h("span", { class: ["cell", "missing", { excluded: !task.included }] }, "缺")
|
||||||
return h(
|
return h(
|
||||||
NText,
|
"button",
|
||||||
{ class: "missing-cell", type: "error" },
|
|
||||||
{ default: () => "缺交" },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return h(
|
|
||||||
NButton,
|
|
||||||
{
|
{
|
||||||
text: true,
|
class: ["cell", "score", { unrated: cell.score === 0, excluded: !task.included }],
|
||||||
type: task.included ? "primary" : "default",
|
title: cell.score === 0 ? "交了,还没人评分" : `${formatScore(cell.score)},点开批改`,
|
||||||
class: ["score-link", { muted: !task.included }],
|
onClick: () => openSubmission(cell),
|
||||||
onClick: (event: MouseEvent) => {
|
|
||||||
event.stopPropagation()
|
|
||||||
openSubmission(cell)
|
|
||||||
},
|
},
|
||||||
},
|
cell.score === 0 ? "未评" : cell.score.toFixed(1),
|
||||||
{ default: () => formatScore(cell.score) },
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderMissingCount(value: number) {
|
function taskColumn(task: GradebookTask): DataTableColumn<GradebookRow> {
|
||||||
if (value <= 0) return "0"
|
return {
|
||||||
return h(NText, { type: "error" }, { default: () => String(value) })
|
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 columns = computed<DataTableColumn<GradebookRow>[]>(() => {
|
||||||
const tasks = gradebook.value?.tasks ?? []
|
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 [
|
return [
|
||||||
{
|
{
|
||||||
title: "排名",
|
title: "#",
|
||||||
key: "rank",
|
key: "rank",
|
||||||
width: 60,
|
width: 40,
|
||||||
fixed: "left",
|
fixed: "left",
|
||||||
},
|
align: "right",
|
||||||
{
|
sorter: (a, b) => a.rank - b.rank,
|
||||||
title: "平时等级",
|
render: (row) => h("span", { class: "rank" }, row.rank),
|
||||||
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,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "学生",
|
title: "学生",
|
||||||
key: "username",
|
key: "username",
|
||||||
width: 80,
|
width: 92,
|
||||||
fixed: "left",
|
fixed: "left",
|
||||||
render: (row) => {
|
ellipsis: { tooltip: true },
|
||||||
const studentName = displayGradebookStudentName(row)
|
render: (row) => displayGradebookStudentName(row),
|
||||||
return h(NText, { title: studentName }, { default: () => studentName })
|
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "平时",
|
||||||
|
key: "grade",
|
||||||
|
width: 52,
|
||||||
|
fixed: "left",
|
||||||
|
align: "center",
|
||||||
|
render: (row) => renderGrade(row.grade),
|
||||||
},
|
},
|
||||||
...tasks.map((task) => ({
|
{
|
||||||
title: () => renderTaskHeader(task),
|
title: "考核",
|
||||||
key: `task-${task.id}`,
|
key: "assessment_score",
|
||||||
width: 96,
|
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,
|
align: "center" as const,
|
||||||
className: task.included ? "" : "excluded-task-column",
|
children: g.items.map(taskColumn),
|
||||||
render: (row: GradebookRow) => renderScore(row, task),
|
|
||||||
})),
|
})),
|
||||||
{
|
|
||||||
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: "总分",
|
title: "总分",
|
||||||
key: "total_score",
|
key: "total_score",
|
||||||
width: 82,
|
width: 72,
|
||||||
fixed: "right",
|
fixed: "right",
|
||||||
render: (row) => formatScore(row.total_score),
|
align: "right",
|
||||||
},
|
sorter: (a, b) => a.total_score - b.total_score,
|
||||||
|
render: (row) =>
|
||||||
|
h(
|
||||||
|
"span",
|
||||||
{
|
{
|
||||||
title: "平均",
|
class: "total",
|
||||||
key: "average_score",
|
title: `教程合计 ${formatScore(row.tutorial_total)}\n挑战合计 ${formatScore(row.challenge_total)}\n平均 ${formatScore(row.average_score)}`,
|
||||||
width: 82,
|
},
|
||||||
fixed: "right",
|
formatScore(row.total_score),
|
||||||
render: (row) => formatScore(row.average_score),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "已交",
|
title: "已交",
|
||||||
key: "submitted_task_count",
|
key: "submitted_task_count",
|
||||||
width: 70,
|
width: 64,
|
||||||
fixed: "right",
|
fixed: "right",
|
||||||
},
|
align: "right",
|
||||||
|
sorter: (a, b) => a.missing_task_count - b.missing_task_count,
|
||||||
|
render: (row) =>
|
||||||
|
h(
|
||||||
|
"span",
|
||||||
{
|
{
|
||||||
title: "缺交",
|
class: ["done", { short: row.missing_task_count > 0 }],
|
||||||
key: "missing_task_count",
|
title: row.missing_task_count ? `缺交 ${row.missing_task_count} 个` : "都交了",
|
||||||
width: 70,
|
},
|
||||||
fixed: "right",
|
`${row.submitted_task_count}/${row.submitted_task_count + row.missing_task_count}`,
|
||||||
render: (row) => renderMissingCount(row.missing_task_count),
|
),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
@@ -315,7 +306,11 @@ async function loadClasses() {
|
|||||||
try {
|
try {
|
||||||
classes.value = await Account.listClasses()
|
classes.value = await Account.listClasses()
|
||||||
if (!query.classname && classes.value.length > 0) {
|
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 {
|
} finally {
|
||||||
classesLoading.value = false
|
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(
|
watch(
|
||||||
() => [query.classname, query.task_type, query.include_all_tasks],
|
() => [query.classname, query.task_type, query.include_all_tasks],
|
||||||
() => loadGradebook(),
|
() => loadGradebook(),
|
||||||
@@ -379,67 +384,194 @@ onMounted(async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.gradebook {
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
flex: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.class-select {
|
.class-select {
|
||||||
|
width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
width: 150px;
|
width: 150px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.type-select {
|
.spacer {
|
||||||
width: 112px;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-input {
|
.summary {
|
||||||
width: 160px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-header {
|
|
||||||
min-width: 0;
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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);
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
|
||||||
|
|
||||||
.score-link.muted {
|
|
||||||
color: var(--wp-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);
|
background: var(--wp-paper);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
.gradebook-table .rank {
|
||||||
.toolbar {
|
font: 12px var(--wp-font-mono);
|
||||||
align-items: flex-start;
|
color: var(--wp-faint);
|
||||||
}
|
}
|
||||||
|
|
||||||
.class-select,
|
.gradebook-table .grade {
|
||||||
.type-select,
|
font-weight: 700;
|
||||||
.search-input {
|
|
||||||
width: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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>
|
</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>
|
||||||
+378
-90
@@ -1,167 +1,455 @@
|
|||||||
<template>
|
<template>
|
||||||
<main class="showcase">
|
<main class="showcase">
|
||||||
<n-flex justify="space-between" align="flex-end" style="margin-bottom: 32px;">
|
<header class="page-head">
|
||||||
<div>
|
<div>
|
||||||
<n-h2 style="margin: 0 0 4px;">创意工坊</n-h2>
|
<h2>作品展</h2>
|
||||||
<n-text depth="3">优秀作品展示</n-text>
|
<p class="muted">老师从大家交的作品里挑出来的,点开能看大图和它是怎么问出来的</p>
|
||||||
</div>
|
</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-spin :show="loading">
|
||||||
<n-empty
|
<n-empty
|
||||||
v-if="!loading && awards.length === 0"
|
v-if="!loading && !awards.length"
|
||||||
description="暂无展示作品"
|
description="还没有展出的作品"
|
||||||
style="margin-top: 72px;"
|
style="margin-top: 72px"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<section
|
<section v-for="(section, si) in awards" :id="`award-${section.id}`" :key="section.id">
|
||||||
v-for="section in awards"
|
<div class="section-head">
|
||||||
:key="section.id"
|
<h3>{{ section.name }}</h3>
|
||||||
style="margin-bottom: 48px;"
|
<span class="muted">{{ section.items.length }} 件</span>
|
||||||
>
|
</div>
|
||||||
<n-flex vertical :size="4" style="margin-bottom: 16px;">
|
<p v-if="section.description" class="desc">{{ section.description }}</p>
|
||||||
<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>
|
|
||||||
|
|
||||||
<div class="card-grid">
|
<div class="grid">
|
||||||
<n-card
|
<button
|
||||||
v-for="item in section.items"
|
v-for="(item, ii) in section.items"
|
||||||
:key="item.submission_id"
|
:key="item.submission_id"
|
||||||
class="work-card"
|
class="card"
|
||||||
content-style="padding: 0;"
|
@click="open(si, ii)"
|
||||||
hoverable
|
|
||||||
@click="openDetail(item)"
|
|
||||||
>
|
>
|
||||||
<div class="card-preview">
|
<div class="thumb">
|
||||||
<iframe
|
<iframe
|
||||||
:srcdoc="buildSrcdoc(item)"
|
:srcdoc="docOf(item)"
|
||||||
sandbox="allow-scripts"
|
:sandbox="PREVIEW_SANDBOX"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
scrolling="no"
|
scrolling="no"
|
||||||
class="preview-iframe"
|
tabindex="-1"
|
||||||
/>
|
></iframe>
|
||||||
<div class="preview-overlay" />
|
|
||||||
</div>
|
</div>
|
||||||
<div class="card-info">
|
<div class="cap">
|
||||||
<n-flex justify="space-between" align="center" :wrap="false">
|
<div class="who">
|
||||||
<n-ellipsis style="font-size: 13px; font-weight: 600; min-width: 0; flex: 1;">
|
<b>{{ item.username }}</b>
|
||||||
{{ item.username }}
|
<span v-if="item.score" class="score">
|
||||||
</n-ellipsis>
|
<Icon icon="lucide:star" :width="12" />{{ item.score.toFixed(1) }}
|
||||||
<n-flex align="center" :wrap="false" :size="8" style="flex-shrink: 0;">
|
</span>
|
||||||
<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>
|
</div>
|
||||||
</n-card>
|
<div class="task">{{ item.task_display }}. {{ item.task_title }}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</n-spin>
|
</n-spin>
|
||||||
</main>
|
</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>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { storageShim } from "../utils/previewStorage"
|
import { computed, onMounted, onUnmounted, ref, watch } from "vue"
|
||||||
import { onMounted, ref } from "vue"
|
|
||||||
import { useRouter } from "vue-router"
|
import { useRouter } from "vue-router"
|
||||||
import { Icon } from "../icons"
|
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"
|
import type { AwardSection, ShowcaseItem } from "../utils/type"
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const awards = ref<AwardSection[]>([])
|
const awards = ref<AwardSection[]>([])
|
||||||
|
// 正在看第几个奖项的第几件
|
||||||
|
const viewing = ref<{ section: number; item: number } | null>(null)
|
||||||
|
const showChain = ref(false)
|
||||||
|
|
||||||
function buildSrcdoc(item: ShowcaseItem): string {
|
const current = computed(() =>
|
||||||
const css = item.css ? `<style>${item.css}</style>` : ""
|
viewing.value ? (awards.value[viewing.value.section]?.items[viewing.value.item] ?? null) : null,
|
||||||
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 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({
|
function awardsOf(submissionId: string) {
|
||||||
name: "submission",
|
return awards.value
|
||||||
params: { id: item.submission_id },
|
.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")
|
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 {
|
try {
|
||||||
awards.value = await Showcase.list()
|
awards.value = await Showcase.list()
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
onUnmounted(() => window.removeEventListener("keydown", onKey))
|
||||||
onMounted(init)
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.showcase {
|
.showcase {
|
||||||
max-width: 1180px;
|
max-width: 1180px;
|
||||||
margin: 0 auto;
|
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;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.work-card {
|
.card {
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--wp-line);
|
||||||
|
border-radius: 10px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
background: var(--wp-surface);
|
||||||
|
color: var(--wp-ink);
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
cursor: pointer;
|
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);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-preview {
|
.thumb {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 160px;
|
aspect-ratio: 16 / 10;
|
||||||
overflow: hidden;
|
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%;
|
width: 200%;
|
||||||
height: 200%;
|
height: 200%;
|
||||||
border: none;
|
border: 0;
|
||||||
transform: scale(0.5);
|
transform: scale(0.5);
|
||||||
transform-origin: top left;
|
transform-origin: 0 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-overlay {
|
.cap {
|
||||||
position: absolute;
|
padding: 10px 12px 12px;
|
||||||
inset: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-info {
|
.who {
|
||||||
min-height: 72px;
|
display: flex;
|
||||||
padding: 10px 12px;
|
align-items: center;
|
||||||
border-top: 1px solid var(--wp-line);
|
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>
|
</style>
|
||||||
+525
-539
File diff suppressed because it is too large.
Load diff
+201
-294
@@ -1,335 +1,217 @@
|
|||||||
<template>
|
<template>
|
||||||
<n-split
|
<div class="subs">
|
||||||
class="container"
|
<main class="list">
|
||||||
direction="horizontal"
|
<div class="toolbar">
|
||||||
:default-size="0.5"
|
<n-input
|
||||||
:min="0.2"
|
v-model:value="query.username"
|
||||||
:max="0.8"
|
size="small"
|
||||||
|
clearable
|
||||||
|
placeholder="搜索学生"
|
||||||
|
style="width: 150px"
|
||||||
>
|
>
|
||||||
<template #1>
|
<template #prefix><Icon icon="lucide:search" :width="14" /></template>
|
||||||
<n-flex
|
</n-input>
|
||||||
vertical
|
<n-radio-group v-model:value="query.task_type" size="small">
|
||||||
style="height: 100%; padding-right: 10px; overflow: hidden"
|
<n-radio-button value="">全部</n-radio-button>
|
||||||
>
|
<n-radio-button value="tutorial">教程</n-radio-button>
|
||||||
<n-flex align="center" justify="end">
|
<n-radio-button value="challenge">挑战</n-radio-button>
|
||||||
|
</n-radio-group>
|
||||||
<n-select
|
<n-select
|
||||||
v-model:value="query.zone"
|
v-model:value="query.zone"
|
||||||
style="width: 110px"
|
size="small"
|
||||||
clearable
|
clearable
|
||||||
placeholder="得分分区"
|
placeholder="得分分区"
|
||||||
:options="zoneOptions"
|
:options="zoneOptions"
|
||||||
|
style="width: 110px"
|
||||||
/>
|
/>
|
||||||
<n-input
|
<n-select
|
||||||
v-if="isAdmin"
|
v-model:value="query.ordering"
|
||||||
style="width: 120px"
|
size="small"
|
||||||
v-model:value="query.username"
|
:options="orderOptions"
|
||||||
placeholder="搜索学生"
|
style="width: 130px"
|
||||||
clearable
|
|
||||||
/>
|
/>
|
||||||
<n-pagination
|
<n-checkbox v-model:checked="query.grouped" size="small">每人每题只看最新一次</n-checkbox>
|
||||||
v-model:page="query.page"
|
<span class="spacer"></span>
|
||||||
:page-size="10"
|
<span class="muted">共 {{ count }} 条</span>
|
||||||
:item-count="count"
|
<n-tooltip>
|
||||||
simple
|
<template #trigger>
|
||||||
/>
|
<n-button size="small" quaternary :loading="loading" @click="load">
|
||||||
<n-button
|
<template #icon><Icon icon="lucide:refresh-cw" /></template>
|
||||||
secondary
|
|
||||||
style="padding: 0 10px"
|
|
||||||
title="刷新"
|
|
||||||
@click="init"
|
|
||||||
>
|
|
||||||
<Icon :width="16" icon="lucide:refresh-cw" />
|
|
||||||
</n-button>
|
</n-button>
|
||||||
</n-flex>
|
</template>
|
||||||
|
刷新
|
||||||
|
</n-tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
<n-data-table
|
<n-data-table
|
||||||
|
class="table"
|
||||||
|
size="small"
|
||||||
flex-height
|
flex-height
|
||||||
striped
|
:loading="loading"
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:data="data"
|
:data="data"
|
||||||
:row-key="(row: SubmissionOut) => row.id"
|
:row-key="(row: SubmissionOut) => row.id"
|
||||||
:expanded-row-keys="expandedKeys"
|
|
||||||
@update:expanded-row-keys="handleExpand"
|
|
||||||
:row-props="rowProps"
|
:row-props="rowProps"
|
||||||
:row-class-name="rowClassName"
|
:row-class-name="(row: SubmissionOut) => (row.id === selectedId ? 'row-active' : '')"
|
||||||
style="flex: 1; min-height: 0"
|
/>
|
||||||
|
|
||||||
|
<n-flex justify="end">
|
||||||
|
<n-pagination
|
||||||
|
v-model:page="query.page"
|
||||||
|
:page-size="PAGE_SIZE"
|
||||||
|
:item-count="count"
|
||||||
|
size="small"
|
||||||
/>
|
/>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</template>
|
</main>
|
||||||
<template #2>
|
|
||||||
<div style="height: 100%; padding-left: 10px">
|
<aside v-if="selectedId" class="detail">
|
||||||
<Preview
|
<ReviewPanel
|
||||||
v-if="submission.id"
|
:submission-id="selectedId"
|
||||||
:html="html"
|
can-delete
|
||||||
:css="css"
|
@close="selectedId = null"
|
||||||
:js="js"
|
@changed="applyPatch"
|
||||||
:submission-id="submission.id"
|
@deleted="onDeleted"
|
||||||
@after-score="afterScore"
|
|
||||||
@show-code="codeModal = true"
|
|
||||||
/>
|
/>
|
||||||
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</n-split>
|
|
||||||
|
|
||||||
<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"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, h, onMounted, onUnmounted, reactive, ref, watch } from "vue"
|
import { h, onMounted, reactive, ref, watch } from "vue"
|
||||||
import { NButton, NDataTable, NTag, useMessage, type DataTableColumn } from "naive-ui"
|
import { useRoute, useRouter } from "vue-router"
|
||||||
|
import { watchDebounced } from "@vueuse/core"
|
||||||
|
import type { DataTableColumn } from "naive-ui"
|
||||||
import { Icon } from "../icons"
|
import { Icon } from "../icons"
|
||||||
import { Submission } from "../api"
|
import { Submission } from "../api"
|
||||||
import type { SubmissionOut } from "../utils/type"
|
import ReviewPanel from "../components/review/ReviewPanel.vue"
|
||||||
import { parseTime } from "../utils/helper"
|
|
||||||
import { goHome } from "../utils/helper"
|
|
||||||
import { saveTaskCode } from "../composables/useTaskCode"
|
|
||||||
import { TASK_TYPE } from "../utils/const"
|
import { TASK_TYPE } from "../utils/const"
|
||||||
import { watchDebounced } from "@vueuse/core"
|
import { parseTime } from "../utils/helper"
|
||||||
import { useRouter, useRoute } from "vue-router"
|
import type { FlagType, SubmissionOut } from "../utils/type"
|
||||||
|
|
||||||
import Preview from "../components/editor/Preview.vue"
|
// 全站提交列表,留给超管查某个学生、处理有问题的提交;平时批改用“批改”页
|
||||||
import TaskTitle from "../components/submissions/TaskTitle.vue"
|
const PAGE_SIZE = 20
|
||||||
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 route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const message = useMessage()
|
|
||||||
|
|
||||||
// 列表数据
|
|
||||||
const data = ref<SubmissionOut[]>([])
|
const data = ref<SubmissionOut[]>([])
|
||||||
const count = ref(0)
|
const count = ref(0)
|
||||||
|
const loading = ref(false)
|
||||||
|
const selectedId = ref<string | null>(null)
|
||||||
|
|
||||||
const query = reactive({
|
const query = reactive({
|
||||||
page: Number(route.params.page),
|
page: Number(route.params.page) || 1,
|
||||||
username: (Array.isArray(route.query.username)
|
username: typeof route.query.username === "string" ? route.query.username : "",
|
||||||
? ""
|
task_type: "" as "" | "tutorial" | "challenge",
|
||||||
: (route.query.username ?? "")) as string,
|
|
||||||
zone: null as string | null,
|
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)
|
// 得分分区(按得分高低和评分人数分的三档,见 server/src/services/score.ts)
|
||||||
const zoneOptions = [
|
const ZONES: Record<string, string> = { featured: "优秀", pending: "待定", low: "待提高" }
|
||||||
{ label: "优秀", value: "featured" },
|
const zoneOptions = Object.entries(ZONES).map(([value, label]) => ({ label, value }))
|
||||||
{ label: "待定", value: "pending" },
|
const orderOptions = [
|
||||||
{ label: "待提高", value: "low" },
|
{ 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>[] = [
|
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: "时间",
|
title: "时间",
|
||||||
key: "created",
|
key: "created",
|
||||||
width: 110,
|
width: 100,
|
||||||
render: (row) => parseTime(row.created, "YYYY-MM-DD HH:mm:ss"),
|
render: (row) => h("span", { class: "mono muted" }, parseTime(row.created, "MM-DD HH:mm")),
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "提交者",
|
|
||||||
key: "user",
|
|
||||||
width: 80,
|
|
||||||
render: (row) => row.username,
|
|
||||||
},
|
},
|
||||||
|
{ title: "学生", key: "username", width: 120, ellipsis: { tooltip: true } },
|
||||||
{
|
{
|
||||||
title: "任务",
|
title: "任务",
|
||||||
key: "task_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: "得分",
|
title: "得分",
|
||||||
key: "score",
|
key: "score",
|
||||||
width: 70,
|
width: 70,
|
||||||
render: (row) => {
|
align: "right",
|
||||||
const myScore = row.my_score > 0 ? String(row.my_score) : "-"
|
render: (row) =>
|
||||||
const avgScore = row.score > 0 ? row.score.toFixed(2) : "-"
|
row.score > 0
|
||||||
return h(
|
? h("span", { class: "mono" }, row.score.toFixed(2))
|
||||||
"div",
|
: h("span", { class: "muted" }, "未评"),
|
||||||
{ style: { display: "flex", gap: "6px", alignItems: "baseline" } },
|
|
||||||
[
|
|
||||||
h("span", avgScore),
|
|
||||||
h("span", { style: { fontSize: "11px", color: "var(--wp-faint)" } }, myScore),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
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",
|
key: "submit_count",
|
||||||
width: 60,
|
width: 60,
|
||||||
render: (row) => row.submit_count || "-",
|
align: "right",
|
||||||
},
|
render: (row) => h("span", { class: "mono muted" }, `${row.submit_count} 次`),
|
||||||
{
|
|
||||||
title: "浏览",
|
|
||||||
key: "view_count",
|
|
||||||
width: 60,
|
|
||||||
render: (row) => row.view_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) {
|
function rowProps(row: SubmissionOut) {
|
||||||
return {
|
return {
|
||||||
style: { cursor: "pointer" },
|
style: { cursor: "pointer" },
|
||||||
onClick: () => {
|
onClick: () => (selectedId.value = row.id),
|
||||||
getSubmissionByID(row.id)
|
|
||||||
handleExpand(
|
|
||||||
expandedKeys.value.includes(row.id)
|
|
||||||
? expandedKeys.value.filter((k) => k !== row.id)
|
|
||||||
: [...expandedKeys.value, row.id],
|
|
||||||
)
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function rowClassName(row: SubmissionOut) {
|
|
||||||
return submission.value.id === row.id ? "row-active" : ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// 快速翻页时只用最后一次请求的结果
|
// 快速翻页时只用最后一次请求的结果
|
||||||
let initSeq = 0
|
let seq = 0
|
||||||
|
|
||||||
async function init() {
|
async function load() {
|
||||||
const seq = ++initSeq
|
const s = ++seq
|
||||||
expandedKeys.value = []
|
loading.value = true
|
||||||
expandedData.clear()
|
try {
|
||||||
const res = await Submission.list(query)
|
const res = await Submission.list({
|
||||||
if (seq !== initSeq) return
|
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
|
data.value = res.items
|
||||||
count.value = res.count
|
count.value = res.count
|
||||||
|
} finally {
|
||||||
|
if (s === seq) loading.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let detailSeq = 0
|
function applyPatch(patch: { id: string; my_score?: number; score?: number; flag?: FlagType }) {
|
||||||
|
const row = data.value.find((d) => d.id === patch.id)
|
||||||
async function getSubmissionByID(id: string) {
|
if (row) Object.assign(row, patch)
|
||||||
const seq = ++detailSeq
|
|
||||||
const res = await Submission.get(id)
|
|
||||||
if (seq === detailSeq) submission.value = res
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function afterScore() {
|
function onDeleted() {
|
||||||
data.value = data.value.map((d) => {
|
selectedId.value = null
|
||||||
if (d.id === submission.value.id) d.my_score = submission.value.my_score
|
load()
|
||||||
return d
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyToEditor() {
|
// 换了筛选条件就回到第一页
|
||||||
// 教程和挑战的代码都按任务各存一份,打开那个任务时读到
|
function reset() {
|
||||||
saveTaskCode(submission.value.task_id, { html: html.value, css: css.value, js: js.value })
|
if (query.page !== 1) query.page = 1
|
||||||
goHome(router, submission.value.task_type, submission.value.task_display)
|
else load()
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => query.page,
|
() => query.page,
|
||||||
(v) => {
|
(page) => {
|
||||||
init()
|
load()
|
||||||
router.push({ params: { page: v } })
|
router.push({ params: { page } })
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
// 浏览器前进 / 后退时,页码跟着地址栏走
|
// 浏览器前进 / 后退时,页码跟着地址栏走
|
||||||
@@ -340,53 +222,78 @@ watch(
|
|||||||
if (page !== query.page) query.page = page
|
if (page !== query.page) query.page = page
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
watchDebounced(
|
watch(() => [query.task_type, query.zone, query.ordering, query.grouped], reset)
|
||||||
() => query.username,
|
watchDebounced(() => query.username, reset, { debounce: 500, maxWait: 1000 })
|
||||||
() => {
|
|
||||||
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()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
onMounted(init)
|
onMounted(load)
|
||||||
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(),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.container {
|
.subs {
|
||||||
padding: 10px;
|
height: 100%;
|
||||||
box-sizing: border-box;
|
display: flex;
|
||||||
width: 100%;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.row-active td) {
|
.list {
|
||||||
background-color: var(--wp-brand-soft) !important;
|
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>
|
</style>
|
||||||
@@ -44,9 +44,10 @@ const routes: RouteRecordRaw[] = [
|
|||||||
meta: { auth: true },
|
meta: { auth: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/submissions/:page",
|
// 学生(和老师自己)的“我的提交”;:page 是旧链接留下的,不用了
|
||||||
|
path: "/submissions/:page?",
|
||||||
name: "submissions",
|
name: "submissions",
|
||||||
component: () => import("./pages/Submissions.vue"),
|
component: () => import("./pages/MySubmissions.vue"),
|
||||||
meta: { auth: true },
|
meta: { auth: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -116,7 +117,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import("./pages/Review.vue"),
|
component: () => import("./pages/Review.vue"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// 和顶层的 /submissions 是同一个页面,放在教学侧栏里打开,侧栏不会消失
|
// 全站提交列表(超管),和学生的“我的提交”是两个页面
|
||||||
path: "submissions/:page",
|
path: "submissions/:page",
|
||||||
name: "teach-submissions",
|
name: "teach-submissions",
|
||||||
component: () => import("./pages/Submissions.vue"),
|
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"
|
if (source === "manual") return "外部 AI"
|
||||||
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
|
username: string
|
||||||
task_title: string
|
task_title: string
|
||||||
task_display: number
|
task_display: number
|
||||||
|
task_type: TASK_TYPE
|
||||||
score: number
|
score: number
|
||||||
view_count: number
|
view_count: number
|
||||||
html: string | null
|
html: string | null
|
||||||
@@ -301,28 +302,22 @@ export interface AwardItemUpdateIn {
|
|||||||
sort_order: number
|
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 {
|
export interface AwardItemManageOut {
|
||||||
id: number
|
id: number
|
||||||
submission_id: string
|
submission_id: string
|
||||||
username: string
|
username: string
|
||||||
task_title: string
|
task_title: string
|
||||||
task_display: number
|
task_display: number
|
||||||
|
task_type: TASK_TYPE
|
||||||
score: number
|
score: number
|
||||||
view_count: number
|
view_count: number
|
||||||
sort_order: number
|
sort_order: number
|
||||||
awarded_at: string
|
awarded_at: string
|
||||||
has_prompt_chain: boolean
|
has_prompt_chain: boolean
|
||||||
is_stale: boolean
|
is_stale: boolean
|
||||||
|
html: string | null
|
||||||
|
css: string | null
|
||||||
|
js: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShowcaseDetail {
|
export interface ShowcaseDetail {
|
||||||
@@ -330,6 +325,7 @@ export interface ShowcaseDetail {
|
|||||||
username: string
|
username: string
|
||||||
task_title: string
|
task_title: string
|
||||||
task_display: number
|
task_display: number
|
||||||
|
task_type: TASK_TYPE
|
||||||
score: number
|
score: number
|
||||||
view_count: number
|
view_count: number
|
||||||
html: string | null
|
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 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,
|
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}
|
s.score, s.view_count, s.html, s.css, s.js, ${HAS_CHAIN}
|
||||||
FROM submission_submissionaward sa
|
FROM submission_submissionaward sa
|
||||||
JOIN submission_submission s ON s.id = sa.submission_id
|
JOIN submission_submission s ON s.id = sa.submission_id
|
||||||
@@ -34,12 +34,17 @@ function itemManageOut(r: any) {
|
|||||||
username: r.username,
|
username: r.username,
|
||||||
task_title: r.task_title,
|
task_title: r.task_title,
|
||||||
task_display: r.task_display,
|
task_display: r.task_display,
|
||||||
|
task_type: r.task_type,
|
||||||
score: r.score,
|
score: r.score,
|
||||||
view_count: r.view_count,
|
view_count: r.view_count,
|
||||||
sort_order: r.sort_order,
|
sort_order: r.sort_order,
|
||||||
awarded_at: iso(r.awarded_at),
|
awarded_at: iso(r.awarded_at),
|
||||||
has_prompt_chain: bool(r.has_prompt_chain),
|
has_prompt_chain: bool(r.has_prompt_chain),
|
||||||
is_stale: bool(r.is_stale),
|
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,
|
username: r.username,
|
||||||
task_title: r.task_title,
|
task_title: r.task_title,
|
||||||
task_display: r.task_display,
|
task_display: r.task_display,
|
||||||
|
task_type: r.task_type,
|
||||||
score: r.score,
|
score: r.score,
|
||||||
view_count: r.view_count,
|
view_count: r.view_count,
|
||||||
html: r.html,
|
html: r.html,
|
||||||
@@ -162,31 +168,6 @@ router.delete("/manage/awards/:award_id", (c) => {
|
|||||||
return c.json({ message: "删除成功" })
|
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) => {
|
router.get("/manage/awards/:award_id/items", (c) => {
|
||||||
requireAdmin(c)
|
requireAdmin(c)
|
||||||
const award = orNotFound(
|
const award = orNotFound(
|
||||||
@@ -301,7 +282,7 @@ router.get("/:submission_id", (c) => {
|
|||||||
assertAwarded(id)
|
assertAwarded(id)
|
||||||
const s = orNotFound(
|
const s = orNotFound(
|
||||||
get(
|
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}
|
s.score, s.view_count, s.html, s.css, s.js, ${HAS_CHAIN}
|
||||||
FROM submission_submission s
|
FROM submission_submission s
|
||||||
JOIN account_user u ON u.id = s.user_id
|
JOIN account_user u ON u.id = s.user_id
|
||||||
@@ -320,6 +301,7 @@ router.get("/:submission_id", (c) => {
|
|||||||
username: s.username,
|
username: s.username,
|
||||||
task_title: s.task_title,
|
task_title: s.task_title,
|
||||||
task_display: s.task_display,
|
task_display: s.task_display,
|
||||||
|
task_type: s.task_type,
|
||||||
score: s.score,
|
score: s.score,
|
||||||
view_count: s.view_count,
|
view_count: s.view_count,
|
||||||
html: s.html,
|
html: s.html,
|
||||||
|
|||||||
@@ -415,7 +415,7 @@ describe("作品展", () => {
|
|||||||
|
|
||||||
const author1 = await client("author")
|
const author1 = await client("author")
|
||||||
expect((await author1("GET", "/api/submission/showcase/")).json).toHaveLength(1)
|
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 })
|
await adm("PUT", `/api/submission/showcase/manage/awards/${award.id}`, { name: "最佳", is_active: false })
|
||||||
expect((await author1("GET", "/api/submission/showcase/")).json).toEqual([])
|
expect((await author1("GET", "/api/submission/showcase/")).json).toEqual([])
|
||||||
|
|||||||
Reference in new issue
Block a user