@@ -102,6 +102,7 @@ async function buildDetail(user: AuthUser, start: string, end: string) {
|
|||||||
const problemIds = firstAc.map((item) => item.problemId)
|
const problemIds = firstAc.map((item) => item.problemId)
|
||||||
if (!problemIds.length) return aiDetailSchema.parse({
|
if (!problemIds.length) return aiDetailSchema.parse({
|
||||||
user: user.username, className: user.className, start, end, solved: [], flowcharts: [], grade: "", tags: {}, difficulty: {}, contestCount: 0,
|
user: user.username, className: user.className, start, end, solved: [], flowcharts: [], grade: "", tags: {}, difficulty: {}, contestCount: 0,
|
||||||
|
rankScope: "global",
|
||||||
})
|
})
|
||||||
const classUsers = user.className ? await db.select({ id: schema.user.id }).from(schema.user).where(eq(schema.user.className, user.className)) : []
|
const classUsers = user.className ? await db.select({ id: schema.user.id }).from(schema.user).where(eq(schema.user.className, user.className)) : []
|
||||||
const scopeIds = classUsers.length > 1 ? classUsers.map((item) => item.id) : null
|
const scopeIds = classUsers.length > 1 ? classUsers.map((item) => item.id) : null
|
||||||
@@ -166,6 +167,7 @@ async function buildDetail(user: AuthUser, start: string, end: string) {
|
|||||||
user: user.username, className: user.className, start, end, solved, flowcharts,
|
user: user.username, className: user.className, start, end, solved, flowcharts,
|
||||||
grade: averageGrade(solved.map((item) => item.grade)), tags: topTags, difficulty,
|
grade: averageGrade(solved.map((item) => item.grade)), tags: topTags, difficulty,
|
||||||
contestCount: new Set(solved.flatMap((item) => item.problem.contestId ?? [])).size,
|
contestCount: new Set(solved.flatMap((item) => item.problem.contestId ?? [])).size,
|
||||||
|
rankScope: scopeIds ? "class" : "global",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,7 +260,8 @@ async function buildDuration(user: AuthUser, endText: string, duration: string)
|
|||||||
const from = bucket.start.getTime()
|
const from = bucket.start.getTime()
|
||||||
const to = bucket.end.getTime()
|
const to = bucket.end.getTime()
|
||||||
const inRange = rows.filter((row) => row.time >= from && row.time <= to)
|
const inRange = rows.filter((row) => row.time >= from && row.time <= to)
|
||||||
const solved = [...new Set(inRange.filter((row) => accepted.includes(row.result)).map((row) => row.problemId))]
|
const acceptedRows = inRange.filter((row) => accepted.includes(row.result))
|
||||||
|
const solved = [...new Set(acceptedRows.map((row) => row.problemId))]
|
||||||
return durationDataSchema.parse({
|
return durationDataSchema.parse({
|
||||||
unit: config.unit,
|
unit: config.unit,
|
||||||
index: config.count - 1 - index,
|
index: config.count - 1 - index,
|
||||||
@@ -266,6 +269,7 @@ async function buildDuration(user: AuthUser, endText: string, duration: string)
|
|||||||
end: bucket.end.toISOString(),
|
end: bucket.end.toISOString(),
|
||||||
grade: solved.length ? bucketGrade(solved, from, to) : "",
|
grade: solved.length ? bucketGrade(solved, from, to) : "",
|
||||||
problemCount: solved.length,
|
problemCount: solved.length,
|
||||||
|
acceptedCount: acceptedRows.length,
|
||||||
submissionCount: inRange.length,
|
submissionCount: inRange.length,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
</n-text>
|
</n-text>
|
||||||
</template>
|
</template>
|
||||||
<div style="height: 300px">
|
<div style="height: 300px">
|
||||||
<Bar :data="data" :options="options" />
|
<Bar :key="chartKey" :data="data" :options="options" />
|
||||||
</div>
|
</div>
|
||||||
</n-card>
|
</n-card>
|
||||||
</template>
|
</template>
|
||||||
@@ -23,11 +23,13 @@ import {
|
|||||||
Legend,
|
Legend,
|
||||||
} from "chart.js"
|
} from "chart.js"
|
||||||
import { useAIStore } from "oj/store/ai"
|
import { useAIStore } from "oj/store/ai"
|
||||||
|
import { useChartTheme } from "shared/composables/chartTheme"
|
||||||
import type { Grade } from "utils/types"
|
import type { Grade } from "utils/types"
|
||||||
|
|
||||||
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend)
|
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend)
|
||||||
|
|
||||||
const aiStore = useAIStore()
|
const aiStore = useAIStore()
|
||||||
|
const { chartKey } = useChartTheme()
|
||||||
|
|
||||||
// 难度和等级的顺序(后端返回的是中文)
|
// 难度和等级的顺序(后端返回的是中文)
|
||||||
const difficultyOrder = ["简单", "中等", "困难"]
|
const difficultyOrder = ["简单", "中等", "困难"]
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<n-text depth="3" style="font-size: 12px"> 全面评估学习情况 </n-text>
|
<n-text depth="3" style="font-size: 12px"> 全面评估学习情况 </n-text>
|
||||||
</template>
|
</template>
|
||||||
<div class="chart">
|
<div class="chart">
|
||||||
<Chart type="bar" :data="data" :options="options" />
|
<Chart type="bar" :key="chartKey" :data="data" :options="options" />
|
||||||
</div>
|
</div>
|
||||||
</n-card>
|
</n-card>
|
||||||
</template>
|
</template>
|
||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
LineController,
|
LineController,
|
||||||
} from "chart.js"
|
} from "chart.js"
|
||||||
import { useAIStore } from "oj/store/ai"
|
import { useAIStore } from "oj/store/ai"
|
||||||
|
import { useChartTheme } from "shared/composables/chartTheme"
|
||||||
import { parseTime } from "utils/functions"
|
import { parseTime } from "utils/functions"
|
||||||
|
|
||||||
// 注册混合图表(Bar + Line)所需的 Chart.js 组件
|
// 注册混合图表(Bar + Line)所需的 Chart.js 组件
|
||||||
@@ -42,6 +43,7 @@ ChartJS.register(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const aiStore = useAIStore()
|
const aiStore = useAIStore()
|
||||||
|
const { chartKey } = useChartTheme()
|
||||||
|
|
||||||
const gradeOrder = ["C", "B", "A", "S"] as const
|
const gradeOrder = ["C", "B", "A", "S"] as const
|
||||||
|
|
||||||
@@ -89,7 +91,6 @@ const data = computed<ChartData<"bar" | "line">>(() => {
|
|||||||
spanGaps: false,
|
spanGaps: false,
|
||||||
tension: 0.4,
|
tension: 0.4,
|
||||||
yAxisID: "y1",
|
yAxisID: "y1",
|
||||||
barThickness: 10,
|
|
||||||
order: 1,
|
order: 1,
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
pointRadius: 4,
|
pointRadius: 4,
|
||||||
@@ -128,8 +129,11 @@ const options = computed<ChartOptions<"bar" | "line">>(() => {
|
|||||||
max: gradeOrder.length - 0.5,
|
max: gradeOrder.length - 0.5,
|
||||||
ticks: {
|
ticks: {
|
||||||
stepSize: 1,
|
stepSize: 1,
|
||||||
|
// 轴是 -0.5 ~ 3.5,chart.js 生成的刻度值就是 -0.5/0.5/1.5/2.5/3.5,
|
||||||
|
// 直接拿去索引 gradeOrder 全是 undefined —— 右轴的 S/A/B/C 一个都不会显示。
|
||||||
|
// 四舍五入到整数档再取,和 ProgressChart 的写法一致
|
||||||
callback: (v) => {
|
callback: (v) => {
|
||||||
const idx = Number(v)
|
const idx = Math.round(Number(v))
|
||||||
return gradeOrder[idx] || ""
|
return gradeOrder[idx] || ""
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -167,24 +171,15 @@ const options = computed<ChartOptions<"bar" | "line">>(() => {
|
|||||||
}
|
}
|
||||||
return `${dsLabel}: ${ctx.formattedValue}`
|
return `${dsLabel}: ${ctx.formattedValue}`
|
||||||
},
|
},
|
||||||
|
// AC 率直接读该周期的 acceptedCount / submissionCount。原来拿柱子上的
|
||||||
|
// 「完成题目数 / 总提交次数」当 AC 率,分子是去重后的题数,不是一回事
|
||||||
footer: (items: TooltipItem<"bar" | "line">[]) => {
|
footer: (items: TooltipItem<"bar" | "line">[]) => {
|
||||||
const barItems = items.filter(
|
const index = items[0]?.dataIndex
|
||||||
(item) => (item.dataset as any).yAxisID === "y",
|
const bucket =
|
||||||
)
|
index === undefined ? undefined : aiStore.durationData[index]
|
||||||
if (barItems.length >= 2) {
|
if (!bucket || bucket.submissionCount === 0) return ""
|
||||||
const problemCount =
|
const rate = (bucket.acceptedCount / bucket.submissionCount) * 100
|
||||||
barItems.find((item) => item.dataset.label === "完成题目数")
|
return `AC率: ${rate.toFixed(1)}%(通过 ${bucket.acceptedCount} / 共 ${bucket.submissionCount} 次)`
|
||||||
?.parsed.y || 0
|
|
||||||
const submissionCount =
|
|
||||||
barItems.find((item) => item.dataset.label === "总提交次数")
|
|
||||||
?.parsed.y || 0
|
|
||||||
const efficiency =
|
|
||||||
submissionCount > 0
|
|
||||||
? ((problemCount / submissionCount) * 100).toFixed(1)
|
|
||||||
: "0"
|
|
||||||
return `AC率: ${efficiency}%`
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<n-text depth="3" style="font-size: 12px">反映刷题质量提升</n-text>
|
<n-text depth="3" style="font-size: 12px">反映刷题质量提升</n-text>
|
||||||
</template>
|
</template>
|
||||||
<div class="chart">
|
<div class="chart">
|
||||||
<Chart type="line" :data="data" :options="options" />
|
<Chart type="line" :key="chartKey" :data="data" :options="options" />
|
||||||
</div>
|
</div>
|
||||||
</n-card>
|
</n-card>
|
||||||
</template>
|
</template>
|
||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
Filler,
|
Filler,
|
||||||
} from "chart.js"
|
} from "chart.js"
|
||||||
import { useAIStore } from "oj/store/ai"
|
import { useAIStore } from "oj/store/ai"
|
||||||
|
import { useChartTheme } from "shared/composables/chartTheme"
|
||||||
import { parseTime } from "utils/functions"
|
import { parseTime } from "utils/functions"
|
||||||
|
|
||||||
// 注册折线图所需的 Chart.js 组件
|
// 注册折线图所需的 Chart.js 组件
|
||||||
@@ -38,6 +39,7 @@ ChartJS.register(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const aiStore = useAIStore()
|
const aiStore = useAIStore()
|
||||||
|
const { chartKey } = useChartTheme()
|
||||||
|
|
||||||
const title = computed(() => {
|
const title = computed(() => {
|
||||||
if (aiStore.duration === "months:2") {
|
if (aiStore.duration === "months:2") {
|
||||||
@@ -60,15 +62,17 @@ const show = computed(() => {
|
|||||||
const efficiencyData = computed(() => {
|
const efficiencyData = computed(() => {
|
||||||
return aiStore.durationData.map((duration) => {
|
return aiStore.durationData.map((duration) => {
|
||||||
const problemCount = duration.problemCount || 0
|
const problemCount = duration.problemCount || 0
|
||||||
|
const acceptedCount = duration.acceptedCount || 0
|
||||||
const submissionCount = duration.submissionCount || 0
|
const submissionCount = duration.submissionCount || 0
|
||||||
|
|
||||||
// 计算效率:提交次数/完成题目数
|
// 计算效率:提交次数/完成题目数
|
||||||
// 值越接近1,说明一次AC率越高
|
// 值越接近1,说明一次AC率越高
|
||||||
const efficiency = problemCount > 0 ? submissionCount / problemCount : 0
|
const efficiency = problemCount > 0 ? submissionCount / problemCount : 0
|
||||||
|
|
||||||
// AC率:AC题目数 / 总提交次数(越高说明提交质量越好)
|
// AC 率 = 判为通过的提交数 / 总提交数。原来分子用的是去重后的题目数,
|
||||||
const onePassRate =
|
// 那既不是 AC 率,算出来还正好是上面 efficiency 的倒数 —— 双轴画的是同一个数
|
||||||
submissionCount > 0 ? (problemCount / submissionCount) * 100 : 0
|
const acRate =
|
||||||
|
submissionCount > 0 ? (acceptedCount / submissionCount) * 100 : 0
|
||||||
|
|
||||||
return {
|
return {
|
||||||
label: [
|
label: [
|
||||||
@@ -76,8 +80,9 @@ const efficiencyData = computed(() => {
|
|||||||
parseTime(duration.end, "M月D日"),
|
parseTime(duration.end, "M月D日"),
|
||||||
].join("~"),
|
].join("~"),
|
||||||
efficiency: efficiency,
|
efficiency: efficiency,
|
||||||
onePassRate: onePassRate,
|
acRate: acRate,
|
||||||
problemCount: problemCount,
|
problemCount: problemCount,
|
||||||
|
acceptedCount: acceptedCount,
|
||||||
submissionCount: submissionCount,
|
submissionCount: submissionCount,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -107,7 +112,7 @@ const data = computed<ChartData<"line">>(() => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "提交AC率",
|
label: "提交AC率",
|
||||||
data: efficiency.map((e) => e.onePassRate),
|
data: efficiency.map((e) => e.acRate),
|
||||||
borderColor: "rgb(34, 197, 94)",
|
borderColor: "rgb(34, 197, 94)",
|
||||||
backgroundColor: "rgba(34, 197, 94, 0.1)",
|
backgroundColor: "rgba(34, 197, 94, 0.1)",
|
||||||
tension: 0.4,
|
tension: 0.4,
|
||||||
@@ -203,8 +208,8 @@ const options = computed(() => {
|
|||||||
} else {
|
} else {
|
||||||
// 提交AC率
|
// 提交AC率
|
||||||
return [
|
return [
|
||||||
`${dsLabel}: ${item.onePassRate.toFixed(1)}%`,
|
`${dsLabel}: ${item.acRate.toFixed(1)}%`,
|
||||||
`提示: AC题目数 / 总提交次数,越高表示提交质量越好`,
|
`通过 ${item.acceptedCount} 次 / 共提交 ${item.submissionCount} 次`,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,7 +4,11 @@
|
|||||||
<n-text depth="3" style="font-size: 12px">激励持续学习</n-text>
|
<n-text depth="3" style="font-size: 12px">激励持续学习</n-text>
|
||||||
</template>
|
</template>
|
||||||
<n-spin :show="aiStore.loading.heatmap" :delay="50">
|
<n-spin :show="aiStore.loading.heatmap" :delay="50">
|
||||||
<div class="heatmap-container" ref="containerRef">
|
<div
|
||||||
|
class="heatmap-container"
|
||||||
|
ref="containerRef"
|
||||||
|
:style="{ '--cell-stroke': cellStroke, '--cell-stroke-hover': cellStrokeHover }"
|
||||||
|
>
|
||||||
<svg
|
<svg
|
||||||
:viewBox="`0 0 ${svgWidth} ${svgHeight}`"
|
:viewBox="`0 0 ${svgWidth} ${svgHeight}`"
|
||||||
preserveAspectRatio="xMinYMin meet"
|
preserveAspectRatio="xMinYMin meet"
|
||||||
@@ -58,8 +62,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useAIStore } from "oj/store/ai"
|
import { useAIStore } from "oj/store/ai"
|
||||||
import { parseTime } from "utils/functions"
|
import { parseTime } from "utils/functions"
|
||||||
|
import { useChartTheme } from "shared/composables/chartTheme"
|
||||||
|
|
||||||
const aiStore = useAIStore()
|
const aiStore = useAIStore()
|
||||||
|
const { isDark } = useChartTheme()
|
||||||
const containerRef = useTemplateRef<HTMLElement>("containerRef")
|
const containerRef = useTemplateRef<HTMLElement>("containerRef")
|
||||||
|
|
||||||
const CELL_SIZE = 12
|
const CELL_SIZE = 12
|
||||||
@@ -68,30 +74,49 @@ const CELL_TOTAL = CELL_SIZE + CELL_GAP
|
|||||||
const DAY_WIDTH = 20
|
const DAY_WIDTH = 20
|
||||||
const MONTH_HEIGHT = 20
|
const MONTH_HEIGHT = 20
|
||||||
const RIGHT_PADDING = 5
|
const RIGHT_PADDING = 5
|
||||||
const COLORS = ["#ebedf0", "#c6e48b", "#7bc96f", "#239a3b", "#196127"]
|
// 深色下空格子不能再用接近白的 #ebedf0,整张图会变成一片发亮的方块
|
||||||
|
const LIGHT_COLORS = ["#ebedf0", "#c6e48b", "#7bc96f", "#239a3b", "#196127"]
|
||||||
|
const DARK_COLORS = ["#22272e", "#0e4429", "#006d32", "#26a641", "#39d353"]
|
||||||
const WEEK_DAYS = ["", "一", "", "三", "", "五", ""]
|
const WEEK_DAYS = ["", "一", "", "三", "", "五", ""]
|
||||||
|
|
||||||
const getColor = (count: number) =>
|
const COLORS = computed(() => (isDark.value ? DARK_COLORS : LIGHT_COLORS))
|
||||||
count === 0
|
const cellStroke = computed(() =>
|
||||||
? COLORS[0]
|
isDark.value ? "rgba(255, 255, 255, 0.08)" : "rgba(0, 0, 0, 0.05)",
|
||||||
: count <= 2
|
)
|
||||||
? COLORS[1]
|
const cellStrokeHover = computed(() =>
|
||||||
: count <= 4
|
isDark.value ? "rgba(255, 255, 255, 0.45)" : "rgba(0, 0, 0, 0.3)",
|
||||||
? COLORS[2]
|
)
|
||||||
: count <= 7
|
|
||||||
? COLORS[3]
|
const getColor = (count: number) => {
|
||||||
: COLORS[4]
|
const palette = COLORS.value
|
||||||
|
if (count === 0) return palette[0]
|
||||||
|
if (count <= 2) return palette[1]
|
||||||
|
if (count <= 4) return palette[2]
|
||||||
|
if (count <= 7) return palette[3]
|
||||||
|
return palette[4]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 第一格未必是周日 —— 后端给的是「今天往前推 364 天」那天开始的 365 天。
|
||||||
|
// 原来直接拿 i % 7 当行号,等于假设首日是周日,左边 一/三/五 的标签和月份标签
|
||||||
|
// 会整体错位(今天是周四就错四行)。这里按首日真实的星期做偏移。
|
||||||
|
const weekdayOffset = computed(() => {
|
||||||
|
const first = aiStore.heatmapData[0]
|
||||||
|
return first ? new Date(first.timestamp).getDay() : 0
|
||||||
|
})
|
||||||
|
|
||||||
const cells = computed(() =>
|
const cells = computed(() =>
|
||||||
aiStore.heatmapData.map((item, i) => ({
|
aiStore.heatmapData.map((item, i) => {
|
||||||
date: new Date(item.timestamp),
|
const slot = weekdayOffset.value + i
|
||||||
count: item.value,
|
return {
|
||||||
color: getColor(item.value),
|
date: new Date(item.timestamp),
|
||||||
week: Math.floor(i / 7),
|
count: item.value,
|
||||||
day: i % 7,
|
color: getColor(item.value),
|
||||||
x: Math.floor(i / 7) * CELL_TOTAL,
|
week: Math.floor(slot / 7),
|
||||||
y: (i % 7) * CELL_TOTAL,
|
day: slot % 7,
|
||||||
})),
|
x: Math.floor(slot / 7) * CELL_TOTAL,
|
||||||
|
y: (slot % 7) * CELL_TOTAL,
|
||||||
|
}
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const monthLabels = computed(() => {
|
const monthLabels = computed(() => {
|
||||||
@@ -100,9 +125,9 @@ const monthLabels = computed(() => {
|
|||||||
|
|
||||||
cells.value.forEach((cell, i) => {
|
cells.value.forEach((cell, i) => {
|
||||||
const month = cell.date.getMonth()
|
const month = cell.date.getMonth()
|
||||||
const isWeekStart = cell.date.getDay() === 0 || i === 0
|
const isWeekStart = cell.day === 0 || i === 0
|
||||||
|
|
||||||
if (month !== lastMonth && (isWeekStart || cell.date.getDay() <= 3)) {
|
if (month !== lastMonth && (isWeekStart || cell.day <= 3)) {
|
||||||
labels.push({
|
labels.push({
|
||||||
text: `${month + 1}月`,
|
text: `${month + 1}月`,
|
||||||
x: DAY_WIDTH + cell.week * CELL_TOTAL,
|
x: DAY_WIDTH + cell.week * CELL_TOTAL,
|
||||||
@@ -114,10 +139,11 @@ const monthLabels = computed(() => {
|
|||||||
return labels
|
return labels
|
||||||
})
|
})
|
||||||
|
|
||||||
const svgWidth = computed(
|
const svgWidth = computed(() => {
|
||||||
() =>
|
const last = cells.value[cells.value.length - 1]
|
||||||
DAY_WIDTH + Math.ceil(cells.value.length / 7) * CELL_TOTAL + RIGHT_PADDING,
|
const weeks = last ? last.week + 1 : 0
|
||||||
)
|
return DAY_WIDTH + weeks * CELL_TOTAL + RIGHT_PADDING
|
||||||
|
})
|
||||||
|
|
||||||
const svgHeight = computed(() => MONTH_HEIGHT + 7 * CELL_TOTAL)
|
const svgHeight = computed(() => MONTH_HEIGHT + 7 * CELL_TOTAL)
|
||||||
|
|
||||||
@@ -188,12 +214,12 @@ const hideTooltip = () => {
|
|||||||
.cell {
|
.cell {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
stroke: rgba(0, 0, 0, 0.05);
|
stroke: var(--cell-stroke);
|
||||||
stroke-width: 0.5;
|
stroke-width: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cell:hover {
|
.cell:hover {
|
||||||
stroke: rgba(0, 0, 0, 0.3);
|
stroke: var(--cell-stroke-hover);
|
||||||
stroke-width: 1.5;
|
stroke-width: 1.5;
|
||||||
filter: brightness(0.9);
|
filter: brightness(0.9);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<n-text depth="3" style="font-size: 12px">追踪学习成长轨迹</n-text>
|
<n-text depth="3" style="font-size: 12px">追踪学习成长轨迹</n-text>
|
||||||
</template>
|
</template>
|
||||||
<div class="chart">
|
<div class="chart">
|
||||||
<Chart type="line" :data="data" :options="options" />
|
<Chart type="line" :key="chartKey" :data="data" :options="options" />
|
||||||
</div>
|
</div>
|
||||||
</n-card>
|
</n-card>
|
||||||
</template>
|
</template>
|
||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
Filler,
|
Filler,
|
||||||
} from "chart.js"
|
} from "chart.js"
|
||||||
import { useAIStore } from "oj/store/ai"
|
import { useAIStore } from "oj/store/ai"
|
||||||
|
import { useChartTheme } from "shared/composables/chartTheme"
|
||||||
import { parseTime } from "utils/functions"
|
import { parseTime } from "utils/functions"
|
||||||
import type { Grade } from "utils/types"
|
import type { Grade } from "utils/types"
|
||||||
|
|
||||||
@@ -41,6 +42,7 @@ ChartJS.register(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const aiStore = useAIStore()
|
const aiStore = useAIStore()
|
||||||
|
const { chartKey } = useChartTheme()
|
||||||
|
|
||||||
const gradeOrder = ["C", "B", "A", "S"] as const
|
const gradeOrder = ["C", "B", "A", "S"] as const
|
||||||
const gradeColors: Record<Grade, string> = {
|
const gradeColors: Record<Grade, string> = {
|
||||||
@@ -78,8 +80,11 @@ const progressData = computed(() => {
|
|||||||
const problemCount = duration.problemCount || 0
|
const problemCount = duration.problemCount || 0
|
||||||
cumulativeCount += problemCount
|
cumulativeCount += problemCount
|
||||||
|
|
||||||
// 计算本期等级的权重值
|
// 契约里空串是「无评级」(该周期没有活动),不能当成 C —— 那会在 tooltip 上
|
||||||
const currentGradeValue = gradeOrder.indexOf(duration.grade || "C")
|
// 把一个没做题的周期写成「本期等级: C」。这里权重取 0,反正 problemCount 也是 0
|
||||||
|
const currentGradeValue = duration.grade
|
||||||
|
? gradeOrder.indexOf(duration.grade)
|
||||||
|
: 0
|
||||||
|
|
||||||
// 累加加权等级
|
// 累加加权等级
|
||||||
totalWeightedGrade += currentGradeValue * problemCount
|
totalWeightedGrade += currentGradeValue * problemCount
|
||||||
@@ -97,7 +102,7 @@ const progressData = computed(() => {
|
|||||||
start: parseTime(duration.start, "YYYY-MM-DD"),
|
start: parseTime(duration.start, "YYYY-MM-DD"),
|
||||||
end: parseTime(duration.end, "YYYY-MM-DD"),
|
end: parseTime(duration.end, "YYYY-MM-DD"),
|
||||||
count: cumulativeCount,
|
count: cumulativeCount,
|
||||||
grade: duration.grade || "C",
|
grade: duration.grade,
|
||||||
gradeValue: currentGradeValue,
|
gradeValue: currentGradeValue,
|
||||||
avgGradeValue: avgGradeValue, // 累计平均等级
|
avgGradeValue: avgGradeValue, // 累计平均等级
|
||||||
problemCount: problemCount,
|
problemCount: problemCount,
|
||||||
@@ -235,7 +240,7 @@ const options = computed<ChartOptions<"line">>(() => {
|
|||||||
const avgIdx = Math.round(Number(ctx.parsed.y))
|
const avgIdx = Math.round(Number(ctx.parsed.y))
|
||||||
return [
|
return [
|
||||||
`${dsLabel}: ${gradeOrder[avgIdx] || ""}`,
|
`${dsLabel}: ${gradeOrder[avgIdx] || ""}`,
|
||||||
`本期等级: ${progress.grade}`,
|
`本期等级: ${progress.grade || "无"}`,
|
||||||
`本期完成: ${progress.problemCount} 题`,
|
`本期完成: ${progress.problemCount} 题`,
|
||||||
]
|
]
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
</n-text>
|
</n-text>
|
||||||
</template>
|
</template>
|
||||||
<div style="height: 300px">
|
<div style="height: 300px">
|
||||||
<Pie :data="data" :options="options" />
|
<Pie :key="chartKey" :data="data" :options="options" />
|
||||||
</div>
|
</div>
|
||||||
</n-card>
|
</n-card>
|
||||||
</template>
|
</template>
|
||||||
@@ -15,10 +15,12 @@
|
|||||||
import { Pie } from "vue-chartjs"
|
import { Pie } from "vue-chartjs"
|
||||||
import { Chart as ChartJS, ArcElement, Title, Tooltip, Legend } from "chart.js"
|
import { Chart as ChartJS, ArcElement, Title, Tooltip, Legend } from "chart.js"
|
||||||
import { useAIStore } from "oj/store/ai"
|
import { useAIStore } from "oj/store/ai"
|
||||||
|
import { useChartTheme } from "shared/composables/chartTheme"
|
||||||
|
|
||||||
ChartJS.register(ArcElement, Title, Tooltip, Legend)
|
ChartJS.register(ArcElement, Title, Tooltip, Legend)
|
||||||
|
|
||||||
const aiStore = useAIStore()
|
const aiStore = useAIStore()
|
||||||
|
const { chartKey } = useChartTheme()
|
||||||
|
|
||||||
// 排名区间定义
|
// 排名区间定义
|
||||||
const RANK_RANGES = [
|
const RANK_RANGES = [
|
||||||
@@ -42,19 +44,21 @@ const rankDistribution = computed(() => {
|
|||||||
const acCount = item.periodAcCount
|
const acCount = item.periodAcCount
|
||||||
|
|
||||||
if (rank && acCount && acCount > 0) {
|
if (rank && acCount && acCount > 0) {
|
||||||
const percentile = (rank / acCount) * 100
|
// 口径和后端 grade() 一致:(rank - 1) / count。少减这个 1 会让每道题整体降一档,
|
||||||
|
// 而且 rank === acCount 时正好算出 100,落在 [70, 100) 之外被 findIndex 丢掉 ——
|
||||||
|
// 班里只有他一个人做出来的题(1 / 1)既被当成垫底又整个消失
|
||||||
|
const percentile = ((rank - 1) / acCount) * 100
|
||||||
|
|
||||||
// 找到对应的区间
|
// 找到对应的区间;万一越界也归到最后一档,别让这道题凭空不见
|
||||||
const rangeIndex = RANK_RANGES.findIndex(
|
const rangeIndex = RANK_RANGES.findIndex(
|
||||||
(r) => percentile >= r.min && percentile < r.max,
|
(r) => percentile >= r.min && percentile < r.max,
|
||||||
)
|
)
|
||||||
|
const index = rangeIndex === -1 ? RANK_RANGES.length - 1 : rangeIndex
|
||||||
|
|
||||||
if (rangeIndex !== -1) {
|
distribution[index].count++
|
||||||
distribution[rangeIndex].count++
|
distribution[index].problems.push(
|
||||||
distribution[rangeIndex].problems.push(
|
`${item.problem.displayId}: ${item.problem.title}`,
|
||||||
`${item.problem.displayId}: ${item.problem.title}`,
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -80,7 +80,10 @@ const columns: DataTableColumn<SolvedProblem>[] = [
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: () => (aiStore.detailsData.className ? "班级排名" : "全服排名"),
|
// 用后端下发的 rankScope,不要看 className 有没有值:班里只有一个人时
|
||||||
|
// 后端会回退到全服排名,那种学生原来看到的是「班级排名」配全服数据
|
||||||
|
title: () =>
|
||||||
|
aiStore.detailsData.rankScope === "class" ? "班级排名" : "全服排名",
|
||||||
key: "rank",
|
key: "rank",
|
||||||
width: 100,
|
width: 100,
|
||||||
align: "center",
|
align: "center",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<n-text depth="3" style="font-size: 12px">可视化知识点覆盖面</n-text>
|
<n-text depth="3" style="font-size: 12px">可视化知识点覆盖面</n-text>
|
||||||
</template>
|
</template>
|
||||||
<div class="chart">
|
<div class="chart">
|
||||||
<Radar :data="data" :options="options" />
|
<Radar :key="chartKey" :data="data" :options="options" />
|
||||||
</div>
|
</div>
|
||||||
</n-card>
|
</n-card>
|
||||||
</template>
|
</template>
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
Legend,
|
Legend,
|
||||||
} from "chart.js"
|
} from "chart.js"
|
||||||
import { useAIStore } from "oj/store/ai"
|
import { useAIStore } from "oj/store/ai"
|
||||||
|
import { useChartTheme } from "shared/composables/chartTheme"
|
||||||
|
|
||||||
// 注册雷达图所需的 Chart.js 组件
|
// 注册雷达图所需的 Chart.js 组件
|
||||||
ChartJS.register(
|
ChartJS.register(
|
||||||
@@ -32,13 +33,15 @@ ChartJS.register(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const aiStore = useAIStore()
|
const aiStore = useAIStore()
|
||||||
|
const { chartKey, gridColor } = useChartTheme()
|
||||||
|
|
||||||
const show = computed(() => {
|
const show = computed(() => {
|
||||||
return Object.keys(aiStore.detailsData.tags).length > 0
|
return Object.keys(aiStore.detailsData.tags).length > 0
|
||||||
})
|
})
|
||||||
|
|
||||||
// 最多显示前10个标签,避免雷达图过于拥挤
|
// 后端 /ai/detail 已经把 tags 截到前 5(routes/ai.ts 的 topTags),这里取同一个上限。
|
||||||
const MAX_TAGS = 10
|
// 原来写 10 是取不满的死数
|
||||||
|
const MAX_TAGS = 5
|
||||||
|
|
||||||
const title = computed(() => {
|
const title = computed(() => {
|
||||||
const totalTags = Object.keys(aiStore.detailsData.tags).length
|
const totalTags = Object.keys(aiStore.detailsData.tags).length
|
||||||
@@ -112,11 +115,11 @@ const options = computed(() => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
grid: {
|
grid: {
|
||||||
color: "rgba(0, 0, 0, 0.1)",
|
color: gridColor.value,
|
||||||
circular: true,
|
circular: true,
|
||||||
},
|
},
|
||||||
angleLines: {
|
angleLines: {
|
||||||
color: "rgba(0, 0, 0, 0.1)",
|
color: gridColor.value,
|
||||||
},
|
},
|
||||||
pointLabels: {
|
pointLabels: {
|
||||||
font: {
|
font: {
|
||||||
@@ -138,7 +141,8 @@ const options = computed(() => {
|
|||||||
const index = context.dataIndex
|
const index = context.dataIndex
|
||||||
const actualValue = tagData[index].value
|
const actualValue = tagData[index].value
|
||||||
const percentage = Math.round(Number(context.parsed.r))
|
const percentage = Math.round(Number(context.parsed.r))
|
||||||
return `完成 ${actualValue} 道题 (掌握度 ${percentage}%)`
|
// 这个百分比是「占最多的那个标签的比例」,不是掌握度 —— 第一名恒为 100%
|
||||||
|
return `完成 ${actualValue} 道题 (占最多标签的 ${percentage}%)`
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
</n-text>
|
</n-text>
|
||||||
</template>
|
</template>
|
||||||
<div style="height: 300px">
|
<div style="height: 300px">
|
||||||
<Bar :data="data" :options="options" />
|
<Bar :key="chartKey" :data="data" :options="options" />
|
||||||
</div>
|
</div>
|
||||||
</n-card>
|
</n-card>
|
||||||
</template>
|
</template>
|
||||||
@@ -23,10 +23,12 @@ import {
|
|||||||
Legend,
|
Legend,
|
||||||
} from "chart.js"
|
} from "chart.js"
|
||||||
import { useAIStore } from "oj/store/ai"
|
import { useAIStore } from "oj/store/ai"
|
||||||
|
import { useChartTheme } from "shared/composables/chartTheme"
|
||||||
|
|
||||||
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend)
|
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend)
|
||||||
|
|
||||||
const aiStore = useAIStore()
|
const aiStore = useAIStore()
|
||||||
|
const { chartKey } = useChartTheme()
|
||||||
|
|
||||||
const WEEKDAYS = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"]
|
const WEEKDAYS = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"]
|
||||||
const TIME_PERIODS = [
|
const TIME_PERIODS = [
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export const useAIStore = defineStore("ai", () => {
|
|||||||
contestCount: 0,
|
contestCount: 0,
|
||||||
solved: [],
|
solved: [],
|
||||||
flowcharts: [],
|
flowcharts: [],
|
||||||
|
rankScope: "global",
|
||||||
})
|
})
|
||||||
const heatmapData = ref<{ timestamp: number; value: number }[]>([])
|
const heatmapData = ref<{ timestamp: number; value: number }[]>([])
|
||||||
|
|
||||||
@@ -51,6 +52,7 @@ export const useAIStore = defineStore("ai", () => {
|
|||||||
detailsData.tags = res.tags
|
detailsData.tags = res.tags
|
||||||
detailsData.difficulty = res.difficulty
|
detailsData.difficulty = res.difficulty
|
||||||
detailsData.contestCount = res.contestCount
|
detailsData.contestCount = res.contestCount
|
||||||
|
detailsData.rankScope = res.rankScope
|
||||||
detailsData.flowcharts = res.flowcharts
|
detailsData.flowcharts = res.flowcharts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
40
apps/web/src/shared/composables/chartTheme.ts
Normal file
40
apps/web/src/shared/composables/chartTheme.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { useDark } from "@vueuse/core"
|
||||||
|
import { Chart as ChartJS } from "chart.js"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* chart.js 的默认配色是写死的浅色(文字 #666、网格线 rgba(0,0,0,0.1)),深色主题下
|
||||||
|
* 坐标轴刻度和图例几乎看不见。这里挂一次全局默认值,跟着 isDark 走。
|
||||||
|
*
|
||||||
|
* 只改默认值还不够:options 不是 computed 的图表在主题切换后不会重绘,
|
||||||
|
* 所以另外给出 chartKey —— 图表绑到 :key 上,切换时整个重新挂载。
|
||||||
|
*/
|
||||||
|
export function useChartTheme() {
|
||||||
|
const isDark = useDark()
|
||||||
|
|
||||||
|
const textColor = computed(() =>
|
||||||
|
isDark.value ? "rgba(255, 255, 255, 0.75)" : "#606266",
|
||||||
|
)
|
||||||
|
const gridColor = computed(() =>
|
||||||
|
isDark.value ? "rgba(255, 255, 255, 0.12)" : "rgba(0, 0, 0, 0.1)",
|
||||||
|
)
|
||||||
|
const chartKey = computed(() => (isDark.value ? "dark" : "light"))
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
ChartJS.defaults.color = textColor.value
|
||||||
|
// 网格线只能从 scale 这边改,**不要**去动 ChartJS.defaults.borderColor:
|
||||||
|
// Colors 插件的 containsDefaultColorsDefenitions() 一看到根上的 borderColor
|
||||||
|
// 不等于出厂值就整个罢工,靠它自动配色的图(DurationChart)会退成近黑的柱子和图例
|
||||||
|
// 网格线走 defaults.set("scale", ...),所有轴类型都继承这一层。
|
||||||
|
// **不要**去动 ChartJS.defaults.borderColor:Colors 插件的
|
||||||
|
// containsDefaultColorsDefenitions() 一看到根上的 borderColor 不等于出厂值
|
||||||
|
// 就整个罢工,靠它自动配色的图(DurationChart)会退成近黑的柱子和图例。
|
||||||
|
// 也不要直接写 defaults.scales.linear.border —— 那要求对应的 scale 已经注册,
|
||||||
|
// 只注册了 radialLinear 的雷达图会在 setup 里抛错。
|
||||||
|
ChartJS.defaults.set("scale", {
|
||||||
|
grid: { color: gridColor.value },
|
||||||
|
border: { color: gridColor.value },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return { isDark, textColor, gridColor, chartKey }
|
||||||
|
}
|
||||||
@@ -13,6 +13,8 @@ export const durationDataSchema = z.object({
|
|||||||
end: z.string(),
|
end: z.string(),
|
||||||
grade: gradeSchema,
|
grade: gradeSchema,
|
||||||
problemCount: z.number().int(),
|
problemCount: z.number().int(),
|
||||||
|
/** 该周期内判为通过的提交数。problemCount 是去重后的题数,两者不能互相代替 */
|
||||||
|
acceptedCount: z.number().int(),
|
||||||
submissionCount: z.number().int(),
|
submissionCount: z.number().int(),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -53,6 +55,11 @@ export const aiDetailSchema = z.object({
|
|||||||
tags: z.record(z.string(), z.number().int()),
|
tags: z.record(z.string(), z.number().int()),
|
||||||
difficulty: z.record(z.string(), z.number().int()),
|
difficulty: z.record(z.string(), z.number().int()),
|
||||||
contestCount: z.number().int(),
|
contestCount: z.number().int(),
|
||||||
|
/**
|
||||||
|
* solved 里的 rank/acCount 是在哪个范围里排的。班里只有一个人时后端会回退到全服,
|
||||||
|
* 前端不能只看 className 有没有值就写「班级排名」。
|
||||||
|
*/
|
||||||
|
rankScope: z.enum(["class", "global"]),
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user