diff --git a/apps/web/src/oj/ai/analysis.vue b/apps/web/src/oj/ai/analysis.vue index abc8d59..a8e366b 100644 --- a/apps/web/src/oj/ai/analysis.vue +++ b/apps/web/src/oj/ai/analysis.vue @@ -1,107 +1,343 @@ - - - - - 请选择时间范围,智能分析学习情况 - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + 智能分析 + + + ‹ + + + + › + + {{ classLabel(report.className) }} + + + + {{ label }} + + + {{ span }} + + + 看的提交 › + + + + + + + + + {{ LEARNING_PERIOD_LABELS[period] }}{{ viewing ? "他" : "" }}还没交过题 + {{ emptyHint }} + + + 看{{ label }} + + + + + 还有 {{ before.unsolved.length }} 道差一点的 + {{ + before.unsolved.map((item) => item.title).join("、") + }} + 去看看 › + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/src/oj/ai/components/AI.vue b/apps/web/src/oj/ai/components/AI.vue deleted file mode 100644 index 0a0cd75..0000000 --- a/apps/web/src/oj/ai/components/AI.vue +++ /dev/null @@ -1,88 +0,0 @@ - - - - - AI 帮你分析 - - - - - - - - - 开始分析 - - - - - - - - diff --git a/apps/web/src/oj/ai/components/AiSummary.vue b/apps/web/src/oj/ai/components/AiSummary.vue new file mode 100644 index 0000000..32b6bb6 --- /dev/null +++ b/apps/web/src/oj/ai/components/AiSummary.vue @@ -0,0 +1,175 @@ + + + + + AI 帮你分析 + + + + + + 展开全文 ▾ + {{ parseTime(createdAt, "M月D日") }} 生成{{ pinned ? " · 老师置顶" : "" }} + 重新分析 + + + + 正在看{{ username ? "他" : "你" }}的做题记录… + + + 让 AI 按这些数据,写一段{{ username ? "给他" : "给你" }}的小结和下一步建议 + + + AI 帮我分析 + + + {{ failed }} + + + + diff --git a/apps/web/src/oj/ai/components/AttemptsChart.vue b/apps/web/src/oj/ai/components/AttemptsChart.vue deleted file mode 100644 index 19afdee..0000000 --- a/apps/web/src/oj/ai/components/AttemptsChart.vue +++ /dev/null @@ -1,89 +0,0 @@ - - - - 通过前提交了几次,看有没有在死磕 - - - - - - - - diff --git a/apps/web/src/oj/ai/components/DifficultyGradeChart.vue b/apps/web/src/oj/ai/components/DifficultyGradeChart.vue deleted file mode 100644 index ae5926d..0000000 --- a/apps/web/src/oj/ai/components/DifficultyGradeChart.vue +++ /dev/null @@ -1,72 +0,0 @@ - - - - 看看简单题和难题各做了多少 - - - - - - - - diff --git a/apps/web/src/oj/ai/components/DurationChart.vue b/apps/web/src/oj/ai/components/DurationChart.vue deleted file mode 100644 index df23b14..0000000 --- a/apps/web/src/oj/ai/components/DurationChart.vue +++ /dev/null @@ -1,171 +0,0 @@ - - - - 做题量和提交质量的变化 - - - - - - - - diff --git a/apps/web/src/oj/ai/components/ErrorTypeChart.vue b/apps/web/src/oj/ai/components/ErrorTypeChart.vue deleted file mode 100644 index 43f67b4..0000000 --- a/apps/web/src/oj/ai/components/ErrorTypeChart.vue +++ /dev/null @@ -1,105 +0,0 @@ - - - - 没通过的提交都是什么错 - - - - - - - - diff --git a/apps/web/src/oj/ai/components/ErrorsCard.vue b/apps/web/src/oj/ai/components/ErrorsCard.vue new file mode 100644 index 0000000..b509288 --- /dev/null +++ b/apps/web/src/oj/ai/components/ErrorsCard.vue @@ -0,0 +1,121 @@ + + + + + + 错在哪没通过的 {{ total }} 次 + + + {{ row.name }} + + + + {{ row.count }} 次 + + {{ tip }} + + + + diff --git a/apps/web/src/oj/ai/components/FlowchartScoreChart.vue b/apps/web/src/oj/ai/components/FlowchartScoreChart.vue deleted file mode 100644 index 5eb2403..0000000 --- a/apps/web/src/oj/ai/components/FlowchartScoreChart.vue +++ /dev/null @@ -1,92 +0,0 @@ - - - - 每题的最高分和平均分 - - - - - - - - diff --git a/apps/web/src/oj/ai/components/Grade.vue b/apps/web/src/oj/ai/components/Grade.vue deleted file mode 100644 index 19ee246..0000000 --- a/apps/web/src/oj/ai/components/Grade.vue +++ /dev/null @@ -1,55 +0,0 @@ - - - - - - - - - - - - - - - - 等级计算说明 - 使用加权平均方法计算综合等级: - • S级 = 4分,A级 = 3分,B级 = 2分,C级 = 1分 - • 根据平均分数确定最终等级: - - S级:≥3.5分 - - A级:2.5-3.5分 - - B级:1.5-2.5分 - - C级:<1.5分 - - - - - - diff --git a/apps/web/src/oj/ai/components/Heatmap.vue b/apps/web/src/oj/ai/components/Heatmap.vue deleted file mode 100644 index 408280f..0000000 --- a/apps/web/src/oj/ai/components/Heatmap.vue +++ /dev/null @@ -1,258 +0,0 @@ - - - - 每格一周,激励持续学习 - - - - - - - {{ label.text }} - - - - - showTooltip(e, cell)" - @mouseleave="hideTooltip" - /> - - - - - {{ tooltip.date }} - - {{ tooltip.text }} - - - - - - - - - - diff --git a/apps/web/src/oj/ai/components/KnowledgeCard.vue b/apps/web/src/oj/ai/components/KnowledgeCard.vue new file mode 100644 index 0000000..453964b --- /dev/null +++ b/apps/web/src/oj/ai/components/KnowledgeCard.vue @@ -0,0 +1,112 @@ + + + + + + 知识点做过 {{ touched }} / {{ knowledge.length }} 个 + + + + {{ item.name }} + + {{ item.solved ? `做对 ${item.solved} 道` : item.tried ? "还没做对" : "还没做过" }} + + + + + + + diff --git a/apps/web/src/oj/ai/components/LessonDays.vue b/apps/web/src/oj/ai/components/LessonDays.vue new file mode 100644 index 0000000..637803b --- /dev/null +++ b/apps/web/src/oj/ai/components/LessonDays.vue @@ -0,0 +1,221 @@ + + + + + + 每节课{{ subtitle }} + + + + {{ dayLabel(day.day).date }} + {{ dayLabel(day.day).weekday }} + + 做对 {{ day.lesson.filter(isDone).length }}/{{ day.lesson.length }} + + + + + + + + 自己练 + + + 还有 {{ day.own.length - OWN_CAP }} 道 ▾ + + + + + + 做对 {{ day.lesson.filter(isDone).length }}/{{ day.lesson.length }} + + + + + 更早的 {{ days.length - DAY_CAP }} 天 ▾ + + + + + diff --git a/apps/web/src/oj/ai/components/Overview.vue b/apps/web/src/oj/ai/components/Overview.vue deleted file mode 100644 index e61e998..0000000 --- a/apps/web/src/oj/ai/components/Overview.vue +++ /dev/null @@ -1,53 +0,0 @@ - - - {{ durationLabel }}, - 你一共解决 - {{ aiStore.detailsData.solvedCount }} - 道题 - - ,并且参加 - {{ aiStore.detailsData.contestCount }} - 次比赛 - - ,综合评价给到 - - {{ greeting }} - - - 开始解题,看看你的学习能力吧! - - - - diff --git a/apps/web/src/oj/ai/components/ProblemChip.vue b/apps/web/src/oj/ai/components/ProblemChip.vue new file mode 100644 index 0000000..605ca00 --- /dev/null +++ b/apps/web/src/oj/ai/components/ProblemChip.vue @@ -0,0 +1,153 @@ + + + + + + + + {{ item.title }} + 交 {{ item.attempts }} 次 + 没做对 · {{ item.attempts }} 次 + 没交 + + + + {{ detail.filter(Boolean).join(" · ") }} + 看{{ you }}的提交 › + 去做 › + + + + + diff --git a/apps/web/src/oj/ai/components/SolvedTable.vue b/apps/web/src/oj/ai/components/SolvedTable.vue deleted file mode 100644 index 8e4ae5b..0000000 --- a/apps/web/src/oj/ai/components/SolvedTable.vue +++ /dev/null @@ -1,166 +0,0 @@ - - - - - - - - - - - - - - diff --git a/apps/web/src/oj/ai/components/StatStrip.vue b/apps/web/src/oj/ai/components/StatStrip.vue new file mode 100644 index 0000000..11513d8 --- /dev/null +++ b/apps/web/src/oj/ai/components/StatStrip.vue @@ -0,0 +1,109 @@ + + + + + + + {{ cell.big }} + {{ cell.unit }} + + {{ cell.sub }} + + + + + diff --git a/apps/web/src/oj/ai/components/TagTitle.vue b/apps/web/src/oj/ai/components/TagTitle.vue deleted file mode 100644 index 08ec43c..0000000 --- a/apps/web/src/oj/ai/components/TagTitle.vue +++ /dev/null @@ -1,17 +0,0 @@ - - - - 比赛 - {{ problem.contestTitle }} - - {{ problem.displayId }} {{ problem.title }} - - - diff --git a/apps/web/src/oj/ai/components/TagsChart.vue b/apps/web/src/oj/ai/components/TagsChart.vue deleted file mode 100644 index 9989edc..0000000 --- a/apps/web/src/oj/ai/components/TagsChart.vue +++ /dev/null @@ -1,76 +0,0 @@ - - - - 看看做过哪几类题 - - - - - - - - diff --git a/apps/web/src/oj/ai/components/TimeActivityHeatmap.vue b/apps/web/src/oj/ai/components/TimeActivityHeatmap.vue deleted file mode 100644 index c914c1f..0000000 --- a/apps/web/src/oj/ai/components/TimeActivityHeatmap.vue +++ /dev/null @@ -1,138 +0,0 @@ - - - - 基于全部提交,发现做题高峰时段 - - - - - - - - diff --git a/apps/web/src/oj/ai/components/UnsolvedCard.vue b/apps/web/src/oj/ai/components/UnsolvedCard.vue new file mode 100644 index 0000000..9e040dc --- /dev/null +++ b/apps/web/src/oj/ai/components/UnsolvedCard.vue @@ -0,0 +1,99 @@ + + + + + 差一点的 + + + + {{ item.displayId }}{{ item.title }} + + + 交了 {{ item.attempts }} 次 + · 最后一次{{ statusName(item.lastResult) }} + + + 看提交 › + + 接着做 + + + + + + diff --git a/apps/web/src/oj/ai/utils.ts b/apps/web/src/oj/ai/utils.ts new file mode 100644 index 0000000..945c9c9 --- /dev/null +++ b/apps/web/src/oj/ai/utils.ts @@ -0,0 +1,20 @@ +import type { LearningItem } from "@oj2/contract" +import { JUDGE_STATUS } from "utils/constants" + +const WEEKDAY = "日一二三四五六" + +/** `2026-09-18` → 9月18日 / 周五。日历日本身就是东八区的,直接拆字符串,不经 Date */ +export function dayLabel(day: string) { + const [year, month, date] = day.split("-").map(Number) as [number, number, number] + const weekday = new Date(Date.UTC(year, month - 1, date)).getUTCDay() + return { date: `${month}月${date}日`, weekday: `周${WEEKDAY[weekday]}` } +} + +export function statusName(result: number | null) { + if (result === null) return "" + return JUDGE_STATUS[result as keyof typeof JUDGE_STATUS]?.name ?? "" +} + +export function isDone(item: LearningItem) { + return item.status === "first" || item.status === "tries" +} diff --git a/apps/web/src/oj/api.ts b/apps/web/src/oj/api.ts index d81200a..b6ec85f 100644 --- a/apps/web/src/oj/api.ts +++ b/apps/web/src/oj/api.ts @@ -1,5 +1,7 @@ import { type AiAnalysisRecord, + type LearningPeriod, + type LearningReport, type AiHintFeedbackRequest, type Contest, type ContestAccess, @@ -35,10 +37,6 @@ import { type FlowchartScores, type FlowchartList, type FlowchartSubmission, - type AiDetail, - type DurationData, - type HeatmapItem, - type SolvedList, type ProblemSet, type ProblemSetClassView, type ProblemSetList, @@ -409,38 +407,20 @@ export function getTutorials(type: "python" | "c") { return api.get("tutorials", { params: { type } }) } -export function getAIDetailData(start: string, end: string, username?: string) { - return api.get("ai/detail", { params: { start, end, username } }) -} - -export function getAISolved( - start: string, - end: string, - offset: number, - limit: number, - username?: string, -) { - return api.get("ai/solved", { - params: { start, end, offset, limit, username }, +/** 智能分析整页的数据。username 只有老师传才生效,学生传了也只会拿到自己 */ +export function getLearningReport(period: LearningPeriod, username?: string) { + return api.get("ai/report", { + params: { period, username: username || undefined }, }) } -export function getAIDurationData(end: string, duration: string, username?: string) { - return api.get("ai/duration", { - params: { end, duration, username }, +/** 这个时间段上次生成的 AI 小结(老师钉住的优先),没有为 null */ +export function getLatestAnalysis(period: LearningPeriod, username?: string) { + return api.get("ai/analysis/latest", { + params: { period, username: username || undefined }, }) } -export function getAIHeatmapData(username?: string) { - return api.get("ai/heatmap", { - params: username ? { username } : {}, - }) -} - -export function getAIPinnedReport() { - return api.get("ai/pinned") -} - /** 学生评价一条 AI 提示。id 来自 /ai/hint 流的 done 事件,可以改票 */ export function submitHintFeedback(hintId: number, helpful: boolean) { return api.post(`ai/hint/${hintId}/feedback`, { diff --git a/apps/web/src/oj/rank/components/RankMe.vue b/apps/web/src/oj/rank/components/RankMe.vue index 078d43f..fa385f6 100644 --- a/apps/web/src/oj/rank/components/RankMe.vue +++ b/apps/web/src/oj/rank/components/RankMe.vue @@ -196,7 +196,7 @@ function threatNote(me: RankRow, behind: RankRow) { > - 看我的智能分析:哪类题卡得多、哪段时间最常做题 › + 看我的智能分析:每节课做得怎样、哪几道还差一点 › diff --git a/apps/web/src/oj/store/ai.ts b/apps/web/src/oj/store/ai.ts deleted file mode 100644 index 01ceff4..0000000 --- a/apps/web/src/oj/store/ai.ts +++ /dev/null @@ -1,203 +0,0 @@ -import type { DetailsData, DurationData, SolvedProblem } from "utils/types" -import { streamAI } from "utils/stream" -import { - getAIDetailData, - getAIDurationData, - getAIHeatmapData, - getAIPinnedReport, - getAISolved, -} from "../api" - -export const useAIStore = defineStore("ai", () => { - const duration = ref("months:6") - const targetUsername = ref("") - // 生成 AI 分析时要把同一段时间原样报给后端(数据由后端重算,前端只报范围) - const rangeStart = ref("") - const rangeEnd = ref("") - const durationData = ref([]) - const detailsData = reactive({ - username: "", - start: "", - end: "", - grade: "", - className: null, - tags: {}, - difficulty: {}, - contestCount: 0, - solvedCount: 0, - attempts: [], - flowcharts: [], - activity: [], - errors: [], - rankScope: "global", - }) - const heatmapData = ref<{ timestamp: number; value: number }[]>([]) - - // 解题明细走服务端分页:一个活跃学生一年几百道题,整份跟着 detail 一起下发没必要 - const solvedRows = ref([]) - const solvedTotal = ref(0) - const solvedPage = ref(1) - const solvedPageSize = ref(20) - - const loading = reactive({ - fetching: false, // 合并 details 和 duration 的 loading - ai: false, - heatmap: false, - solved: 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.start - detailsData.end = res.end - detailsData.grade = res.grade - detailsData.className = res.className - detailsData.tags = res.tags - detailsData.difficulty = res.difficulty - detailsData.contestCount = res.contestCount - detailsData.solvedCount = res.solvedCount - detailsData.attempts = res.attempts - detailsData.activity = res.activity - detailsData.errors = res.errors - detailsData.rankScope = res.rankScope - detailsData.flowcharts = res.flowcharts - } - - async function fetchDurationData(end: string, duration: string) { - const res = await getAIDurationData(end, duration, targetUsername.value || undefined) - durationData.value = res - } - - async function fetchSolved(page = solvedPage.value) { - if (!rangeStart.value || !rangeEnd.value) return - loading.solved = true - try { - const res = await getAISolved( - rangeStart.value, - rangeEnd.value, - (page - 1) * solvedPageSize.value, - solvedPageSize.value, - targetUsername.value || undefined, - ) - solvedRows.value = res.results - solvedTotal.value = res.total - solvedPage.value = page - } finally { - loading.solved = false - } - } - - async function fetchHeatmapData() { - loading.heatmap = true - const res = await getAIHeatmapData(targetUsername.value || undefined) - heatmapData.value = res - loading.heatmap = false - } - - async function fetchAnalysisData(start: string, end: string, duration: string) { - rangeStart.value = start - rangeEnd.value = end - // 换时间范围就回到第一页,否则停在第 5 页但新范围只有两页 - solvedPage.value = 1 - loading.fetching = true - try { - await Promise.all([ - fetchDetailsData(start, end), - fetchDurationData(end, duration), - fetchSolved(1), - ]) - } finally { - loading.fetching = false - } - } - - let aiController: AbortController | null = null - - // 不用 useAIStream:store 没有会卸载的作用域,而且 loading.ai 要和其它几个 - // loading 放在同一个 reactive 里给页面用 - async function fetchAIAnalysis() { - aiController?.abort() - const controller = new AbortController() - aiController = controller - - loading.ai = true - mdContent.value = "" - - try { - await streamAI( - "ai/analysis", - { - start: rangeStart.value, - end: rangeEnd.value, - duration: duration.value, - username: targetUsername.value || undefined, - }, - { - signal: controller.signal, - onDelta(content) { - loading.ai = false - mdContent.value += content - }, - }, - ) - } catch (error) { - if (controller.signal.aborted) return - mdContent.value = `生成失败:${(error as Error).message}` - } finally { - if (aiController === controller) { - aiController = null - loading.ai = false - } - } - } - - async function fetchPinnedReport() { - const res = await getAIPinnedReport() - pinnedReport.value = res - } - - async function simulatePinnedStream() { - if (!pinnedReport.value) return - const text = pinnedReport.value.analysis - mdContent.value = "" - const CHUNK = 6 - const DELAY = 18 - await new Promise((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, - fetchSolved, - solvedRows, - solvedTotal, - solvedPage, - solvedPageSize, - fetchAIAnalysis, - fetchPinnedReport, - simulatePinnedStream, - durationData, - detailsData, - heatmapData, - duration, - targetUsername, - loading, - mdContent, - pinnedReport, - } -}) diff --git a/apps/web/src/oj/user/index.vue b/apps/web/src/oj/user/index.vue index b3a4e73..a646e38 100644 --- a/apps/web/src/oj/user/index.vue +++ b/apps/web/src/oj/user/index.vue @@ -171,7 +171,7 @@ onMounted(() => { @click=" router.push({ name: 'ai', - query: { username: profile.user.username, duration: 'months:6' }, + query: { username: profile.user.username }, }) " > diff --git a/apps/web/src/utils/types.ts b/apps/web/src/utils/types.ts index 5d898da..53eb81a 100644 --- a/apps/web/src/utils/types.ts +++ b/apps/web/src/utils/types.ts @@ -9,7 +9,6 @@ import type { UserProfile, EmbeddedSubmission as ContractEmbeddedSubmission, Message as ContractMessage, - Grade, ProblemDifficulty, JudgeStatus, AstRules, @@ -465,16 +464,7 @@ export type Exercise = Omit & { | ExerciseGroupData } -export type { - DurationData, - FlowchartSummary, - SolvedProblem, - SolvedList, - AiDetail as DetailsData, -} from "@oj2/contract" - -// 评级。空串是「无评级」,后端在没有可用数据时真会下发,见契约 gradeSchema -export type { Grade, ProblemDifficulty } +export type { ProblemDifficulty } // ==================== 成就相关类型 ====================
让 AI 按这些数据,写一段{{ username ? "给他" : "给你" }}的小结和下一步建议
{{ failed }}