Compare commits
14 Commits
2a93c4d19c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 681c6ff4f4 | |||
| 5bb8a1eaa3 | |||
| c12c77ac7e | |||
| 3102e1178a | |||
| a15b3d9c76 | |||
| 05ecf6bebf | |||
| 54d9861bd8 | |||
| 596ceec880 | |||
| 6f99688667 | |||
| 4b32330b60 | |||
| 1566a4e275 | |||
| f7817d8c26 | |||
| 53a0759507 | |||
| e2b3f61863 |
@@ -9,6 +9,7 @@ export default defineConfig({
|
|||||||
template: "./index.html",
|
template: "./index.html",
|
||||||
},
|
},
|
||||||
source: {
|
source: {
|
||||||
|
include: [/node_modules[\\/]marked[\\/]/],
|
||||||
entry: {
|
entry: {
|
||||||
index: "./src/main.ts",
|
index: "./src/main.ts",
|
||||||
},
|
},
|
||||||
|
|||||||
15
src/api.ts
15
src/api.ts
@@ -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
|
||||||
@@ -421,6 +429,13 @@ export const Showcase = {
|
|||||||
return res.data
|
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> {
|
async getDetail(submissionId: string): Promise<ShowcaseDetail> {
|
||||||
const res = await http.get(`/submission/showcase/${submissionId}/`)
|
const res = await http.get(`/submission/showcase/${submissionId}/`)
|
||||||
return res.data
|
return res.data
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
type="password"
|
type="password"
|
||||||
v-model:value="studentPassword"
|
v-model:value="studentPassword"
|
||||||
name="password"
|
name="password"
|
||||||
|
@keyup.enter="submitStudent"
|
||||||
/>
|
/>
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
<n-alert
|
<n-alert
|
||||||
@@ -73,6 +74,7 @@
|
|||||||
type="password"
|
type="password"
|
||||||
v-model:value="adminPassword"
|
v-model:value="adminPassword"
|
||||||
name="password"
|
name="password"
|
||||||
|
@keyup.enter="submitAdmin"
|
||||||
/>
|
/>
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
<n-alert
|
<n-alert
|
||||||
@@ -101,6 +103,7 @@ import { ref, computed, onMounted } from "vue"
|
|||||||
import { Account } from "../api"
|
import { Account } from "../api"
|
||||||
import { loginModal } from "../store/modal"
|
import { loginModal } from "../store/modal"
|
||||||
import { user } from "../store/user"
|
import { user } from "../store/user"
|
||||||
|
import { router } from "../router"
|
||||||
|
|
||||||
// Tab state
|
// Tab state
|
||||||
const activeTab = ref("student")
|
const activeTab = ref("student")
|
||||||
@@ -188,6 +191,9 @@ async function submitStudent() {
|
|||||||
user.role = data.role
|
user.role = data.role
|
||||||
user.loaded = true
|
user.loaded = true
|
||||||
loginModal.value = false
|
loginModal.value = false
|
||||||
|
router.replace(
|
||||||
|
window.location.pathname + window.location.search + window.location.hash,
|
||||||
|
)
|
||||||
} catch {
|
} catch {
|
||||||
showStudentError.value = true
|
showStudentError.value = true
|
||||||
} finally {
|
} finally {
|
||||||
@@ -205,6 +211,9 @@ async function submitAdmin() {
|
|||||||
user.role = data.role
|
user.role = data.role
|
||||||
user.loaded = true
|
user.loaded = true
|
||||||
loginModal.value = false
|
loginModal.value = false
|
||||||
|
router.replace(
|
||||||
|
window.location.pathname + window.location.search + window.location.hash,
|
||||||
|
)
|
||||||
} catch {
|
} catch {
|
||||||
showAdminError.value = true
|
showAdminError.value = true
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="streaming" class="guidance-msg assistant">
|
<div v-if="streaming" class="guidance-msg assistant">
|
||||||
<div class="msg-role">AI 教练</div>
|
<div class="msg-role">AI</div>
|
||||||
<div v-if="!displayStreamingContent" class="typing-indicator">
|
<div v-if="!displayStreamingContent" class="typing-indicator">
|
||||||
<span></span><span></span><span></span>
|
<span></span><span></span><span></span>
|
||||||
</div>
|
</div>
|
||||||
@@ -84,8 +84,8 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, nextTick } from "vue"
|
import { ref, computed, watch, nextTick } from "vue"
|
||||||
import { marked } from "marked"
|
|
||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "@iconify/vue"
|
||||||
|
import { renderMarkdown } from "../../utils/markdown"
|
||||||
import {
|
import {
|
||||||
messages,
|
messages,
|
||||||
streaming,
|
streaming,
|
||||||
@@ -110,10 +110,6 @@ const displayStreamingContent = computed(() =>
|
|||||||
streamingContent.value.replace(/^\[READY\]\n?/, "")
|
streamingContent.value.replace(/^\[READY\]\n?/, "")
|
||||||
)
|
)
|
||||||
|
|
||||||
function renderMarkdown(text: string): string {
|
|
||||||
return marked.parse(text) as string
|
|
||||||
}
|
|
||||||
|
|
||||||
function send() {
|
function send() {
|
||||||
const text = draftPrompt.value.trim()
|
const text = draftPrompt.value.trim()
|
||||||
if (!text || streaming.value) return
|
if (!text || streaming.value) return
|
||||||
|
|||||||
@@ -75,17 +75,36 @@
|
|||||||
{{ item.source === "manual" ? "手动提交" : "AI 对话" }}
|
{{ item.source === "manual" ? "手动提交" : "AI 对话" }}
|
||||||
</n-tag>
|
</n-tag>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
<n-tag
|
<n-flex align="center" :wrap="false" :size="4">
|
||||||
v-if="selectedAssistantMessageId === item.assistant_message_id"
|
<n-tag
|
||||||
size="small"
|
v-if="selectedAssistantMessageId === item.assistant_message_id"
|
||||||
type="success"
|
size="small"
|
||||||
:bordered="false"
|
type="success"
|
||||||
>
|
:bordered="false"
|
||||||
正在预览
|
>
|
||||||
</n-tag>
|
正在预览
|
||||||
<n-text depth="3">
|
</n-tag>
|
||||||
{{ parseTime(item.created, "YYYY-MM-DD HH:mm") }}
|
<n-text depth="3">
|
||||||
</n-text>
|
{{ 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>
|
</n-flex>
|
||||||
<div
|
<div
|
||||||
class="prompt-markdown markdown-body"
|
class="prompt-markdown markdown-body"
|
||||||
@@ -112,7 +131,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref, watch } from "vue"
|
import { onMounted, ref, watch } from "vue"
|
||||||
import { Icon } from "@iconify/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 { Prompt } from "../../api"
|
||||||
import type { PromptHistoryItem } from "../../utils/type"
|
import type { PromptHistoryItem } from "../../utils/type"
|
||||||
import { parseTime } from "../../utils/helper"
|
import { parseTime } from "../../utils/helper"
|
||||||
@@ -127,6 +147,7 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
select: [code: { html: string; css: string; js: string }]
|
select: [code: { html: string; css: string; js: string }]
|
||||||
|
deleted: [assistantMessageId: number]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
type HistoryViewItem = PromptHistoryItem & {
|
type HistoryViewItem = PromptHistoryItem & {
|
||||||
@@ -137,7 +158,11 @@ type HistoryViewItem = PromptHistoryItem & {
|
|||||||
const items = ref<HistoryViewItem[]>([])
|
const items = ref<HistoryViewItem[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const selectedAssistantMessageId = ref<number | null>(null)
|
const selectedAssistantMessageId = ref<number | null>(null)
|
||||||
|
const deletingId = ref<number | null>(null)
|
||||||
let loadedTaskId = 0
|
let loadedTaskId = 0
|
||||||
|
let pendingRefresh = false
|
||||||
|
|
||||||
|
const naiveMessage = useMessage()
|
||||||
|
|
||||||
function toViewItem(item: PromptHistoryItem): HistoryViewItem {
|
function toViewItem(item: PromptHistoryItem): HistoryViewItem {
|
||||||
const html = item.code_html ?? ""
|
const html = item.code_html ?? ""
|
||||||
@@ -155,8 +180,25 @@ function toViewItem(item: PromptHistoryItem): HistoryViewItem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderMarkdown(text: string): string {
|
async function deleteItem(item: HistoryViewItem, e: Event) {
|
||||||
return marked.parse(text) as string
|
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) {
|
function selectItem(item: HistoryViewItem) {
|
||||||
@@ -194,7 +236,14 @@ async function load(force = true) {
|
|||||||
watch(
|
watch(
|
||||||
() => [props.active, props.taskId] as const,
|
() => [props.active, props.taskId] as const,
|
||||||
([active]) => {
|
([active]) => {
|
||||||
if (active) load(false)
|
if (active) {
|
||||||
|
if (pendingRefresh) {
|
||||||
|
pendingRefresh = false
|
||||||
|
load(true)
|
||||||
|
} else {
|
||||||
|
load(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -202,6 +251,7 @@ watch(
|
|||||||
() => props.refreshKey,
|
() => props.refreshKey,
|
||||||
() => {
|
() => {
|
||||||
if (props.active) load(true)
|
if (props.active) load(true)
|
||||||
|
else pendingRefresh = true
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -243,6 +293,7 @@ onMounted(() => {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.history-card.is-selected {
|
.history-card.is-selected {
|
||||||
--n-color: #f7fffa;
|
--n-color: #f7fffa;
|
||||||
box-shadow: 0 10px 24px rgba(24, 160, 88, 0.14);
|
box-shadow: 0 10px 24px rgba(24, 160, 88, 0.14);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
@@ -41,7 +51,7 @@
|
|||||||
class="message-content"
|
class="message-content"
|
||||||
v-html="renderMarkdown(streamingContent)"
|
v-html="renderMarkdown(streamingContent)"
|
||||||
></div>
|
></div>
|
||||||
<div class="streaming-hint">AI 正在思考中…</div>
|
<div class="streaming-hint">AI 正在生成中…</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<GuidancePanel @generate="onGuidanceGenerate" />
|
<GuidancePanel @generate="onGuidanceGenerate" />
|
||||||
@@ -78,7 +88,7 @@
|
|||||||
<n-select
|
<n-select
|
||||||
v-model:value="selectedModel"
|
v-model:value="selectedModel"
|
||||||
:options="modelOptions"
|
:options="modelOptions"
|
||||||
style="width: 120px"
|
style="width: 150px"
|
||||||
:disabled="streaming"
|
:disabled="streaming"
|
||||||
/>
|
/>
|
||||||
<n-button v-if="streaming" type="error" @click="stopPrompt">
|
<n-button v-if="streaming" type="error" @click="stopPrompt">
|
||||||
@@ -100,7 +110,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, nextTick, computed } from "vue"
|
import { ref, watch, nextTick, computed } from "vue"
|
||||||
import { useStorage } from "@vueuse/core"
|
import { useStorage } from "@vueuse/core"
|
||||||
import { marked, Renderer } from "marked"
|
|
||||||
import { useMessage } from "naive-ui"
|
import { useMessage } from "naive-ui"
|
||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "@iconify/vue"
|
||||||
import GuidancePanel from "./GuidancePanel.vue"
|
import GuidancePanel from "./GuidancePanel.vue"
|
||||||
@@ -113,8 +122,13 @@ import {
|
|||||||
sendPrompt,
|
sendPrompt,
|
||||||
stopPrompt,
|
stopPrompt,
|
||||||
currentTaskId,
|
currentTaskId,
|
||||||
|
removeMessagePair,
|
||||||
|
markMessageSubmitted,
|
||||||
} from "../../store/prompt"
|
} 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 input = ref("")
|
||||||
const messagesRef = ref<HTMLElement>()
|
const messagesRef = ref<HTMLElement>()
|
||||||
@@ -123,14 +137,23 @@ const naiveMessage = useMessage()
|
|||||||
const modelOptions = [
|
const modelOptions = [
|
||||||
{ label: "豆包", value: "doubao-seed-2-0-lite-260215" },
|
{ label: "豆包", value: "doubao-seed-2-0-lite-260215" },
|
||||||
{ label: "DeepSeek", value: "deepseek-v4-flash" },
|
{ label: "DeepSeek", value: "deepseek-v4-flash" },
|
||||||
|
{ label: "DeepSeek(思考)", value: "deepseek-v4-flash-thinking" },
|
||||||
]
|
]
|
||||||
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
|
||||||
@@ -147,16 +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)
|
||||||
const msgIdx = messages.value.findIndex((m) => m.id === assistantMsgId)
|
removeMessagePair(assistantMsgId)
|
||||||
if (msgIdx >= 1) {
|
|
||||||
messages.value.splice(msgIdx - 1, 2)
|
|
||||||
}
|
|
||||||
naiveMessage.success("已删除")
|
naiveMessage.success("已删除")
|
||||||
} catch {
|
emit("deleted")
|
||||||
naiveMessage.error("删除失败,请重试")
|
} catch (error: any) {
|
||||||
|
naiveMessage.error(error.response?.data?.detail ?? "删除失败,请重试")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,56 +230,6 @@ function onGuidanceGenerate(finalPrompt: string) {
|
|||||||
input.value = ""
|
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 {
|
function renderContent(msg: { role: string; content: string }): string {
|
||||||
return renderMarkdown(msg.content)
|
return renderMarkdown(msg.content)
|
||||||
}
|
}
|
||||||
@@ -288,47 +290,10 @@ watch([() => messages.value.length, streamingContent], () => {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-content :deep(.code-placeholder) {
|
.message-actions {
|
||||||
display: flex;
|
margin-top: 6px;
|
||||||
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-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 {
|
@keyframes pulse {
|
||||||
0%,
|
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">
|
<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)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ async function render() {
|
|||||||
const data = await Tutorial.get(step.value)
|
const data = await Tutorial.get(step.value)
|
||||||
taskId.value = data.task_ptr
|
taskId.value = data.task_ptr
|
||||||
assetBaseUrl.value = `/media/tasks/tutorial/${step.value}/`
|
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}`
|
const merged = `# ${data.display}. ${data.title}\n${data.content}`
|
||||||
content.value = await marked.parse(merged, { async: true })
|
content.value = await marked.parse(merged, { async: true })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -36,9 +33,27 @@
|
|||||||
<n-tab-pane name="desc" tab="挑战描述" display-directive="show">
|
<n-tab-pane name="desc" tab="挑战描述" display-directive="show">
|
||||||
<div class="desc-pane">
|
<div class="desc-pane">
|
||||||
<div class="challenge-meta">
|
<div class="challenge-meta">
|
||||||
<n-text depth="3">
|
<n-flex align="center" justify="space-between">
|
||||||
出题人:{{ challengeAuthor || "未设置" }}
|
<n-text depth="3">
|
||||||
</n-text>
|
出题人:{{ 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>
|
||||||
<div
|
<div
|
||||||
class="markdown-body content no-select"
|
class="markdown-body content no-select"
|
||||||
@@ -49,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 />
|
<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++" />
|
||||||
@@ -61,6 +79,7 @@
|
|||||||
:asset-base-url="assetBaseUrl"
|
:asset-base-url="assetBaseUrl"
|
||||||
:refresh-key="historyRefreshKey"
|
:refresh-key="historyRefreshKey"
|
||||||
@select="previewHistoryItem"
|
@select="previewHistoryItem"
|
||||||
|
@deleted="removeMessagePair"
|
||||||
/>
|
/>
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
</n-tabs>
|
</n-tabs>
|
||||||
@@ -79,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"
|
||||||
@@ -128,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"
|
||||||
@@ -139,6 +160,8 @@ import {
|
|||||||
disconnectPrompt,
|
disconnectPrompt,
|
||||||
streaming,
|
streaming,
|
||||||
setOnCodeComplete,
|
setOnCodeComplete,
|
||||||
|
removeMessagePair,
|
||||||
|
markMessageSubmitted,
|
||||||
} from "../store/prompt"
|
} from "../store/prompt"
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -180,6 +203,7 @@ const showStats = ref(false)
|
|||||||
const showAssets = ref(false)
|
const showAssets = ref(false)
|
||||||
const assets = ref<TaskAsset[]>([])
|
const assets = ref<TaskAsset[]>([])
|
||||||
const historyRefreshKey = ref(0)
|
const historyRefreshKey = ref(0)
|
||||||
|
const exampleCode = ref<{ html: string; css: string; js: string } | null>(null)
|
||||||
|
|
||||||
const assetBaseUrl = computed(
|
const assetBaseUrl = computed(
|
||||||
() => `/media/tasks/challenge/${challengeDisplay.value}/`,
|
() => `/media/tasks/challenge/${challengeDisplay.value}/`,
|
||||||
@@ -193,14 +217,26 @@ async function loadChallenge() {
|
|||||||
const display = Number(route.params.display)
|
const display = Number(route.params.display)
|
||||||
taskTab.value = TASK_TYPE.Challenge
|
taskTab.value = TASK_TYPE.Challenge
|
||||||
challengeDisplay.value = display
|
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
|
taskId.value = data.task_ptr
|
||||||
challengeAuthor.value = data.author_name ?? ""
|
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, {
|
challengeContent.value = await marked.parse(data.content, {
|
||||||
async: true,
|
async: true,
|
||||||
renderer: challengeRenderer,
|
renderer: challengeRenderer,
|
||||||
} as MarkedOptions)
|
} as MarkedOptions)
|
||||||
assets.value = await TaskAssets.listChallenge(display)
|
assets.value = fetchedAssets
|
||||||
if (!authed.value) return
|
if (!authed.value) return
|
||||||
connectPrompt(data.task_ptr)
|
connectPrompt(data.task_ptr)
|
||||||
setOnCodeComplete(async (code, messageId) => {
|
setOnCodeComplete(async (code, messageId) => {
|
||||||
@@ -214,10 +250,11 @@ async function loadChallenge() {
|
|||||||
},
|
},
|
||||||
messageId,
|
messageId,
|
||||||
)
|
)
|
||||||
|
markMessageSubmitted(messageId)
|
||||||
historyRefreshKey.value++
|
historyRefreshKey.value++
|
||||||
message.success("已自动提交本次对话生成的代码")
|
message.success("已自动提交本次对话生成的代码")
|
||||||
} catch {
|
} 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() {
|
function clearAll() {
|
||||||
html.value = ""
|
html.value = ""
|
||||||
css.value = ""
|
css.value = ""
|
||||||
|
|||||||
@@ -76,11 +76,20 @@
|
|||||||
</n-button>
|
</n-button>
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
</n-form>
|
</n-form>
|
||||||
<TaskAssetManager
|
<n-flex>
|
||||||
v-if="challenge.display"
|
<TaskAssetManager
|
||||||
task-type="challenge"
|
v-if="challenge.display"
|
||||||
:display="challenge.display"
|
task-type="challenge"
|
||||||
/>
|
:display="challenge.display"
|
||||||
|
/>
|
||||||
|
<n-button
|
||||||
|
v-if="challenge.display"
|
||||||
|
size="small"
|
||||||
|
@click="showExampleModal = true"
|
||||||
|
>
|
||||||
|
示例代码
|
||||||
|
</n-button>
|
||||||
|
</n-flex>
|
||||||
<MarkdownEditor
|
<MarkdownEditor
|
||||||
style="height: calc(100vh - 100px)"
|
style="height: calc(100vh - 100px)"
|
||||||
v-model="challenge.content"
|
v-model="challenge.content"
|
||||||
@@ -88,9 +97,28 @@
|
|||||||
</n-flex>
|
</n-flex>
|
||||||
</n-gi>
|
</n-gi>
|
||||||
</n-grid>
|
</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>
|
</template>
|
||||||
<script lang="ts" setup>
|
<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 { useRoute, useRouter } from "vue-router"
|
||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "@iconify/vue"
|
||||||
import { Challenge } from "../api"
|
import { Challenge } from "../api"
|
||||||
@@ -105,6 +133,55 @@ const message = useMessage()
|
|||||||
const confirm = useDialog()
|
const confirm = useDialog()
|
||||||
|
|
||||||
const list = ref<ChallengeSlim[]>([])
|
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({
|
const challenge = reactive({
|
||||||
display: 0,
|
display: 0,
|
||||||
title: "",
|
title: "",
|
||||||
@@ -112,6 +189,9 @@ const challenge = reactive({
|
|||||||
score: 0,
|
score: 0,
|
||||||
is_public: false,
|
is_public: false,
|
||||||
author_name: "",
|
author_name: "",
|
||||||
|
example_html: null as string | null,
|
||||||
|
example_css: null as string | null,
|
||||||
|
example_js: null as string | null,
|
||||||
})
|
})
|
||||||
|
|
||||||
const canSubmit = computed(
|
const canSubmit = computed(
|
||||||
@@ -135,6 +215,9 @@ function createNew() {
|
|||||||
challenge.score = 0
|
challenge.score = 0
|
||||||
challenge.is_public = false
|
challenge.is_public = false
|
||||||
challenge.author_name = ""
|
challenge.author_name = ""
|
||||||
|
challenge.example_html = null
|
||||||
|
challenge.example_css = null
|
||||||
|
challenge.example_js = null
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
@@ -147,6 +230,9 @@ async function submit() {
|
|||||||
challenge.score = 0
|
challenge.score = 0
|
||||||
challenge.is_public = false
|
challenge.is_public = false
|
||||||
challenge.author_name = ""
|
challenge.author_name = ""
|
||||||
|
challenge.example_html = null
|
||||||
|
challenge.example_css = null
|
||||||
|
challenge.example_js = null
|
||||||
await getContent()
|
await getContent()
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
message.error(error.response.data.detail)
|
message.error(error.response.data.detail)
|
||||||
@@ -176,6 +262,9 @@ async function show(display: number) {
|
|||||||
challenge.score = item.score
|
challenge.score = item.score
|
||||||
challenge.is_public = item.is_public
|
challenge.is_public = item.is_public
|
||||||
challenge.author_name = item.author_name ?? ""
|
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) {
|
async function togglePublic(display: number) {
|
||||||
|
|||||||
@@ -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: "提交",
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
class="work-card"
|
class="work-card"
|
||||||
content-style="padding: 0;"
|
content-style="padding: 0;"
|
||||||
hoverable
|
hoverable
|
||||||
@click="openDetail(item.submission_id)"
|
@click="openDetail(item)"
|
||||||
>
|
>
|
||||||
<div class="card-preview">
|
<div class="card-preview">
|
||||||
<iframe
|
<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>`
|
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) {
|
function openDetail(item: ShowcaseItem) {
|
||||||
router.push({ name: "showcase-detail", params: { id } })
|
const { href } = router.resolve({
|
||||||
|
name: "submission",
|
||||||
|
params: { id: item.submission_id },
|
||||||
|
})
|
||||||
|
window.open(href, "_blank")
|
||||||
}
|
}
|
||||||
|
|
||||||
async function init() {
|
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 { computed, h, onMounted, reactive, ref } from "vue"
|
||||||
import {
|
import {
|
||||||
NButton,
|
NButton,
|
||||||
|
NButtonGroup,
|
||||||
NInputNumber,
|
NInputNumber,
|
||||||
NTag,
|
NTag,
|
||||||
useMessage,
|
useMessage,
|
||||||
@@ -268,6 +269,7 @@ const itemsLoading = ref(false)
|
|||||||
const savingAward = ref(false)
|
const savingAward = ref(false)
|
||||||
const deletingAward = ref(false)
|
const deletingAward = ref(false)
|
||||||
const updatingItemIds = ref(new Set<number>())
|
const updatingItemIds = ref(new Set<number>())
|
||||||
|
const refreshingItemIds = ref(new Set<number>())
|
||||||
const addWorkModalVisible = ref(false)
|
const addWorkModalVisible = ref(false)
|
||||||
const lookupSubmissionId = ref("")
|
const lookupSubmissionId = ref("")
|
||||||
const lookupLoading = ref(false)
|
const lookupLoading = ref(false)
|
||||||
@@ -347,22 +349,49 @@ const itemColumns: DataTableColumn<AwardItemManageOut>[] = [
|
|||||||
{ default: () => (row.has_prompt_chain ? "有" : "无") },
|
{ 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: "",
|
title: "",
|
||||||
key: "actions",
|
key: "actions",
|
||||||
width: 54,
|
width: 100,
|
||||||
render: (row) =>
|
render: (row) =>
|
||||||
h(
|
h(NButtonGroup, { size: "small" }, {
|
||||||
NButton,
|
default: () => [
|
||||||
{
|
row.is_stale
|
||||||
size: "small",
|
? h(
|
||||||
tertiary: true,
|
NButton,
|
||||||
type: "error",
|
{
|
||||||
title: "移除",
|
size: "small",
|
||||||
onClick: () => removeAwardItem(row),
|
secondary: true,
|
||||||
},
|
type: "warning",
|
||||||
{ icon: () => h(Icon, { icon: "lucide:trash-2", width: 15 }) },
|
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
|
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) {
|
async function updateItemOrder(row: AwardItemManageOut, sortOrder: number) {
|
||||||
if (row.sort_order === sortOrder) return
|
if (row.sort_order === sortOrder) return
|
||||||
row.sort_order = sortOrder
|
row.sort_order = sortOrder
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -61,11 +61,20 @@
|
|||||||
</n-button>
|
</n-button>
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
</n-form>
|
</n-form>
|
||||||
<TaskAssetManager
|
<n-flex>
|
||||||
v-if="tutorial.display"
|
<TaskAssetManager
|
||||||
task-type="tutorial"
|
v-if="tutorial.display"
|
||||||
:display="tutorial.display"
|
task-type="tutorial"
|
||||||
/>
|
:display="tutorial.display"
|
||||||
|
/>
|
||||||
|
<n-button
|
||||||
|
v-if="tutorial.display"
|
||||||
|
size="small"
|
||||||
|
@click="showExampleModal = true"
|
||||||
|
>
|
||||||
|
示例代码
|
||||||
|
</n-button>
|
||||||
|
</n-flex>
|
||||||
<MarkdownEditor
|
<MarkdownEditor
|
||||||
style="height: calc(100vh - 100px)"
|
style="height: calc(100vh - 100px)"
|
||||||
v-model="tutorial.content"
|
v-model="tutorial.content"
|
||||||
@@ -73,9 +82,28 @@
|
|||||||
</n-flex>
|
</n-flex>
|
||||||
</n-gi>
|
</n-gi>
|
||||||
</n-grid>
|
</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>
|
</template>
|
||||||
<script lang="ts" setup>
|
<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 { useRoute, useRouter } from "vue-router"
|
||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "@iconify/vue"
|
||||||
import { Tutorial } from "../api"
|
import { Tutorial } from "../api"
|
||||||
@@ -90,11 +118,63 @@ const message = useMessage()
|
|||||||
const confirm = useDialog()
|
const confirm = useDialog()
|
||||||
|
|
||||||
const list = ref<TutorialSlim[]>([])
|
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({
|
const tutorial = reactive({
|
||||||
display: 0,
|
display: 0,
|
||||||
title: "",
|
title: "",
|
||||||
content: "",
|
content: "",
|
||||||
is_public: false,
|
is_public: false,
|
||||||
|
example_html: null as string | null,
|
||||||
|
example_css: null as string | null,
|
||||||
|
example_js: null as string | null,
|
||||||
})
|
})
|
||||||
|
|
||||||
const canSubmit = computed(
|
const canSubmit = computed(
|
||||||
@@ -116,6 +196,9 @@ function createNew() {
|
|||||||
tutorial.title = ""
|
tutorial.title = ""
|
||||||
tutorial.content = ""
|
tutorial.content = ""
|
||||||
tutorial.is_public = false
|
tutorial.is_public = false
|
||||||
|
tutorial.example_html = null
|
||||||
|
tutorial.example_css = null
|
||||||
|
tutorial.example_js = null
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
@@ -126,6 +209,9 @@ async function submit() {
|
|||||||
tutorial.title = ""
|
tutorial.title = ""
|
||||||
tutorial.content = ""
|
tutorial.content = ""
|
||||||
tutorial.is_public = false
|
tutorial.is_public = false
|
||||||
|
tutorial.example_html = null
|
||||||
|
tutorial.example_css = null
|
||||||
|
tutorial.example_js = null
|
||||||
await getContent()
|
await getContent()
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
message.error(error.response.data.detail)
|
message.error(error.response.data.detail)
|
||||||
@@ -153,6 +239,9 @@ async function show(display: number) {
|
|||||||
tutorial.title = item.title
|
tutorial.title = item.title
|
||||||
tutorial.content = item.content
|
tutorial.content = item.content
|
||||||
tutorial.is_public = item.is_public
|
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) {
|
async function togglePublic(display: number) {
|
||||||
|
|||||||
@@ -3,27 +3,31 @@ import { loginModal } from "./store/modal"
|
|||||||
|
|
||||||
import Workspace from "./pages/Workspace.vue"
|
import Workspace from "./pages/Workspace.vue"
|
||||||
import { STORAGE_KEY } from "./utils/const"
|
import { STORAGE_KEY } from "./utils/const"
|
||||||
|
import { authed } from "./store/user"
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{ path: "/", name: "home", component: Workspace },
|
{ path: "/", name: "home", component: Workspace, meta: { auth: true } },
|
||||||
{ path: "/tutorial", name: "home-tutorial-list", component: Workspace },
|
{ path: "/tutorial", name: "home-tutorial-list", component: Workspace, meta: { auth: true } },
|
||||||
{ path: "/tutorial/:display", name: "home-tutorial", component: Workspace },
|
{ path: "/tutorial/:display", name: "home-tutorial", component: Workspace, meta: { auth: true } },
|
||||||
{ path: "/challenge", name: "home-challenge-list", component: Workspace },
|
{ path: "/challenge", name: "home-challenge-list", component: Workspace, meta: { auth: true } },
|
||||||
{
|
{
|
||||||
path: "/challenge/:display",
|
path: "/challenge/:display",
|
||||||
name: "home-challenge",
|
name: "home-challenge",
|
||||||
component: () => import("./pages/ChallengeDetail.vue"),
|
component: () => import("./pages/ChallengeDetail.vue"),
|
||||||
|
meta: { auth: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/submissions/:page",
|
path: "/submissions/:page",
|
||||||
name: "submissions",
|
name: "submissions",
|
||||||
component: () => import("./pages/Submissions.vue"),
|
component: () => import("./pages/Submissions.vue"),
|
||||||
|
meta: { auth: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/submission/:id",
|
path: "/submission/:id",
|
||||||
name: "submission",
|
name: "submission",
|
||||||
component: () => import("./pages/Submission.vue"),
|
component: () => import("./pages/Submission.vue"),
|
||||||
props: true,
|
props: true,
|
||||||
|
meta: { auth: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/showcase",
|
path: "/showcase",
|
||||||
@@ -31,13 +35,6 @@ const routes = [
|
|||||||
component: () => import("./pages/Showcase.vue"),
|
component: () => import("./pages/Showcase.vue"),
|
||||||
meta: { auth: true },
|
meta: { auth: true },
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: "/showcase/:id",
|
|
||||||
name: "showcase-detail",
|
|
||||||
component: () => import("./pages/ShowcaseDetail.vue"),
|
|
||||||
props: true,
|
|
||||||
meta: { auth: true },
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: "/dashboard",
|
path: "/dashboard",
|
||||||
name: "dashboard",
|
name: "dashboard",
|
||||||
@@ -79,7 +76,8 @@ export const router = createRouter({
|
|||||||
})
|
})
|
||||||
|
|
||||||
router.beforeEach((to) => {
|
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) {
|
if (to.meta.auth && !isLoggedIn) {
|
||||||
loginModal.value = true
|
loginModal.value = true
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,10 +29,8 @@ export function setOnCodeComplete(fn: typeof _onCodeComplete) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let ws: WebSocket | null = null
|
let ws: WebSocket | null = null
|
||||||
let _currentTaskId = 0
|
|
||||||
|
|
||||||
export function connectPrompt(taskId: number) {
|
export function connectPrompt(taskId: number) {
|
||||||
_currentTaskId = taskId
|
|
||||||
currentTaskId.value = taskId
|
currentTaskId.value = taskId
|
||||||
if (ws) ws.close()
|
if (ws) ws.close()
|
||||||
|
|
||||||
@@ -68,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) {
|
||||||
@@ -102,7 +102,6 @@ export function disconnectPrompt() {
|
|||||||
streaming.value = false
|
streaming.value = false
|
||||||
streamingContent.value = ""
|
streamingContent.value = ""
|
||||||
currentTaskId.value = 0
|
currentTaskId.value = 0
|
||||||
_currentTaskId = 0
|
|
||||||
_onCodeComplete = null
|
_onCodeComplete = null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,8 +121,20 @@ export function stopPrompt() {
|
|||||||
}
|
}
|
||||||
streaming.value = false
|
streaming.value = false
|
||||||
streamingContent.value = ""
|
streamingContent.value = ""
|
||||||
if (_currentTaskId) {
|
if (currentTaskId.value) {
|
||||||
connectPrompt(_currentTaskId)
|
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 {
|
export interface TutorialReturn extends TutorialSlim {
|
||||||
content: string
|
content: string
|
||||||
|
example_html: string | null
|
||||||
|
example_css: string | null
|
||||||
|
example_js: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TutorialIn {
|
export interface TutorialIn {
|
||||||
display: number
|
display: number
|
||||||
title: string
|
title: string
|
||||||
content: string
|
content: string
|
||||||
|
example_html?: string | null
|
||||||
|
example_css?: string | null
|
||||||
|
example_js?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChallengeSlim {
|
export interface ChallengeSlim {
|
||||||
@@ -72,12 +78,22 @@ export interface ChallengeSlim {
|
|||||||
author_name: string | null
|
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 {
|
export interface ChallengeIn {
|
||||||
display: number
|
display: number
|
||||||
title: string
|
title: string
|
||||||
content: string
|
content: string
|
||||||
score: number
|
score: number
|
||||||
is_public: boolean
|
is_public: boolean
|
||||||
|
example_html?: string | null
|
||||||
|
example_css?: string | null
|
||||||
|
example_js?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
@@ -293,6 +309,7 @@ export interface AwardItemManageOut {
|
|||||||
sort_order: number
|
sort_order: number
|
||||||
awarded_at: string
|
awarded_at: string
|
||||||
has_prompt_chain: boolean
|
has_prompt_chain: boolean
|
||||||
|
is_stale: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShowcaseDetail {
|
export interface ShowcaseDetail {
|
||||||
@@ -318,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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user