feat(智能分析): 解题表格改服务端分页,/ai/detail 不再下发整份 solved
Some checks failed
Deploy / deploy (push) Has been cancelled
Some checks failed
Deploy / deploy (push) Has been cancelled
原来 /ai/detail 把区间内做出来的每道题连排名带等级整份下发,一个活跃学生一年几百道, 一次请求就是几百 KB,而表格一屏只看得到二十行。 拆成两支: - GET /ai/detail 只留聚合(solvedCount、difficulty、tags、attempts、activity、errors…) - GET /ai/solved?offset&limit 按首次通过时间升序分页给逐题明细 排名只跟当前这批题有关,所以分页那支只算一页的 problemIds,不必把整年算一遍。抽出 firstAcQuery / buildSolved / listSolved 三个函数,detail 和分页两边共用。 前端相应改动: - 难度分布改读后端的 difficulty 聚合(本来就在下发,之前是从逐题列表里现数的) - 几次做对改读新的 attempts 数组(每道题的尝试次数);tooltip 里的题名去掉了 —— 明细是分页拿的,不该为了一个 tooltip 把全量拉回来 - Overview 用 solvedCount - SolvedTable 的代码提交那张走 remote 分页,流程图那张仍是本地分页(一个 OJ 的 流程图题就那么几道,全量下发没问题);换时间范围回到第一页 - 表格原来是 max-height 1500 的滚动区,现在由分页兜住,滚动条和分页器不再并存 /ai/analysis 的 prompt 仍然带逐题明细(少了它模型只剩聚合数字),但顺手卡了 200 条 —— 以前是整份 solved 无上限塞进去,题做得多的学生一次调用能顶好几倍 token。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LZuPwqDmLEiK9zgQ9z9sVn
This commit is contained in:
@@ -28,8 +28,8 @@ ChartJS.register(CategoryScale, LinearScale, BarElement, Tooltip)
|
||||
const aiStore = useAIStore()
|
||||
const { chartKey } = useChartTheme()
|
||||
|
||||
// solved[].attempts 是后端算的「到首次通过为止的提交次数」,分档放在前端,
|
||||
// 想调档位不用改契约
|
||||
// detailsData.attempts 是后端算的「每道题到首次通过为止的提交次数」,分档放在前端,
|
||||
// 想调档位不用改契约。逐题的题名不在这里 —— 明细是分页拿的,别为了 tooltip 把全量拉回来
|
||||
const BUCKETS = [
|
||||
{ label: "一次过", color: "#18A058", min: 1, max: 1 },
|
||||
{ label: "2-3 次", color: "#2080F0", min: 2, max: 3 },
|
||||
@@ -40,20 +40,20 @@ const BUCKETS = [
|
||||
const buckets = computed(() =>
|
||||
BUCKETS.map((bucket) => ({
|
||||
...bucket,
|
||||
problems: aiStore.detailsData.solved.filter(
|
||||
(item) => item.attempts >= bucket.min && item.attempts <= bucket.max,
|
||||
),
|
||||
count: aiStore.detailsData.attempts.filter(
|
||||
(value) => value >= bucket.min && value <= bucket.max,
|
||||
).length,
|
||||
})),
|
||||
)
|
||||
|
||||
const show = computed(() => aiStore.detailsData.solved.length > 0)
|
||||
const show = computed(() => aiStore.detailsData.attempts.length > 0)
|
||||
|
||||
const data = computed(() => ({
|
||||
labels: buckets.value.map((bucket) => bucket.label),
|
||||
datasets: [
|
||||
{
|
||||
label: "题目数量",
|
||||
data: buckets.value.map((bucket) => bucket.problems.length),
|
||||
data: buckets.value.map((bucket) => bucket.count),
|
||||
backgroundColor: buckets.value.map((bucket) => bucket.color),
|
||||
borderColor: buckets.value.map((bucket) => bucket.color),
|
||||
borderWidth: 1,
|
||||
@@ -78,14 +78,11 @@ const options = computed<ChartOptions<"bar">>(() => ({
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (ctx) => `${ctx.parsed.y} 道题`,
|
||||
afterLabel: (ctx) => {
|
||||
const titles = buckets.value[ctx.dataIndex]!.problems.map(
|
||||
(item) => `${item.problem.displayId} ${item.problem.title}`,
|
||||
)
|
||||
if (!titles.length) return ""
|
||||
if (titles.length <= 5) return titles
|
||||
return [...titles.slice(0, 4), `… 还有 ${titles.length - 4} 道`]
|
||||
label: (ctx) => {
|
||||
const value = Number(ctx.parsed.y)
|
||||
const total = aiStore.detailsData.attempts.length
|
||||
const percent = total ? (value / total) * 100 : 0
|
||||
return `${value} 道题(占 ${percent.toFixed(0)}%)`
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -34,16 +34,13 @@ const difficultyOrder = ["简单", "中等", "困难"]
|
||||
const difficultyColors = ["#18A058", "#F0A020", "#D03050"]
|
||||
|
||||
// 只按难度分。原来还往里叠了一层 S/A/B/C 等级,3×4 十二个格子,
|
||||
// 学生两个月做十来道题的话大部分格子恒为 0;等级信息在下面的解题表格里逐题都有
|
||||
// 学生两个月做十来道题的话大部分格子恒为 0;等级信息在下面的解题表格里逐题都有。
|
||||
// 直接读后端的 difficulty 聚合 —— 逐题列表现在是分页拿的,前端手上没有全量
|
||||
const counts = computed(() =>
|
||||
difficultyOrder.map(
|
||||
(name) =>
|
||||
aiStore.detailsData.solved.filter((item) => item.difficulty === name)
|
||||
.length,
|
||||
),
|
||||
difficultyOrder.map((name) => aiStore.detailsData.difficulty[name] ?? 0),
|
||||
)
|
||||
|
||||
const show = computed(() => aiStore.detailsData.solved.length > 0)
|
||||
const show = computed(() => aiStore.detailsData.solvedCount > 0)
|
||||
|
||||
const data = computed(() => ({
|
||||
labels: difficultyOrder,
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
<n-alert
|
||||
:show-icon="false"
|
||||
type="success"
|
||||
v-if="aiStore.detailsData.solved.length"
|
||||
v-if="aiStore.detailsData.solvedCount"
|
||||
>
|
||||
<span>{{ durationLabel }},</span>
|
||||
<span>你一共解决 </span>
|
||||
<b class="charming"> {{ aiStore.detailsData.solved.length }} </b>
|
||||
<b class="charming"> {{ aiStore.detailsData.solvedCount }} </b>
|
||||
<span> 道题</span>
|
||||
<span v-if="aiStore.detailsData.contestCount > 0">
|
||||
,并且参加
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
<template>
|
||||
<n-tabs animated v-if="submissions.length && flowcharts.length">
|
||||
<n-tabs animated v-if="hasSolved && flowcharts.length">
|
||||
<n-tab-pane name="代码提交">
|
||||
<n-data-table
|
||||
remote
|
||||
striped
|
||||
:data="submissions"
|
||||
:data="aiStore.solvedRows"
|
||||
:columns="columns"
|
||||
:max-height="isDesktop ? 1500 : 500"
|
||||
:loading="aiStore.loading.solved"
|
||||
:pagination="solvedPagination"
|
||||
@update:page="aiStore.fetchSolved"
|
||||
/>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="流程图提交">
|
||||
@@ -13,23 +16,26 @@
|
||||
striped
|
||||
:data="flowcharts"
|
||||
:columns="flowchartsColumns"
|
||||
:max-height="isDesktop ? 1500 : 500"
|
||||
:pagination="paginationFor(flowcharts)"
|
||||
/>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
<n-data-table
|
||||
v-else-if="submissions.length"
|
||||
v-else-if="hasSolved"
|
||||
remote
|
||||
striped
|
||||
:data="submissions"
|
||||
:data="aiStore.solvedRows"
|
||||
:columns="columns"
|
||||
:max-height="isDesktop ? 1500 : 500"
|
||||
:loading="aiStore.loading.solved"
|
||||
:pagination="solvedPagination"
|
||||
@update:page="aiStore.fetchSolved"
|
||||
/>
|
||||
<n-data-table
|
||||
v-else-if="flowcharts.length"
|
||||
striped
|
||||
:data="flowcharts"
|
||||
:columns="flowchartsColumns"
|
||||
:max-height="isDesktop ? 1500 : 500"
|
||||
:pagination="paginationFor(flowcharts)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -38,16 +44,30 @@ import { NButton, NTooltip } from "naive-ui"
|
||||
import TagTitle from "./TagTitle.vue"
|
||||
import type { FlowchartSummary, SolvedProblem } from "utils/types"
|
||||
import { useAIStore } from "oj/store/ai"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { parseTime } from "utils/functions"
|
||||
|
||||
const router = useRouter()
|
||||
const aiStore = useAIStore()
|
||||
|
||||
const { isDesktop } = useBreakpoints()
|
||||
|
||||
const submissions = computed(() => aiStore.detailsData.solved)
|
||||
const hasSolved = computed(() => aiStore.detailsData.solvedCount > 0)
|
||||
const flowcharts = computed(() => aiStore.detailsData.flowcharts)
|
||||
|
||||
// 代码提交这张走服务端分页:翻页只拉一页,不把整年的题一次发给浏览器。
|
||||
// 流程图那张仍然是全量下发的(一个 OJ 的流程图题就那么几道),本地分页即可。
|
||||
// 行数不够一页时不显示分页器,和 ExerciseAttempts.vue 一致
|
||||
const solvedPagination = computed(() =>
|
||||
aiStore.solvedTotal > aiStore.solvedPageSize
|
||||
? {
|
||||
page: aiStore.solvedPage,
|
||||
pageSize: aiStore.solvedPageSize,
|
||||
itemCount: aiStore.solvedTotal,
|
||||
}
|
||||
: false,
|
||||
)
|
||||
function paginationFor(rows: unknown[]) {
|
||||
const pageSize = aiStore.solvedPageSize
|
||||
return rows.length > pageSize ? { pageSize } : false
|
||||
}
|
||||
const columns: DataTableColumn<SolvedProblem>[] = [
|
||||
{
|
||||
title: "完成的题目",
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
type DurationData,
|
||||
type HeatmapItem,
|
||||
type LoginSummary,
|
||||
type SolvedList,
|
||||
type ProblemSet,
|
||||
type ProblemSetBadge,
|
||||
type ProblemSetList,
|
||||
@@ -302,6 +303,18 @@ export function getAIDetailData(start: string, end: string, username?: string) {
|
||||
return api.get<AiDetail>("ai/detail", { params: { start, end, username } })
|
||||
}
|
||||
|
||||
export function getAISolved(
|
||||
start: string,
|
||||
end: string,
|
||||
offset: number,
|
||||
limit: number,
|
||||
username?: string,
|
||||
) {
|
||||
return api.get<SolvedList>("ai/solved", {
|
||||
params: { start, end, offset, limit, username },
|
||||
})
|
||||
}
|
||||
|
||||
export function getAIDurationData(
|
||||
end: string,
|
||||
duration: string,
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { DetailsData, DurationData } from "utils/types"
|
||||
import type { DetailsData, DurationData, SolvedProblem } from "utils/types"
|
||||
import { aiStreamError, consumeJSONEventStream } from "utils/stream"
|
||||
import {
|
||||
getAIDetailData,
|
||||
getAIDurationData,
|
||||
getAIHeatmapData,
|
||||
getAIPinnedReport,
|
||||
getAISolved,
|
||||
} from "../api"
|
||||
|
||||
export const useAIStore = defineStore("ai", () => {
|
||||
@@ -23,7 +24,8 @@ export const useAIStore = defineStore("ai", () => {
|
||||
tags: {},
|
||||
difficulty: {},
|
||||
contestCount: 0,
|
||||
solved: [],
|
||||
solvedCount: 0,
|
||||
attempts: [],
|
||||
flowcharts: [],
|
||||
activity: [],
|
||||
errors: [],
|
||||
@@ -31,10 +33,17 @@ export const useAIStore = defineStore("ai", () => {
|
||||
})
|
||||
const heatmapData = ref<{ timestamp: number; value: number }[]>([])
|
||||
|
||||
// 解题明细走服务端分页:一个活跃学生一年几百道题,整份跟着 detail 一起下发没必要
|
||||
const solvedRows = ref<SolvedProblem[]>([])
|
||||
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("")
|
||||
@@ -48,12 +57,13 @@ export const useAIStore = defineStore("ai", () => {
|
||||
)
|
||||
detailsData.start = res.start
|
||||
detailsData.end = res.end
|
||||
detailsData.solved = res.solved
|
||||
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
|
||||
@@ -69,6 +79,25 @@ export const useAIStore = defineStore("ai", () => {
|
||||
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)
|
||||
@@ -83,11 +112,14 @@ export const useAIStore = defineStore("ai", () => {
|
||||
) {
|
||||
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
|
||||
@@ -196,6 +228,11 @@ export const useAIStore = defineStore("ai", () => {
|
||||
return {
|
||||
fetchAnalysisData,
|
||||
fetchHeatmapData,
|
||||
fetchSolved,
|
||||
solvedRows,
|
||||
solvedTotal,
|
||||
solvedPage,
|
||||
solvedPageSize,
|
||||
fetchAIAnalysis,
|
||||
fetchPinnedReport,
|
||||
simulatePinnedStream,
|
||||
|
||||
Reference in New Issue
Block a user