Compare commits

...

14 Commits

Author SHA1 Message Date
681c6ff4f4 update
Some checks failed
Deploy / deploy (build, debian, 22) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822) (push) Has been cancelled
2026-06-14 06:32:09 -06:00
5bb8a1eaa3 update
Some checks failed
Deploy / deploy (build, debian, 22) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822) (push) Has been cancelled
2026-06-11 21:24:50 -06:00
c12c77ac7e fix
Some checks failed
Deploy / deploy (build, debian, 22) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822) (push) Has been cancelled
2026-06-11 21:08:35 -06:00
3102e1178a fix
Some checks failed
Deploy / deploy (build, debian, 22) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822) (push) Has been cancelled
2026-06-11 20:48:51 -06:00
a15b3d9c76 rating when ai is steaming
Some checks failed
Deploy / deploy (build, debian, 22) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822) (push) Has been cancelled
2026-06-11 19:53:15 -06:00
05ecf6bebf update
Some checks failed
Deploy / deploy (build, debian, 22) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822) (push) Has been cancelled
2026-06-11 22:35:14 +08:00
54d9861bd8 update 2026-06-11 22:21:59 +08:00
596ceec880 fix
Some checks failed
Deploy / deploy (build, debian, 22) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822) (push) Has been cancelled
2026-06-10 20:29:40 -06:00
6f99688667 update
Some checks failed
Deploy / deploy (build, debian, 22) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822) (push) Has been cancelled
2026-06-07 05:50:51 -06:00
4b32330b60 check if stable
Some checks failed
Deploy / deploy (build, debian, 22) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822) (push) Has been cancelled
2026-06-07 05:05:52 -06:00
1566a4e275 update
Some checks failed
Deploy / deploy (build, debian, 22) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822) (push) Has been cancelled
2026-06-02 20:25:41 -06:00
f7817d8c26 fix
Some checks failed
Deploy / deploy (build, debian, 22) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822) (push) Has been cancelled
2026-05-09 01:30:36 -06:00
53a0759507 add ds thinking
Some checks failed
Deploy / deploy (build, debian, 22) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822) (push) Has been cancelled
2026-05-07 21:25:25 -06:00
e2b3f61863 fix 2026-05-07 21:08:19 -06:00
22 changed files with 687 additions and 478 deletions

View File

@@ -9,6 +9,7 @@ export default defineConfig({
template: "./index.html",
},
source: {
include: [/node_modules[\\/]marked[\\/]/],
entry: {
index: "./src/main.ts",
},

View File

@@ -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

View File

@@ -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 {

View File

@@ -20,7 +20,7 @@
</div>
<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">
<span></span><span></span><span></span>
</div>
@@ -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

View File

@@ -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);

View File

@@ -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>
@@ -41,7 +51,7 @@
class="message-content"
v-html="renderMarkdown(streamingContent)"
></div>
<div class="streaming-hint">AI 正在思考</div>
<div class="streaming-hint">AI 正在生成</div>
</div>
</div>
<GuidancePanel @generate="onGuidanceGenerate" />
@@ -78,7 +88,7 @@
<n-select
v-model:value="selectedModel"
:options="modelOptions"
style="width: 120px"
style="width: 150px"
:disabled="streaming"
/>
<n-button v-if="streaming" type="error" @click="stopPrompt">
@@ -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>()
@@ -123,14 +137,23 @@ const naiveMessage = useMessage()
const modelOptions = [
{ label: "豆包", value: "doubao-seed-2-0-lite-260215" },
{ label: "DeepSeek", value: "deepseek-v4-flash" },
{ label: "DeepSeek(思考)", value: "deepseek-v4-flash-thinking" },
]
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
@@ -147,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 ?? "删除失败,请重试")
}
}
@@ -178,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)
}
@@ -288,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%,

View File

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

View File

@@ -238,13 +238,15 @@
<script setup lang="ts">
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)

View File

@@ -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"

View File

@@ -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 })
}

View File

@@ -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 = ""

View File

@@ -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) {

View File

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

View File

@@ -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() {

View File

@@ -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>

View File

@@ -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

View File

@@ -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(

View File

@@ -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) {

View File

@@ -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

View File

@@ -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
View File

@@ -0,0 +1,5 @@
import { marked } from "marked"
export function renderMarkdown(text: string): string {
return marked.parse(text) as string
}

View File

@@ -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
}