这几页的 Markdown 只在点开抽屉 / 弹层、或 AI 报告出来时才用得上, 却连着整套代码高亮(CodeMirror 和三种语言的解析器)一起静态打进了页面。 改成用到才加载:排名 356→133KB、看板 380→157KB、PK 311→88KB、 智能分析 308→86KB(gzip 后)。 网站图标原来塞了 16 到 256 像素九张图共 192KB,只留标签页用得上的 16 到 48 像素四张(原图像素不变),17KB。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
178 lines
4.7 KiB
Vue
178 lines
4.7 KiB
Vue
<script setup lang="ts">
|
||
import type { LearningPeriod } from "@oj2/contract"
|
||
import { Icon } from "@iconify/vue"
|
||
import { useThemeVars } from "naive-ui"
|
||
import { parseTime } from "utils/functions"
|
||
import { streamAI } from "utils/stream"
|
||
import { getLatestAnalysis } from "../../api"
|
||
|
||
// Markdown 连着整套代码高亮(两百多 KB),这里要等点开 / AI 写完才用得上,别让它拖慢页面
|
||
const Markdown = defineAsyncComponent(() => import("shared/components/Markdown.vue"))
|
||
|
||
/**
|
||
* AI 小结。进来先拿这个时间段上次生成的那份(老师钉住的优先),没有才出按钮 ——
|
||
* 一份小结要等十几秒、也要花钱,不在进页面时自动生成。
|
||
*/
|
||
const props = defineProps<{
|
||
period: LearningPeriod
|
||
username?: string
|
||
/** 手机:默认只露前几行 */
|
||
compact?: boolean
|
||
}>()
|
||
|
||
const theme = useThemeVars()
|
||
const content = ref("")
|
||
const createdAt = ref("")
|
||
const pinned = ref(false)
|
||
const generating = ref(false)
|
||
const waiting = ref(false)
|
||
const failed = ref("")
|
||
const expanded = ref(false)
|
||
let controller: AbortController | null = null
|
||
|
||
async function load() {
|
||
controller?.abort()
|
||
generating.value = false
|
||
failed.value = ""
|
||
expanded.value = false
|
||
const period = props.period
|
||
const username = props.username
|
||
const record = await getLatestAnalysis(period, username).catch(() => null)
|
||
if (period !== props.period || username !== props.username) return
|
||
content.value = record?.analysis ?? ""
|
||
createdAt.value = record?.createTime ?? ""
|
||
pinned.value = record?.isPinned ?? false
|
||
}
|
||
|
||
watch(() => [props.period, props.username], load, { immediate: true })
|
||
onBeforeUnmount(() => controller?.abort())
|
||
|
||
async function generate() {
|
||
controller?.abort()
|
||
const current = new AbortController()
|
||
controller = current
|
||
generating.value = true
|
||
waiting.value = true
|
||
failed.value = ""
|
||
content.value = ""
|
||
pinned.value = false
|
||
expanded.value = true
|
||
try {
|
||
await streamAI(
|
||
"ai/analysis",
|
||
{ period: props.period, username: props.username || undefined },
|
||
{
|
||
signal: current.signal,
|
||
onDelta(delta) {
|
||
waiting.value = false
|
||
content.value += delta
|
||
},
|
||
},
|
||
)
|
||
createdAt.value = new Date().toISOString()
|
||
} catch (error) {
|
||
if (current.signal.aborted) return
|
||
failed.value = (error as Error).message || "AI 服务暂时不可用,请稍后再试"
|
||
} finally {
|
||
if (controller === current) {
|
||
generating.value = false
|
||
waiting.value = false
|
||
}
|
||
}
|
||
}
|
||
|
||
const folded = computed(() => props.compact && !expanded.value && !generating.value)
|
||
</script>
|
||
|
||
<template>
|
||
<section class="card">
|
||
<h3><Icon icon="ph:sparkle" />AI 帮你分析</h3>
|
||
<template v-if="content">
|
||
<div class="body" :class="{ folded }">
|
||
<Markdown :content="content" />
|
||
</div>
|
||
<div v-if="!generating" class="foot">
|
||
<a v-if="folded" href="#" @click.prevent="expanded = true">展开全文 ▾</a>
|
||
<span v-if="createdAt"
|
||
>{{ parseTime(createdAt, "M月D日") }} 生成{{ pinned ? " · 老师置顶" : "" }}</span
|
||
>
|
||
<a href="#" @click.prevent="generate">重新分析</a>
|
||
</div>
|
||
</template>
|
||
<n-spin v-else-if="waiting" size="small">
|
||
<div class="waiting">正在看{{ username ? "他" : "你" }}的做题记录…</div>
|
||
</n-spin>
|
||
<div v-else class="empty">
|
||
<p>让 AI 按这些数据,写一段{{ username ? "给他" : "给你" }}的小结和下一步建议</p>
|
||
<n-button type="primary" @click="generate">
|
||
<template #icon><Icon icon="ph:sparkle" /></template>
|
||
AI 帮我分析
|
||
</n-button>
|
||
</div>
|
||
<p v-if="failed" class="failed">{{ failed }}</p>
|
||
</section>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.card {
|
||
padding: 16px 18px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
border: 1px solid rgba(107, 71, 194, 0.3);
|
||
border-radius: var(--oj-radius);
|
||
background: v-bind("theme.cardColor");
|
||
}
|
||
|
||
h3 {
|
||
margin: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: var(--oj-fs-h2);
|
||
color: #7c5cd0;
|
||
}
|
||
|
||
.body.folded {
|
||
max-height: 150px;
|
||
overflow: hidden;
|
||
/* Chrome 105 只认带前缀的,不带前缀的到 120 才有 */
|
||
-webkit-mask-image: linear-gradient(#000 60%, transparent);
|
||
mask-image: linear-gradient(#000 60%, transparent);
|
||
}
|
||
|
||
.foot {
|
||
display: flex;
|
||
gap: 12px;
|
||
font-size: var(--oj-fs-meta);
|
||
color: v-bind("theme.textColor3");
|
||
}
|
||
|
||
.foot a {
|
||
color: #18a058;
|
||
text-decoration: none;
|
||
}
|
||
|
||
.waiting {
|
||
height: 60px;
|
||
}
|
||
|
||
.empty {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
gap: 10px;
|
||
}
|
||
|
||
.empty p,
|
||
.failed {
|
||
margin: 0;
|
||
font-size: var(--oj-fs-sec);
|
||
color: v-bind("theme.textColor2");
|
||
}
|
||
|
||
.failed {
|
||
color: v-bind("theme.errorColor");
|
||
}
|
||
</style>
|