Compare commits

...

7 Commits

Author SHA1 Message Date
681c6ff4f4 update
Some checks failed
Deploy / deploy (build, debian, 22) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822) (push) Has been cancelled
2026-06-14 06:32:09 -06:00
5bb8a1eaa3 update
Some checks failed
Deploy / deploy (build, debian, 22) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822) (push) Has been cancelled
2026-06-11 21:24:50 -06:00
c12c77ac7e fix
Some checks failed
Deploy / deploy (build, debian, 22) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822) (push) Has been cancelled
2026-06-11 21:08:35 -06:00
3102e1178a fix
Some checks failed
Deploy / deploy (build, debian, 22) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822) (push) Has been cancelled
2026-06-11 20:48:51 -06:00
a15b3d9c76 rating when ai is steaming
Some checks failed
Deploy / deploy (build, debian, 22) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822) (push) Has been cancelled
2026-06-11 19:53:15 -06:00
05ecf6bebf update
Some checks failed
Deploy / deploy (build, debian, 22) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822) (push) Has been cancelled
2026-06-11 22:35:14 +08:00
54d9861bd8 update 2026-06-11 22:21:59 +08:00
12 changed files with 274 additions and 37 deletions

View File

@@ -20,6 +20,7 @@ import type {
ShowcaseSubmissionLookupOut, ShowcaseSubmissionLookupOut,
ShowcaseDetail, ShowcaseDetail,
PromptRound, PromptRound,
RandomRatingItem,
} from "./utils/type" } from "./utils/type"
import { BASE_URL, STORAGE_KEY } from "./utils/const" import { BASE_URL, STORAGE_KEY } from "./utils/const"
@@ -237,6 +238,13 @@ export const Submission = {
return res.data return res.data
}, },
async randomForRating(excludeId?: string) {
const res = await http.get("/submission/random-for-rating/", {
params: excludeId ? { exclude_id: excludeId } : {},
})
return res.data as RandomRatingItem | null
},
async updateFlag(id: string, flag: FlagType) { async updateFlag(id: string, flag: FlagType) {
const res = await http.put(`/submission/${id}/flag`, { flag }) const res = await http.put(`/submission/${id}/flag`, { flag })
return res.data return res.data

View File

@@ -194,8 +194,8 @@ async function deleteItem(item: HistoryViewItem, e: Event) {
} }
emit("deleted", item.assistant_message_id) emit("deleted", item.assistant_message_id)
naiveMessage.success("已删除") naiveMessage.success("已删除")
} catch { } catch (error: any) {
naiveMessage.error("删除失败,请重试") naiveMessage.error(error.response?.data?.detail ?? "删除失败,请重试")
} finally { } finally {
deletingId.value = null deletingId.value = null
} }

View File

@@ -27,6 +27,16 @@
<div v-if="pair.assistantMsg" class="message assistant"> <div v-if="pair.assistantMsg" class="message assistant">
<div class="message-role">AI</div> <div class="message-role">AI</div>
<div class="message-content" v-html="renderContent(pair.assistantMsg)"></div> <div class="message-content" v-html="renderContent(pair.assistantMsg)"></div>
<div v-if="hasCode(pair.assistantMsg)" class="message-actions">
<n-button
size="tiny"
:disabled="pair.assistantMsg.submitted"
:loading="submittingId === pair.assistantMsg.id"
@click="submitVersion(pair.assistantMsg)"
>
{{ pair.assistantMsg.submitted ? "已提交" : "提交此版本" }}
</n-button>
</div>
</div> </div>
</div> </div>
@@ -113,11 +123,12 @@ import {
stopPrompt, stopPrompt,
currentTaskId, currentTaskId,
removeMessagePair, removeMessagePair,
markMessageSubmitted,
} from "../../store/prompt" } from "../../store/prompt"
import { Prompt } from "../../api" import { Prompt, Submission } from "../../api"
import { renderMarkdown } from "../../utils/markdown" import { renderMarkdown } from "../../utils/markdown"
const emit = defineEmits<{ deleted: [] }>() const emit = defineEmits<{ deleted: []; submitted: [] }>()
const input = ref("") const input = ref("")
const messagesRef = ref<HTMLElement>() const messagesRef = ref<HTMLElement>()
@@ -131,10 +142,18 @@ const modelOptions = [
const selectedModel = useStorage("prompt-model", "deepseek-v4-flash") const selectedModel = useStorage("prompt-model", "deepseek-v4-flash")
// Group messages into user+assistant pairs // Group messages into user+assistant pairs
const submittingId = ref<number | null>(null)
const pairs = computed(() => { const pairs = computed(() => {
const result: Array<{ const result: Array<{
userMsg: { role: string; content: string; id?: number } userMsg: { role: string; content: string; id?: number }
assistantMsg: { role: string; content: string; id?: number; code?: any } | null assistantMsg: {
role: string
content: string
id?: number
code?: any
submitted?: boolean
} | null
index: number index: number
}> = [] }> = []
const msgs = messages.value const msgs = messages.value
@@ -151,14 +170,45 @@ const pairs = computed(() => {
return result return result
}) })
function hasCode(msg: { code?: any } | null): boolean {
if (!msg?.code) return false
return !!(msg.code.html || msg.code.css || msg.code.js)
}
async function submitVersion(msg: {
id?: number
code?: { html: string | null; css: string | null; js: string | null }
}) {
if (!msg.id || !msg.code || !currentTaskId.value) return
submittingId.value = msg.id
try {
await Submission.create(
currentTaskId.value,
{
html: msg.code.html ?? "",
css: msg.code.css ?? "",
js: msg.code.js ?? "",
},
msg.id,
)
markMessageSubmitted(msg.id)
naiveMessage.success("提交成功")
emit("submitted")
} catch {
naiveMessage.error("提交失败,请重试")
} finally {
submittingId.value = null
}
}
async function deletePair(assistantMsgId: number) { async function deletePair(assistantMsgId: number) {
try { try {
await Prompt.deleteMessagePair(assistantMsgId) await Prompt.deleteMessagePair(assistantMsgId)
removeMessagePair(assistantMsgId) removeMessagePair(assistantMsgId)
naiveMessage.success("已删除") naiveMessage.success("已删除")
emit("deleted") emit("deleted")
} catch { } catch (error: any) {
naiveMessage.error("删除失败,请重试") naiveMessage.error(error.response?.data?.detail ?? "删除失败,请重试")
} }
} }
@@ -240,6 +290,10 @@ watch([() => messages.value.length, streamingContent], () => {
font-size: 13px; font-size: 13px;
} }
.message-actions {
margin-top: 6px;
}
@keyframes pulse { @keyframes pulse {
0%, 0%,

View File

@@ -0,0 +1,135 @@
<template>
<n-modal
preset="card"
:show="show"
title="等待 AI 回复时,给同学的作品打个分吧"
style="width: 90vw; max-width: 960px"
@update:show="onUpdateShow"
>
<n-text
v-if="current"
depth="3"
style="font-size: 12px; display: block; margin-bottom: 8px"
>
{{ current.task_title }} · 提交者 {{ current.username }}
</n-text>
<div class="preview-wrapper">
<iframe class="preview-iframe" :srcdoc="previewContent"></iframe>
</div>
<div class="rate-row">
<n-rate
v-if="current"
:key="current.submission_id"
:size="32"
@update:value="onRate"
/>
</div>
<n-text
depth="3"
style="font-size: 11px; display: block; text-align: center; margin-top: 8px"
>
打分后自动换下一个作品AI 回复完成后弹窗会自动关闭
</n-text>
</n-modal>
</template>
<script setup lang="ts">
import { ref, watch, onUnmounted } from "vue"
import { useMessage } from "naive-ui"
import { Submission } from "../../api"
import { streaming } from "../../store/prompt"
import { buildPreviewDocument } from "../../utils/previewDocument"
import type { RandomRatingItem } from "../../utils/type"
const message = useMessage()
const show = ref(false)
const current = ref<RandomRatingItem | null>(null)
const previewContent = ref("")
let reshowTimer: ReturnType<typeof setTimeout> | null = null
function clearReshowTimer() {
if (reshowTimer !== null) {
clearTimeout(reshowTimer)
reshowTimer = null
}
}
async function fetchAndShow() {
try {
const item = await Submission.randomForRating(current.value?.submission_id)
if (item) {
current.value = item
previewContent.value = buildPreviewDocument({
html: item.html ?? "",
css: item.css ?? "",
js: item.js ?? "",
})
show.value = true
} else {
show.value = false
}
} catch (err: any) {
message.error(err.response?.data?.detail ?? "获取作品失败,请重试")
}
}
async function onRate(score: number) {
if (!current.value) return
try {
await Submission.updateScore(current.value.submission_id, score)
message.success("感谢评分!")
await fetchAndShow()
} catch (err: any) {
message.error(err.response?.data?.detail ?? "打分失败,请重试")
}
}
function onUpdateShow(value: boolean) {
show.value = value
if (!value) {
clearReshowTimer()
if (streaming.value) {
reshowTimer = setTimeout(fetchAndShow, 30_000)
}
}
}
watch(
streaming,
(val) => {
clearReshowTimer()
if (val) {
fetchAndShow()
} else {
show.value = false
}
},
{ immediate: true },
)
onUnmounted(() => {
clearReshowTimer()
})
</script>
<style scoped>
.preview-wrapper {
height: 70vh;
border: 1px solid #eee;
border-radius: 4px;
overflow: hidden;
}
.preview-iframe {
width: 100%;
height: 100%;
border: none;
}
.rate-row {
display: flex;
justify-content: center;
padding: 16px 0;
}
</style>

View File

@@ -238,13 +238,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from "vue" import { computed, ref, watch } from "vue"
import { NPopconfirm, NButton } from "naive-ui" import { NPopconfirm, NButton, useMessage } from "naive-ui"
import { Icon } from "@iconify/vue" import { Icon } from "@iconify/vue"
import { marked } from "marked" import { marked } from "marked"
import { Prompt, Submission } from "../../api" import { Prompt, Submission } from "../../api"
import type { PromptRound } from "../../utils/type" import type { PromptRound } from "../../utils/type"
import { user, roleSuper } from "../../store/user" import { user, roleSuper } from "../../store/user"
const message = useMessage()
const props = defineProps<{ const props = defineProps<{
show: boolean show: boolean
submissionId: string submissionId: string
@@ -270,7 +272,12 @@ const rounds = ref<ChainRound[]>([])
async function deleteRound(index: number) { async function deleteRound(index: number) {
const round = rounds.value[index] const round = rounds.value[index]
if (!round.assistantMsgId) return if (!round.assistantMsgId) return
await Prompt.deleteMessagePair(round.assistantMsgId) try {
await Prompt.deleteMessagePair(round.assistantMsgId)
} catch (error: any) {
message.error(error.response?.data?.detail ?? "删除失败,请重试")
return
}
await loadMessages() await loadMessages()
if (selectedRound.value >= rounds.value.length) { if (selectedRound.value >= rounds.value.length) {
selectedRound.value = Math.max(0, rounds.value.length - 1) selectedRound.value = Math.max(0, rounds.value.length - 1)

View File

@@ -1,9 +1,6 @@
<template> <template>
<n-flex vertical> <n-flex vertical>
<n-flex align="center"> <n-button size="small" @click="showUpload = true">图片素材</n-button>
<n-text strong>图片素材</n-text>
<n-button size="small" @click="showUpload = true">上传</n-button>
</n-flex>
<n-flex v-if="assets.length" wrap> <n-flex v-if="assets.length" wrap>
<n-card <n-card
v-for="asset in assets" v-for="asset in assets"

View File

@@ -9,9 +9,6 @@
</template> </template>
<template #suffix> <template #suffix>
<n-flex style="margin: 0 8px"> <n-flex style="margin: 0 8px">
<n-button v-if="assets.length" text @click="showAssets = true">
<Icon :width="16" icon="lucide:image" />
</n-button>
<n-button <n-button
v-if="roleAdmin || roleSuper" v-if="roleAdmin || roleSuper"
text text
@@ -40,13 +37,22 @@
<n-text depth="3"> <n-text depth="3">
出题人{{ challengeAuthor || "未设置" }} 出题人{{ challengeAuthor || "未设置" }}
</n-text> </n-text>
<n-button <n-flex align="center" size="small">
v-if="exampleCode" <n-button
size="small" v-if="assets.length"
@click="previewExample" size="small"
> @click="showAssets = true"
看示例 >
</n-button> 看素材
</n-button>
<n-button
v-if="exampleCode"
size="small"
@click="previewExample"
>
看示例
</n-button>
</n-flex>
</n-flex> </n-flex>
</div> </div>
<div <div
@@ -58,7 +64,10 @@
</div> </div>
</n-tab-pane> </n-tab-pane>
<n-tab-pane name="chat" tab="AI 对话" display-directive="show"> <n-tab-pane name="chat" tab="AI 对话" display-directive="show">
<PromptPanel @deleted="historyRefreshKey++" /> <PromptPanel
@deleted="historyRefreshKey++"
@submitted="historyRefreshKey++"
/>
</n-tab-pane> </n-tab-pane>
<n-tab-pane name="external" tab="手动提交" display-directive="show"> <n-tab-pane name="external" tab="手动提交" display-directive="show">
<ExternalAIPanel :task-id="taskId" @submitted="historyRefreshKey++" /> <ExternalAIPanel :task-id="taskId" @submitted="historyRefreshKey++" />
@@ -89,6 +98,7 @@
</div> </div>
</div> </div>
<TaskStatsModal v-model:show="showStats" :task-id="taskId" /> <TaskStatsModal v-model:show="showStats" :task-id="taskId" />
<RandomRatingModal v-if="authed" />
<n-modal <n-modal
v-model:show="showAssets" v-model:show="showAssets"
preset="card" preset="card"
@@ -138,6 +148,7 @@ import ExternalAIPanel from "../components/ai/ExternalAIPanel.vue"
import PromptHistoryPanel from "../components/ai/PromptHistoryPanel.vue" import PromptHistoryPanel from "../components/ai/PromptHistoryPanel.vue"
import Preview from "../components/editor/Preview.vue" import Preview from "../components/editor/Preview.vue"
import TaskStatsModal from "../components/task/TaskStatsModal.vue" import TaskStatsModal from "../components/task/TaskStatsModal.vue"
import RandomRatingModal from "../components/ai/RandomRatingModal.vue"
import { Challenge, Submission, TaskAssets } from "../api" import { Challenge, Submission, TaskAssets } from "../api"
import type { TaskAsset } from "../utils/type" import type { TaskAsset } from "../utils/type"
import { html, css, js } from "../store/editors" import { html, css, js } from "../store/editors"
@@ -150,6 +161,7 @@ import {
streaming, streaming,
setOnCodeComplete, setOnCodeComplete,
removeMessagePair, removeMessagePair,
markMessageSubmitted,
} from "../store/prompt" } from "../store/prompt"
const route = useRoute() const route = useRoute()
@@ -238,10 +250,11 @@ async function loadChallenge() {
}, },
messageId, messageId,
) )
markMessageSubmitted(messageId)
historyRefreshKey.value++ historyRefreshKey.value++
message.success("已自动提交本次对话生成的代码") message.success("已自动提交本次对话生成的代码")
} catch { } catch {
// 静默失败,不打扰用户 message.error("自动提交失败,请稍后重试")
} }
}) })
} }

View File

@@ -51,7 +51,7 @@ const menu = computed(() =>
{ {
label: "成绩", label: "成绩",
route: { name: "gradebook" }, route: { name: "gradebook" },
show: roleAdmin.value || roleSuper.value, show: roleSuper.value,
}, },
{ {
label: "提交", label: "提交",

View File

@@ -79,10 +79,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from "vue" import { onMounted, ref } from "vue"
import { useRouter } from "vue-router"
import { Icon } from "@iconify/vue" import { Icon } from "@iconify/vue"
import { Showcase, Submission } from "../api" import { Showcase } from "../api"
import type { AwardSection, ShowcaseItem } from "../utils/type" import type { AwardSection, ShowcaseItem } from "../utils/type"
const router = useRouter()
const loading = ref(true) const loading = ref(true)
const awards = ref<AwardSection[]>([]) const awards = ref<AwardSection[]>([])
@@ -93,14 +95,11 @@ function buildSrcdoc(item: ShowcaseItem): string {
} }
function openDetail(item: ShowcaseItem) { function openDetail(item: ShowcaseItem) {
const srcdoc = buildSrcdoc(item) const { href } = router.resolve({
const win = window.open("", "_blank") name: "submission",
if (win) { params: { id: item.submission_id },
win.document.open() })
win.document.write(srcdoc) window.open(href, "_blank")
win.document.close()
}
void Submission.incrementView(item.submission_id)
} }
async function init() { async function init() {

View File

@@ -97,7 +97,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, h, onMounted, onUnmounted, reactive, ref, watch } from "vue" import { computed, h, onMounted, onUnmounted, reactive, ref, watch } from "vue"
import { NButton, NDataTable, NTag, type DataTableColumn } from "naive-ui" import { NButton, NDataTable, NTag, useMessage, type DataTableColumn } from "naive-ui"
import { Icon } from "@iconify/vue" import { Icon } from "@iconify/vue"
import { Submission } from "../api" import { Submission } from "../api"
import type { SubmissionOut, FlagType } from "../utils/type" import type { SubmissionOut, FlagType } from "../utils/type"
@@ -122,6 +122,7 @@ import { roleAdmin, roleSuper, user } 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[]>([])
@@ -304,7 +305,12 @@ async function handleExpand(keys: (string | number)[]) {
} }
async function handleDelete(row: SubmissionOut, parentId: string) { async function handleDelete(row: SubmissionOut, parentId: string) {
await Submission.delete(row.id) try {
await Submission.delete(row.id)
} catch (error: any) {
message.error(error.response?.data?.detail ?? "删除失败请重试")
return
}
const items = expandedData.get(parentId) const items = expandedData.get(parentId)
if (items) if (items)
expandedData.set( expandedData.set(

View File

@@ -7,6 +7,7 @@ export interface PromptMessage {
content: string content: string
id?: number // assistant message backend pk (for deletion) id?: number // assistant message backend pk (for deletion)
code?: { html: string | null; css: string | null; js: string | null } code?: { html: string | null; css: string | null; js: string | null }
submitted?: boolean // whether this assistant message's code has been submitted
created?: string created?: string
} }
@@ -66,6 +67,7 @@ export function connectPrompt(taskId: number) {
content: streamingContent.value, content: streamingContent.value,
id: data.message_id, id: data.message_id,
code: data.code, code: data.code,
submitted: false,
}) })
streamingContent.value = "" streamingContent.value = ""
if (data.code) { if (data.code) {
@@ -124,6 +126,11 @@ export function stopPrompt() {
} }
} }
export function markMessageSubmitted(assistantMsgId: number) {
const msg = messages.value.find((m) => m.id === assistantMsgId)
if (msg) msg.submitted = true
}
export function removeMessagePair(assistantMsgId: number) { export function removeMessagePair(assistantMsgId: number) {
const idx = messages.value.findIndex((m) => m.id === assistantMsgId) const idx = messages.value.findIndex((m) => m.id === assistantMsgId)
if (idx >= 1) { if (idx >= 1) {

View File

@@ -335,3 +335,14 @@ export interface PromptRound {
css: string | null css: string | null
js: string | null js: string | null
} }
export interface RandomRatingItem {
submission_id: string
username: string
task_title: string
task_display: number
task_type: TASK_TYPE
html: string | null
css: string | null
js: string | null
}