Compare commits
12 Commits
53a0759507
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 681c6ff4f4 | |||
| 5bb8a1eaa3 | |||
| c12c77ac7e | |||
| 3102e1178a | |||
| a15b3d9c76 | |||
| 05ecf6bebf | |||
| 54d9861bd8 | |||
| 596ceec880 | |||
| 6f99688667 | |||
| 4b32330b60 | |||
| 1566a4e275 | |||
| f7817d8c26 |
15
src/api.ts
15
src/api.ts
@@ -20,6 +20,7 @@ import type {
|
||||
ShowcaseSubmissionLookupOut,
|
||||
ShowcaseDetail,
|
||||
PromptRound,
|
||||
RandomRatingItem,
|
||||
} from "./utils/type"
|
||||
import { BASE_URL, STORAGE_KEY } from "./utils/const"
|
||||
|
||||
@@ -237,6 +238,13 @@ export const Submission = {
|
||||
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) {
|
||||
const res = await http.put(`/submission/${id}/flag`, { flag })
|
||||
return res.data
|
||||
@@ -421,6 +429,13 @@ export const Showcase = {
|
||||
return res.data
|
||||
},
|
||||
|
||||
async refreshAwardItem(itemId: number): Promise<AwardItemManageOut> {
|
||||
const res = await http.put(
|
||||
`/submission/showcase/manage/items/${itemId}/refresh`,
|
||||
)
|
||||
return res.data
|
||||
},
|
||||
|
||||
async getDetail(submissionId: string): Promise<ShowcaseDetail> {
|
||||
const res = await http.get(`/submission/showcase/${submissionId}/`)
|
||||
return res.data
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
type="password"
|
||||
v-model:value="studentPassword"
|
||||
name="password"
|
||||
@keyup.enter="submitStudent"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-alert
|
||||
@@ -73,6 +74,7 @@
|
||||
type="password"
|
||||
v-model:value="adminPassword"
|
||||
name="password"
|
||||
@keyup.enter="submitAdmin"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-alert
|
||||
@@ -101,6 +103,7 @@ import { ref, computed, onMounted } from "vue"
|
||||
import { Account } from "../api"
|
||||
import { loginModal } from "../store/modal"
|
||||
import { user } from "../store/user"
|
||||
import { router } from "../router"
|
||||
|
||||
// Tab state
|
||||
const activeTab = ref("student")
|
||||
@@ -188,6 +191,9 @@ async function submitStudent() {
|
||||
user.role = data.role
|
||||
user.loaded = true
|
||||
loginModal.value = false
|
||||
router.replace(
|
||||
window.location.pathname + window.location.search + window.location.hash,
|
||||
)
|
||||
} catch {
|
||||
showStudentError.value = true
|
||||
} finally {
|
||||
@@ -205,6 +211,9 @@ async function submitAdmin() {
|
||||
user.role = data.role
|
||||
user.loaded = true
|
||||
loginModal.value = false
|
||||
router.replace(
|
||||
window.location.pathname + window.location.search + window.location.hash,
|
||||
)
|
||||
} catch {
|
||||
showAdminError.value = true
|
||||
} finally {
|
||||
|
||||
@@ -84,8 +84,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick } from "vue"
|
||||
import { marked } from "marked"
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { renderMarkdown } from "../../utils/markdown"
|
||||
import {
|
||||
messages,
|
||||
streaming,
|
||||
@@ -110,10 +110,6 @@ const displayStreamingContent = computed(() =>
|
||||
streamingContent.value.replace(/^\[READY\]\n?/, "")
|
||||
)
|
||||
|
||||
function renderMarkdown(text: string): string {
|
||||
return marked.parse(text) as string
|
||||
}
|
||||
|
||||
function send() {
|
||||
const text = draftPrompt.value.trim()
|
||||
if (!text || streaming.value) return
|
||||
|
||||
@@ -75,17 +75,36 @@
|
||||
{{ item.source === "manual" ? "手动提交" : "AI 对话" }}
|
||||
</n-tag>
|
||||
</n-flex>
|
||||
<n-tag
|
||||
v-if="selectedAssistantMessageId === item.assistant_message_id"
|
||||
size="small"
|
||||
type="success"
|
||||
:bordered="false"
|
||||
>
|
||||
正在预览
|
||||
</n-tag>
|
||||
<n-text depth="3">
|
||||
{{ parseTime(item.created, "YYYY-MM-DD HH:mm") }}
|
||||
</n-text>
|
||||
<n-flex align="center" :wrap="false" :size="4">
|
||||
<n-tag
|
||||
v-if="selectedAssistantMessageId === item.assistant_message_id"
|
||||
size="small"
|
||||
type="success"
|
||||
:bordered="false"
|
||||
>
|
||||
正在预览
|
||||
</n-tag>
|
||||
<n-text depth="3">
|
||||
{{ parseTime(item.created, "YYYY-MM-DD HH:mm") }}
|
||||
</n-text>
|
||||
<n-tooltip placement="top">
|
||||
<template #trigger>
|
||||
<n-button
|
||||
quaternary
|
||||
circle
|
||||
size="small"
|
||||
:loading="deletingId === item.assistant_message_id"
|
||||
:disabled="deletingId !== null"
|
||||
@click="deleteItem(item, $event)"
|
||||
>
|
||||
<template #icon>
|
||||
<Icon icon="lucide:trash-2" />
|
||||
</template>
|
||||
</n-button>
|
||||
</template>
|
||||
删除这条历史对话
|
||||
</n-tooltip>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
<div
|
||||
class="prompt-markdown markdown-body"
|
||||
@@ -112,7 +131,8 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from "vue"
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { marked } from "marked"
|
||||
import { useMessage } from "naive-ui"
|
||||
import { renderMarkdown } from "../../utils/markdown"
|
||||
import { Prompt } from "../../api"
|
||||
import type { PromptHistoryItem } from "../../utils/type"
|
||||
import { parseTime } from "../../utils/helper"
|
||||
@@ -127,6 +147,7 @@ const props = defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [code: { html: string; css: string; js: string }]
|
||||
deleted: [assistantMessageId: number]
|
||||
}>()
|
||||
|
||||
type HistoryViewItem = PromptHistoryItem & {
|
||||
@@ -137,7 +158,11 @@ type HistoryViewItem = PromptHistoryItem & {
|
||||
const items = ref<HistoryViewItem[]>([])
|
||||
const loading = ref(false)
|
||||
const selectedAssistantMessageId = ref<number | null>(null)
|
||||
const deletingId = ref<number | null>(null)
|
||||
let loadedTaskId = 0
|
||||
let pendingRefresh = false
|
||||
|
||||
const naiveMessage = useMessage()
|
||||
|
||||
function toViewItem(item: PromptHistoryItem): HistoryViewItem {
|
||||
const html = item.code_html ?? ""
|
||||
@@ -155,8 +180,25 @@ function toViewItem(item: PromptHistoryItem): HistoryViewItem {
|
||||
}
|
||||
}
|
||||
|
||||
function renderMarkdown(text: string): string {
|
||||
return marked.parse(text) as string
|
||||
async function deleteItem(item: HistoryViewItem, e: Event) {
|
||||
e.stopPropagation()
|
||||
if (deletingId.value !== null) return
|
||||
deletingId.value = item.assistant_message_id
|
||||
try {
|
||||
await Prompt.deleteMessagePair(item.assistant_message_id)
|
||||
items.value = items.value.filter(
|
||||
(i) => i.assistant_message_id !== item.assistant_message_id,
|
||||
)
|
||||
if (selectedAssistantMessageId.value === item.assistant_message_id) {
|
||||
selectedAssistantMessageId.value = null
|
||||
}
|
||||
emit("deleted", item.assistant_message_id)
|
||||
naiveMessage.success("已删除")
|
||||
} catch (error: any) {
|
||||
naiveMessage.error(error.response?.data?.detail ?? "删除失败,请重试")
|
||||
} finally {
|
||||
deletingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function selectItem(item: HistoryViewItem) {
|
||||
@@ -194,7 +236,14 @@ async function load(force = true) {
|
||||
watch(
|
||||
() => [props.active, props.taskId] as const,
|
||||
([active]) => {
|
||||
if (active) load(false)
|
||||
if (active) {
|
||||
if (pendingRefresh) {
|
||||
pendingRefresh = false
|
||||
load(true)
|
||||
} else {
|
||||
load(false)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -202,6 +251,7 @@ watch(
|
||||
() => props.refreshKey,
|
||||
() => {
|
||||
if (props.active) load(true)
|
||||
else pendingRefresh = true
|
||||
},
|
||||
)
|
||||
|
||||
@@ -243,6 +293,7 @@ onMounted(() => {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
||||
.history-card.is-selected {
|
||||
--n-color: #f7fffa;
|
||||
box-shadow: 0 10px 24px rgba(24, 160, 88, 0.14);
|
||||
|
||||
@@ -27,6 +27,16 @@
|
||||
<div v-if="pair.assistantMsg" class="message assistant">
|
||||
<div class="message-role">AI</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>
|
||||
|
||||
@@ -100,7 +110,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick, computed } from "vue"
|
||||
import { useStorage } from "@vueuse/core"
|
||||
import { marked, Renderer } from "marked"
|
||||
import { useMessage } from "naive-ui"
|
||||
import { Icon } from "@iconify/vue"
|
||||
import GuidancePanel from "./GuidancePanel.vue"
|
||||
@@ -113,8 +122,13 @@ import {
|
||||
sendPrompt,
|
||||
stopPrompt,
|
||||
currentTaskId,
|
||||
removeMessagePair,
|
||||
markMessageSubmitted,
|
||||
} from "../../store/prompt"
|
||||
import { Prompt } from "../../api"
|
||||
import { Prompt, Submission } from "../../api"
|
||||
import { renderMarkdown } from "../../utils/markdown"
|
||||
|
||||
const emit = defineEmits<{ deleted: []; submitted: [] }>()
|
||||
|
||||
const input = ref("")
|
||||
const messagesRef = ref<HTMLElement>()
|
||||
@@ -128,10 +142,18 @@ const modelOptions = [
|
||||
const selectedModel = useStorage("prompt-model", "deepseek-v4-flash")
|
||||
|
||||
// Group messages into user+assistant pairs
|
||||
const submittingId = ref<number | null>(null)
|
||||
|
||||
const pairs = computed(() => {
|
||||
const result: Array<{
|
||||
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
|
||||
}> = []
|
||||
const msgs = messages.value
|
||||
@@ -148,16 +170,45 @@ const pairs = computed(() => {
|
||||
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) {
|
||||
try {
|
||||
await Prompt.deleteMessagePair(assistantMsgId)
|
||||
const msgIdx = messages.value.findIndex((m) => m.id === assistantMsgId)
|
||||
if (msgIdx >= 1) {
|
||||
messages.value.splice(msgIdx - 1, 2)
|
||||
}
|
||||
removeMessagePair(assistantMsgId)
|
||||
naiveMessage.success("已删除")
|
||||
} catch {
|
||||
naiveMessage.error("删除失败,请重试")
|
||||
emit("deleted")
|
||||
} catch (error: any) {
|
||||
naiveMessage.error(error.response?.data?.detail ?? "删除失败,请重试")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,56 +230,6 @@ function onGuidanceGenerate(finalPrompt: string) {
|
||||
input.value = ""
|
||||
}
|
||||
|
||||
const renderer = new Renderer()
|
||||
renderer.code = function ({ lang }: { text: string; lang?: string }) {
|
||||
const label = lang ? lang.toUpperCase() : "CODE"
|
||||
const colors: Record<
|
||||
string,
|
||||
{ bg: string; fg: string; dot: string; border: string; shimmer: string }
|
||||
> = {
|
||||
html: {
|
||||
bg: "#f0fff4",
|
||||
fg: "#18a058",
|
||||
dot: "#18a058",
|
||||
border: "#b8e8cc",
|
||||
shimmer: "#f0fff4, #e0f7ea, #f0fff4",
|
||||
},
|
||||
css: {
|
||||
bg: "#f0f0ff",
|
||||
fg: "#6060d0",
|
||||
dot: "#6060d0",
|
||||
border: "#d0d0f0",
|
||||
shimmer: "#f0f0ff, #e8e8fa, #f0f0ff",
|
||||
},
|
||||
js: {
|
||||
bg: "#fffbf0",
|
||||
fg: "#c0960a",
|
||||
dot: "#c0960a",
|
||||
border: "#f0e0b0",
|
||||
shimmer: "#fffbf0, #fff5e0, #fffbf0",
|
||||
},
|
||||
javascript: {
|
||||
bg: "#fffbf0",
|
||||
fg: "#c0960a",
|
||||
dot: "#c0960a",
|
||||
border: "#f0e0b0",
|
||||
shimmer: "#fffbf0, #fff5e0, #fffbf0",
|
||||
},
|
||||
}
|
||||
const c = colors[(lang ?? "").toLowerCase()] ?? {
|
||||
bg: "#f0f7ff",
|
||||
fg: "#2080f0",
|
||||
dot: "#2080f0",
|
||||
border: "#e0eaf5",
|
||||
shimmer: "#f0f7ff, #e8f4f8, #f0f7ff",
|
||||
}
|
||||
return `<div class="code-placeholder" style="background: linear-gradient(90deg, ${c.shimmer}); background-size: 200% 100%; border-color: ${c.border}"><span class="code-placeholder-dot" style="background: ${c.dot}"></span><span class="code-placeholder-label" style="color: ${c.fg}; background: ${c.fg}18">${label}</span><span class="code-placeholder-text">代码正在生成中,结束后会自动应用到预览区</span></div>`
|
||||
}
|
||||
|
||||
function renderMarkdown(text: string): string {
|
||||
return marked.parse(text, { renderer }) as string
|
||||
}
|
||||
|
||||
function renderContent(msg: { role: string; content: string }): string {
|
||||
return renderMarkdown(msg.content)
|
||||
}
|
||||
@@ -289,47 +290,10 @@ watch([() => messages.value.length, streamingContent], () => {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.message-content :deep(.code-placeholder) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
margin: 8px 0;
|
||||
background: linear-gradient(90deg, #f0f7ff, #e8f4f8, #f0f7ff);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 2s ease-in-out infinite;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #e0eaf5;
|
||||
.message-actions {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.message-content :deep(.code-placeholder-dot) {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #2080f0;
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.message-content :deep(.code-placeholder-label) {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.message-content :deep(.code-placeholder-text) {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
|
||||
135
src/components/ai/RandomRatingModal.vue
Normal file
135
src/components/ai/RandomRatingModal.vue
Normal 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>
|
||||
@@ -238,13 +238,15 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
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 { marked } from "marked"
|
||||
import { Prompt, Submission } from "../../api"
|
||||
import type { PromptRound } from "../../utils/type"
|
||||
import { user, roleSuper } from "../../store/user"
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
submissionId: string
|
||||
@@ -270,7 +272,12 @@ const rounds = ref<ChainRound[]>([])
|
||||
async function deleteRound(index: number) {
|
||||
const round = rounds.value[index]
|
||||
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()
|
||||
if (selectedRound.value >= rounds.value.length) {
|
||||
selectedRound.value = Math.max(0, rounds.value.length - 1)
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
<template>
|
||||
<n-flex vertical>
|
||||
<n-flex align="center">
|
||||
<n-text strong>图片素材</n-text>
|
||||
<n-button size="small" @click="showUpload = true">上传</n-button>
|
||||
</n-flex>
|
||||
<n-button size="small" @click="showUpload = true">图片素材</n-button>
|
||||
<n-flex v-if="assets.length" wrap>
|
||||
<n-card
|
||||
v-for="asset in assets"
|
||||
|
||||
@@ -40,6 +40,9 @@ async function render() {
|
||||
const data = await Tutorial.get(step.value)
|
||||
taskId.value = data.task_ptr
|
||||
assetBaseUrl.value = `/media/tasks/tutorial/${step.value}/`
|
||||
html.value = data.example_html ?? ""
|
||||
css.value = data.example_css ?? ""
|
||||
js.value = data.example_js ?? ""
|
||||
const merged = `# ${data.display}. ${data.title}\n${data.content}`
|
||||
content.value = await marked.parse(merged, { async: true })
|
||||
}
|
||||
|
||||
@@ -9,9 +9,6 @@
|
||||
</template>
|
||||
<template #suffix>
|
||||
<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
|
||||
v-if="roleAdmin || roleSuper"
|
||||
text
|
||||
@@ -36,9 +33,27 @@
|
||||
<n-tab-pane name="desc" tab="挑战描述" display-directive="show">
|
||||
<div class="desc-pane">
|
||||
<div class="challenge-meta">
|
||||
<n-text depth="3">
|
||||
出题人:{{ challengeAuthor || "未设置" }}
|
||||
</n-text>
|
||||
<n-flex align="center" justify="space-between">
|
||||
<n-text depth="3">
|
||||
出题人:{{ challengeAuthor || "未设置" }}
|
||||
</n-text>
|
||||
<n-flex align="center" size="small">
|
||||
<n-button
|
||||
v-if="assets.length"
|
||||
size="small"
|
||||
@click="showAssets = true"
|
||||
>
|
||||
看素材
|
||||
</n-button>
|
||||
<n-button
|
||||
v-if="exampleCode"
|
||||
size="small"
|
||||
@click="previewExample"
|
||||
>
|
||||
看示例
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
</div>
|
||||
<div
|
||||
class="markdown-body content no-select"
|
||||
@@ -49,7 +64,10 @@
|
||||
</div>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="chat" tab="AI 对话" display-directive="show">
|
||||
<PromptPanel />
|
||||
<PromptPanel
|
||||
@deleted="historyRefreshKey++"
|
||||
@submitted="historyRefreshKey++"
|
||||
/>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="external" tab="手动提交" display-directive="show">
|
||||
<ExternalAIPanel :task-id="taskId" @submitted="historyRefreshKey++" />
|
||||
@@ -61,6 +79,7 @@
|
||||
:asset-base-url="assetBaseUrl"
|
||||
:refresh-key="historyRefreshKey"
|
||||
@select="previewHistoryItem"
|
||||
@deleted="removeMessagePair"
|
||||
/>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
@@ -79,6 +98,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<TaskStatsModal v-model:show="showStats" :task-id="taskId" />
|
||||
<RandomRatingModal v-if="authed" />
|
||||
<n-modal
|
||||
v-model:show="showAssets"
|
||||
preset="card"
|
||||
@@ -128,6 +148,7 @@ import ExternalAIPanel from "../components/ai/ExternalAIPanel.vue"
|
||||
import PromptHistoryPanel from "../components/ai/PromptHistoryPanel.vue"
|
||||
import Preview from "../components/editor/Preview.vue"
|
||||
import TaskStatsModal from "../components/task/TaskStatsModal.vue"
|
||||
import RandomRatingModal from "../components/ai/RandomRatingModal.vue"
|
||||
import { Challenge, Submission, TaskAssets } from "../api"
|
||||
import type { TaskAsset } from "../utils/type"
|
||||
import { html, css, js } from "../store/editors"
|
||||
@@ -139,6 +160,8 @@ import {
|
||||
disconnectPrompt,
|
||||
streaming,
|
||||
setOnCodeComplete,
|
||||
removeMessagePair,
|
||||
markMessageSubmitted,
|
||||
} from "../store/prompt"
|
||||
|
||||
const route = useRoute()
|
||||
@@ -180,6 +203,7 @@ const showStats = ref(false)
|
||||
const showAssets = ref(false)
|
||||
const assets = ref<TaskAsset[]>([])
|
||||
const historyRefreshKey = ref(0)
|
||||
const exampleCode = ref<{ html: string; css: string; js: string } | null>(null)
|
||||
|
||||
const assetBaseUrl = computed(
|
||||
() => `/media/tasks/challenge/${challengeDisplay.value}/`,
|
||||
@@ -193,14 +217,26 @@ async function loadChallenge() {
|
||||
const display = Number(route.params.display)
|
||||
taskTab.value = TASK_TYPE.Challenge
|
||||
challengeDisplay.value = display
|
||||
const data = await Challenge.get(display)
|
||||
const [data, fetchedAssets] = await Promise.all([
|
||||
Challenge.get(display),
|
||||
TaskAssets.listChallenge(display),
|
||||
])
|
||||
taskId.value = data.task_ptr
|
||||
challengeAuthor.value = data.author_name ?? ""
|
||||
if (data.example_html || data.example_css || data.example_js) {
|
||||
exampleCode.value = {
|
||||
html: data.example_html ?? "",
|
||||
css: data.example_css ?? "",
|
||||
js: data.example_js ?? "",
|
||||
}
|
||||
} else {
|
||||
exampleCode.value = null
|
||||
}
|
||||
challengeContent.value = await marked.parse(data.content, {
|
||||
async: true,
|
||||
renderer: challengeRenderer,
|
||||
} as MarkedOptions)
|
||||
assets.value = await TaskAssets.listChallenge(display)
|
||||
assets.value = fetchedAssets
|
||||
if (!authed.value) return
|
||||
connectPrompt(data.task_ptr)
|
||||
setOnCodeComplete(async (code, messageId) => {
|
||||
@@ -214,10 +250,11 @@ async function loadChallenge() {
|
||||
},
|
||||
messageId,
|
||||
)
|
||||
markMessageSubmitted(messageId)
|
||||
historyRefreshKey.value++
|
||||
message.success("已自动提交本次对话生成的代码")
|
||||
} catch {
|
||||
// 静默失败,不打扰用户
|
||||
message.error("自动提交失败,请稍后重试")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -229,6 +266,13 @@ function edit() {
|
||||
})
|
||||
}
|
||||
|
||||
function previewExample() {
|
||||
if (!exampleCode.value) return
|
||||
html.value = exampleCode.value.html
|
||||
css.value = exampleCode.value.css
|
||||
js.value = exampleCode.value.js
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
html.value = ""
|
||||
css.value = ""
|
||||
|
||||
@@ -76,11 +76,20 @@
|
||||
</n-button>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<TaskAssetManager
|
||||
v-if="challenge.display"
|
||||
task-type="challenge"
|
||||
:display="challenge.display"
|
||||
/>
|
||||
<n-flex>
|
||||
<TaskAssetManager
|
||||
v-if="challenge.display"
|
||||
task-type="challenge"
|
||||
:display="challenge.display"
|
||||
/>
|
||||
<n-button
|
||||
v-if="challenge.display"
|
||||
size="small"
|
||||
@click="showExampleModal = true"
|
||||
>
|
||||
示例代码
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<MarkdownEditor
|
||||
style="height: calc(100vh - 100px)"
|
||||
v-model="challenge.content"
|
||||
@@ -88,9 +97,28 @@
|
||||
</n-flex>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
|
||||
<n-modal
|
||||
v-model:show="showExampleModal"
|
||||
preset="card"
|
||||
title="示例代码(点击「看示例」时显示效果)"
|
||||
style="width: 640px"
|
||||
>
|
||||
<n-input
|
||||
type="textarea"
|
||||
v-model:value="rawCode"
|
||||
placeholder="粘贴完整的前端代码,自动拆分为 HTML / CSS / JS..."
|
||||
:autosize="{ minRows: 10, maxRows: 30 }"
|
||||
/>
|
||||
<n-flex v-if="splitResult" style="margin-top: 8px">
|
||||
<n-tag size="small" type="success">HTML · {{ splitResult.html.length }} 字符</n-tag>
|
||||
<n-tag size="small" type="info">CSS · {{ splitResult.css.length }} 字符</n-tag>
|
||||
<n-tag size="small" type="warning">JS · {{ splitResult.js.length }} 字符</n-tag>
|
||||
</n-flex>
|
||||
</n-modal>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, reactive, ref } from "vue"
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue"
|
||||
import { useRoute, useRouter } from "vue-router"
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { Challenge } from "../api"
|
||||
@@ -105,6 +133,55 @@ const message = useMessage()
|
||||
const confirm = useDialog()
|
||||
|
||||
const list = ref<ChallengeSlim[]>([])
|
||||
const showExampleModal = ref(false)
|
||||
const rawCode = ref("")
|
||||
const splitResult = ref<{ html: string; css: string; js: string } | null>(null)
|
||||
|
||||
function splitHtml(raw: string) {
|
||||
let result = raw
|
||||
const cssBlocks: string[] = []
|
||||
const jsBlocks: string[] = []
|
||||
result = result.replace(/<style[^>]*>([\s\S]*?)<\/style>/gi, (_, c) => {
|
||||
cssBlocks.push(c.trim())
|
||||
return ""
|
||||
})
|
||||
result = result.replace(
|
||||
/<script(?![^>]*\bsrc\b)[^>]*>([\s\S]*?)<\/script>/gi,
|
||||
(_, c) => {
|
||||
jsBlocks.push(c.trim())
|
||||
return ""
|
||||
},
|
||||
)
|
||||
return {
|
||||
html: result.trim(),
|
||||
css: cssBlocks.join("\n\n"),
|
||||
js: jsBlocks.join("\n\n"),
|
||||
}
|
||||
}
|
||||
|
||||
watch(rawCode, (val) => {
|
||||
if (!val.trim()) {
|
||||
splitResult.value = null
|
||||
challenge.example_html = null
|
||||
challenge.example_css = null
|
||||
challenge.example_js = null
|
||||
return
|
||||
}
|
||||
const split = splitHtml(val)
|
||||
splitResult.value = split
|
||||
challenge.example_html = split.html || null
|
||||
challenge.example_css = split.css || null
|
||||
challenge.example_js = split.js || null
|
||||
})
|
||||
|
||||
watch(showExampleModal, (visible) => {
|
||||
if (!visible) return
|
||||
const parts: string[] = []
|
||||
if (challenge.example_css) parts.push(`<style>\n${challenge.example_css}\n</style>`)
|
||||
if (challenge.example_html) parts.push(challenge.example_html)
|
||||
if (challenge.example_js) parts.push(`<script>\n${challenge.example_js}\n<\/script>`)
|
||||
rawCode.value = parts.join("\n\n")
|
||||
})
|
||||
const challenge = reactive({
|
||||
display: 0,
|
||||
title: "",
|
||||
@@ -112,6 +189,9 @@ const challenge = reactive({
|
||||
score: 0,
|
||||
is_public: false,
|
||||
author_name: "",
|
||||
example_html: null as string | null,
|
||||
example_css: null as string | null,
|
||||
example_js: null as string | null,
|
||||
})
|
||||
|
||||
const canSubmit = computed(
|
||||
@@ -135,6 +215,9 @@ function createNew() {
|
||||
challenge.score = 0
|
||||
challenge.is_public = false
|
||||
challenge.author_name = ""
|
||||
challenge.example_html = null
|
||||
challenge.example_css = null
|
||||
challenge.example_js = null
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
@@ -147,6 +230,9 @@ async function submit() {
|
||||
challenge.score = 0
|
||||
challenge.is_public = false
|
||||
challenge.author_name = ""
|
||||
challenge.example_html = null
|
||||
challenge.example_css = null
|
||||
challenge.example_js = null
|
||||
await getContent()
|
||||
} catch (error: any) {
|
||||
message.error(error.response.data.detail)
|
||||
@@ -176,6 +262,9 @@ async function show(display: number) {
|
||||
challenge.score = item.score
|
||||
challenge.is_public = item.is_public
|
||||
challenge.author_name = item.author_name ?? ""
|
||||
challenge.example_html = item.example_html ?? null
|
||||
challenge.example_css = item.example_css ?? null
|
||||
challenge.example_js = item.example_js ?? null
|
||||
}
|
||||
|
||||
async function togglePublic(display: number) {
|
||||
|
||||
@@ -51,7 +51,7 @@ const menu = computed(() =>
|
||||
{
|
||||
label: "成绩",
|
||||
route: { name: "gradebook" },
|
||||
show: roleAdmin.value || roleSuper.value,
|
||||
show: roleSuper.value,
|
||||
},
|
||||
{
|
||||
label: "提交",
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
class="work-card"
|
||||
content-style="padding: 0;"
|
||||
hoverable
|
||||
@click="openDetail(item.submission_id)"
|
||||
@click="openDetail(item)"
|
||||
>
|
||||
<div class="card-preview">
|
||||
<iframe
|
||||
@@ -94,8 +94,12 @@ function buildSrcdoc(item: ShowcaseItem): string {
|
||||
return `<!DOCTYPE html><html><head><meta charset="utf-8"><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>`
|
||||
}
|
||||
|
||||
function openDetail(id: string) {
|
||||
router.push({ name: "showcase-detail", params: { id } })
|
||||
function openDetail(item: ShowcaseItem) {
|
||||
const { href } = router.resolve({
|
||||
name: "submission",
|
||||
params: { id: item.submission_id },
|
||||
})
|
||||
window.open(href, "_blank")
|
||||
}
|
||||
|
||||
async function init() {
|
||||
|
||||
@@ -1,294 +0,0 @@
|
||||
<template>
|
||||
<main v-if="detail" class="detail-layout">
|
||||
<section class="preview-panel">
|
||||
<div class="back-bar">
|
||||
<n-button text @click="router.back()">
|
||||
<template #icon>
|
||||
<Icon icon="lucide:arrow-left" />
|
||||
</template>
|
||||
返回创意工坊
|
||||
</n-button>
|
||||
</div>
|
||||
<iframe
|
||||
v-if="detailSrcdoc"
|
||||
:srcdoc="detailSrcdoc"
|
||||
class="preview-iframe"
|
||||
sandbox="allow-scripts"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<aside class="info-panel">
|
||||
<n-flex vertical :size="0">
|
||||
<n-h3 style="margin: 0 0 4px;">{{ detail.task_title }}</n-h3>
|
||||
<n-text depth="3">{{ detail.username }}</n-text>
|
||||
<n-flex wrap :size="8" style="margin-top: 12px;">
|
||||
<n-tag
|
||||
v-for="award in detail.awards"
|
||||
:key="award"
|
||||
type="warning"
|
||||
size="small"
|
||||
>
|
||||
{{ award }}
|
||||
</n-tag>
|
||||
</n-flex>
|
||||
<n-flex :size="18" style="margin-top: 14px;">
|
||||
<n-flex align="center" :size="6">
|
||||
<Icon icon="lucide:star" :width="16" />
|
||||
<n-text strong style="font-size: 14px;">
|
||||
{{ detail.score.toFixed(1) }}
|
||||
</n-text>
|
||||
</n-flex>
|
||||
<n-flex align="center" :size="6">
|
||||
<Icon icon="lucide:eye" :width="16" />
|
||||
<n-text strong style="font-size: 14px;">
|
||||
{{ detail.view_count }}
|
||||
</n-text>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
|
||||
<n-divider v-if="detail.has_prompt_chain" />
|
||||
|
||||
<n-collapse
|
||||
v-if="detail.has_prompt_chain"
|
||||
@update:expanded-names="onCollapseChange"
|
||||
>
|
||||
<n-collapse-item title="创作过程" name="chain">
|
||||
<template #header-extra>
|
||||
<n-text depth="3" style="font-size: 12px;">点击展开</n-text>
|
||||
</template>
|
||||
<n-spin :show="chainLoading">
|
||||
<n-empty
|
||||
v-if="!chainLoading && rounds.length === 0"
|
||||
description="暂无记录"
|
||||
/>
|
||||
<n-flex v-else vertical :size="12">
|
||||
<n-scrollbar style="max-height: 260px;">
|
||||
<n-flex vertical :size="8" style="padding-right: 4px;">
|
||||
<n-card
|
||||
v-for="(round, i) in rounds"
|
||||
:key="i"
|
||||
size="small"
|
||||
content-style="padding: 8px;"
|
||||
:style="{
|
||||
cursor: 'pointer',
|
||||
borderColor: selectedRound === i ? '#2080f0' : undefined,
|
||||
background: selectedRound === i ? '#e8f0fe' : undefined,
|
||||
}"
|
||||
@click="selectedRound = i"
|
||||
>
|
||||
<n-flex align="flex-start" :size="8">
|
||||
<n-avatar
|
||||
round
|
||||
:size="20"
|
||||
:color="selectedRound === i ? '#2080f0' : '#9db7e8'"
|
||||
style="font-size: 11px; font-weight: 700; flex-shrink: 0;"
|
||||
>
|
||||
{{ i + 1 }}
|
||||
</n-avatar>
|
||||
<n-flex vertical :size="4" style="min-width: 0; flex: 1;">
|
||||
<n-text style="font-size: 12px; line-height: 1.5;">
|
||||
{{ round.question }}
|
||||
</n-text>
|
||||
<n-flex :size="5">
|
||||
<n-tag size="small" style="font-size: 10px;">
|
||||
{{ round.source === "conversation" ? "对话" : "手动" }}
|
||||
</n-tag>
|
||||
<n-text
|
||||
v-if="round.prompt_level"
|
||||
:style="{
|
||||
color: levelColors[round.prompt_level],
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
}"
|
||||
>
|
||||
L{{ round.prompt_level }}
|
||||
</n-text>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
</n-card>
|
||||
</n-flex>
|
||||
</n-scrollbar>
|
||||
|
||||
<n-flex vertical :size="8">
|
||||
<n-text strong style="font-size: 12px; color: #555;">
|
||||
第 {{ selectedRound + 1 }} 轮效果
|
||||
</n-text>
|
||||
<iframe
|
||||
v-if="selectedRoundSrcdoc"
|
||||
:key="selectedRound"
|
||||
:srcdoc="selectedRoundSrcdoc"
|
||||
sandbox="allow-scripts"
|
||||
class="round-iframe"
|
||||
/>
|
||||
<n-flex
|
||||
v-else
|
||||
justify="center"
|
||||
align="center"
|
||||
style="min-height: 240px;"
|
||||
>
|
||||
<n-empty description="该轮无网页代码" />
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
</n-spin>
|
||||
</n-collapse-item>
|
||||
</n-collapse>
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
<n-flex
|
||||
v-else-if="notFound"
|
||||
justify="center"
|
||||
align="center"
|
||||
style="min-height: 100vh; padding: 40px;"
|
||||
>
|
||||
<n-empty description="作品不存在" />
|
||||
</n-flex>
|
||||
|
||||
<n-flex v-else justify="center" align="center" style="min-height: 100vh;">
|
||||
<n-spin />
|
||||
</n-flex>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue"
|
||||
import { useRouter } from "vue-router"
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { Showcase, Submission } from "../api"
|
||||
import type { PromptRound, ShowcaseDetail } from "../utils/type"
|
||||
|
||||
const props = defineProps<{
|
||||
id: string
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
const detail = ref<ShowcaseDetail | null>(null)
|
||||
const notFound = ref(false)
|
||||
const rounds = ref<PromptRound[]>([])
|
||||
const chainLoading = ref(false)
|
||||
const selectedRound = ref(0)
|
||||
const chainLoaded = ref(false)
|
||||
|
||||
const levelColors: Record<number, string> = {
|
||||
1: "#888",
|
||||
2: "#4f8f7f",
|
||||
3: "#2f7bc1",
|
||||
4: "#aa5f9f",
|
||||
5: "#c48620",
|
||||
6: "#c94f4f",
|
||||
}
|
||||
|
||||
const detailSrcdoc = computed(() => {
|
||||
if (!detail.value) return null
|
||||
return buildDetailHtml(detail.value)
|
||||
})
|
||||
|
||||
const selectedRoundSrcdoc = 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"><meta name="viewport" content="width=device-width, initial-scale=1.0"><link rel="stylesheet" href="/normalize.min.css" />${style}</head><body>${round.html}${script}</body></html>`
|
||||
})
|
||||
|
||||
function buildDetailHtml(d: ShowcaseDetail) {
|
||||
const css = d.css ? `<style>${d.css}</style>` : ""
|
||||
const js = d.js ? `<script>${d.js}<\/script>` : ""
|
||||
return `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><link rel="stylesheet" href="/normalize.min.css" />${css}</head><body>${d.html ?? ""}${js}</body></html>`
|
||||
}
|
||||
|
||||
async function loadChain() {
|
||||
if (chainLoaded.value) return
|
||||
chainLoading.value = true
|
||||
try {
|
||||
rounds.value = await Showcase.getPromptChain(props.id)
|
||||
selectedRound.value = Math.max(0, rounds.value.length - 1)
|
||||
chainLoaded.value = true
|
||||
} finally {
|
||||
chainLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onCollapseChange(
|
||||
names: string | number | Array<string | number> | null,
|
||||
) {
|
||||
const expanded = Array.isArray(names) ? names : names == null ? [] : [names]
|
||||
if (expanded.includes("chain")) void loadChain()
|
||||
}
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
detail.value = await Showcase.getDetail(props.id)
|
||||
void Submission.incrementView(props.id)
|
||||
} catch {
|
||||
notFound.value = true
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(init)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.detail-layout {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.preview-panel {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid #e6e6e6;
|
||||
}
|
||||
|
||||
.back-bar {
|
||||
flex-shrink: 0;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.preview-iframe {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.info-panel {
|
||||
width: 360px;
|
||||
flex-shrink: 0;
|
||||
padding: 20px 16px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.round-iframe {
|
||||
min-height: 240px;
|
||||
flex: 1;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.detail-layout {
|
||||
height: auto;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.preview-panel {
|
||||
min-height: 56vh;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid #e6e6e6;
|
||||
}
|
||||
|
||||
.info-panel {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -242,6 +242,7 @@
|
||||
import { computed, h, onMounted, reactive, ref } from "vue"
|
||||
import {
|
||||
NButton,
|
||||
NButtonGroup,
|
||||
NInputNumber,
|
||||
NTag,
|
||||
useMessage,
|
||||
@@ -268,6 +269,7 @@ const itemsLoading = ref(false)
|
||||
const savingAward = ref(false)
|
||||
const deletingAward = ref(false)
|
||||
const updatingItemIds = ref(new Set<number>())
|
||||
const refreshingItemIds = ref(new Set<number>())
|
||||
const addWorkModalVisible = ref(false)
|
||||
const lookupSubmissionId = ref("")
|
||||
const lookupLoading = ref(false)
|
||||
@@ -347,22 +349,49 @@ const itemColumns: DataTableColumn<AwardItemManageOut>[] = [
|
||||
{ default: () => (row.has_prompt_chain ? "有" : "无") },
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
key: "is_stale",
|
||||
width: 96,
|
||||
render: (row) =>
|
||||
row.is_stale
|
||||
? h(NTag, { size: "small", type: "warning" }, { default: () => "有新提交" })
|
||||
: null,
|
||||
},
|
||||
{
|
||||
title: "",
|
||||
key: "actions",
|
||||
width: 54,
|
||||
width: 100,
|
||||
render: (row) =>
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
size: "small",
|
||||
tertiary: true,
|
||||
type: "error",
|
||||
title: "移除",
|
||||
onClick: () => removeAwardItem(row),
|
||||
},
|
||||
{ icon: () => h(Icon, { icon: "lucide:trash-2", width: 15 }) },
|
||||
),
|
||||
h(NButtonGroup, { size: "small" }, {
|
||||
default: () => [
|
||||
row.is_stale
|
||||
? h(
|
||||
NButton,
|
||||
{
|
||||
size: "small",
|
||||
secondary: true,
|
||||
type: "warning",
|
||||
title: "切换到最新提交",
|
||||
loading: refreshingItemIds.value.has(row.id),
|
||||
onClick: () => refreshAwardItem(row),
|
||||
},
|
||||
{ icon: () => h(Icon, { icon: "lucide:refresh-cw", width: 14 }) },
|
||||
)
|
||||
: null,
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
size: "small",
|
||||
tertiary: true,
|
||||
type: "error",
|
||||
title: "移除",
|
||||
onClick: () => removeAwardItem(row),
|
||||
},
|
||||
{ icon: () => h(Icon, { icon: "lucide:trash-2", width: 15 }) },
|
||||
),
|
||||
],
|
||||
}),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -470,6 +499,27 @@ function setUpdatingItem(id: number, loading: boolean) {
|
||||
updatingItemIds.value = next
|
||||
}
|
||||
|
||||
function setRefreshingItem(id: number, loading: boolean) {
|
||||
const next = new Set(refreshingItemIds.value)
|
||||
if (loading) next.add(id)
|
||||
else next.delete(id)
|
||||
refreshingItemIds.value = next
|
||||
}
|
||||
|
||||
async function refreshAwardItem(row: AwardItemManageOut) {
|
||||
setRefreshingItem(row.id, true)
|
||||
try {
|
||||
const updated = await Showcase.refreshAwardItem(row.id)
|
||||
const idx = awardItems.value.findIndex((item) => item.id === row.id)
|
||||
if (idx !== -1) awardItems.value[idx] = updated
|
||||
message.success("已切换到最新提交")
|
||||
} catch (err: any) {
|
||||
message.error(err.response?.data?.detail ?? "切换失败")
|
||||
} finally {
|
||||
setRefreshingItem(row.id, false)
|
||||
}
|
||||
}
|
||||
|
||||
async function updateItemOrder(row: AwardItemManageOut, sortOrder: number) {
|
||||
if (row.sort_order === sortOrder) return
|
||||
row.sort_order = sortOrder
|
||||
|
||||
@@ -97,7 +97,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
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 { Submission } from "../api"
|
||||
import type { SubmissionOut, FlagType } from "../utils/type"
|
||||
@@ -122,6 +122,7 @@ import { roleAdmin, roleSuper, user } from "../store/user"
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
|
||||
// 列表数据
|
||||
const data = ref<SubmissionOut[]>([])
|
||||
@@ -304,7 +305,12 @@ async function handleExpand(keys: (string | number)[]) {
|
||||
}
|
||||
|
||||
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)
|
||||
if (items)
|
||||
expandedData.set(
|
||||
|
||||
@@ -61,11 +61,20 @@
|
||||
</n-button>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<TaskAssetManager
|
||||
v-if="tutorial.display"
|
||||
task-type="tutorial"
|
||||
:display="tutorial.display"
|
||||
/>
|
||||
<n-flex>
|
||||
<TaskAssetManager
|
||||
v-if="tutorial.display"
|
||||
task-type="tutorial"
|
||||
:display="tutorial.display"
|
||||
/>
|
||||
<n-button
|
||||
v-if="tutorial.display"
|
||||
size="small"
|
||||
@click="showExampleModal = true"
|
||||
>
|
||||
示例代码
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<MarkdownEditor
|
||||
style="height: calc(100vh - 100px)"
|
||||
v-model="tutorial.content"
|
||||
@@ -73,9 +82,28 @@
|
||||
</n-flex>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
|
||||
<n-modal
|
||||
v-model:show="showExampleModal"
|
||||
preset="card"
|
||||
title="示例代码(加载教程时自动填入编辑器)"
|
||||
style="width: 640px"
|
||||
>
|
||||
<n-input
|
||||
type="textarea"
|
||||
v-model:value="rawCode"
|
||||
placeholder="粘贴完整的前端代码,自动拆分为 HTML / CSS / JS..."
|
||||
:autosize="{ minRows: 10, maxRows: 30 }"
|
||||
/>
|
||||
<n-flex v-if="splitResult" style="margin-top: 8px">
|
||||
<n-tag size="small" type="success">HTML · {{ splitResult.html.length }} 字符</n-tag>
|
||||
<n-tag size="small" type="info">CSS · {{ splitResult.css.length }} 字符</n-tag>
|
||||
<n-tag size="small" type="warning">JS · {{ splitResult.js.length }} 字符</n-tag>
|
||||
</n-flex>
|
||||
</n-modal>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, reactive, ref } from "vue"
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue"
|
||||
import { useRoute, useRouter } from "vue-router"
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { Tutorial } from "../api"
|
||||
@@ -90,11 +118,63 @@ const message = useMessage()
|
||||
const confirm = useDialog()
|
||||
|
||||
const list = ref<TutorialSlim[]>([])
|
||||
const showExampleModal = ref(false)
|
||||
const rawCode = ref("")
|
||||
const splitResult = ref<{ html: string; css: string; js: string } | null>(null)
|
||||
|
||||
function splitHtml(raw: string) {
|
||||
let result = raw
|
||||
const cssBlocks: string[] = []
|
||||
const jsBlocks: string[] = []
|
||||
result = result.replace(/<style[^>]*>([\s\S]*?)<\/style>/gi, (_, c) => {
|
||||
cssBlocks.push(c.trim())
|
||||
return ""
|
||||
})
|
||||
result = result.replace(
|
||||
/<script(?![^>]*\bsrc\b)[^>]*>([\s\S]*?)<\/script>/gi,
|
||||
(_, c) => {
|
||||
jsBlocks.push(c.trim())
|
||||
return ""
|
||||
},
|
||||
)
|
||||
return {
|
||||
html: result.trim(),
|
||||
css: cssBlocks.join("\n\n"),
|
||||
js: jsBlocks.join("\n\n"),
|
||||
}
|
||||
}
|
||||
|
||||
watch(rawCode, (val) => {
|
||||
if (!val.trim()) {
|
||||
splitResult.value = null
|
||||
tutorial.example_html = null
|
||||
tutorial.example_css = null
|
||||
tutorial.example_js = null
|
||||
return
|
||||
}
|
||||
const split = splitHtml(val)
|
||||
splitResult.value = split
|
||||
tutorial.example_html = split.html || null
|
||||
tutorial.example_css = split.css || null
|
||||
tutorial.example_js = split.js || null
|
||||
})
|
||||
|
||||
watch(showExampleModal, (visible) => {
|
||||
if (!visible) return
|
||||
const parts: string[] = []
|
||||
if (tutorial.example_css) parts.push(`<style>\n${tutorial.example_css}\n</style>`)
|
||||
if (tutorial.example_html) parts.push(tutorial.example_html)
|
||||
if (tutorial.example_js) parts.push(`<script>\n${tutorial.example_js}\n<\/script>`)
|
||||
rawCode.value = parts.join("\n\n")
|
||||
})
|
||||
const tutorial = reactive({
|
||||
display: 0,
|
||||
title: "",
|
||||
content: "",
|
||||
is_public: false,
|
||||
example_html: null as string | null,
|
||||
example_css: null as string | null,
|
||||
example_js: null as string | null,
|
||||
})
|
||||
|
||||
const canSubmit = computed(
|
||||
@@ -116,6 +196,9 @@ function createNew() {
|
||||
tutorial.title = ""
|
||||
tutorial.content = ""
|
||||
tutorial.is_public = false
|
||||
tutorial.example_html = null
|
||||
tutorial.example_css = null
|
||||
tutorial.example_js = null
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
@@ -126,6 +209,9 @@ async function submit() {
|
||||
tutorial.title = ""
|
||||
tutorial.content = ""
|
||||
tutorial.is_public = false
|
||||
tutorial.example_html = null
|
||||
tutorial.example_css = null
|
||||
tutorial.example_js = null
|
||||
await getContent()
|
||||
} catch (error: any) {
|
||||
message.error(error.response.data.detail)
|
||||
@@ -153,6 +239,9 @@ async function show(display: number) {
|
||||
tutorial.title = item.title
|
||||
tutorial.content = item.content
|
||||
tutorial.is_public = item.is_public
|
||||
tutorial.example_html = item.example_html ?? null
|
||||
tutorial.example_css = item.example_css ?? null
|
||||
tutorial.example_js = item.example_js ?? null
|
||||
}
|
||||
|
||||
async function togglePublic(display: number) {
|
||||
|
||||
@@ -3,27 +3,31 @@ import { loginModal } from "./store/modal"
|
||||
|
||||
import Workspace from "./pages/Workspace.vue"
|
||||
import { STORAGE_KEY } from "./utils/const"
|
||||
import { authed } from "./store/user"
|
||||
|
||||
const routes = [
|
||||
{ path: "/", name: "home", component: Workspace },
|
||||
{ path: "/tutorial", name: "home-tutorial-list", component: Workspace },
|
||||
{ path: "/tutorial/:display", name: "home-tutorial", component: Workspace },
|
||||
{ path: "/challenge", name: "home-challenge-list", component: Workspace },
|
||||
{ path: "/", name: "home", component: Workspace, meta: { auth: true } },
|
||||
{ path: "/tutorial", name: "home-tutorial-list", component: Workspace, meta: { auth: true } },
|
||||
{ path: "/tutorial/:display", name: "home-tutorial", component: Workspace, meta: { auth: true } },
|
||||
{ path: "/challenge", name: "home-challenge-list", component: Workspace, meta: { auth: true } },
|
||||
{
|
||||
path: "/challenge/:display",
|
||||
name: "home-challenge",
|
||||
component: () => import("./pages/ChallengeDetail.vue"),
|
||||
meta: { auth: true },
|
||||
},
|
||||
{
|
||||
path: "/submissions/:page",
|
||||
name: "submissions",
|
||||
component: () => import("./pages/Submissions.vue"),
|
||||
meta: { auth: true },
|
||||
},
|
||||
{
|
||||
path: "/submission/:id",
|
||||
name: "submission",
|
||||
component: () => import("./pages/Submission.vue"),
|
||||
props: true,
|
||||
meta: { auth: true },
|
||||
},
|
||||
{
|
||||
path: "/showcase",
|
||||
@@ -31,13 +35,6 @@ const routes = [
|
||||
component: () => import("./pages/Showcase.vue"),
|
||||
meta: { auth: true },
|
||||
},
|
||||
{
|
||||
path: "/showcase/:id",
|
||||
name: "showcase-detail",
|
||||
component: () => import("./pages/ShowcaseDetail.vue"),
|
||||
props: true,
|
||||
meta: { auth: true },
|
||||
},
|
||||
{
|
||||
path: "/dashboard",
|
||||
name: "dashboard",
|
||||
@@ -79,7 +76,8 @@ export const router = createRouter({
|
||||
})
|
||||
|
||||
router.beforeEach((to) => {
|
||||
const isLoggedIn = localStorage.getItem(STORAGE_KEY.LOGIN) === "true"
|
||||
const isLoggedIn =
|
||||
authed.value || localStorage.getItem(STORAGE_KEY.LOGIN) === "true"
|
||||
if (to.meta.auth && !isLoggedIn) {
|
||||
loginModal.value = true
|
||||
return false
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface PromptMessage {
|
||||
content: string
|
||||
id?: number // assistant message backend pk (for deletion)
|
||||
code?: { html: string | null; css: string | null; js: string | null }
|
||||
submitted?: boolean // whether this assistant message's code has been submitted
|
||||
created?: string
|
||||
}
|
||||
|
||||
@@ -28,10 +29,8 @@ export function setOnCodeComplete(fn: typeof _onCodeComplete) {
|
||||
}
|
||||
|
||||
let ws: WebSocket | null = null
|
||||
let _currentTaskId = 0
|
||||
|
||||
export function connectPrompt(taskId: number) {
|
||||
_currentTaskId = taskId
|
||||
currentTaskId.value = taskId
|
||||
if (ws) ws.close()
|
||||
|
||||
@@ -68,6 +67,7 @@ export function connectPrompt(taskId: number) {
|
||||
content: streamingContent.value,
|
||||
id: data.message_id,
|
||||
code: data.code,
|
||||
submitted: false,
|
||||
})
|
||||
streamingContent.value = ""
|
||||
if (data.code) {
|
||||
@@ -102,7 +102,6 @@ export function disconnectPrompt() {
|
||||
streaming.value = false
|
||||
streamingContent.value = ""
|
||||
currentTaskId.value = 0
|
||||
_currentTaskId = 0
|
||||
_onCodeComplete = null
|
||||
}
|
||||
|
||||
@@ -122,8 +121,20 @@ export function stopPrompt() {
|
||||
}
|
||||
streaming.value = false
|
||||
streamingContent.value = ""
|
||||
if (_currentTaskId) {
|
||||
connectPrompt(_currentTaskId)
|
||||
if (currentTaskId.value) {
|
||||
connectPrompt(currentTaskId.value)
|
||||
}
|
||||
}
|
||||
|
||||
export function markMessageSubmitted(assistantMsgId: number) {
|
||||
const msg = messages.value.find((m) => m.id === assistantMsgId)
|
||||
if (msg) msg.submitted = true
|
||||
}
|
||||
|
||||
export function removeMessagePair(assistantMsgId: number) {
|
||||
const idx = messages.value.findIndex((m) => m.id === assistantMsgId)
|
||||
if (idx >= 1) {
|
||||
messages.value.splice(idx - 1, 2)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
5
src/utils/markdown.ts
Normal file
5
src/utils/markdown.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { marked } from "marked"
|
||||
|
||||
export function renderMarkdown(text: string): string {
|
||||
return marked.parse(text) as string
|
||||
}
|
||||
@@ -54,12 +54,18 @@ export interface TutorialSlim {
|
||||
|
||||
export interface TutorialReturn extends TutorialSlim {
|
||||
content: string
|
||||
example_html: string | null
|
||||
example_css: string | null
|
||||
example_js: string | null
|
||||
}
|
||||
|
||||
export interface TutorialIn {
|
||||
display: number
|
||||
title: string
|
||||
content: string
|
||||
example_html?: string | null
|
||||
example_css?: string | null
|
||||
example_js?: string | null
|
||||
}
|
||||
|
||||
export interface ChallengeSlim {
|
||||
@@ -72,12 +78,22 @@ export interface ChallengeSlim {
|
||||
author_name: string | null
|
||||
}
|
||||
|
||||
export interface ChallengeReturn extends ChallengeSlim {
|
||||
content: string
|
||||
example_html: string | null
|
||||
example_css: string | null
|
||||
example_js: string | null
|
||||
}
|
||||
|
||||
export interface ChallengeIn {
|
||||
display: number
|
||||
title: string
|
||||
content: string
|
||||
score: number
|
||||
is_public: boolean
|
||||
example_html?: string | null
|
||||
example_css?: string | null
|
||||
example_js?: string | null
|
||||
}
|
||||
|
||||
export interface User {
|
||||
@@ -293,6 +309,7 @@ export interface AwardItemManageOut {
|
||||
sort_order: number
|
||||
awarded_at: string
|
||||
has_prompt_chain: boolean
|
||||
is_stale: boolean
|
||||
}
|
||||
|
||||
export interface ShowcaseDetail {
|
||||
@@ -318,3 +335,14 @@ export interface PromptRound {
|
||||
css: 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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user