update
This commit is contained in:
@@ -86,6 +86,7 @@ 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 { conversationId } from "../store/prompt"
|
||||
import { Submission } from "../api"
|
||||
import { NCode, useDialog, useMessage } from "naive-ui"
|
||||
import { h, ref } from "vue"
|
||||
@@ -145,6 +146,7 @@ async function doSubmit() {
|
||||
html: html.value,
|
||||
css: css.value,
|
||||
js: js.value,
|
||||
conversationId: conversationId.value || undefined,
|
||||
})
|
||||
message.success("提交成功")
|
||||
} catch (err) {
|
||||
|
||||
@@ -2,62 +2,216 @@
|
||||
<n-modal
|
||||
preset="card"
|
||||
title="登录"
|
||||
style="width: 400px"
|
||||
style="width: 420px"
|
||||
v-model:show="loginModal"
|
||||
>
|
||||
<n-form>
|
||||
<n-form-item label="用户名">
|
||||
<n-input v-model:value="name" name="username"></n-input>
|
||||
</n-form-item>
|
||||
<n-form-item label="密码">
|
||||
<n-input
|
||||
type="password"
|
||||
v-model:value="password"
|
||||
name="password"
|
||||
></n-input>
|
||||
</n-form-item>
|
||||
<n-alert
|
||||
type="error"
|
||||
v-if="showMessage"
|
||||
class="message"
|
||||
title="登录失败,请检查用户名和密码"
|
||||
></n-alert>
|
||||
<n-flex>
|
||||
<n-button block :loading="loading" @click="submit" type="primary"
|
||||
>登录</n-button
|
||||
>
|
||||
</n-flex>
|
||||
</n-form>
|
||||
<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"
|
||||
/>
|
||||
</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"
|
||||
/>
|
||||
</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 } from "vue"
|
||||
import { ref, computed, onMounted } from "vue"
|
||||
import { Account } from "../api"
|
||||
import { loginModal } from "../store/modal"
|
||||
import { user } from "../store/user"
|
||||
|
||||
const name = ref("")
|
||||
const password = ref("")
|
||||
const loading = ref(false)
|
||||
const showMessage = ref(false)
|
||||
// Tab state
|
||||
const activeTab = ref("student")
|
||||
|
||||
async function submit() {
|
||||
loading.value = true
|
||||
// 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 {
|
||||
const data = await Account.login(name.value, password.value)
|
||||
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
|
||||
loading.value = false
|
||||
} catch (err) {
|
||||
showMessage.value = true
|
||||
loading.value = false
|
||||
} 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
|
||||
} catch {
|
||||
showAdminError.value = true
|
||||
} finally {
|
||||
adminLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.message {
|
||||
margin-bottom: 20px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<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">
|
||||
<n-button quaternary @click="emits('showCode')">代码</n-button>
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
<template>
|
||||
<div class="prompt-panel">
|
||||
<div class="messages" ref="messagesRef">
|
||||
<div v-if="historyLoading" class="history-loading">
|
||||
<n-spin size="small" />
|
||||
<span>加载历史记录…</span>
|
||||
</div>
|
||||
<div v-for="(msg, i) in messages" :key="i" :class="['message', msg.role]">
|
||||
<div class="message-role">{{ msg.role === 'user' ? '我' : 'AI' }}</div>
|
||||
<div class="message-content" v-html="renderContent(msg)"></div>
|
||||
</div>
|
||||
<div v-if="streaming" class="message assistant">
|
||||
<div class="message-role">AI</div>
|
||||
<div class="message-content" v-html="renderMarkdown(streamingContent)"></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>
|
||||
<div class="input-area">
|
||||
@@ -45,6 +53,7 @@ import {
|
||||
streamingContent,
|
||||
sendPrompt,
|
||||
newConversation,
|
||||
historyLoading,
|
||||
} from "../store/prompt"
|
||||
|
||||
const input = ref("")
|
||||
@@ -58,10 +67,16 @@ function send() {
|
||||
}
|
||||
|
||||
const renderer = new Renderer()
|
||||
renderer.code = function ({ text, lang }: { text: string; lang?: string }) {
|
||||
const escape = (s: string) =>
|
||||
s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """)
|
||||
return `<pre><code class="hljs${lang ? ` language-${escape(lang)}` : ""}">${text}</code></pre>`
|
||||
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: "#fff5f0", fg: "#e05020", dot: "#e05020", border: "#f0d0c0", shimmer: "#fff5f0, #ffeee5, #fff5f0" },
|
||||
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 {
|
||||
@@ -131,9 +146,96 @@ watch(
|
||||
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-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%, 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 {
|
||||
padding: 12px;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.history-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
color: #aaa;
|
||||
font-size: 13px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -57,6 +57,7 @@ const menu = computed(() => [
|
||||
icon: () =>
|
||||
h(Icon, {
|
||||
icon: "streamline-emojis:robot-face-1",
|
||||
width: 20,
|
||||
}),
|
||||
},
|
||||
{
|
||||
@@ -66,6 +67,7 @@ const menu = computed(() => [
|
||||
icon: () =>
|
||||
h(Icon, {
|
||||
icon: "skill-icons:django",
|
||||
width: 20,
|
||||
}),
|
||||
},
|
||||
{
|
||||
@@ -74,6 +76,16 @@ const menu = computed(() => [
|
||||
icon: () =>
|
||||
h(Icon, {
|
||||
icon: "streamline-emojis:bar-chart",
|
||||
width: 20,
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: "排名榜",
|
||||
key: "ranking",
|
||||
icon: () =>
|
||||
h(Icon, {
|
||||
icon: "streamline-emojis:sunglasses",
|
||||
width: 20,
|
||||
}),
|
||||
},
|
||||
{
|
||||
@@ -82,6 +94,7 @@ const menu = computed(() => [
|
||||
icon: () =>
|
||||
h(Icon, {
|
||||
icon: "streamline-emojis:hot-beverage-2",
|
||||
width: 20,
|
||||
}),
|
||||
},
|
||||
])
|
||||
@@ -106,6 +119,9 @@ function clickMenu(name: string) {
|
||||
query: { username: user.username },
|
||||
})
|
||||
break
|
||||
case "ranking":
|
||||
router.push({ name: "ranking" })
|
||||
break
|
||||
case "logout":
|
||||
handleLogout()
|
||||
break
|
||||
|
||||
148
src/components/submissions/ChainModal.vue
Normal file
148
src/components/submissions/ChainModal.vue
Normal file
@@ -0,0 +1,148 @@
|
||||
<template>
|
||||
<n-modal
|
||||
:show="show"
|
||||
preset="card"
|
||||
title="提示词"
|
||||
style="width: 90vw; max-width: 1400px"
|
||||
@update:show="$emit('update:show', $event)"
|
||||
>
|
||||
<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',
|
||||
}"
|
||||
>
|
||||
{{ round.question }}
|
||||
</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 { Prompt } from "../../api"
|
||||
import type { PromptMessage } from "../../utils/type"
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
conversationId?: string
|
||||
}>()
|
||||
|
||||
defineEmits<{ "update:show": [value: boolean] }>()
|
||||
|
||||
const loading = ref(false)
|
||||
const messages = ref<PromptMessage[]>([])
|
||||
const selectedRound = ref(0)
|
||||
|
||||
const rounds = computed(() => {
|
||||
const result: { question: string; html: string | null; css: string | null; js: string | null }[] = []
|
||||
for (const [i, msg] of messages.value.entries()) {
|
||||
if (msg.role !== "user") continue
|
||||
let html: string | null = null, css: string | null = null, js: string | null = null
|
||||
for (const reply of messages.value.slice(i + 1)) {
|
||||
if (reply.role === "user") break
|
||||
if (reply.role === "assistant" && reply.code_html) {
|
||||
html = reply.code_html
|
||||
css = reply.code_css
|
||||
js = reply.code_js
|
||||
break
|
||||
}
|
||||
}
|
||||
result.push({ question: msg.content, html, css, js })
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
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>`
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.conversationId,
|
||||
async (id) => {
|
||||
if (!id || !props.show) return
|
||||
loading.value = true
|
||||
messages.value = []
|
||||
selectedRound.value = 0
|
||||
try {
|
||||
messages.value = await Prompt.getMessages(id)
|
||||
const last = rounds.value.length - 1
|
||||
if (last >= 0) selectedRound.value = last
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
async (visible) => {
|
||||
if (!visible || !props.conversationId) return
|
||||
loading.value = true
|
||||
messages.value = []
|
||||
selectedRound.value = 0
|
||||
try {
|
||||
messages.value = await Prompt.getMessages(props.conversationId)
|
||||
const last = rounds.value.length - 1
|
||||
if (last >= 0) selectedRound.value = last
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
},
|
||||
)
|
||||
</script>
|
||||
35
src/components/submissions/CodeModal.vue
Normal file
35
src/components/submissions/CodeModal.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<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>
|
||||
107
src/components/submissions/ExpandedSubTable.vue
Normal file
107
src/components/submissions/ExpandedSubTable.vue
Normal file
@@ -0,0 +1,107 @@
|
||||
<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 } 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": [conversationId: 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),
|
||||
])
|
||||
},
|
||||
},
|
||||
...(isChallenge.value
|
||||
? [{
|
||||
title: "提示词",
|
||||
key: "conversation_id",
|
||||
width: 70,
|
||||
render: (r: SubmissionOut) => {
|
||||
if (!r.conversation_id) return "-"
|
||||
return h(
|
||||
NButton,
|
||||
{ text: true, type: "primary", onClick: (e: Event) => { e.stopPropagation(); emit("show-chain", r.conversation_id!) } },
|
||||
() => "查看",
|
||||
)
|
||||
},
|
||||
} as DataTableColumn<SubmissionOut>]
|
||||
: []),
|
||||
...(!isChallenge.value
|
||||
? [{
|
||||
title: "操作",
|
||||
key: "actions",
|
||||
width: 60,
|
||||
render: (r: SubmissionOut) => {
|
||||
if (r.username !== user.username) return null
|
||||
return 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>
|
||||
61
src/components/submissions/FlagCell.vue
Normal file
61
src/components/submissions/FlagCell.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user