Compare commits

...

5 Commits

Author SHA1 Message Date
ecce21aaaf ramove rank
Some checks failed
Deploy / deploy (build, debian, 22) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822) (push) Has been cancelled
2026-03-29 10:07:27 -06:00
951e53c1dd update 2026-03-29 09:46:03 -06:00
f26b06877c feat: add 外部AI tab to challenge page 2026-03-29 09:30:02 -06:00
1744c405a5 fix: clear stale split result on code edit, show error on submit failure 2026-03-29 09:28:41 -06:00
5f95b88914 feat: add ExternalAIPanel component for external AI code submission 2026-03-29 09:25:45 -06:00
8 changed files with 143 additions and 153 deletions

View File

@@ -181,7 +181,6 @@ export const Submission = {
score_min?: number score_min?: number
score_max_exclusive?: number score_max_exclusive?: number
score_lt_threshold?: number score_lt_threshold?: number
nominated?: boolean
ordering?: string ordering?: string
grouped?: boolean grouped?: boolean
}) { }) {
@@ -223,11 +222,6 @@ export const Submission = {
return res.data as { cleared: number } return res.data as { cleared: number }
}, },
async nominate(id: string) {
const res = await http.put(`/submission/${id}/nominate`)
return res.data as { nominated: boolean }
},
async getStats(taskId: number, classname?: string): Promise<TaskStatsOut> { async getStats(taskId: number, classname?: string): Promise<TaskStatsOut> {
const params: Record<string, string | number> = {} const params: Record<string, string | number> = {}
if (classname) params.classname = classname if (classname) params.classname = classname

View File

@@ -0,0 +1,139 @@
<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="field-label" style="margin-top: 12px">AI 代码</div>
<n-input
v-model:value="rawCode"
type="textarea"
:autosize="{ minRows: 6, maxRows: 16 }"
placeholder="粘贴外部 AI 返回的完整 HTML 代码..."
/>
<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" :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 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,
})
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;
}
.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>

View File

@@ -279,72 +279,6 @@
</div> </div>
</div> </div>
<!-- 人气提交 Top 5 -->
<div style="margin-bottom: 12px">
<div
style="
font-weight: 600;
font-size: 13px;
margin-bottom: 8px;
color: #333;
"
>
人气提交 Top 5
<span style="font-size: 11px; color: #aaa; font-weight: 400"
>按打分人数</span
>
</div>
<div style="display: flex; flex-direction: column; gap: 5px">
<div
v-for="(sub, i) in stats.top_submissions"
:key="sub.submission_id"
:style="{
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: '6px 10px',
background: rankBg(i),
borderRadius: '6px',
cursor: 'pointer',
}"
@click="viewSubmission(sub.submission_id)"
>
<div
:style="{
width: '20px',
height: '20px',
background: rankColor(i),
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
fontWeight: '700',
fontSize: '11px',
flexShrink: 0,
}"
>
{{ i + 1 }}
</div>
<div style="flex: 1">
<div style="font-weight: 500; font-size: 13px">
{{ displayName(sub.username, sub.classname) }}
</div>
<div style="color: #aaa; font-size: 11px">
{{ sub.score.toFixed(1) }} ·
{{ sub.rating_count }} 人打分
</div>
</div>
<div style="color: #2080f0; font-size: 12px">查看 </div>
</div>
<span
v-if="!stats.top_submissions.length"
style="color: #aaa; font-size: 12px"
>暂无打分记录</span
>
</div>
</div>
<!-- 标记统计 --> <!-- 标记统计 -->
<div> <div>
<div <div
@@ -436,20 +370,6 @@ function viewSubmission(id: string) {
window.open(href, "_blank") window.open(href, "_blank")
} }
function rankColor(i: number) {
return (
(["#f0a020", "#909090", "#cd7f32", "#8899aa", "#7a8fa0"] as const)[i] ??
"#aaa"
)
}
function rankBg(i: number) {
return (
(["#fffbef", "#f8f8f8", "#fdf5ee", "#f2f5f8", "#eef2f5"] as const)[i] ??
"#f8f8f8"
)
}
function bucketPct(value: number) { function bucketPct(value: number) {
const total = stats.value?.submitted_count ?? 0 const total = stats.value?.submitted_count ?? 0
if (!total) return "0%" if (!total) return "0%"
@@ -467,7 +387,6 @@ const metrics = computed(() => {
color: "#2080f0", color: "#2080f0",
}, },
{ label: "未打分", value: stats.value.unrated_count, color: "#d03050" }, { label: "未打分", value: stats.value.unrated_count, color: "#d03050" },
{ label: "参与排名", value: stats.value.nominated_count, color: "#f0a020" },
] ]
}) })

View File

@@ -77,15 +77,6 @@ const menu = computed(() => [
width: 20, width: 20,
}), }),
}, },
{
label: "排名榜",
key: "ranking",
icon: () =>
h(Icon, {
icon: "streamline-emojis:sunglasses",
width: 20,
}),
},
{ {
label: "退出账号", label: "退出账号",
key: "logout", key: "logout",
@@ -117,9 +108,6 @@ function clickMenu(name: string) {
query: { username: user.username }, query: { username: user.username },
}) })
break break
case "ranking":
router.push({ name: "ranking" })
break
case "logout": case "logout":
handleLogout() handleLogout()
break break

View File

@@ -37,7 +37,6 @@ const emit = defineEmits<{
select: [id: string] select: [id: string]
delete: [row: SubmissionOut, parentId: string] delete: [row: SubmissionOut, parentId: string]
"show-chain": [conversationId: string] "show-chain": [conversationId: string]
nominate: [row: SubmissionOut]
}>() }>()
const isChallenge = computed(() => props.row.task_type === TASK_TYPE.Challenge) const isChallenge = computed(() => props.row.task_type === TASK_TYPE.Challenge)
@@ -77,30 +76,6 @@ const subColumns = computed((): DataTableColumn<SubmissionOut>[] => [
) )
}, },
}, },
{
title: "排名",
key: "nominated",
width: 60,
render: (r: SubmissionOut) => {
if (r.username !== user.username) {
return r.nominated
? h("span", { style: { color: "#f0a020" } }, "🏅")
: null
}
return h(
NButton,
{
text: true,
title: r.nominated ? "已参与排名点击可重新提名" : "参与排名",
onClick: (e: Event) => {
e.stopPropagation()
emit("nominate", r)
},
},
() => (r.nominated ? "🏅" : ""),
)
},
},
...(isChallenge.value ...(isChallenge.value
? [ ? [
{ {

View File

@@ -31,6 +31,9 @@
<n-tab-pane name="chat" tab="AI 对话" display-directive="show"> <n-tab-pane name="chat" tab="AI 对话" display-directive="show">
<PromptPanel /> <PromptPanel />
</n-tab-pane> </n-tab-pane>
<n-tab-pane name="external" tab="手动提交" display-directive="show">
<ExternalAIPanel :task-id="taskId" />
</n-tab-pane>
</n-tabs> </n-tabs>
</div> </div>
<div class="challenge-content"> <div class="challenge-content">
@@ -72,6 +75,7 @@ import { useMessage } from "naive-ui"
import { Icon } from "@iconify/vue" import { Icon } from "@iconify/vue"
import { marked } from "marked" import { marked } from "marked"
import PromptPanel from "../components/PromptPanel.vue" import PromptPanel from "../components/PromptPanel.vue"
import ExternalAIPanel from "../components/ExternalAIPanel.vue"
import Preview from "../components/Preview.vue" import Preview from "../components/Preview.vue"
import { Challenge, Submission } from "../api" import { Challenge, Submission } from "../api"
import { html, css, js } from "../store/editors" import { html, css, js } from "../store/editors"

View File

@@ -195,7 +195,6 @@ const columns: DataTableColumn<SubmissionOut>[] = [
onSelect: (id) => getSubmissionByID(id), onSelect: (id) => getSubmissionByID(id),
onDelete: (r, parentId) => handleDelete(r, parentId), onDelete: (r, parentId) => handleDelete(r, parentId),
"onShow-chain": (id) => showChain(id), "onShow-chain": (id) => showChain(id),
onNominate: (r) => handleNominateChild(r, row.id),
}), }),
}, },
{ {
@@ -313,23 +312,6 @@ async function getSubmissionByID(id: string) {
submission.value = await Submission.get(id) submission.value = await Submission.get(id)
} }
async function handleNominateChild(row: SubmissionOut, parentId: string) {
await Submission.nominate(row.id)
const items = expandedData.get(parentId)
if (items) {
expandedData.set(
parentId,
items.map((d) => ({ ...d, nominated: d.id === row.id })),
)
}
data.value = data.value.map((d) => {
if (d.username === user.username && d.task_id === row.task_id) {
d.nominated = d.id === row.id
}
return d
})
}
function afterScore() { function afterScore() {
data.value = data.value.map((d) => { data.value = data.value.map((d) => {
if (d.id === submission.value.id) d.my_score = submission.value.my_score if (d.id === submission.value.id) d.my_score = submission.value.my_score

View File

@@ -78,7 +78,6 @@ export interface SubmissionOut {
my_score: number my_score: number
conversation_id?: string conversation_id?: string
flag?: FlagType flag?: FlagType
nominated: boolean
submit_count: number submit_count: number
created: Date created: Date
modified: Date modified: Date
@@ -107,14 +106,6 @@ export interface UserTag {
classname: string classname: string
} }
export interface TopSubmission {
submission_id: string
username: string
classname: string
score: number
rating_count: number
}
export interface SubmissionCountBucket { export interface SubmissionCountBucket {
count_1: number count_1: number
count_2: number count_2: number
@@ -142,12 +133,10 @@ export interface TaskStatsOut {
unsubmitted_count: number unsubmitted_count: number
average_score: number | null average_score: number | null
unrated_count: number unrated_count: number
nominated_count: number
unsubmitted_users: UserTag[] unsubmitted_users: UserTag[]
unrated_users: UserTag[] unrated_users: UserTag[]
submission_count_distribution: SubmissionCountBucket submission_count_distribution: SubmissionCountBucket
score_distribution: ScoreBucket score_distribution: ScoreBucket
top_submissions: TopSubmission[]
flag_stats: FlagStats flag_stats: FlagStats
classes: string[] classes: string[]
} }