feat(阶段1): 搬入 ojnext 为 apps/web,未改业务代码
This commit is contained in:
1 parent
3c975e85ee
commit
ae1fb329b5
258 files changed
+40490
-91
No files matched your search
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
Reference in new issue
Block a user