feat(阶段1): 搬入 ojnext 为 apps/web,未改业务代码

This commit is contained in:
xuyue committed 2026-08-06 21:18:16 -06:00
1 parent 3c975e85ee
commit ae1fb329b5
258 files changed
+40490 -91

No files matched your search

+206
View File
@@ -0,0 +1,206 @@
import type { DetailsData, DurationData } from "utils/types"
import { consumeJSONEventStream } from "utils/stream"
import {
getAIDetailData,
getAIDurationData,
getAIHeatmapData,
getAIPinnedReport,
} from "../api"
import { getCSRFToken } from "utils/functions"
export const useAIStore = defineStore("ai", () => {
const duration = ref("months:6")
const targetUsername = ref("")
const durationData = ref<DurationData[]>([])
const detailsData = reactive<DetailsData>({
start: "",
end: "",
grade: "B",
class_name: "",
tags: {},
difficulty: {},
contest_count: 0,
solved: [],
flowcharts: [],
})
const heatmapData = ref<{ timestamp: number; value: number }[]>([])
const loading = reactive({
fetching: false, // 合并 details 和 duration 的 loading
ai: false,
heatmap: false,
})
const mdContent = ref("")
const pinnedReport = ref<{ analysis: string } | null>(null)
async function fetchDetailsData(start: string, end: string) {
const res = await getAIDetailData(
start,
end,
targetUsername.value || undefined,
)
detailsData.start = res.data.start
detailsData.end = res.data.end
detailsData.solved = res.data.solved
detailsData.grade = res.data.grade
detailsData.class_name = res.data.class_name
detailsData.tags = res.data.tags
detailsData.difficulty = res.data.difficulty
detailsData.contest_count = res.data.contest_count
detailsData.flowcharts = res.data.flowcharts
}
async function fetchDurationData(end: string, duration: string) {
const res = await getAIDurationData(
end,
duration,
targetUsername.value || undefined,
)
durationData.value = res.data
}
async function fetchHeatmapData() {
loading.heatmap = true
const res = await getAIHeatmapData(targetUsername.value || undefined)
heatmapData.value = res.data
loading.heatmap = false
}
async function fetchAnalysisData(
start: string,
end: string,
duration: string,
) {
loading.fetching = true
try {
await Promise.all([
fetchDetailsData(start, end),
fetchDurationData(end, duration),
])
} finally {
loading.fetching = false
}
}
let aiController: AbortController | null = null
async function fetchAIAnalysis() {
if (aiController) {
aiController.abort()
}
const controller = new AbortController()
aiController = controller
loading.ai = true
mdContent.value = ""
const headers: Record<string, string> = {
"Content-Type": "application/json",
}
const csrfToken = getCSRFToken()
if (csrfToken) {
headers["X-CSRFToken"] = csrfToken
}
try {
const response = await fetch("/api/ai/analysis", {
method: "POST",
headers,
body: JSON.stringify({
details: detailsData,
duration: durationData.value,
}),
signal: controller.signal,
})
if (!response.ok) {
throw new Error("AI 分析生成失败")
}
let hasStarted = false
await consumeJSONEventStream(response, {
signal: controller.signal,
onEvent(event) {
if (event === "end" && !hasStarted) {
loading.ai = false
}
},
onMessage(payload) {
const parsed = payload as {
type?: string
content?: string
message?: string
}
if (parsed.type === "delta" && parsed.content) {
if (!hasStarted) {
hasStarted = true
loading.ai = false
}
mdContent.value += parsed.content
} else if (parsed.type === "error") {
throw new Error(parsed.message || "AI 服务异常")
} else if (parsed.type === "done" && !hasStarted) {
loading.ai = false
}
},
})
} catch (error: any) {
if (controller.signal.aborted) {
return
}
console.error("生成 AI 分析失败", error)
const message = error?.message || "生成失败,请稍后再试"
mdContent.value = `生成失败:${message}`
} finally {
if (aiController === controller) {
aiController = null
loading.ai = false
}
}
}
async function fetchPinnedReport() {
const res = await getAIPinnedReport()
pinnedReport.value = res.data
}
async function simulatePinnedStream() {
if (!pinnedReport.value) return
const text = pinnedReport.value.analysis
mdContent.value = ""
const CHUNK = 6
const DELAY = 18
await new Promise<void>((resolve) => {
let i = 0
function step() {
if (i >= text.length) {
resolve()
return
}
mdContent.value += text.slice(i, i + CHUNK)
i += CHUNK
setTimeout(step, DELAY)
}
step()
})
}
return {
fetchAnalysisData,
fetchHeatmapData,
fetchAIAnalysis,
fetchPinnedReport,
simulatePinnedStream,
durationData,
detailsData,
heatmapData,
duration,
targetUsername,
loading,
mdContent,
pinnedReport,
}
})
+94
View File
@@ -0,0 +1,94 @@
import { defineStore } from "pinia"
import { STORAGE_KEY } from "utils/constants"
import storage from "utils/storage"
import type { Code, LANGUAGE } from "utils/types"
/**
* 代码编辑器状态管理 Store
* 管理全局的代码、输入、输出状态
*/
export const useCodeStore = defineStore("code", () => {
// ==================== 状态 ====================
const code = reactive<Code>({
value: "",
language: storage.get(STORAGE_KEY.LANGUAGE) || "Python3",
})
const input = ref("")
const output = ref("")
// ==================== 计算属性 ====================
const isEmpty = computed(() => code.value.trim() === "")
// ==================== 操作 ====================
/**
* 设置代码内容
*/
function setCode(value: string) {
code.value = value
}
/**
* 设置编程语言
*/
function setLanguage(language: LANGUAGE) {
code.language = language
storage.set(STORAGE_KEY.LANGUAGE, language)
}
/**
* 设置输入
*/
function setInput(value: string) {
input.value = value
}
/**
* 设置输出
*/
function setOutput(value: string) {
output.value = value
}
/**
* 重置所有状态
*/
function reset() {
code.value = ""
input.value = ""
output.value = ""
}
/**
* 清空输出
*/
function clearOutput() {
output.value = ""
}
// 监听语言变化,保存到本地存储
watch(
() => code.language,
(newLanguage) => {
storage.set(STORAGE_KEY.LANGUAGE, newLanguage)
},
)
return {
// 状态
code,
input,
output,
// 计算属性
isEmpty,
// 操作
setCode,
setLanguage,
setInput,
setOutput,
reset,
clearOutput,
}
})
+116
View File
@@ -0,0 +1,116 @@
import { formatISO, getTime, parseISO } from "date-fns"
import { useUserStore } from "shared/store/user"
import { ContestStatus, ContestType } from "utils/constants"
import { duration } from "utils/functions"
import type { Contest, Problem } from "utils/types"
import {
checkContestPassword,
getContest,
getContestAccess,
getContestProblems,
} from "../api"
export const useContestStore = defineStore("contest", () => {
const userStore = useUserStore()
const [access, toggleAccess] = useToggle(false)
const contest = ref<Contest | null>(null)
const problems = ref<Problem[]>([])
const now = ref(0)
let timer = 0
const contestStatus = computed<ContestStatus>(() => {
if (!contest.value) return ContestStatus.initial
const start = getTime(parseISO(contest.value.start_time.toString()))
const end = getTime(parseISO(contest.value.end_time.toString()))
if (start > now.value) {
return ContestStatus.not_started
} else if (end < now.value) {
return ContestStatus.finished
} else {
return ContestStatus.underway
}
})
const countdown = computed(() => {
if (contestStatus.value === ContestStatus.finished) {
return "已结束"
} else if (contestStatus.value === ContestStatus.not_started) {
const d = duration(formatISO(now.value), contest.value!.start_time, true)
return "距离比赛开始 " + d
} else {
const d = duration(formatISO(now.value), contest.value!.end_time, true)
return "距离比赛结束 " + d
}
})
const isContestAdmin = computed(
() =>
userStore.isSuperAdmin ||
(userStore.isAuthed &&
contest.value?.created_by.id === userStore.user!.id),
)
const isPrivate = computed(
() => contest.value!.contest_type === ContestType.private,
)
async function init(contestID: string) {
problems.value = []
const res = await getContest(contestID)
contest.value = res.data
now.value = getTime(parseISO(res.data.now))
if (contestStatus.value !== ContestStatus.finished) {
timer = setInterval(() => {
now.value = now.value + 1000
}, 1000)
}
if (contest.value?.contest_type === ContestType.private) {
const res = await getContestAccess(contestID)
toggleAccess(res.data.access)
}
_getProblems(contestID)
}
function clear() {
contest.value = null
problems.value = []
toggleAccess(false)
now.value = 0
if (timer) clearInterval(timer)
}
async function checkPassword(contestID: string, password: string) {
try {
const res = await checkContestPassword(contestID, password)
toggleAccess(res.data)
if (res.data) {
_getProblems(contestID)
}
} catch (err) {
toggleAccess(false)
}
}
async function _getProblems(contestID: string) {
try {
problems.value = await getContestProblems(contestID)
} catch (err) {
problems.value = []
toggleAccess(false)
}
}
return {
contest,
contestStatus,
isContestAdmin,
access,
problems,
isPrivate,
countdown,
init,
clear,
checkPassword,
}
})
+34
View File
@@ -0,0 +1,34 @@
import { defineStore } from "pinia"
import type { LANGUAGE, Problem } from "utils/types"
export const useProblemStore = defineStore("problem", () => {
const problem = ref<Problem | null>(null)
const route = useRoute()
const failCount = ref(0)
const languages = computed<LANGUAGE[]>(() => {
if (route.name === "problem" && problem.value?.allow_flowchart) {
return ["Flowchart", ...problem.value?.languages]
}
return problem.value?.languages ?? []
})
function incrementFailCount() {
failCount.value++
}
watch(
() => problem.value?.id,
() => {
failCount.value = 0
},
)
return {
problem,
failCount,
languages,
incrementFailCount,
}
})