Files
OJ2/apps/web/src/admin/ai/list.vue
T
xuyueandClaude Opus 5.5 6f47ad8358 perf(Markdown): 题面、教程、AI 提示每次打开都去 unpkg.com 拉四个库,机房网络下又慢又不稳
md-editor-v3 的 MdPreview 不管正文里有没有代码、公式,都会从 unpkg.com 加载
highlight.js、katex、mermaid、echarts(mermaid 还是另一份 11.x)。而我们只用到最基础的
Markdown:题面 95% 是富文本存的 HTML,教程是标题、表格、图片,AI 输出是标题、列表、
表格、少量代码块,库里一处公式、mermaid 都没有。

- 显示:新组件 shared/components/Markdown.vue,直接用 markdown-it,代码高亮走本地的
  highlight.js(和练一练共用 shared/highlight.ts,补了 C++ / SQL)。9 处 MdPreview 全换掉。
  正文默认 16px,教程也统一成 16px。
- 编辑:后台教程编辑器换成项目已有的 CodeMirror,加一排格式按钮;图片可以按按钮选、
  粘贴、拖进来,都是先上传再插入。md-editor-v3 从依赖里删掉。
- AI 生成的内容(AI 提示、AI 分析、班级报告)不再放行原生 HTML:学生能在代码里诱导
  AI 吐出 <img onerror>,老师在后台一打开就执行。库里 1196 条 AI 输出没有一个 HTML
  标签,关掉不影响显示。题面、教程照旧放行。
- 长英文改成在单词之间折行,原来 break-all 会把 "I live" 断成 "I l / ive"。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:02:54 +08:00

176 lines
4.8 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<PageHeader title="AI 学习分析报告">
<template #filters>
<n-input
v-model:value="query.username"
clearable
placeholder="输入用户名筛选"
style="width: 220px"
/>
</template>
</PageHeader>
<n-alert
v-if="pinnedReports.length > 0"
type="warning"
:show-icon="true"
style="margin-bottom: 12px"
>
以下 <strong>{{ pinnedReports.length }}</strong> 位用户的 AI
分析报告已被锁定,前台将固定显示该报告:
<n-flex style="margin-top: 8px" :wrap="true" :size="[8, 6]">
<n-tag
v-for="r in pinnedReports"
:key="r.id"
type="warning"
size="small"
closable
@close="togglePin(r)"
>
{{ r.username }}
</n-tag>
</n-flex>
</n-alert>
<n-data-table striped :columns="columns" :data="reports" />
<Pagination :total="total" v-model:limit="query.limit" v-model:page="query.page" />
<n-modal
v-model:show="showModal"
preset="card"
title="分析报告详情"
style="width: 800px; max-width: 95vw"
>
<n-spin :show="loadingDetail">
<div v-if="detail" class="detail">
<n-descriptions :column="3" bordered size="small" class="meta">
<n-descriptions-item label="用户">
{{ detail.username }}
</n-descriptions-item>
<n-descriptions-item label="班级">
{{ detail.className || "-" }}
</n-descriptions-item>
<n-descriptions-item label="时间">
{{ parseTime(detail.createTime, "YYYY-MM-DD HH:mm:ss") }}
</n-descriptions-item>
</n-descriptions>
<n-scrollbar style="max-height: 60vh; margin-top: 12px">
<Markdown :content="detail.analysis" />
</n-scrollbar>
</div>
</n-spin>
</n-modal>
</template>
<script lang="ts" setup>
import PageHeader from "admin/components/PageHeader.vue"
import Markdown from "shared/components/Markdown.vue"
import Pagination from "shared/components/Pagination.vue"
import { parseTime } from "utils/functions"
import { getAIReportList, getAIReportDetail, pinAIReport, getPinnedAIReports } from "../api"
import { NButton, NTag } from "naive-ui"
import type { AdminAiReport, AdminAiReportListItem } from "utils/types"
type ReportItem = AdminAiReportListItem
type ReportDetail = AdminAiReport
const reports = ref<ReportItem[]>([])
const total = ref(0)
const query = reactive({ limit: 10, page: 1, username: "" })
const pinnedReports = ref<ReportItem[]>([])
const showModal = ref(false)
const loadingDetail = ref(false)
const detail = ref<ReportDetail | null>(null)
const columns: DataTableColumn<ReportItem>[] = [
{ title: "ID", key: "id", width: 80 },
{
title: "用户名",
key: "username",
width: 150,
render: (row) => h("span", { style: row.isPinned ? "font-weight:600" : "" }, row.username),
},
{
title: "AI 分析内容",
key: "analysis_excerpt",
render: (row) => row.analysisExcerpt || "-",
},
{
title: "生成时间",
key: "create_time",
width: 200,
render: (row) => parseTime(row.createTime, "YYYY-MM-DD HH:mm:ss"),
},
{
title: "PIN 状态",
key: "is_pinned",
width: 100,
render: (row) =>
row.isPinned ? h(NTag, { type: "warning", size: "small" }, () => "已锁定") : null,
},
{
title: "操作",
key: "action",
width: 160,
render: (row) =>
h("span", { style: "display:flex;gap:8px" }, [
h(
NButton,
{ size: "small", type: "primary", onClick: () => openDetail(row.id) },
() => "查看",
),
h(
NButton,
{
size: "small",
type: row.isPinned ? "error" : "default",
onClick: () => togglePin(row),
},
() => (row.isPinned ? "取消 PIN" : "PIN"),
),
]),
},
]
async function loadPinnedReports() {
const res = await getPinnedAIReports()
pinnedReports.value = res.results
}
async function togglePin(row: ReportItem) {
await pinAIReport(row.id)
await Promise.all([listReports(), loadPinnedReports()])
}
async function listReports() {
const offset = (query.page - 1) * query.limit
const res = await getAIReportList(offset, query.limit, query.username)
reports.value = res.results
total.value = res.total
}
async function openDetail(id: number) {
showModal.value = true
loadingDetail.value = true
detail.value = null
try {
const res = await getAIReportDetail(id)
detail.value = res
} finally {
loadingDetail.value = false
}
}
onMounted(() => Promise.all([listReports(), loadPinnedReports()]))
watch(() => [query.page, query.limit], listReports)
watchDebounced(() => query.username, listReports, {
debounce: 500,
maxWait: 1000,
})
</script>
<style scoped>
.detail .meta {
margin-bottom: 0;
}
</style>