update
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled

This commit is contained in:
2026-06-05 09:03:38 -06:00
parent 324e85d2c0
commit f9d7c2ff92
18 changed files with 335 additions and 164 deletions

View File

@@ -8,8 +8,14 @@
style="width: 200px"
/>
</n-flex>
<n-alert v-if="pinnedReports.length > 0" type="warning" :show-icon="true" style="margin-bottom: 12px">
以下 <strong>{{ pinnedReports.length }}</strong> 位用户的 AI 分析报告已被锁定前台将固定显示该报告
<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"
@@ -30,13 +36,24 @@
v-model:page="query.page"
/>
<n-modal v-model:show="showModal" preset="card" title="分析报告详情" style="width: 800px; max-width: 95vw">
<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="2" bordered size="small" class="meta">
<n-descriptions-item label="用户">{{ detail.username }}</n-descriptions-item>
<n-descriptions-item label="班级">{{ detail.class_name || "-" }}</n-descriptions-item>
<n-descriptions-item label="时间" :span="2">{{ parseTime(detail.create_time, "YYYY-MM-DD HH:mm:ss") }}</n-descriptions-item>
<n-descriptions-item label="用户">{{
detail.username
}}</n-descriptions-item>
<n-descriptions-item label="班级">{{
detail.class_name || "-"
}}</n-descriptions-item>
<n-descriptions-item label="时间" :span="2">{{
parseTime(detail.create_time, "YYYY-MM-DD HH:mm:ss")
}}</n-descriptions-item>
</n-descriptions>
<n-scrollbar style="max-height: 60vh; margin-top: 12px">
<MdPreview :model-value="detail.analysis" />
@@ -51,7 +68,12 @@ import { MdPreview } from "md-editor-v3"
import "md-editor-v3/lib/preview.css"
import Pagination from "shared/components/Pagination.vue"
import { parseTime } from "utils/functions"
import { getAIReportList, getAIReportDetail, pinAIReport, getPinnedAIReports } from "../api"
import {
getAIReportList,
getAIReportDetail,
pinAIReport,
getPinnedAIReports,
} from "../api"
import { NButton, NTag } from "naive-ui"
interface ReportItem {
@@ -83,7 +105,11 @@ const columns: DataTableColumn<ReportItem>[] = [
key: "username",
width: 150,
render: (row) =>
h("span", { style: row.is_pinned ? "font-weight:600" : "" }, row.username),
h(
"span",
{ style: row.is_pinned ? "font-weight:600" : "" },
row.username,
),
},
{
title: "AI 分析内容",
@@ -111,7 +137,11 @@ const columns: DataTableColumn<ReportItem>[] = [
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: "primary", onClick: () => openDetail(row.id) },
() => "查看",
),
h(
NButton,
{
@@ -156,7 +186,10 @@ async function openDetail(id: number) {
onMounted(() => Promise.all([listReports(), loadPinnedReports()]))
watch(() => [query.page, query.limit], listReports)
watchDebounced(() => query.username, listReports, { debounce: 500, maxWait: 1000 })
watchDebounced(() => query.username, listReports, {
debounce: 500,
maxWait: 1000,
})
</script>
<style scoped>

View File

@@ -1,4 +1,5 @@
import http from "utils/http"
import { toProblemListItem } from "admin/transforms"
import type {
AdminProblem,
Announcement,
@@ -30,30 +31,21 @@ export async function getProblemList(
contestID?: string,
) {
const endpoint = !!contestID ? "admin/contest/problem" : "admin/problem"
const res = await http.get(endpoint, {
params: {
paging: true,
offset,
limit,
keyword,
author,
contest_id: contestID,
const res = await http.get<{ results: AdminProblem[]; total: number }>(
endpoint,
{
params: {
paging: true,
offset,
limit,
keyword,
author,
contest_id: contestID,
},
},
})
)
return {
results: res.data.results.map((result: AdminProblem) => ({
id: result.id,
_id: result._id,
title: result.title,
username: result.created_by.username,
create_time: result.create_time,
visible: result.visible,
difficulty: result.difficulty,
tags: result.tags,
has_ast_rules: result.has_ast_rules,
allow_flowchart: result.allow_flowchart,
show_flowchart: result.show_flowchart,
})),
results: res.data.results.map(toProblemListItem),
total: res.data.total,
}
}
@@ -133,10 +125,10 @@ export function getContestList(offset = 0, limit = 10, keyword: string) {
export async function uploadImage(file: File): Promise<string> {
const form = new window.FormData()
form.append("image", file)
const res: { success: boolean; file_path: string; msg: "Success" } =
await http.post("admin/upload_image", form, {
headers: { "content-type": "multipart/form-data" },
})
// 该端点不走 { error, data } 信封,直接返回上传结果
const res = (await http.post("admin/upload_image", form, {
headers: { "content-type": "multipart/form-data" },
})) as unknown as { success: boolean; file_path: string; msg: "Success" }
return res.success ? res.file_path : ""
}
@@ -244,17 +236,17 @@ export function deleteComment(id: number) {
}
export async function getTutorialList() {
const res = await http.get("admin/tutorial")
const res = await http.get<Tutorial[]>("admin/tutorial")
return res.data
}
export async function getTutorial(id: number) {
const res = await http.get("admin/tutorial", { params: { id } })
const res = await http.get<Tutorial>("admin/tutorial", { params: { id } })
return res.data
}
export async function createTutorial(data: Partial<Tutorial>) {
const res = await http.post("admin/tutorial", data)
const res = await http.post<Tutorial>("admin/tutorial", data)
return res.data
}
@@ -272,10 +264,10 @@ export function setTutorialVisibility(id: number, is_public: boolean) {
}
export async function getAdminExercises(tutorialId: number) {
const res = await http.get("admin/exercise", {
const res = await http.get<Exercise[]>("admin/exercise", {
params: { tutorial_id: tutorialId },
})
return res.data as Exercise[]
return res.data
}
export async function createExercise(data: {
@@ -284,8 +276,8 @@ export async function createExercise(data: {
data: object
order: number
}) {
const res = await http.post("admin/exercise", data)
return res.data as Exercise
const res = await http.post<Exercise>("admin/exercise", data)
return res.data
}
export async function updateExercise(data: {

18
src/admin/transforms.ts Normal file
View File

@@ -0,0 +1,18 @@
import type { AdminProblem } from "utils/types"
// 把后端的 AdminProblem 塑形成管理端列表项,与请求逻辑解耦。
export function toProblemListItem(result: AdminProblem) {
return {
id: result.id,
_id: result._id,
title: result.title,
username: result.created_by.username,
create_time: result.create_time,
visible: result.visible,
difficulty: result.difficulty,
tags: result.tags,
has_ast_rules: result.has_ast_rules,
allow_flowchart: result.allow_flowchart,
show_flowchart: result.show_flowchart,
}
}

View File

@@ -24,7 +24,13 @@ const isNotRegularUser = computed(
>
{{ getUserRole(props.user.admin_type).label }}
</n-tag>
<n-tag size="small" v-if="props.user.admin_type === USER_TYPE.STUDENT_ADMIN || props.user.admin_type === USER_TYPE.TEACHER_ADMIN">
<n-tag
size="small"
v-if="
props.user.admin_type === USER_TYPE.STUDENT_ADMIN ||
props.user.admin_type === USER_TYPE.TEACHER_ADMIN
"
>
{{
props.user.problem_permission === PROBLEM_PERMISSION.ALL
? "全部"

View File

@@ -314,7 +314,11 @@ watch(() => [query.page, query.limit, query.type, query.orderBy], listUsers)
<n-input v-model:value="password" />
</n-form-item-gi>
<n-form-item-gi
v-if="!create && (userEditing.admin_type === USER_TYPE.STUDENT_ADMIN || userEditing.admin_type === USER_TYPE.TEACHER_ADMIN)"
v-if="
!create &&
(userEditing.admin_type === USER_TYPE.STUDENT_ADMIN ||
userEditing.admin_type === USER_TYPE.TEACHER_ADMIN)
"
:span="1"
label="出题权限"
>