chore: 将前端移动到 client/ 目录,准备合并为单仓库

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-26 16:01:25 +08:00
1 parent 1f6935e31e
commit d1c6b8c201
79 files changed

No files matched your search

+229
View File
@@ -0,0 +1,229 @@
<template>
<n-modal
preset="card"
title="登录"
style="width: 420px"
v-model:show="loginModal"
>
<n-tabs v-model:value="activeTab" @update:value="onTabChange">
<n-tab-pane name="student" tab="学生登录">
<n-form>
<n-form-item label="班级">
<n-select
v-model:value="selectedClass"
:options="classOptions"
placeholder="选择班级"
:loading="classesLoading"
@update:value="onClassChange"
/>
</n-form-item>
<n-form-item label="姓名">
<n-select
v-model:value="selectedUsername"
:options="nameOptions"
placeholder="选择姓名"
:loading="namesLoading"
:disabled="!selectedClass || namesLoading"
/>
</n-form-item>
<n-form-item label="密码">
<n-input
type="password"
v-model:value="studentPassword"
name="password"
@keyup.enter="submitStudent"
/>
</n-form-item>
<n-alert
type="error"
v-if="classesError"
class="message"
title="加载班级列表失败,请刷新重试"
/>
<n-alert
type="error"
v-if="namesError"
class="message"
title="加载姓名列表失败,请重新选择班级"
/>
<n-alert
type="error"
v-if="showStudentError"
class="message"
title="登录失败,请检查密码"
/>
<n-button
block
type="primary"
:loading="studentLoading"
:disabled="!selectedClass || !selectedUsername || !studentPassword"
@click="submitStudent"
>
登录
</n-button>
</n-form>
</n-tab-pane>
<n-tab-pane name="admin" tab="管理员登录">
<n-form>
<n-form-item label="用户名">
<n-input v-model:value="adminName" name="username" />
</n-form-item>
<n-form-item label="密码">
<n-input
type="password"
v-model:value="adminPassword"
name="password"
@keyup.enter="submitAdmin"
/>
</n-form-item>
<n-alert
type="error"
v-if="showAdminError"
class="message"
title="登录失败,请检查用户名和密码"
/>
<n-button
block
type="primary"
:loading="adminLoading"
:disabled="!adminName || !adminPassword"
@click="submitAdmin"
>
登录
</n-button>
</n-form>
</n-tab-pane>
</n-tabs>
</n-modal>
</template>
<script lang="ts" setup>
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")
// Student tab state
const selectedClass = ref<string | null>(null)
const selectedUsername = ref<string | null>(null)
const studentPassword = ref("")
const studentLoading = ref(false)
const showStudentError = ref(false)
// Classes data
const classes = ref<string[]>([])
const classesLoading = ref(false)
const classesError = ref(false)
const classOptions = computed(() =>
classes.value.map((c) => ({ label: c, value: c })),
)
// Names data
const names = ref<{ name: string; username: string }[]>([])
const namesLoading = ref(false)
const namesError = ref(false)
const nameOptions = computed(() =>
names.value.map((n) => ({ label: n.name, value: n.username })),
)
// Admin tab state
const adminName = ref("")
const adminPassword = ref("")
const adminLoading = ref(false)
const showAdminError = ref(false)
// Load classes on mount (cached — not reloaded on tab switch)
onMounted(async () => {
classesLoading.value = true
classesError.value = false
try {
classes.value = await Account.listClasses()
} catch {
classesError.value = true
} finally {
classesLoading.value = false
}
})
function onTabChange() {
// Reset student tab state
selectedClass.value = null
selectedUsername.value = null
studentPassword.value = ""
showStudentError.value = false
namesError.value = false
names.value = []
// Reset admin tab state
adminName.value = ""
adminPassword.value = ""
showAdminError.value = false
}
async function onClassChange(classname: string) {
selectedUsername.value = null
names.value = []
namesError.value = false
namesLoading.value = true
try {
names.value = await Account.listNamesByClass(classname)
} catch {
namesError.value = true
} finally {
namesLoading.value = false
}
}
async function submitStudent() {
if (!selectedUsername.value || !studentPassword.value) return
studentLoading.value = true
showStudentError.value = false
try {
const data = await Account.login(
selectedUsername.value,
studentPassword.value,
)
user.username = data.username
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 {
studentLoading.value = false
}
}
async function submitAdmin() {
if (!adminName.value || !adminPassword.value) return
adminLoading.value = true
showAdminError.value = false
try {
const data = await Account.login(adminName.value, adminPassword.value)
user.username = data.username
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 {
adminLoading.value = false
}
}
</script>
<style scoped>
.message {
margin-bottom: 12px;
}
</style>
@@ -0,0 +1,182 @@
<template>
<div class="external-panel">
<div class="content-area">
<div class="field-label">提示词</div>
<n-input
v-model:value="promptText"
type="textarea"
:autosize="{ minRows: 3, maxRows: 8 }"
placeholder="粘贴你发给外部 AI 的提示词..."
/>
<div class="code-field">
<div class="field-label" style="margin-top: 12px">完整的代码</div>
<n-input
v-model:value="rawCode"
type="textarea"
class="code-input"
placeholder="粘贴完整的前端代码..."
/>
</div>
<div v-if="splitResult" class="split-result">
<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
>
</div>
</div>
<div class="action-bar">
<n-button :disabled="!rawCode.trim()" @click="applyPreview"
>应用预览</n-button
>
<n-button
type="primary"
:disabled="!splitResult || !promptText.trim()"
:loading="submitting"
@click="submit"
>
提交
</n-button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from "vue"
import { useMessage } from "naive-ui"
import { html, css, js } from "../../store/editors"
import { Submission } from "../../api"
const props = defineProps<{ taskId: number }>()
const emit = defineEmits<{
submitted: []
}>()
const message = useMessage()
const promptText = ref("")
const rawCode = ref("")
const splitResult = ref<{ html: string; css: string; js: string } | null>(null)
const submitting = ref(false)
watch(rawCode, () => {
splitResult.value = null
})
function splitHtml(raw: string): { html: string; css: string; js: string } {
let result = raw
const cssBlocks: string[] = []
const jsBlocks: string[] = []
result = result.replace(/<style[^>]*>([\s\S]*?)<\/style>/gi, (_, content) => {
cssBlocks.push(content.trim())
return ""
})
result = result.replace(
/<script(?![^>]*\bsrc\b)[^>]*>([\s\S]*?)<\/script>/gi,
(_, content) => {
jsBlocks.push(content.trim())
return ""
},
)
return {
html: result.trim(),
css: cssBlocks.join("\n\n"),
js: jsBlocks.join("\n\n"),
}
}
function applyPreview() {
const result = splitHtml(rawCode.value)
splitResult.value = result
html.value = result.html
css.value = result.css
js.value = result.js
}
async function submit() {
if (!splitResult.value) return
submitting.value = true
try {
await Submission.create(props.taskId, {
html: splitResult.value.html,
css: splitResult.value.css,
js: splitResult.value.js,
prompt: promptText.value.trim(),
})
emit("submitted")
message.success("提交成功")
promptText.value = ""
rawCode.value = ""
splitResult.value = null
} catch {
message.error("提交失败,请重试")
} finally {
submitting.value = false
}
}
</script>
<style scoped>
.external-panel {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.content-area {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 12px;
display: flex;
flex-direction: column;
}
.code-field {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.code-input {
flex: 1;
min-height: 0;
}
.code-input :deep(.n-input__textarea) {
height: 100%;
}
.code-input :deep(.n-input__textarea-el) {
height: 100%;
resize: none;
}
.field-label {
font-size: 12px;
font-weight: bold;
color: #666;
margin-bottom: 6px;
}
.split-result {
display: flex;
gap: 8px;
margin-top: 12px;
flex-wrap: wrap;
}
.action-bar {
padding: 12px;
border-top: 1px solid #e0e0e0;
display: flex;
justify-content: space-between;
}
</style>
+240
View File
@@ -0,0 +1,240 @@
<template>
<n-modal
:show="isOpen"
preset="card"
title="调教提示词"
style="width: min(560px, 92vw)"
:mask-closable="!streaming"
@update:show="onModalClose"
>
<div class="guidance-body">
<div class="guidance-messages" ref="messagesRef">
<div
v-for="(msg, i) in messages"
:key="msg.id ?? i"
class="guidance-msg"
:class="msg.role"
>
<div class="msg-role">{{ msg.role === "user" ? "你" : "AI" }}</div>
<div class="msg-content" v-html="renderMarkdown(msg.content)"></div>
</div>
<div v-if="streaming" class="guidance-msg assistant">
<div class="msg-role">AI</div>
<div v-if="!displayStreamingContent" class="typing-indicator">
<span></span><span></span><span></span>
</div>
<div
v-else
class="msg-content"
v-html="renderMarkdown(displayStreamingContent)"
></div>
</div>
</div>
<div v-if="isReady" class="ready-hint">
<Icon icon="lucide:check-circle" :width="16" />
提示词已优化,可以生成代码了!
</div>
<div class="guidance-input">
<n-input
v-model:value="draftPrompt"
type="textarea"
:autosize="{ minRows: 2, maxRows: 5 }"
placeholder="修改你的提示词后发送..."
:disabled="streaming"
@keydown.enter.exact.prevent="send"
/>
<n-flex justify="space-between" align="center" style="margin-top: 8px">
<div>
<n-button
v-if="streaming"
type="error"
size="small"
@click="stopGuidance"
>
停止
</n-button>
<n-button
v-else
size="small"
:disabled="!draftPrompt.trim() || !connected"
@click="send"
>
发送
</n-button>
</div>
<n-button
:type="isReady ? 'primary' : 'default'"
size="small"
:disabled="!draftPrompt.trim()"
@click="generate"
>
<template #icon>
<Icon icon="lucide:play" :width="14" />
</template>
生成代码
</n-button>
</n-flex>
</div>
</div>
</n-modal>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick } from "vue"
import { Icon } from "@iconify/vue"
import { renderMarkdown } from "../../utils/markdown"
import {
messages,
streaming,
streamingContent,
isReady,
isOpen,
connected,
initialPrompt,
sendGuidance,
stopGuidance,
closeGuidance,
} from "../../store/guidance"
const emit = defineEmits<{
generate: [prompt: string]
}>()
const draftPrompt = ref("")
const messagesRef = ref<HTMLElement>()
const displayStreamingContent = computed(() =>
streamingContent.value.replace(/^\[READY\]\n?/, "")
)
function send() {
const text = draftPrompt.value.trim()
if (!text || streaming.value) return
sendGuidance(text)
}
function generate() {
const text = draftPrompt.value.trim()
if (!text) return
emit("generate", text)
closeGuidance()
}
function onModalClose(show: boolean) {
if (!show && !streaming.value) closeGuidance()
}
watch(isOpen, (val) => {
if (val) draftPrompt.value = initialPrompt.value
})
watch([() => messages.value.length, streamingContent], () => {
nextTick(() => {
if (messagesRef.value) {
messagesRef.value.scrollTop = messagesRef.value.scrollHeight
}
})
})
</script>
<style scoped>
.guidance-body {
display: flex;
flex-direction: column;
gap: 12px;
}
.guidance-messages {
min-height: 160px;
max-height: 360px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 12px;
}
.guidance-msg .msg-role {
font-size: 12px;
font-weight: bold;
margin-bottom: 4px;
color: #888;
}
.guidance-msg.user .msg-role {
color: #2080f0;
}
.guidance-msg.assistant .msg-role {
color: #18a058;
}
.guidance-msg.assistant .msg-content {
background: #f0f7ff;
border-left: 3px solid #2080f0;
padding: 8px 12px;
border-radius: 0 6px 6px 0;
font-size: 14px;
line-height: 1.6;
}
.guidance-msg.user .msg-content {
font-size: 14px;
line-height: 1.6;
color: #333;
}
.msg-content :deep(p) {
margin: 0;
}
.ready-hint {
display: flex;
align-items: center;
gap: 6px;
color: #18a058;
font-size: 13px;
font-weight: 500;
padding: 6px 10px;
background: #f0fff4;
border-radius: 6px;
border: 1px solid #b8e8cc;
}
.typing-indicator {
display: flex;
gap: 5px;
padding: 8px 0;
}
.typing-indicator span {
width: 7px;
height: 7px;
border-radius: 50%;
background: #2080f0;
animation: bounce 1.4s ease-in-out infinite;
}
.typing-indicator span:nth-child(2) {
animation-delay: 0.2s;
}
.typing-indicator span:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes bounce {
0%,
60%,
100% {
transform: translateY(0);
opacity: 0.4;
}
30% {
transform: translateY(-5px);
opacity: 1;
}
}
</style>
@@ -0,0 +1,367 @@
<template>
<div class="history-panel">
<n-flex
class="history-toolbar"
align="center"
justify="space-between"
:wrap="false"
>
<n-text depth="3">共 {{ items.length }} 条历史对话</n-text>
<n-tooltip>
<template #trigger>
<n-button
quaternary
circle
size="small"
:loading="loading"
aria-label="刷新历史对话"
@click="load(true)"
>
<template #icon>
<Icon icon="lucide:refresh-cw" />
</template>
</n-button>
</template>
刷新历史对话
</n-tooltip>
</n-flex>
<n-spin v-if="loading" class="state" />
<n-empty
v-else-if="!items.length"
class="state"
description="暂无历史对话"
/>
<n-scrollbar v-else class="history-scrollbar">
<n-flex vertical :size="12" class="history-list">
<n-card
v-for="(item, index) in items"
:key="item.assistant_message_id"
class="history-card"
:class="{
'is-selected':
selectedAssistantMessageId === item.assistant_message_id,
}"
size="small"
:bordered="true"
hoverable
:embedded="selectedAssistantMessageId === item.assistant_message_id"
:content-style="{ padding: 0 }"
@click="selectItem(item)"
>
<n-flex
class="history-main"
align="center"
justify="space-between"
:wrap="false"
>
<n-flex align="center" :wrap="false" :size="6">
<n-tag
round
size="small"
:bordered="false"
:type="
selectedAssistantMessageId === item.assistant_message_id
? 'success'
: 'default'
"
>
#{{ index + 1 }}
</n-tag>
<n-tag
size="small"
:type="item.source === 'manual' ? 'info' : 'success'"
>
{{ item.source === "manual" ? "手动提交" : "AI 对话" }}
</n-tag>
</n-flex>
<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"
v-html="renderMarkdown(item.prompt)"
/>
<div class="thumbnail" aria-label="页面缩略图">
<iframe
v-if="item.hasPage"
title="页面缩略图"
loading="lazy"
sandbox="allow-scripts"
referrerpolicy="no-referrer"
tabindex="-1"
:srcdoc="item.previewDoc"
/>
<n-empty v-else size="small" description="未生成页面" />
</div>
</n-card>
</n-flex>
</n-scrollbar>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref, watch } from "vue"
import { Icon } from "@iconify/vue"
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"
import { buildPreviewDocument } from "../../utils/previewDocument"
const props = defineProps<{
taskId: number
active: boolean
assetBaseUrl?: string
refreshKey?: number
}>()
const emit = defineEmits<{
select: [code: { html: string; css: string; js: string }]
deleted: [assistantMessageId: number]
}>()
type HistoryViewItem = PromptHistoryItem & {
hasPage: boolean
previewDoc: string
}
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 ?? ""
const css = item.code_css ?? ""
const js = item.code_js ?? ""
return {
...item,
hasPage: !!(html.trim() || css.trim() || js.trim()),
previewDoc: buildPreviewDocument({
html,
css,
js,
assetBaseUrl: props.assetBaseUrl,
}),
}
}
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) {
selectedAssistantMessageId.value = item.assistant_message_id
emit("select", {
html: item.code_html ?? "",
css: item.code_css ?? "",
js: item.code_js ?? "",
})
}
async function load(force = true) {
if (!props.taskId || loading.value) return
if (!force && loadedTaskId === props.taskId) return
loading.value = true
try {
const taskChanged = loadedTaskId !== props.taskId
const data = await Prompt.listHistory(props.taskId)
items.value = data.map(toViewItem)
if (
taskChanged ||
!items.value.some(
(item) =>
item.assistant_message_id === selectedAssistantMessageId.value,
)
) {
selectedAssistantMessageId.value = null
}
loadedTaskId = props.taskId
} finally {
loading.value = false
}
}
watch(
() => [props.active, props.taskId] as const,
([active]) => {
if (active) {
if (pendingRefresh) {
pendingRefresh = false
load(true)
} else {
load(false)
}
}
},
)
watch(
() => props.refreshKey,
() => {
if (props.active) load(true)
else pendingRefresh = true
},
)
onMounted(() => {
if (props.active) load(false)
})
</script>
<style scoped>
.history-panel {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.history-toolbar {
height: 42px;
padding: 0 12px;
border-bottom: 1px solid var(--n-border-color, #efeff5);
flex-shrink: 0;
}
.state {
margin: auto;
}
.history-scrollbar {
flex: 1;
min-height: 0;
}
.history-list {
padding: 12px;
}
.history-card {
cursor: pointer;
overflow: hidden;
}
.history-card.is-selected {
--n-color: #f7fffa;
box-shadow: 0 10px 24px rgba(24, 160, 88, 0.14);
}
.history-card.is-selected .history-main {
background: #eefaf3;
}
.history-main {
padding: 10px 12px;
transition: background-color 0.18s ease;
}
.prompt-markdown {
margin: 0;
padding: 0 12px 12px;
color: #333;
font-size: 14px;
line-height: 1.6;
word-break: break-word;
background: transparent;
}
.prompt-markdown :deep(p),
.prompt-markdown :deep(ul),
.prompt-markdown :deep(ol),
.prompt-markdown :deep(blockquote),
.prompt-markdown :deep(pre) {
margin-top: 0;
margin-bottom: 6px;
}
.prompt-markdown :deep(:last-child) {
margin-bottom: 0;
}
.prompt-markdown :deep(pre) {
padding: 8px;
overflow-x: auto;
font-size: 12px;
}
.prompt-markdown :deep(code) {
white-space: pre-wrap;
word-break: break-word;
}
.thumbnail {
height: 160px;
border-top: 1px solid var(--n-border-color, #efeff5);
background: #f7f7f9;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.history-card.is-selected .thumbnail {
border-top-color: #d8f1e2;
background: #f4fbf6;
}
.thumbnail iframe {
width: 100%;
height: 100%;
border: 0;
background: #fff;
pointer-events: none;
}
</style>
+402
View File
@@ -0,0 +1,402 @@
<template>
<div class="prompt-panel">
<div class="messages" ref="messagesRef">
<div
v-for="(pair, pi) in pairs"
:key="pair.assistantMsg?.id ?? 'user-' + pair.index"
class="message-pair"
:class="{ 'has-delete': pair.assistantMsg?.id && !streaming }"
>
<!-- Delete button: only shown on hover when pair has assistant id and not streaming -->
<button
v-if="pair.assistantMsg?.id && !streaming"
class="pair-delete-btn"
@click="deletePair(pair.assistantMsg!.id!)"
title="删除这轮对话及关联提交"
>
<Icon icon="lucide:trash-2" :width="14" />
</button>
<!-- User message -->
<div class="message user">
<div class="message-role">我</div>
<div
class="message-content"
v-html="renderContent(pair.userMsg)"
></div>
</div>
<!-- Assistant message -->
<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="allowSubmit && 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>
<!-- Streaming indicator -->
<div v-if="streaming" class="message assistant">
<div class="message-role">AI</div>
<div v-if="!streamingContent" class="typing-indicator">
<span></span><span></span><span></span>
</div>
<div
v-else
class="message-content"
v-html="renderMarkdown(streamingContent)"
></div>
<div class="streaming-hint">AI 正在生成中…</div>
</div>
</div>
<GuidancePanel @generate="onGuidanceGenerate" />
<div class="input-area">
<n-flex
class="prompt-input-row"
align="flex-start"
:wrap="false"
:size="8"
>
<n-input
v-model:value="input"
type="textarea"
:autosize="{ minRows: 1, maxRows: 4 }"
placeholder="描述你想要的网页效果..."
:disabled="streaming"
style="flex: 1; min-width: 0"
@keydown.enter.exact.prevent="send"
/>
<n-button
secondary
:disabled="!input.trim() || !connected || !currentTaskId || streaming"
style="flex-shrink: 0"
title="调教提示词"
aria-label="调教提示词"
@click="startGuidance"
>
<template #icon>
<Icon icon="lucide:lightbulb" :width="16" />
</template>
</n-button>
</n-flex>
<n-flex justify="flex-end" align="center" style="margin-top: 8px">
<n-select
v-model:value="selectedModel"
:options="modelOptions"
style="width: 150px"
:disabled="streaming"
/>
<n-button v-if="streaming" type="error" @click="stopPrompt">
停止
</n-button>
<n-button
v-else
type="primary"
:disabled="!input.trim() || !connected"
@click="send"
>
发送
</n-button>
</n-flex>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, nextTick, computed } from "vue"
import { useStorage } from "@vueuse/core"
import { useMessage } from "naive-ui"
import { Icon } from "@iconify/vue"
import GuidancePanel from "./GuidancePanel.vue"
import { openGuidance } from "../../store/guidance"
import {
messages,
streaming,
streamingContent,
connected,
sendPrompt,
stopPrompt,
currentTaskId,
removeMessagePair,
markMessageSubmitted,
} from "../../store/prompt"
import { Prompt, Submission } from "../../api"
import { renderMarkdown } from "../../utils/markdown"
const { allowSubmit = true } = defineProps<{ allowSubmit?: boolean }>()
const emit = defineEmits<{ deleted: []; submitted: [] }>()
const input = ref("")
const messagesRef = ref<HTMLElement>()
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
submitted?: boolean
} | null
index: number
}> = []
const msgs = messages.value
let i = 0
while (i < msgs.length) {
if (msgs[i].role === "user") {
const assistantMsg =
msgs[i + 1]?.role === "assistant" ? msgs[i + 1] : null
result.push({ userMsg: msgs[i], assistantMsg, index: i })
i += assistantMsg ? 2 : 1
} else {
i++
}
}
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)
removeMessagePair(assistantMsgId)
naiveMessage.success("已删除")
emit("deleted")
} catch (error: any) {
naiveMessage.error(error.response?.data?.detail ?? "删除失败,请重试")
}
}
function send() {
const text = input.value.trim()
if (!text || streaming.value) return
sendPrompt(text, selectedModel.value)
input.value = ""
}
function startGuidance() {
const text = input.value.trim()
if (!text || !currentTaskId.value) return
openGuidance(currentTaskId.value, text)
}
function onGuidanceGenerate(finalPrompt: string) {
sendPrompt(finalPrompt, selectedModel.value)
input.value = ""
}
function renderContent(msg: { role: string; content: string }): string {
return renderMarkdown(msg.content)
}
watch([() => messages.value.length, streamingContent], () => {
nextTick(() => {
if (messagesRef.value) {
messagesRef.value.scrollTop = messagesRef.value.scrollHeight
}
})
})
</script>
<style scoped>
.prompt-panel {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.messages {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 12px;
}
.message {
margin-bottom: 16px;
}
.message-role {
font-size: 12px;
font-weight: bold;
margin-bottom: 4px;
color: #666;
}
.message.user .message-role {
color: #2080f0;
}
.message.assistant .message-role {
color: #18a058;
}
.message-content {
font-size: 14px;
line-height: 1.6;
}
.message-content :deep(pre) {
background: #f5f5f5;
padding: 8px 12px;
border-radius: 4px;
overflow-x: auto;
font-size: 13px;
}
.message-actions {
margin-top: 6px;
}
@keyframes pulse {
0%,
100% {
opacity: 0.4;
}
50% {
opacity: 1;
}
}
.typing-indicator {
display: flex;
gap: 5px;
padding: 8px 0;
}
.typing-indicator span {
width: 8px;
height: 8px;
border-radius: 50%;
background: #18a058;
animation: bounce 1.4s ease-in-out infinite;
}
.typing-indicator span:nth-child(2) {
animation-delay: 0.2s;
}
.typing-indicator span:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes bounce {
0%,
60%,
100% {
transform: translateY(0);
opacity: 0.4;
}
30% {
transform: translateY(-6px);
opacity: 1;
}
}
.streaming-hint {
font-size: 11px;
color: #aaa;
margin-top: 4px;
animation: pulse 1.5s ease-in-out infinite;
}
.input-area {
flex-shrink: 0;
padding: 12px;
border-top: 1px solid #e0e0e0;
}
.message-pair {
position: relative;
margin-bottom: 4px;
}
.message-pair .message {
margin-bottom: 16px;
}
.pair-delete-btn {
display: none;
position: absolute;
top: 0;
right: 0;
background: none;
border: 1px solid #e0e0e0;
border-radius: 4px;
padding: 3px 6px;
cursor: pointer;
color: #bbb;
line-height: 1;
transition:
color 0.15s,
border-color 0.15s;
}
.pair-delete-btn:hover {
color: #e03e3e;
border-color: #e03e3e;
}
.message-pair.has-delete:hover .pair-delete-btn {
display: inline-flex;
align-items: center;
}
</style>
@@ -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>
@@ -0,0 +1,35 @@
<template>
<MdEditor v-bind="props" v-model="modelValue" @upload-img="uploadImg" />
</template>
<script lang="ts" setup>
import { MdEditor } from "md-editor-v3"
//@ts-ignore
import "md-editor-v3/lib/style.css"
import { Helper } from "../../api"
import { useMessage } from "naive-ui"
const message = useMessage()
const modelValue = defineModel<string>()
const props = defineProps()
const uploadImg = async (files: File[], callback: (urls: string[]) => void) => {
try {
const res = await Promise.all(
files.map(async (file) => {
const path = await Helper.upload(file)
if (!path) {
message.error("图片上传失败")
return ""
}
return path
}),
)
callback(res.filter((url) => url !== ""))
} catch (err) {
message.error("图片上传失败")
callback([])
}
}
</script>
@@ -0,0 +1,48 @@
<template>
<n-flex>
<n-button secondary v-if="user.role !== Role.Super" @click="edit">
修改
</n-button>
<n-button
secondary
type="error"
v-if="user.role !== Role.Super && user.is_active"
@click="toggleUserActive"
>
封号
</n-button>
<n-button
secondary
type="primary"
v-if="user.role !== Role.Super && !user.is_active"
@click="toggleUserActive"
>
解封
</n-button>
</n-flex>
</template>
<script lang="ts" setup>
import { useMessage } from "naive-ui"
import { Account } from "../../api"
import { ADMIN_URL } from "../../utils/const"
import { Role, type User } from "../../utils/type"
interface Props {
user: User
}
const props = defineProps<Props>()
const emits = defineEmits(["reload"])
const message = useMessage()
function edit() {
window.open(`${ADMIN_URL}/account/user/${props.user.id}/change/`)
}
async function toggleUserActive() {
const data = await Account.toggleActive(props.user.id)
message.success(data.message)
emits("reload")
}
</script>
+63
View File
@@ -0,0 +1,63 @@
<script lang="ts" setup>
import { EditorView } from "@codemirror/view"
import { Codemirror } from "vue-codemirror"
import { css } from "@codemirror/lang-css"
import { javascript } from "@codemirror/lang-javascript"
import { html } from "@codemirror/lang-html"
import { computed } from "vue"
const styleTheme = EditorView.baseTheme({
"& .cm-scroller": {
"font-family": "Monaco",
},
"&.cm-editor.cm-focused": {
outline: "none",
},
"&.cm-editor .cm-tooltip.cm-tooltip-autocomplete ul": {
"font-family": "Monaco",
},
})
const bgColors: Record<string, string> = {
html: "#fff6f3",
css: "#f3f6ff",
js: "#fffdf0",
}
interface Props {
language?: "html" | "css" | "js"
fontSize?: number
}
const props = withDefaults(defineProps<Props>(), {
language: "html",
fontSize: 20,
})
const code = defineModel<string>("value")
const lang = computed(() => {
if (props.language === "html") return html()
if (props.language === "css") return css()
return javascript()
})
const bgTheme = computed(() => {
const bg = bgColors[props.language] ?? "#ffffff"
return EditorView.theme({
"&": { backgroundColor: bg },
".cm-gutters": { backgroundColor: bg },
})
})
const extensions = computed(() => [styleTheme, bgTheme.value, lang.value])
</script>
<template>
<Codemirror
v-model="code"
indentWithTab
:extensions="extensions"
:tabSize="4"
:style="{ height: '100%', fontSize: props.fontSize + 'px' }"
/>
</template>
+215
View File
@@ -0,0 +1,215 @@
<template>
<div class="editors-root">
<n-tabs
:value="tab"
:class="`tab-active-${tab}`"
pane-class="pane"
style="height: 100%"
type="card"
@update:value="changeTab"
>
<n-tab-pane name="html" tab="HTML">
<template #tab>
<n-flex align="center">
<Icon :width="20" icon="skill-icons:html"></Icon>
<span>HTML</span>
</n-flex>
</template>
<Editor v-model:value="html" :font-size="size" language="html" />
</n-tab-pane>
<n-tab-pane name="css" tab="CSS">
<template #tab>
<n-flex align="center">
<Icon :width="20" icon="skill-icons:css"></Icon>
<span>CSS</span>
</n-flex>
</template>
<Editor v-model:value="css" :font-size="size" language="css" />
</n-tab-pane>
<n-tab-pane name="js" tab="JS">
<template #tab>
<n-flex align="center">
<Icon :width="20" icon="skill-icons:javascript"></Icon>
<span>JS</span>
</n-flex>
</template>
<Editor v-model:value="js" :font-size="size" language="js" />
</n-tab-pane>
<n-tab-pane name="actions" tab="选项">
<template #tab>
<n-flex align="center">
<Icon :width="20" icon="skill-icons:actix-dark"></Icon>
<span>选项</span>
</n-flex>
</template>
<n-flex class="wrapper" vertical>
<n-flex align="center">
<span class="label">重置</span>
<n-button @click="reset('html')">HTML</n-button>
<n-button @click="reset('css')">CSS</n-button>
<n-button @click="reset('js')">JS</n-button>
</n-flex>
<n-flex align="center">
<span class="label">字号</span>
<n-flex align="center">
<span :style="{ 'font-size': size + 'px' }">{{ size }}</span>
<n-button :disabled="size === 20" @click="changeSize(size - 2)">
调小
</n-button>
<n-button :disabled="size === 40" @click="changeSize(size + 2)">
调大
</n-button>
</n-flex>
</n-flex>
<n-flex align="center">
<span class="label">预加载</span>
<n-tag type="success">Normalize.css</n-tag>
</n-flex>
</n-flex>
</n-tab-pane>
<template #suffix>
<Toolbar
:submit-loading="submitLoading"
@format="format"
@submit="formatAndSubmit"
/>
</template>
</n-tabs>
</div>
</template>
<script lang="ts" setup>
import { Icon } from "@iconify/vue"
import prettier from "prettier/standalone"
import * as htmlParser from "prettier/parser-html"
import * as cssParser from "prettier/parser-postcss"
import * as babelParser from "prettier/parser-babel"
import * as estreeParser from "prettier/plugins/estree"
import Editor from "./Editor.vue"
import Toolbar from "./Toolbar.vue"
import { html, css, js, tab, size, reset } from "../../store/editors"
import { taskId } from "../../store/task"
import { Submission } from "../../api"
import { NCode, useDialog, useMessage } from "naive-ui"
import { h, ref } from "vue"
const dialog = useDialog()
const message = useMessage()
const submitLoading = ref(false)
function changeTab(name: string) {
tab.value = name
}
function changeSize(num: number) {
size.value = num
}
async function formatCode() {
const [htmlFormatted, cssFormatted, jsFormatted] = await Promise.all([
prettier.format(html.value, {
parser: "html",
//@ts-ignore
plugins: [htmlParser, babelParser, estreeParser, cssParser],
tabWidth: 4,
}),
prettier.format(css.value, {
parser: "css",
plugins: [cssParser],
tabWidth: 4,
}),
prettier.format(js.value, {
parser: "babel",
//@ts-ignore
plugins: [babelParser, estreeParser],
tabWidth: 2,
}),
])
html.value = htmlFormatted
css.value = cssFormatted
js.value = jsFormatted
}
async function format() {
try {
await formatCode()
} catch (err: any) {
dialog.error({
title: "格式化失败",
content: () => h(NCode, { code: err.message }),
style: { width: "auto" },
})
}
}
async function doSubmit() {
try {
await Submission.create(taskId.value, {
html: html.value,
css: css.value,
js: js.value,
})
message.success("提交成功")
} catch (err) {
message.error("提交失败")
}
}
async function formatAndSubmit() {
submitLoading.value = true
try {
await formatCode()
await doSubmit()
} catch (err: any) {
dialog.warning({
title: "代码整理失败",
content: () => h(NCode, { code: err.message }),
positiveText: "忽略并提交",
negativeText: "取消",
style: { width: "auto" },
onPositiveClick: async () => {
await doSubmit()
},
})
} finally {
submitLoading.value = false
}
}
</script>
<style scoped>
.pane {
height: 100%;
overflow: auto;
}
.wrapper {
padding-left: 16px;
}
.label {
font-size: 16px;
}
:deep(.n-tabs-nav .n-tabs-tab[data-name="html"]) {
background-color: #fff6f3 !important;
}
:deep(.n-tabs-nav .n-tabs-tab[data-name="css"]) {
background-color: #f3f6ff !important;
}
:deep(.n-tabs-nav .n-tabs-tab[data-name="js"]) {
background-color: #fffdf0 !important;
}
.editors-root {
height: 100%;
}
:deep(.tab-active-html .n-tab-pane) {
background-color: #fff6f3;
}
:deep(.tab-active-css .n-tab-pane) {
background-color: #f3f6ff;
}
:deep(.tab-active-js .n-tab-pane) {
background-color: #fffdf0;
}
</style>
+210
View File
@@ -0,0 +1,210 @@
<template>
<n-flex align="center" justify="space-between" class="title">
<div>
<n-text class="titleText">预览</n-text>
<n-text v-if="!!submission.id" depth="3">
({{ submission.view_count || 0 }})
</n-text>
</div>
<n-flex>
<n-tooltip>
<template #trigger>
<n-button quaternary @click="cycleLayout">
<template #icon>
<Icon :icon="layoutIcon" />
</template>
</n-button>
</template>
{{ layoutLabel }}
</n-tooltip>
<n-button quaternary @click="download" :disabled="!showDL">下载</n-button>
<n-button quaternary @click="open">全屏</n-button>
<n-button quaternary v-if="props.clearable" @click="clear">清空</n-button>
<n-button
quaternary
v-if="props.showCodeButton"
@click="emits('showCode')"
>
代码
</n-button>
<n-button quaternary v-if="props.submissionId" @click="copyLink">
链接
</n-button>
<n-flex v-if="!!submission.id" align="center">
<n-button quaternary @click="emits('showCode')">代码</n-button>
<n-popover v-if="submission.my_score === 0">
<template #trigger>
<n-button secondary type="primary">打分</n-button>
</template>
<n-rate :size="30" @update:value="updateScore" />
</n-popover>
</n-flex>
</n-flex>
</n-flex>
<div class="iframe-wrapper" :style="iframeWrapperStyle">
<iframe class="iframe" :srcdoc="previewContent"></iframe>
</div>
</template>
<script lang="ts" setup>
import { watchDebounced } from "@vueuse/core"
import { computed, onMounted, ref } from "vue"
import { useRouter } from "vue-router"
import { Submission } from "../../api"
import { submission } from "../../store/submission"
import { useMessage } from "naive-ui"
import { Icon } from "@iconify/vue"
import copy from "copy-text-to-clipboard"
import { buildPreviewDocument } from "../../utils/previewDocument"
interface Props {
html: string
css: string
js: string
assetBaseUrl?: string
submissionId?: string
showCodeButton?: boolean
clearable?: boolean
}
const props = defineProps<Props>()
const emits = defineEmits(["afterScore", "showCode", "clear"])
type Layout = "desktop" | "mobile" | "tablet"
const layouts: Layout[] = ["desktop", "mobile", "tablet"]
const layoutConfig: Record<
Layout,
{ icon: string; label: string; width: string }
> = {
desktop: {
icon: "material-symbols:desktop-windows-outline",
label: "桌面",
width: "100%",
},
mobile: {
icon: "material-symbols:smartphone-outline",
label: "移动端 (375px)",
width: "375px",
},
tablet: {
icon: "material-symbols:tablet-outline",
label: "平板 (768px)",
width: "768px",
},
}
const layoutIndex = ref(0)
const layoutIcon = computed(
() => layoutConfig[layouts[layoutIndex.value]!].icon,
)
const layoutLabel = computed(
() => layoutConfig[layouts[layoutIndex.value]!].label,
)
const iframeWrapperStyle = computed(() => ({
maxWidth: layoutConfig[layouts[layoutIndex.value]!].width,
}))
function cycleLayout() {
layoutIndex.value = (layoutIndex.value + 1) % layouts.length
}
const message = useMessage()
const router = useRouter()
const showDL = computed(() => props.html || props.css || props.js)
const previewContent = ref("")
function getContent() {
return buildPreviewDocument({
html: props.html,
css: props.css,
js: props.js,
assetBaseUrl: props.assetBaseUrl,
})
}
function preview() {
previewContent.value = getContent()
}
function download() {
const content = getContent()
const blob = new Blob([content], { type: "text/html" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = "index.html"
a.click()
URL.revokeObjectURL(url)
}
function open() {
if (props.submissionId) {
const data = router.resolve({
name: "submission",
params: { id: props.submissionId },
})
window.open(data.href, "_blank")
} else {
const newTab = window.open("about:blank", "_blank")
if (!newTab) return
newTab.document.open()
newTab.document.write(getContent())
newTab.document.close()
}
}
function clear() {
emits("clear")
}
function copyLink() {
copy(`${document.location.origin}/submission/${props.submissionId}`)
message.success("该提交的链接已复制")
}
async function updateScore(score: number) {
try {
const res = await Submission.updateScore(submission.value.id, score)
message.success(res.message)
submission.value.my_score = score
emits("afterScore")
} catch (err: any) {
message.error(err.response.data.detail)
}
}
watchDebounced(
() => [props.html, props.css, props.js, props.assetBaseUrl],
preview,
{
debounce: 500,
maxWait: 1000,
},
)
onMounted(preview)
</script>
<style scoped>
.title {
height: 43px;
padding: 0 20px;
border-bottom: 1px solid rgb(239, 239, 245);
box-sizing: border-box;
}
.titleText {
font-size: 16px;
}
.iframe-wrapper {
width: 100%;
height: 100%;
margin: 0 auto;
transition: max-width 0.3s ease;
overflow: hidden;
}
.iframe {
width: 100%;
height: 100%;
border: none;
outline: none;
}
</style>
+139
View File
@@ -0,0 +1,139 @@
<template>
<n-flex align="center" class="corner">
<n-button quaternary v-if="!show" @click="showTutorial"> 教程 </n-button>
<template v-if="user.loaded && authed">
<n-button quaternary @click="emit('format')">整理</n-button>
<n-button
type="primary"
secondary
:disabled="submitDisabled"
:loading="submitLoading"
@click="emit('submit')"
>
提交
</n-button>
<n-dropdown :options="menu" @select="clickMenu">
<n-button>{{ user.username }}</n-button>
</n-dropdown>
</template>
<n-button
v-if="user.loaded && !authed"
@click="handleLogin"
secondary
type="primary"
>
登录
</n-button>
</n-flex>
</template>
<script lang="ts" setup>
import { computed, h } from "vue"
import { Icon } from "@iconify/vue"
import {
authed,
roleAdmin,
roleNormal,
roleSuper,
user,
} from "../../store/user"
import { loginModal } from "../../store/modal"
import { show, panelSize } from "../../store/panel"
import { taskId } from "../../store/task"
import { Account } from "../../api"
import { Role } from "../../utils/type"
import { router } from "../../router"
import { ADMIN_URL } from "../../utils/const"
const props = defineProps<{
submitLoading: boolean
}>()
const emit = defineEmits(["format", "submit"])
const submitDisabled = computed(() => {
return taskId.value === 0
})
const menu = computed(() => [
{
label: "后台管理",
key: "dashboard",
show: !roleNormal.value,
icon: () =>
h(Icon, {
icon: "streamline-emojis:robot-face-1",
width: 20,
}),
},
{
label: "数据管理",
key: "admin",
show: roleSuper.value,
icon: () =>
h(Icon, {
icon: "skill-icons:django",
width: 20,
}),
},
{
label: "我的提交",
key: "submissions",
icon: () =>
h(Icon, {
icon: "streamline-emojis:bar-chart",
width: 20,
}),
},
{
label: "退出账号",
key: "logout",
icon: () =>
h(Icon, {
icon: "streamline-emojis:hot-beverage-2",
width: 20,
}),
},
])
function showTutorial() {
show.value = true
panelSize.value = 2 / 5
}
function clickMenu(name: string) {
switch (name) {
case "dashboard": {
const route = roleAdmin.value ? "challenge-editor" : "tutorial-editor"
router.push({ name: route, params: { display: 0 } })
break
}
case "admin":
window.open(ADMIN_URL)
break
case "submissions":
router.push({
name: "submissions",
params: { page: 1 },
query: { username: user.username },
})
break
case "logout":
handleLogout()
break
}
}
function handleLogin() {
loginModal.value = true
}
async function handleLogout() {
await Account.logout()
user.username = ""
user.role = Role.Normal
}
</script>
<style scoped>
.corner {
margin-right: 20px;
}
</style>
@@ -0,0 +1,395 @@
<template>
<n-modal
:show="show"
preset="card"
style="width: 90vw; max-width: 1400px"
@update:show="$emit('update:show', $event)"
>
<template #header>
<n-flex justify="start" align="center">
<span>提示词</span>
<n-tooltip placement="bottom-start">
<template #trigger>
<Icon icon="lucide:info" :width="20" style="color: #aaa" />
</template>
<div style="line-height: 2">
<div>
<span :style="{ color: levelColors[1], fontWeight: 'bold' }">
L1
</span>
— 记忆:复述或识别知识点
</div>
<div>
<span :style="{ color: levelColors[2], fontWeight: 'bold' }">
L2
</span>
— 理解:用自己的话解释概念
</div>
<div>
<span :style="{ color: levelColors[3], fontWeight: 'bold' }">
L3
</span>
— 应用:将知识用于新情境
</div>
<div>
<span :style="{ color: levelColors[4], fontWeight: 'bold' }">
L4
</span>
— 分析:拆解结构、找出规律
</div>
<div>
<span :style="{ color: levelColors[5], fontWeight: 'bold' }">
L5
</span>
— 评价:基于标准作出判断
</div>
<div>
<span :style="{ color: levelColors[6], fontWeight: 'bold' }">
L6
</span>
— 创造:整合信息产出新成果
</div>
</div>
</n-tooltip>
</n-flex>
</template>
<n-spin :show="loading">
<n-empty
v-if="!loading && rounds.length === 0"
description="暂无对话记录"
/>
<div
v-else
style="
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
height: 75vh;
"
>
<div
style="
overflow-y: auto;
padding-right: 8px;
border-right: 1px solid #e0e0e0;
display: flex;
flex-direction: column;
gap: 8px;
"
>
<div
v-for="(round, index) in rounds"
:key="index"
style="
display: flex;
gap: 10px;
align-items: flex-start;
cursor: pointer;
"
@click="selectedRound = index"
>
<div
:style="{
flexShrink: 0,
width: '22px',
height: '22px',
borderRadius: '50%',
background: selectedRound === index ? '#2080f0' : '#c2d5fb',
color: '#fff',
fontSize: '12px',
fontWeight: 'bold',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginTop: '2px',
transition: 'background 0.2s',
}"
>
{{ index + 1 }}
</div>
<div
:style="{
flex: 1,
padding: '10px 14px',
borderRadius: '8px',
background: selectedRound === index ? '#e8f0fe' : '#f5f5f5',
border:
selectedRound === index
? '1px solid #2080f0'
: '1px solid #e0e0e0',
fontSize: '13px',
lineHeight: '1.6',
transition: 'all 0.2s',
}"
>
<div
style="
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 8px;
"
>
<div
class="prompt-markdown markdown-body"
:class="{
'is-collapsed':
isPromptLong(round.question) && !isExpanded(index),
}"
v-html="renderMarkdown(round.question)"
></div>
<div
style="
display: flex;
flex-direction: row;
align-items: center;
gap: 4px;
flex-shrink: 0;
"
>
<span
v-if="round.source"
:style="{
fontSize: '10px',
padding: '1px 5px',
borderRadius: '4px',
background:
round.source === 'conversation' ? '#e8f0fe' : '#f0f0f0',
color:
round.source === 'conversation' ? '#2060c0' : '#888',
fontWeight: 500,
whiteSpace: 'nowrap',
}"
>{{
round.source === "conversation" ? "对话" : "手动"
}}</span
>
<span
v-if="round.prompt_level"
:style="{
fontSize: '11px',
fontWeight: 'bold',
color: levelColors[round.prompt_level],
}"
>L{{ round.prompt_level }}</span
>
<n-button
v-if="isPromptLong(round.question)"
text
size="tiny"
type="primary"
@click.stop="toggleExpanded(index)"
>
<Icon
:icon="
isExpanded(index)
? 'lucide:chevron-up'
: 'lucide:chevron-down'
"
:width="12"
/>
{{ isExpanded(index) ? "收起" : "展开" }}
</n-button>
<n-popconfirm
v-if="round.assistantMsgId && canDelete"
:show-icon="false"
@positive-click="deleteRound(index)"
>
<template #trigger>
<n-button
text
size="tiny"
type="error"
style="margin-left: 2px"
@click.stop
>
<Icon icon="lucide:trash-2" :width="12" />
</n-button>
</template>
确定删除这一轮?
</n-popconfirm>
</div>
</div>
</div>
</div>
</div>
<div style="display: flex; flex-direction: column; gap: 8px">
<div style="font-weight: bold; font-size: 13px; color: #555">
第 {{ selectedRound + 1 }} 轮网页
</div>
<iframe
v-if="selectedPageHtml"
:srcdoc="selectedPageHtml"
:key="selectedRound"
sandbox="allow-scripts"
style="
flex: 1;
border: 1px solid #e0e0e0;
border-radius: 6px;
background: #fff;
"
/>
<n-empty v-else description="该轮无网页代码" style="margin: auto" />
</div>
</div>
</n-spin>
</n-modal>
</template>
<script setup lang="ts">
import { computed, ref, watch } from "vue"
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
username?: string
}>()
const canDelete = computed(
() =>
roleSuper.value || (!!props.username && props.username === user.username),
)
defineEmits<{ "update:show": [value: boolean] }>()
const loading = ref(false)
const selectedRound = ref(0)
const expandedRounds = ref<Set<number>>(new Set())
type ChainRound = Omit<PromptRound, "source"> & {
source: string | null
assistantMsgId: number | null
}
const rounds = ref<ChainRound[]>([])
async function deleteRound(index: number) {
const round = rounds.value[index]
if (!round.assistantMsgId) return
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)
}
}
const levelColors: Record<number, string> = {
1: "#aaa",
2: "#6aab9c",
3: "#4a9ade",
4: "#c47ab8",
5: "#e0a040",
6: "#e05c5c",
}
const selectedPageHtml = computed(() => {
const round = rounds.value[selectedRound.value]
if (!round?.html) return null
const style = round.css ? `<style>${round.css}</style>` : ""
const script = round.js ? `<script>${round.js}<\/script>` : ""
return `<!DOCTYPE html><html><head><meta charset="utf-8">${style}</head><body>${round.html}${script}</body></html>`
})
function renderMarkdown(text: string): string {
return marked.parse(text) as string
}
function isPromptLong(text: string): boolean {
return text.length > 220 || text.split(/\r?\n/).length > 4
}
function isExpanded(index: number): boolean {
return expandedRounds.value.has(index)
}
function toggleExpanded(index: number) {
const next = new Set(expandedRounds.value)
if (next.has(index)) {
next.delete(index)
} else {
next.add(index)
}
expandedRounds.value = next
}
async function loadMessages() {
if (!props.submissionId) return
loading.value = true
rounds.value = []
selectedRound.value = 0
expandedRounds.value = new Set()
try {
const data = await Submission.getPromptChain(props.submissionId)
rounds.value = data.map((round) => ({
...round,
source: round.source ?? null,
assistantMsgId: round.assistant_msg_id ?? null,
}))
const last = rounds.value.length - 1
if (last >= 0) selectedRound.value = last
} catch {
rounds.value = []
} finally {
loading.value = false
}
}
watch(
() => [props.show, props.submissionId] as const,
([visible]) => {
if (visible) loadMessages()
},
)
</script>
<style scoped>
.prompt-markdown {
flex: 1;
min-width: 0;
background: transparent;
color: inherit;
font-size: 13px;
line-height: 1.6;
}
.prompt-markdown.is-collapsed {
position: relative;
max-height: 126px;
overflow: hidden;
}
.prompt-markdown :deep(p),
.prompt-markdown :deep(ul),
.prompt-markdown :deep(ol),
.prompt-markdown :deep(blockquote),
.prompt-markdown :deep(pre) {
margin-top: 0;
margin-bottom: 6px;
}
.prompt-markdown :deep(:last-child) {
margin-bottom: 0;
}
.prompt-markdown :deep(pre) {
padding: 8px;
overflow-x: auto;
font-size: 12px;
}
.prompt-markdown :deep(code) {
white-space: pre-wrap;
word-break: break-word;
}
</style>
@@ -0,0 +1,42 @@
<template>
<n-modal
preset="card"
:show="show"
style="max-width: 60%"
@update:show="$emit('update:show', $event)"
>
<template #header>
<n-flex align="center">
<span>前端代码</span>
<n-button tertiary @click="$emit('copy-to-editor')"
>复制到编辑框</n-button
>
</n-flex>
</template>
<n-tabs animated type="segment">
<n-tab-pane name="html" tab="html">
<n-code :code="html" language="html" word-wrap />
</n-tab-pane>
<n-tab-pane name="css" tab="css">
<n-code :code="css" language="css" word-wrap />
</n-tab-pane>
<n-tab-pane v-if="!!js" name="js" tab="js">
<n-code :code="js" language="js" word-wrap />
</n-tab-pane>
</n-tabs>
</n-modal>
</template>
<script setup lang="ts">
defineProps<{
show: boolean
html: string
css: string
js: string
}>()
defineEmits<{
"update:show": [value: boolean]
"copy-to-editor": []
}>()
</script>
@@ -0,0 +1,126 @@
<template>
<n-spin v-if="loading" size="small" style="padding: 12px" />
<n-data-table
v-else-if="items"
:columns="subColumns"
:data="items"
size="small"
striped
:row-key="(r: SubmissionOut) => r.id"
:row-props="rowProps"
:row-class-name="rowClassName"
/>
</template>
<script setup lang="ts">
import { computed, h } from "vue"
import {
NButton,
NDataTable,
NPopconfirm,
NSpin,
type DataTableColumn,
} from "naive-ui"
import type { SubmissionOut } from "../../utils/type"
import { TASK_TYPE } from "../../utils/const"
import { parseTime } from "../../utils/helper"
import { user, roleSuper } from "../../store/user"
import { submission } from "../../store/submission"
const props = defineProps<{
row: SubmissionOut
items: SubmissionOut[] | undefined
loading: boolean
}>()
const emit = defineEmits<{
select: [id: string]
delete: [row: SubmissionOut, parentId: string]
"show-chain": [submissionId: string, username: string]
}>()
const isChallenge = computed(() => props.row.task_type === TASK_TYPE.Challenge)
function rowProps(r: SubmissionOut) {
return {
style: { cursor: "pointer" },
onClick: () => emit("select", r.id),
}
}
function rowClassName(r: SubmissionOut) {
return submission.value.id === r.id ? "row-active" : ""
}
const subColumns = computed((): DataTableColumn<SubmissionOut>[] => [
{
title: "时间",
key: "created",
width: 160,
render: (r) => parseTime(r.created, "YYYY-MM-DD HH:mm:ss"),
},
{
title: "得分",
key: "score",
width: 80,
render: (r) => {
const myScore = r.my_score > 0 ? String(r.my_score) : "-"
const avgScore = r.score > 0 ? r.score.toFixed(2) : "-"
return h(
"div",
{ style: { display: "flex", gap: "6px", alignItems: "baseline" } },
[
h("span", avgScore),
h("span", { style: { fontSize: "11px", color: "#999" } }, myScore),
],
)
},
},
{
title: "操作",
key: "actions",
width: isChallenge.value ? 110 : 60,
render: (r: SubmissionOut) =>
h("div", { style: { display: "flex", gap: "8px" } }, [
...(isChallenge.value
? [
h(
NButton,
{
text: true,
type: "primary",
onClick: (e: Event) => {
e.stopPropagation()
emit("show-chain", r.id, r.username)
},
},
() => "查看",
),
]
: []),
...(r.username === user.username || roleSuper.value
? [
h(
NPopconfirm,
{ onPositiveClick: () => emit("delete", r, props.row.id) },
{
trigger: () =>
h(
NButton,
{
text: true,
type: "error",
size: "small",
onClick: (e: Event) => e.stopPropagation(),
},
() => "删除",
),
default: () => "确定删除这次提交?",
},
),
]
: []),
]),
} as DataTableColumn<SubmissionOut>,
])
</script>
@@ -0,0 +1,74 @@
<template>
<n-popover v-if="isAdmin" trigger="click">
<template #trigger>
<span :style="dotStyle" />
</template>
<n-space vertical size="small">
<n-button
v-for="opt in FLAG_OPTIONS"
:key="opt.value"
text
@click="$emit('update:flag', opt.value)"
>
<span style="display: flex; align-items: center; gap: 6px">
<span
:style="{
display: 'inline-block',
width: '10px',
height: '10px',
borderRadius: '50%',
backgroundColor: opt.color,
}"
/>
{{ opt.label }}
</span>
</n-button>
<n-button
v-if="flag"
text
block
type="error"
@click="$emit('update:flag', null)"
>
清除
</n-button>
</n-space>
</n-popover>
<span v-else :style="dotStyle" />
</template>
<script setup lang="ts">
import { computed } from "vue"
import type { FlagType } from "../../utils/type"
const FLAG_OPTIONS: {
value: NonNullable<FlagType>
color: string
label: string
}[] = [
{ value: "red", color: "#e03030", label: "值得展示" },
{ value: "blue", color: "#2080f0", label: "需要讲解" },
{ value: "green", color: "#18a058", label: "优秀作品" },
{ value: "yellow", color: "#f0a020", label: "需要改进" },
]
const props = defineProps<{
flag: FlagType
isAdmin: boolean
}>()
defineEmits<{ "update:flag": [value: FlagType] }>()
const dotStyle = computed(() => {
const match = FLAG_OPTIONS.find((f) => f.value === props.flag)
return {
display: "inline-block",
width: "12px",
height: "12px",
borderRadius: "50%",
backgroundColor: match ? match.color : "transparent",
border: match ? "none" : "1px dashed #ccc",
cursor: props.isAdmin ? "pointer" : "default",
}
})
</script>
@@ -0,0 +1,37 @@
<template>
<n-flex>
<n-tag
size="small"
:bordered="false"
type="primary"
v-if="props.submission.task_type === 'tutorial'"
>
教程
</n-tag>
<n-tag
:bordered="false"
size="small"
type="error"
v-if="props.submission.task_type === 'challenge'"
>
挑战
</n-tag>
<n-button text @click="open">{{ props.submission.task_title }}</n-button>
</n-flex>
</template>
<script setup lang="ts">
import { useRouter } from "vue-router"
import type { SubmissionOut } from "../../utils/type"
import { goHome } from "../../utils/helper"
interface Props {
submission: SubmissionOut
}
const props = defineProps<Props>()
const router = useRouter()
function open() {
goHome(router, props.submission.task_type, props.submission.task_display)
}
</script>
@@ -0,0 +1,83 @@
<template>
<div class="container" v-if="taskTab === TASK_TYPE.Challenge">
<n-empty v-if="!challenges.length">暂无挑战,敬请期待</n-empty>
<n-flex v-else vertical :size="12">
<n-card
v-for="item in challenges"
:key="item.display"
hoverable
:class="['challenge-card', { submitted: item.submitted }]"
@click="select(item)"
>
<template #header>
<n-flex align="center" :size="6">
<span v-if="item.submitted" class="check-icon">✓</span>
<span :class="{ 'submitted-title': item.submitted }">{{
item.title
}}</span>
</n-flex>
</template>
<template #header-extra>
<n-flex :size="6">
<n-tag type="warning" size="small">{{ item.score }} 分</n-tag>
<n-tag v-if="item.pass_score != null" size="small"
>及格 {{ item.pass_score }} 分</n-tag
>
</n-flex>
</template>
<n-text depth="3" class="challenge-author">
出题人:{{ item.author_name || "未设置" }}
</n-text>
</n-card>
</n-flex>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from "vue"
import { useRouter } from "vue-router"
import { Challenge } from "../../api"
import { taskTab } from "../../store/task"
import { TASK_TYPE } from "../../utils/const"
import type { ChallengeSlim } from "../../utils/type"
const router = useRouter()
const challenges = ref<ChallengeSlim[]>([])
function select(item: ChallengeSlim) {
router.push({ name: "home-challenge", params: { display: item.display } })
}
onMounted(async () => {
challenges.value = await Challenge.listDisplay()
})
</script>
<style scoped>
.container {
padding: 16px;
overflow: auto;
}
.challenge-card {
cursor: pointer;
}
.challenge-card.submitted {
background-color: #f6ffed;
border-color: #b7eb8f;
}
.check-icon {
color: #52c41a;
font-size: 14px;
font-weight: bold;
}
.submitted-title {
color: #888;
}
.challenge-author {
display: block;
font-size: 13px;
}
</style>
@@ -0,0 +1,149 @@
<template>
<n-flex vertical>
<n-button size="small" @click="showUpload = true">图片素材</n-button>
<n-flex v-if="assets.length" wrap>
<n-card
v-for="asset in assets"
:key="asset.name"
size="small"
style="width: 120px"
>
<template #cover>
<n-image
:src="asset.url"
style="width: 100%; height: 100px; object-fit: contain"
/>
</template>
<n-flex align="center" justify="space-between">
<n-text style="font-size: 12px; word-break: break-all">
{{ asset.name }}
</n-text>
<n-button
size="tiny"
quaternary
type="error"
@click="remove(asset.name)"
>
<template #icon>
<Icon icon="material-symbols:close-rounded" />
</template>
</n-button>
</n-flex>
</n-card>
</n-flex>
<n-modal
v-model:show="showUpload"
preset="card"
title="上传素材"
style="width: 400px"
>
<n-form>
<n-form-item label="文件名(如 1.png)">
<n-input v-model:value="uploadName" placeholder="1.png" />
</n-form-item>
<n-form-item label="选择文件">
<n-upload :max="1" @change="onFileChange" :default-upload="false">
<n-button>选择文件</n-button>
</n-upload>
</n-form-item>
</n-form>
<template #footer>
<n-flex justify="end">
<n-button @click="showUpload = false">取消</n-button>
<n-button
type="primary"
:disabled="!uploadName || !uploadFile"
:loading="uploading"
@click="upload"
>
上传
</n-button>
</n-flex>
</template>
</n-modal>
</n-flex>
</template>
<script lang="ts" setup>
import { ref, watch } from "vue"
import { Icon } from "@iconify/vue"
import { useMessage } from "naive-ui"
import type { UploadFileInfo } from "naive-ui"
import { TaskAssets } from "../../api"
import type { TaskAsset } from "../../utils/type"
const props = defineProps<{
taskType: "challenge" | "tutorial"
display: number
}>()
const message = useMessage()
const assets = ref<TaskAsset[]>([])
const showUpload = ref(false)
const uploadName = ref("")
const uploadFile = ref<File | null>(null)
const uploading = ref(false)
async function load() {
if (!props.display) return
try {
assets.value =
props.taskType === "challenge"
? await TaskAssets.listChallenge(props.display)
: await TaskAssets.listTutorial(props.display)
} catch {
assets.value = []
}
}
function onFileChange({ file }: { file: UploadFileInfo }) {
if (file.status === "pending") {
uploadFile.value = file.file ?? null
}
}
async function upload() {
if (!uploadName.value || !uploadFile.value) return
uploading.value = true
try {
const asset =
props.taskType === "challenge"
? await TaskAssets.uploadChallenge(
props.display,
uploadName.value,
uploadFile.value,
)
: await TaskAssets.uploadTutorial(
props.display,
uploadName.value,
uploadFile.value,
)
const idx = assets.value.findIndex((a) => a.name === asset.name)
if (idx >= 0) assets.value[idx] = asset
else assets.value.push(asset)
message.success(`${asset.name} 上传成功`)
showUpload.value = false
uploadName.value = ""
uploadFile.value = null
} catch (err: any) {
message.error(err?.response?.data?.detail ?? "上传失败")
} finally {
uploading.value = false
}
}
async function remove(name: string) {
try {
props.taskType === "challenge"
? await TaskAssets.deleteChallenge(props.display, name)
: await TaskAssets.deleteTutorial(props.display, name)
assets.value = assets.value.filter((a) => a.name !== name)
message.success("删除成功")
} catch (err: any) {
message.error(err?.response?.data?.detail ?? "删除失败")
}
}
watch(() => props.display, load, { immediate: true })
</script>
+234
View File
@@ -0,0 +1,234 @@
<template>
<div class="container">
<n-flex align="center" justify="space-between" class="title">
<n-flex align="center">
<Icon
:icon="
taskTab === TASK_TYPE.Tutorial
? 'twemoji:books'
: 'twemoji:crossed-swords'
"
:width="20"
></Icon>
<n-tabs
style="width: 150px"
type="segment"
animated
:value="taskTab"
@update:value="changeTab"
>
<n-tab name="tutorial" tab="教程"></n-tab>
<n-tab name="challenge" tab="挑战"></n-tab>
</n-tabs>
<template v-if="!hideNav">
<n-button text @click="prev()" :disabled="prevDisabled()">
<Icon :width="24" icon="pepicons-pencil:arrow-left"></Icon>
</n-button>
<span v-if="progressText" class="progress-text">
{{ progressText }}
</span>
<n-button text @click="next()" :disabled="nextDisabled()">
<Icon :width="24" icon="pepicons-pencil:arrow-right"></Icon>
</n-button>
</template>
</n-flex>
<n-flex>
<n-tooltip
v-if="tutorialAssets.length && taskTab === TASK_TYPE.Tutorial"
trigger="hover"
>
<template #trigger>
<n-button text @click="showAssets = true">
<Icon :width="16" icon="lucide:image"></Icon>
</n-button>
</template>
素材
</n-tooltip>
<n-tooltip v-if="authed" trigger="hover">
<template #trigger>
<n-button
text
@click="
$router.push({ name: 'submissions', params: { page: 1 } })
"
>
<Icon :width="16" icon="lucide:list"></Icon>
</n-button>
</template>
提交记录
</n-tooltip>
<n-tooltip v-if="authed" trigger="hover">
<template #trigger>
<n-button text @click="$router.push({ name: 'creation-list' })">
<Icon :width="16" icon="lucide:sparkles"></Icon>
</n-button>
</template>
自由创作
</n-tooltip>
<n-tooltip v-if="authed" trigger="hover">
<template #trigger>
<n-button text @click="$router.push({ name: 'showcase' })">
<Icon :width="16" icon="lucide:award"></Icon>
</n-button>
</template>
创意工坊
</n-tooltip>
<n-tooltip v-if="roleSuper" trigger="hover">
<template #trigger>
<n-button text @click="edit">
<Icon :width="16" icon="lucide:edit"></Icon>
</n-button>
</template>
编辑
</n-tooltip>
<n-tooltip trigger="hover">
<template #trigger>
<n-button text @click="$emit('hide')">
<Icon :width="24" icon="material-symbols:close-rounded"></Icon>
</n-button>
</template>
关闭
</n-tooltip>
</n-flex>
</n-flex>
<TutorialContent v-if="taskTab === TASK_TYPE.Tutorial" />
<ChallengeList v-else />
</div>
<n-modal
v-model:show="showAssets"
preset="card"
title="图片素材"
style="width: 570px"
>
<n-flex wrap>
<n-card
v-for="asset in tutorialAssets"
:key="asset.name"
:title="asset.name"
size="small"
style="width: 120px"
>
<template #cover>
<n-image
:src="asset.url"
style="width: 100%; height: 100px; object-fit: contain"
/>
</template>
</n-card>
</n-flex>
</n-modal>
</template>
<script lang="ts" setup>
import { Icon } from "@iconify/vue"
import { computed, ref, watch } from "vue"
import {
step,
tutorialIds,
prev,
next,
prevDisabled,
nextDisabled,
} from "../../store/tutorial"
import { authed, roleSuper } from "../../store/user"
import { taskTab, taskId, challengeDisplay } from "../../store/task"
import { useRoute, useRouter } from "vue-router"
import { TASK_TYPE } from "../../utils/const"
import { TaskAssets } from "../../api"
import type { TaskAsset } from "../../utils/type"
import ChallengeList from "./ChallengeList.vue"
import TutorialContent from "./TutorialContent.vue"
const tutorialAssets = ref<TaskAsset[]>([])
const showAssets = ref(false)
async function loadTutorialAssets(display: number) {
if (!display) {
tutorialAssets.value = []
return
}
try {
tutorialAssets.value = await TaskAssets.listTutorial(display)
} catch {
tutorialAssets.value = []
}
}
watch(step, loadTutorialAssets, { immediate: true })
const route = useRoute()
const router = useRouter()
// 路由同步:初始执行 + watch 响应 SPA 内部导航
function syncRoute(routeName: string) {
if (routeName.startsWith("home-tutorial")) {
taskTab.value = TASK_TYPE.Tutorial
if (route.params.display) step.value = Number(route.params.display)
} else if (routeName.startsWith("home-challenge")) {
taskTab.value = TASK_TYPE.Challenge
if (route.params.display)
challengeDisplay.value = Number(route.params.display)
}
}
syncRoute(route.name as string)
watch(() => route.name as string, syncRoute)
defineEmits(["hide"])
const hideNav = computed(
() => taskTab.value !== TASK_TYPE.Tutorial || tutorialIds.value.length <= 1,
)
const progressText = computed(() => {
const ids = tutorialIds.value
if (!ids.length) return ""
const i = ids.indexOf(step.value)
return i === -1 ? "" : `${i + 1} / ${ids.length}`
})
function changeTab(v: TASK_TYPE) {
taskId.value = 0
taskTab.value = v
if (v === TASK_TYPE.Tutorial) {
router.push(
step.value
? { name: "home-tutorial", params: { display: step.value } }
: { name: "home-tutorial-list" },
)
} else if (v === TASK_TYPE.Challenge) {
router.push({ name: "home-challenge-list" })
}
}
function edit() {
const name =
taskTab.value === TASK_TYPE.Tutorial
? "tutorial-editor"
: "challenge-editor"
const display =
taskTab.value === TASK_TYPE.Tutorial ? step.value : challengeDisplay.value
router.push({ name, params: { display } })
}
</script>
<style scoped>
.container {
display: flex;
flex-direction: column;
height: 100%;
}
.title {
height: 43px;
padding: 0 20px;
flex-shrink: 0;
border-bottom: 1px solid rgb(239, 239, 245);
box-sizing: border-box;
}
.progress-text {
font-size: 12px;
color: #999;
min-width: 36px;
text-align: center;
user-select: none;
}
</style>
@@ -0,0 +1,549 @@
<template>
<n-modal
:show="show"
@update:show="$emit('update:show', $event)"
preset="card"
title="提交统计"
style="max-width: 660px"
:bordered="false"
>
<div style="max-height: 75vh; overflow-y: auto">
<!-- 初始加载 -->
<template v-if="!stats && loading">
<n-flex justify="center" style="padding: 40px">
<n-spin size="large" />
</n-flex>
</template>
<template v-else-if="stats">
<!-- 班级筛选 -->
<n-flex
align="center"
style="margin-bottom: 16px; flex-wrap: wrap; gap: 6px"
>
<span style="color: #666; font-size: 12px">班级筛选:</span>
<n-button
size="small"
:type="selectedClass === null ? 'primary' : 'default'"
@click="selectClass(null)"
>全部</n-button
>
<n-button
v-for="c in stats.classes"
:key="c"
size="small"
:type="selectedClass === c ? 'primary' : 'default'"
@click="selectClass(c)"
>{{ c }}</n-button
>
</n-flex>
<n-spin :show="loading">
<!-- 关键指标 -->
<div
style="
display: grid;
grid-template-columns: repeat(5, 1fr);
border: 1px solid #eee;
border-radius: 6px;
overflow: hidden;
margin-bottom: 12px;
"
>
<div
v-for="(metric, i) in metrics"
:key="metric.label"
:style="{
padding: '12px 8px',
textAlign: 'center',
borderRight: i < metrics.length - 1 ? '1px solid #eee' : 'none',
}"
>
<div
:style="{
fontSize: '20px',
fontWeight: '700',
color: metric.color,
}"
>
{{ metric.value }}
</div>
<div style="color: #888; font-size: 11px; margin-top: 2px">
{{ metric.label }}
</div>
</div>
</div>
<!-- 未提交名单(可折叠) -->
<div
style="
border: 1px solid #eee;
border-radius: 6px;
margin-bottom: 8px;
overflow: hidden;
"
>
<div
style="
padding: 10px 14px;
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
background: #fff8f8;
"
@click="showUnsubmitted = !showUnsubmitted"
>
<n-flex align="center" :size="6">
<span
style="
width: 7px;
height: 7px;
background: #d03050;
border-radius: 50%;
display: inline-block;
"
></span>
<span style="font-weight: 600; color: #d03050; font-size: 12px"
>未提交({{ stats.unsubmitted_count }}人)</span
>
</n-flex>
<Icon
:icon="
showUnsubmitted
? 'lucide:chevron-down'
: 'lucide:chevron-right'
"
:width="14"
style="color: #aaa"
/>
</div>
<div
v-if="showUnsubmitted"
style="
padding: 10px 14px;
background: #fff8f8;
display: flex;
flex-wrap: wrap;
gap: 5px;
"
>
<n-tag
v-for="u in stats.unsubmitted_users"
:key="u.username"
size="small"
:bordered="true"
style="border-color: #ffd0d0; background: #fff"
>{{ displayName(u.username, u.classname) }}</n-tag
>
<span
v-if="!stats.unsubmitted_users.length"
style="color: #aaa; font-size: 12px"
>暂无</span
>
</div>
</div>
<!-- 未打分名单(可折叠) -->
<div
style="
border: 1px solid #eee;
border-radius: 6px;
margin-bottom: 12px;
overflow: hidden;
"
>
<div
style="
padding: 10px 14px;
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
background: #fffaf5;
"
@click="showUnrated = !showUnrated"
>
<n-flex align="center" :size="6">
<span
style="
width: 7px;
height: 7px;
background: #e07800;
border-radius: 50%;
display: inline-block;
"
></span>
<span style="font-weight: 600; color: #e07800; font-size: 12px"
>未打分({{ stats.unrated_count }}人)</span
>
<span style="font-size: 11px; color: #aaa"
>— 还没给任何提交打过分</span
>
</n-flex>
<Icon
:icon="
showUnrated ? 'lucide:chevron-down' : 'lucide:chevron-right'
"
:width="14"
style="color: #aaa"
/>
</div>
<div
v-if="showUnrated"
style="
padding: 10px 14px;
background: #fffaf5;
display: flex;
flex-wrap: wrap;
gap: 5px;
"
>
<n-tag
v-for="u in stats.unrated_users"
:key="u.username"
size="small"
:bordered="true"
style="border-color: #ffd0a0; background: #fff"
>{{ displayName(u.username, u.classname) }}</n-tag
>
<span
v-if="!stats.unrated_users.length"
style="color: #aaa; font-size: 12px"
>暂无</span
>
</div>
</div>
<!-- 提交次数分布 -->
<div style="margin-bottom: 12px">
<div
style="
font-weight: 600;
font-size: 13px;
margin-bottom: 8px;
color: #333;
"
>
提交次数分布
<span style="font-size: 11px; color: #aaa; font-weight: 400"
>(已提交的 {{ stats.submitted_count }} 人)</span
>
</div>
<div style="display: flex; gap: 8px">
<div
v-for="bucket in countBuckets"
:key="bucket.label"
:style="{
flex: 1,
border: `1px solid ${bucket.borderColor}`,
borderRadius: '6px',
padding: '10px 8px',
textAlign: 'center',
background: bucket.bg,
}"
>
<div
:style="{
fontSize: '20px',
fontWeight: '700',
color: bucket.color,
}"
>
{{ bucket.value }}
</div>
<div style="color: #aaa; font-size: 11px; margin-top: 2px">
{{ bucket.label }}
</div>
<div
style="
margin-top: 8px;
background: #e8e8e8;
border-radius: 3px;
height: 4px;
"
>
<div
:style="{
background: bucket.color,
height: '4px',
borderRadius: '3px',
width: bucketPct(bucket.value),
}"
></div>
</div>
<div style="color: #bbb; font-size: 10px; margin-top: 3px">
{{ bucketPct(bucket.value) }}
</div>
</div>
</div>
</div>
<!-- 标记统计 -->
<div>
<div
style="
font-weight: 600;
font-size: 13px;
margin-bottom: 8px;
color: #333;
"
>
标记统计
</div>
<n-flex :size="8" style="flex-wrap: wrap">
<div
v-for="flag in flagBadges"
:key="flag.label"
:style="{
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '4px 10px',
background: flag.bg,
borderRadius: '4px',
border: `1px solid ${flag.border}`,
}"
>
<span
:style="{
width: '7px',
height: '7px',
background: flag.color,
borderRadius: '50%',
display: 'inline-block',
}"
></span>
<span :style="{ color: flag.color, fontSize: '12px' }">{{
flag.label
}}</span>
<span style="font-weight: 700; font-size: 13px; color: #333">{{
flag.value
}}</span>
</div>
</n-flex>
</div>
<!-- 人气前五 -->
<div style="margin-top: 12px">
<div
style="
font-weight: 600;
font-size: 13px;
margin-bottom: 8px;
color: #333;
"
>
人气前五
</div>
<div
v-if="stats.top_viewed.length === 0"
style="color: #aaa; font-size: 12px"
>
暂无
</div>
<div v-else style="display: flex; flex-direction: column; gap: 6px">
<div
v-for="(item, i) in stats.top_viewed"
:key="item.submission_id"
style="
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border: 1px solid #eee;
border-radius: 6px;
cursor: pointer;
background: #fafafa;
"
@click="viewSubmission(item.submission_id)"
>
<span
:style="{
width: '20px',
height: '20px',
borderRadius: '50%',
background:
i < 3 ? ['#f0a020', '#888', '#a07040'][i] : '#ddd',
color: '#fff',
fontSize: '11px',
fontWeight: '700',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}"
>{{ i + 1 }}</span
>
<span style="flex: 1; font-size: 13px; color: #333">
{{ displayName(item.username, item.classname) }}
</span>
<span style="font-size: 12px; color: #2080f0; font-weight: 600">
{{ item.view_count }} 次
</span>
</div>
</div>
</div>
</n-spin>
</template>
</div>
</n-modal>
</template>
<script lang="ts" setup>
import { ref, computed, watch } from "vue"
import { Icon } from "@iconify/vue"
import { useRouter } from "vue-router"
import { Submission } from "../../api"
import type { TaskStatsOut } from "../../utils/type"
const props = defineProps<{ taskId: number; show: boolean }>()
const emit = defineEmits<{ (e: "update:show", v: boolean): void }>()
const router = useRouter()
const stats = ref<TaskStatsOut | null>(null)
const loading = ref(false)
const selectedClass = ref<string | null>(null)
const showUnsubmitted = ref(false)
const showUnrated = ref(false)
async function load(classname?: string) {
loading.value = true
try {
stats.value = await Submission.getStats(props.taskId, classname)
} finally {
loading.value = false
}
}
function displayName(username: string, classname: string) {
const prefix = "web" + classname
return username.startsWith(prefix) ? username.slice(prefix.length) : username
}
function selectClass(c: string | null) {
selectedClass.value = c
load(c ?? undefined)
}
function viewSubmission(id: string) {
const { href } = router.resolve({ name: "submission", params: { id } })
window.open(href, "_blank")
}
function bucketPct(value: number) {
const total = stats.value?.submitted_count ?? 0
if (!total) return "0%"
return Math.round((value / total) * 100) + "%"
}
const metrics = computed(() => {
if (!stats.value) return []
return [
{ label: "已提交", value: stats.value.submitted_count, color: "#18a058" },
{ label: "未提交", value: stats.value.unsubmitted_count, color: "#d03050" },
{
label: "平均分",
value: stats.value.average_score?.toFixed(1) ?? "—",
color: "#2080f0",
},
{ label: "未打分", value: stats.value.unrated_count, color: "#d03050" },
]
})
const countBuckets = computed(() => {
if (!stats.value) return []
const d = stats.value.submission_count_distribution
return [
{
label: "4 次+",
value: d.count_4_plus,
color: "#f0a020",
bg: "#fffbf0",
borderColor: "#ffe0a0",
},
{
label: "3 次",
value: d.count_3,
color: "#18a058",
bg: "#f0fff4",
borderColor: "#c8e8d0",
},
{
label: "2 次",
value: d.count_2,
color: "#2080f0",
bg: "#f0f7ff",
borderColor: "#d0e8ff",
},
{
label: "仅 1 次",
value: d.count_1,
color: "#888",
bg: "#fafafa",
borderColor: "#e0e0e0",
},
]
})
const scoreBars = computed(() => {
if (!stats.value) return []
const d = stats.value.score_distribution
const vals = [d.range_1_2, d.range_2_3, d.range_3_4, d.range_4_5, d.range_5]
const max = Math.max(...vals, 1)
const colors = ["#d03050", "#f0a020", "#2080f0", "#18a058", "#18a058"]
const labels = ["★", "★★", "★★★", "★★★★", "★★★★★"]
return vals.map((v, i) => ({
value: v,
label: labels[i],
color: colors[i],
height: Math.max(Math.round((v / max) * 68), 3) + "px",
}))
})
const flagBadges = computed(() => {
if (!stats.value) return []
const f = stats.value.flag_stats
return [
{
label: "值得展示",
value: f.red,
color: "#d03050",
bg: "#fff0f0",
border: "#ffd0d0",
},
{
label: "需要讲解",
value: f.blue,
color: "#2080f0",
bg: "#f0f7ff",
border: "#c8deff",
},
{
label: "优秀作品",
value: f.green,
color: "#18a058",
bg: "#f0fff4",
border: "#b8e8c8",
},
{
label: "需要改进",
value: f.yellow,
color: "#f0a020",
bg: "#fffbf0",
border: "#ffe8a0",
},
]
})
// Load when modal opens
watch(
() => props.show,
(val) => {
if (val && props.taskId) {
selectedClass.value = null
load()
}
},
)
</script>
@@ -0,0 +1,175 @@
<template>
<div class="markdown-body" v-html="content" ref="$content" />
</template>
<script lang="ts" setup>
import { onMounted, ref, useTemplateRef, watch } from "vue"
import { marked } from "marked"
import copyFn from "copy-text-to-clipboard"
import { css, html, js, tab } from "../../store/editors"
import { Tutorial } from "../../api"
import { step, tutorialIds, loadTutorials } from "../../store/tutorial"
import { taskId, assetBaseUrl } from "../../store/task"
import { useRouter } from "vue-router"
marked.use({
renderer: {
code({ text, lang }) {
const language = lang?.toLowerCase() ?? "html"
return `<div class="codeblock-wrapper" data-lang="${language}">
<div class="codeblock-action">
<span class="lang">${language.toUpperCase()}</span>
<div class="btn-group">
<button class="action-btn" data-action="copy">复制</button>
<button class="action-btn" data-action="replace">替换</button>
</div>
</div>
<pre><code class="language-${language}">${text}</code></pre>
</div>`
},
link({ href, text }) {
return `<a href="${href}" target="_blank">${text}</a>`
},
},
})
const router = useRouter()
const content = ref("")
const $content = useTemplateRef<any>("$content")
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 })
}
function flash(btn: HTMLButtonElement, done: string, original: string) {
btn.textContent = done
setTimeout(() => {
btn.textContent = original
}, 1000)
}
function setupCodeActions() {
$content.value?.addEventListener("click", (e: MouseEvent) => {
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>(
"[data-action]",
)
if (!btn) return
const wrapper = btn.closest<HTMLElement>("[data-lang]")!
const lang = wrapper.dataset.lang ?? "html"
const code = wrapper.querySelector("code")?.textContent ?? ""
if (btn.dataset.action === "copy") {
copyFn(code)
flash(btn, "已复制", "复制")
} else if (btn.dataset.action === "replace") {
tab.value = lang
if (lang === "html") html.value = code
if (lang === "css") css.value = code
if (lang === "js") js.value = code
flash(btn, "已替换", "替换")
}
})
}
async function init() {
await loadTutorials()
if (!tutorialIds.value.length) {
content.value = "暂无教程"
return
}
render()
}
onMounted(() => {
setupCodeActions()
init()
})
watch(step, (v) => {
router.push({ name: "home-tutorial", params: { display: v } })
render()
})
</script>
<style scoped>
.markdown-body {
padding: 16px;
box-sizing: border-box;
overflow: auto;
}
</style>
<style>
.markdown-body pre {
position: relative;
}
.markdown-body pre code {
padding: 0;
font-size: 1rem;
font-family: Monaco;
}
.markdown-body .codeblock-wrapper {
padding: 1rem;
background-color: #f6f8fa;
border-radius: 6px;
margin-bottom: 1rem;
overflow: auto;
}
.markdown-body .codeblock-wrapper pre {
padding: 0;
background-color: transparent;
border-radius: 0;
margin-bottom: 0;
overflow: visible;
}
.codeblock-action {
margin-bottom: 0.5rem;
font-family:
v-sans,
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif,
"Apple Color Emoji",
"Segoe UI Emoji",
"Segoe UI Symbol";
display: flex;
align-items: center;
justify-content: space-between;
}
.codeblock-action .lang {
font-size: 0.9rem;
font-weight: bold;
}
.codeblock-action .btn-group {
display: flex;
gap: 0.5rem;
}
.codeblock-action .action-btn {
height: 28px;
padding: 0 14px;
font-size: 14px;
border-radius: 3px;
border: 1px solid #d9d9d9;
background-color: #fff;
color: #333;
cursor: pointer;
transition: all 0.2s ease;
}
.codeblock-action .action-btn:hover {
border-color: #18a058;
color: #18a058;
}
</style>