Compare commits

..
1 Commits
Author SHA1 Message Date
xuyue 16a6163544 update
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
2026-07-26 08:10:58 -06:00
57 changed files with 1517 additions and 3534 deletions
+580 -459
View File
File diff suppressed because it is too large Load Diff
+15 -14
View File
@@ -20,46 +20,47 @@
"@vue-flow/minimap": "^1.5.4", "@vue-flow/minimap": "^1.5.4",
"@vue-flow/node-resizer": "^1.5.1", "@vue-flow/node-resizer": "^1.5.1",
"@vue-flow/node-toolbar": "^1.1.1", "@vue-flow/node-toolbar": "^1.1.1",
"@vueuse/core": "^14.4.0", "@vueuse/core": "^14.3.0",
"@vueuse/router": "^14.4.0", "@vueuse/router": "^14.3.0",
"@wangeditor-next/editor": "^6.2.0", "@wangeditor-next/editor": "^6.0.2",
"@wangeditor-next/editor-for-vue": "^6.2.0", "@wangeditor-next/editor-for-vue": "^5.1.14",
"axios": "^1.19.0", "axios": "^1.18.1",
"canvas-confetti": "^1.9.4", "canvas-confetti": "^1.9.4",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"chartjs-chart-wordcloud": "^4.4.5", "chartjs-chart-wordcloud": "^4.4.5",
"client-zip": "^2.5.0",
"codemirror": "^6.0.2", "codemirror": "^6.0.2",
"copy-text-to-clipboard": "^3.2.2", "copy-text-to-clipboard": "^3.2.2",
"date-fns": "^4.4.0", "date-fns": "^4.4.0",
"fflate": "^0.8.3", "fflate": "^0.8.3",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"md-editor-v3": "^6.5.6", "md-editor-v3": "^6.5.4",
"mermaid": "^11.16.1", "mermaid": "^11.16.0",
"mermaid-legacy": "npm:mermaid@^9.4.3", "mermaid-legacy": "npm:mermaid@^9.4.3",
"naive-ui": "^2.44.1", "naive-ui": "^2.44.1",
"nanoid": "^6.0.1", "nanoid": "^6.0.0",
"normalize.css": "^8.0.1", "normalize.css": "^8.0.1",
"pinia": "^4.0.2", "pinia": "^4.0.2",
"skulpt": "^1.2.0", "skulpt": "^1.2.0",
"vue": "^3.5.41", "vue": "^3.5.40",
"vue-chartjs": "^5.3.4", "vue-chartjs": "^5.3.4",
"vue-codemirror": "^6.1.1", "vue-codemirror": "^6.1.1",
"vue-router": "^5.2.0", "vue-router": "^5.2.0",
"y-codemirror.next": "^0.3.5", "y-codemirror.next": "^0.3.5",
"y-webrtc": "^10.3.0", "y-webrtc": "^10.3.0",
"yjs": "^13.6.32" "yjs": "^13.6.31"
}, },
"devDependencies": { "devDependencies": {
"@iconify/vue": "^5.0.1", "@iconify/vue": "^5.0.1",
"@types/canvas-confetti": "^1.9.0", "@types/canvas-confetti": "^1.9.0",
"@types/node": "^26.1.2", "@types/node": "^26.1.1",
"@vitejs/plugin-legacy": "^8.2.3", "@vitejs/plugin-legacy": "^8.2.2",
"@vitejs/plugin-vue": "^6.0.8", "@vitejs/plugin-vue": "^6.0.8",
"@vue/tsconfig": "^0.9.1", "@vue/tsconfig": "^0.9.1",
"prettier": "^3.9.6", "prettier": "^3.9.6",
"typescript": "^7.0.2", "typescript": "^7.0.2",
"unplugin-auto-import": "^21.1.0", "unplugin-auto-import": "^21.0.0",
"unplugin-vue-components": "^32.1.0", "unplugin-vue-components": "^32.1.0",
"vite": "^8.2.1" "vite": "^8.1.5"
} }
} }
@@ -1,185 +0,0 @@
<script setup lang="ts">
import {
createAchievement,
getMetricOptions,
updateAchievement,
type AdminAchievement,
type MetricOption,
} from "admin/api"
import AchievementIcon from "shared/components/AchievementIcon.vue"
import { RARITY_LABEL } from "utils/constants"
const rarityOptions = Object.entries(RARITY_LABEL).map(([value, label]) => ({
label,
value,
}))
const props = defineProps<{
show: boolean
editing: AdminAchievement | null
}>()
const emit = defineEmits<{ "update:show": [boolean]; saved: [] }>()
const message = useMessage()
const metrics = ref<MetricOption[]>([])
const saving = ref(false)
function emptyForm() {
return {
name: "",
description: "",
icon: "noto:trophy",
rarity: "bronze",
hidden: false,
metric: "",
operator: "gte" as "gte" | "lte",
threshold: 1,
visible: true,
order: 0,
}
}
const form = ref(emptyForm())
const metricOptions = computed(() =>
metrics.value.map((m) => ({ label: `${m.name}${m.key}`, value: m.key })),
)
const metricHelp = computed(
() => metrics.value.find((m) => m.key === form.value.metric)?.help_text ?? "",
)
watch(
() => props.show,
async (show) => {
if (!show) return
if (!metrics.value.length) {
const res = await getMetricOptions()
metrics.value = res.data
}
if (props.editing) {
form.value = { ...emptyForm(), ...props.editing }
} else {
form.value = { ...emptyForm(), metric: metrics.value[0]?.key ?? "" }
}
},
)
async function save() {
if (!form.value.name || !form.value.metric) {
message.error("名称和指标不能为空")
return
}
saving.value = true
try {
if (props.editing) {
await updateAchievement({ ...form.value, id: props.editing.id })
} else {
await createAchievement(form.value)
}
message.success("保存成功")
emit("update:show", false)
emit("saved")
} finally {
saving.value = false
}
}
</script>
<template>
<n-modal
:show="show"
preset="card"
style="width: 560px"
:title="editing ? '编辑成就' : '新建成就'"
@update:show="emit('update:show', $event)"
>
<n-form label-placement="left" :label-width="80">
<n-form-item label="名称" required>
<n-input v-model:value="form.name" placeholder="成就名称" />
</n-form-item>
<n-form-item label="描述" required>
<n-input
v-model:value="form.description"
type="textarea"
placeholder="达成条件的描述,展示给学生看"
/>
</n-form-item>
<n-form-item label="图标">
<n-flex vertical :size="4" style="flex: 1">
<n-flex align="center" :size="10" :wrap="false">
<div class="icon-preview">
<AchievementIcon :icon="form.icon" :size="28" />
</div>
<n-input
v-model:value="form.icon"
placeholder="iconify 图标名,例如 noto:owl"
/>
</n-flex>
<n-text depth="3" style="font-size: 12px">
iconify 图标名推荐 noto: 开头的彩色 emoji 图标左侧是实时
预览预览不出来说明名字写错了图标名可在 icon-sets.iconify.design
搜索
</n-text>
</n-flex>
</n-form-item>
<n-form-item label="稀有度">
<n-select v-model:value="form.rarity" :options="rarityOptions" />
</n-form-item>
<n-form-item label="指标" required>
<n-select
v-model:value="form.metric"
:options="metricOptions"
filterable
/>
</n-form-item>
<n-form-item v-if="metricHelp" label=" ">
<n-text depth="3">{{ metricHelp }}</n-text>
</n-form-item>
<n-form-item label="条件">
<n-flex align="center">
<n-select
v-model:value="form.operator"
style="width: 130px"
:options="[
{ label: '大于等于', value: 'gte' },
{ label: '小于等于', value: 'lte' },
]"
/>
<n-input-number v-model:value="form.threshold" :min="0" />
</n-flex>
</n-form-item>
<n-form-item label="隐藏成就">
<n-switch v-model:value="form.hidden" />
<n-text depth="3" style="margin-left: 12px">
未解锁时学生只能看到 ???
</n-text>
</n-form-item>
<n-form-item label="上架">
<n-switch v-model:value="form.visible" />
</n-form-item>
<n-form-item label="排序">
<n-input-number v-model:value="form.order" />
</n-form-item>
</n-form>
<template #footer>
<n-flex justify="end">
<n-button @click="emit('update:show', false)">取消</n-button>
<n-button type="primary" :loading="saving" @click="save">
保存
</n-button>
</n-flex>
</template>
</n-modal>
</template>
<style scoped>
.icon-preview {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 34px;
flex: none;
}
</style>
-144
View File
@@ -1,144 +0,0 @@
<script setup lang="ts">
import { NButton, NFlex } from "naive-ui"
import {
deleteAchievement,
getAdminAchievements,
type AdminAchievement,
} from "admin/api"
import AchievementIcon from "shared/components/AchievementIcon.vue"
import AchievementModal from "./components/AchievementModal.vue"
import { RARITY_LABEL } from "utils/constants"
const message = useMessage()
const dialog = useDialog()
const list = ref<AdminAchievement[]>([])
const loading = ref(false)
const showModal = ref(false)
const editing = ref<AdminAchievement | null>(null)
// 下架的只在有的时候才提,全部上架时标题不啰嗦
const title = computed(() => {
if (!list.value.length) return "成就管理"
const offline = list.value.filter((a) => !a.visible).length
return offline
? `成就管理(${list.value.length} 条,${offline} 条下架)`
: `成就管理(${list.value.length} 条)`
})
async function load() {
loading.value = true
try {
const res = await getAdminAchievements()
list.value = res.data
} finally {
loading.value = false
}
}
function create() {
editing.value = null
showModal.value = true
}
function edit(row: AdminAchievement) {
editing.value = row
showModal.value = true
}
function remove(row: AdminAchievement) {
dialog.warning({
title: "删除成就",
content: `确定删除「${row.name}」?已解锁记录会一并删除。`,
positiveText: "删除",
negativeText: "取消",
onPositiveClick: async () => {
await deleteAchievement(row.id)
message.success("已删除")
load()
},
})
}
const columns: DataTableColumn<AdminAchievement>[] = [
{
title: "图标",
key: "icon",
width: 60,
render: (row) => h(AchievementIcon, { icon: row.icon, size: 24 }),
},
{ title: "名称", key: "name" },
{
title: "稀有度",
key: "rarity",
width: 90,
render: (row) => RARITY_LABEL[row.rarity] ?? row.rarity,
},
{ title: "指标", key: "metric_name" },
{
title: "条件",
key: "threshold",
width: 110,
render: (row) => `${row.operator === "gte" ? "≥" : "≤"} ${row.threshold}`,
},
{
title: "隐藏",
key: "hidden",
width: 70,
render: (row) => (row.hidden ? "是" : "—"),
},
{
title: "上架",
key: "visible",
width: 70,
render: (row) => (row.visible ? "是" : "否"),
},
{ title: "已解锁人数", key: "unlock_count", width: 110 },
{
title: "操作",
key: "actions",
width: 130,
render: (row) =>
h(NFlex, { size: 8 }, () => [
h(
NButton,
{ text: true, type: "primary", onClick: () => edit(row) },
() => "编辑",
),
h(
NButton,
{ text: true, type: "error", onClick: () => remove(row) },
() => "删除",
),
]),
},
]
onMounted(load)
</script>
<template>
<n-card :title="title">
<template #header-extra>
<n-button type="primary" @click="create">新建成就</n-button>
</template>
<n-alert type="info" style="margin-bottom: 12px">
已解锁人数是唯一的仪表盘配置一周后仍为
0多半是阈值配错了而不是太难
</n-alert>
<n-data-table
:loading="loading"
:data="list"
:columns="columns"
:row-key="(row: AdminAchievement) => row.id"
/>
<AchievementModal
v-model:show="showModal"
:editing="editing"
@saved="load"
/>
</n-card>
</template>
+10 -77
View File
@@ -2,7 +2,6 @@ import http from "utils/http"
import { toProblemListItem } from "admin/transforms" import { toProblemListItem } from "admin/transforms"
import type { import type {
AdminProblem, AdminProblem,
AdminTag,
Announcement, Announcement,
AnnouncementEdit, AnnouncementEdit,
BlankContest, BlankContest,
@@ -32,7 +31,6 @@ export async function getProblemList(
keyword: string, keyword: string,
author?: string, author?: string,
contestID?: string, contestID?: string,
tagId?: number,
) { ) {
const endpoint = !!contestID ? "admin/contest/problem" : "admin/problem" const endpoint = !!contestID ? "admin/contest/problem" : "admin/problem"
const res = await http.get<{ results: AdminProblem[]; total: number }>( const res = await http.get<{ results: AdminProblem[]; total: number }>(
@@ -45,7 +43,6 @@ export async function getProblemList(
keyword, keyword,
author, author,
contest_id: contestID, contest_id: contestID,
tag_id: tagId,
}, },
}, },
) )
@@ -87,35 +84,6 @@ export function getContestProblem(id: number) {
return http.get("admin/contest/problem", { params: { id } }) return http.get("admin/contest/problem", { params: { id } })
} }
// 标签管理
export function getTagAdminList(keyword = "") {
return http.get<AdminTag[]>("admin/problem/tag", { params: { keyword } })
}
export function renameTag(id: number, name: string) {
return http.put<{
merged: boolean
id: number
name: string
affected_count: number
}>("admin/problem/tag", { id, name })
}
export function deleteTag(id: number) {
return http.delete("admin/problem/tag", { params: { id } })
}
export function batchTagProblems(
problemIds: number[],
tagNames: string[],
action: "add" | "remove",
) {
return http.post<{ problem_count: number; tag_count: number }>(
"admin/problem/batch_tag",
{ problem_ids: problemIds, tag_names: tagNames, action },
)
}
// 用户列表 // 用户列表
export function getUserList( export function getUserList(
offset = 0, offset = 0,
@@ -287,6 +255,16 @@ export function createAnnouncement(announcement: AnnouncementEdit) {
return http.post("admin/announcement", announcement) return http.post("admin/announcement", announcement)
} }
export function getCommentList(offset = 0, limit = 10, problem: string) {
return http.get("admin/comment", {
params: { offset, limit, problem },
})
}
export function deleteComment(id: number) {
return http.delete("admin/comment", { params: { id } })
}
export async function getTutorialList() { export async function getTutorialList() {
const res = await http.get<Tutorial[]>("admin/tutorial") const res = await http.get<Tutorial[]>("admin/tutorial")
return res.data return res.data
@@ -553,48 +531,3 @@ export function pinAIReport(id: number) {
export function getPinnedAIReports() { export function getPinnedAIReports() {
return http.get("admin/ai/reports", { params: { pinned_only: "true" } }) return http.get("admin/ai/reports", { params: { pinned_only: "true" } })
} }
// ==================== 成就 ====================
export interface AdminAchievement {
id: number
name: string
description: string
icon: string
rarity: string
hidden: boolean
metric: string
metric_name: string
operator: "gte" | "lte"
threshold: number
visible: boolean
unlock_count: number
order: number
create_time: string
}
export interface MetricOption {
key: string
name: string
help_text: string
}
export function getAdminAchievements() {
return http.get<AdminAchievement[]>("admin/achievement")
}
export function getMetricOptions() {
return http.get<MetricOption[]>("admin/achievement/metrics")
}
export function createAchievement(data: Partial<AdminAchievement>) {
return http.post<AdminAchievement>("admin/achievement", data)
}
export function updateAchievement(data: Partial<AdminAchievement>) {
return http.put<AdminAchievement>("admin/achievement", data)
}
export function deleteAchievement(id: number) {
return http.delete("admin/achievement", { params: { id } })
}
+114
View File
@@ -0,0 +1,114 @@
<template>
<n-flex justify="space-between" class="titleWrapper">
<h2 class="title">评论列表只列出有内容的</h2>
<div>
<n-input
v-model:value="query.problem"
clearable
placeholder="输入题目序号"
/>
</div>
</n-flex>
<n-data-table striped :columns="columns" :data="comments" />
<Pagination
:total="total"
v-model:limit="query.limit"
v-model:page="query.page"
/>
</template>
<script lang="ts" setup>
import { NButton } from "naive-ui"
import Pagination from "shared/components/Pagination.vue"
import { parseTime } from "utils/functions"
import type { Comment } from "utils/types"
import { getCommentList } from "../api"
import CommentActions from "./components/CommentActions.vue"
const comments = ref<Comment[]>([])
const total = ref(0)
const query = reactive({
limit: 10,
page: 1,
problem: "",
})
const columns: DataTableColumn<Comment>[] = [
{
title: "题目",
key: "problem",
width: 100,
render: (row) =>
h(
NButton,
{
text: true,
type: "info",
onClick: () => window.open("/problem/" + row.problem, "_blank"),
},
() => row.problem,
),
},
{
title: "提交",
key: "submission",
width: 200,
render: (row) =>
h(
NButton,
{
text: true,
type: "info",
onClick: () => window.open("/submission/" + row.submission, "_blank"),
},
() => row.submission.slice(0, 12),
),
},
{ title: "描述评分", key: "description_rating", width: 100 },
{ title: "难度评分", key: "difficulty_rating", width: 100 },
{ title: "综合评分", key: "comprehensive_rating", width: 100 },
{ title: "用户", key: "user.username", width: 150 },
{
title: "时间",
key: "create_time",
render: (row) => parseTime(row.create_time, "YYYY-MM-DD HH:mm:ss"),
width: 200,
},
{
title: "内容",
key: "content",
minWidth: 200,
maxWidth: 300,
ellipsis: true,
},
{
title: "选项",
key: "action",
width: 100,
render: (row) =>
h(CommentActions, { commentID: row.id, onDeleted: listComments }),
},
]
async function listComments() {
const offset = (query.page - 1) * query.limit
const res = await getCommentList(offset, query.limit, query.problem)
comments.value = res.data.results
total.value = res.data.total
}
onMounted(listComments)
watch(() => [query.page, query.limit], listComments)
watchDebounced(() => query.problem, listComments, {
debounce: 500,
maxWait: 1000,
})
</script>
<style scoped>
.titleWrapper {
margin-bottom: 16px;
}
.title {
margin: 0;
}
</style>
@@ -0,0 +1,22 @@
<script lang="ts" setup>
import { deleteComment } from "admin/api"
const props = defineProps<{ commentID: number }>()
const emit = defineEmits(["deleted"])
const message = useMessage()
async function submit() {
await deleteComment(props.commentID)
message.success("成功删除")
emit("deleted")
}
</script>
<template>
<n-popconfirm @positive-click="submit">
<template #trigger>
<n-button secondary size="small" type="error">删除</n-button>
</template>
确定删除这条评论吗
</n-popconfirm>
</template>
@@ -1,109 +0,0 @@
<script setup lang="ts">
import type { AdminTag } from "utils/types"
import { batchTagProblems, getTagAdminList } from "admin/api"
interface Props {
show: boolean
problemIds: number[]
action: "add" | "remove"
}
const props = defineProps<Props>()
const emit = defineEmits<{
"update:show": [value: boolean]
done: []
}>()
const message = useMessage()
const tags = ref<AdminTag[]>([])
const selected = ref<string[]>([])
const newTags = ref<string[]>([])
const title = computed(() =>
props.action === "add" ? "批量添加标签" : "批量移除标签",
)
const selectedSet = computed(() => new Set(selected.value))
const names = computed(() =>
props.action === "add"
? Array.from(new Set([...selected.value, ...newTags.value]))
: selected.value,
)
function toggleTag(name: string) {
const set = new Set(selected.value)
if (set.has(name)) set.delete(name)
else set.add(name)
selected.value = Array.from(set)
}
async function listTags() {
const res = await getTagAdminList()
tags.value = res.data
}
function close() {
emit("update:show", false)
}
async function submit() {
if (!names.value.length) {
message.error("请先选择标签")
return
}
const res = await batchTagProblems(
props.problemIds,
names.value,
props.action,
)
const verb = props.action === "add" ? "添加" : "移除"
message.success(
`已为 ${res.data.problem_count} 道题${verb} ${res.data.tag_count} 个标签`,
)
close()
emit("done")
}
watch(
() => props.show,
(show) => {
if (!show) return
selected.value = []
newTags.value = []
listTags()
},
)
</script>
<template>
<n-modal
:show="show"
preset="card"
:title="title"
style="width: 600px"
:mask-closable="false"
@close="close"
>
<n-flex vertical size="large">
<div>已选中 {{ problemIds.length }} 道题目</div>
<n-flex size="small">
<n-tag
v-for="tag in tags"
:key="tag.id"
checkable
:checked="selectedSet.has(tag.name)"
@update:checked="toggleTag(tag.name)"
>
{{ tag.name }}{{ tag.problem_count }}
</n-tag>
</n-flex>
<n-dynamic-tags v-if="action === 'add'" v-model:value="newTags" />
<n-flex justify="end">
<n-button @click="close">取消</n-button>
<n-button type="primary" @click="submit">确定</n-button>
</n-flex>
</n-flex>
</n-modal>
</template>
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { downloadZip } from "client-zip"
import type { LANGUAGE, SQLDisplay, Testcase } from "utils/types" import type { LANGUAGE, SQLDisplay, Testcase } from "utils/types"
import { createZipBlob } from "utils/functions"
import SQLDataTable from "oj/problem/components/SQLDataTable.vue" import SQLDataTable from "oj/problem/components/SQLDataTable.vue"
import { import {
generateSQLTestcase, generateSQLTestcase,
@@ -156,13 +156,15 @@ async function preview() {
async function upload() { async function upload() {
isUploading.value = true isUploading.value = true
try { try {
const now = new Date()
const data = scripts.value const data = scripts.value
.filter((s) => s.sql.trim()) .filter((s) => s.sql.trim())
.map((s, i) => ({ .map((s, i) => ({
name: `${i + 1}.sql`, name: `${i + 1}.sql`,
content: s.sql, input: s.sql,
lastModified: now,
})) }))
const blob = createZipBlob(data) const blob = await downloadZip(data).blob()
const file = new File([blob], "testcase.zip", { type: "application/zip" }) const file = new File([blob], "testcase.zip", { type: "application/zip" })
const res = await uploadTestcases(file, { sql: true }) const res = await uploadTestcases(file, { sql: true })
@@ -1,147 +0,0 @@
<script setup lang="ts">
import { NButton, NTag } from "naive-ui"
import Pagination from "shared/components/Pagination.vue"
import type { AdminProblemFiltered } from "utils/types"
import { batchTagProblems, getProblemList } from "admin/api"
interface Props {
show: boolean
tagId: number
tagName: string
}
const props = defineProps<Props>()
const emit = defineEmits<{
"update:show": [value: boolean]
changed: []
}>()
const router = useRouter()
const message = useMessage()
const problems = ref<AdminProblemFiltered[]>([])
const total = ref(0)
const page = ref(1)
const limit = ref(10)
const keyword = ref("")
const columns: DataTableColumn<AdminProblemFiltered>[] = [
{ title: "显示编号", key: "_id", width: 100 },
{
title: "标题",
key: "title",
minWidth: 200,
render: (row) =>
h(
NButton,
{ text: true, type: "primary", onClick: () => goEdit(row) },
() => row.title,
),
},
{
title: "可见",
key: "visible",
width: 80,
render: (row) =>
h(
NTag,
{ size: "small", type: row.visible ? "success" : "default" },
() => (row.visible ? "公开" : "隐藏"),
),
},
{
title: "选项",
key: "actions",
width: 110,
render: (row) =>
h(
NButton,
{ size: "small", type: "error", onClick: () => removeTag(row) },
() => "移除标签",
),
},
]
async function listProblems() {
if (page.value < 1) page.value = 1
const offset = (page.value - 1) * limit.value
const res = await getProblemList(
offset,
limit.value,
keyword.value,
"",
undefined,
props.tagId,
)
problems.value = res.results
total.value = res.total
}
function close() {
emit("update:show", false)
}
function goEdit(row: AdminProblemFiltered) {
close()
router.push({ name: "admin problem edit", params: { problemID: row.id } })
}
async function removeTag(row: AdminProblemFiltered) {
await batchTagProblems([row.id], [props.tagName], "remove")
message.success(`已移除「${row.title}」的标签`)
emit("changed")
// 移掉本页最后一条时退回上一页,交给下面的 watcher 重新拉取
if (problems.value.length === 1 && page.value > 1) {
page.value -= 1
} else {
listProblems()
}
}
// 改搜索词就回到第一页
watch(keyword, () => (page.value = 1))
// 每次打开弹窗重置状态,拉取交给下面的 watcher
watch(
() => props.show,
(show) => {
if (!show) return
page.value = 1
keyword.value = ""
},
)
// 打开 / 翻页 / 改每页条数 / 改搜索词都走这里,防抖把同一批变更合并成一次请求
watchDebounced(
() => [props.show, props.tagId, page.value, limit.value, keyword.value],
() => {
if (!props.show) return
listProblems()
},
{ debounce: 300, maxWait: 800 },
)
</script>
<template>
<n-modal
:show="show"
preset="card"
:title="`标签「${tagName}」下的题目`"
style="width: 720px"
@close="close"
>
<n-flex vertical size="large">
<n-flex justify="space-between" align="center">
<span> {{ total }} 道题</span>
<n-input
v-model:value="keyword"
style="width: 220px"
placeholder="输入标题关键字"
clearable
/>
</n-flex>
<n-data-table striped :columns="columns" :data="problems" />
<Pagination :total="total" v-model:limit="limit" v-model:page="page" />
</n-flex>
</n-modal>
</template>
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { downloadZip } from "client-zip"
import type { LANGUAGE, Testcase } from "utils/types" import type { LANGUAGE, Testcase } from "utils/types"
import { createZipBlob } from "utils/functions"
import { createTestSubmission } from "utils/judge" import { createTestSubmission } from "utils/judge"
import { uploadTestcases } from "../../api" import { uploadTestcases } from "../../api"
@@ -158,14 +158,15 @@ async function run() {
async function upload() { async function upload() {
isUploading.value = true isUploading.value = true
try { try {
const now = new Date()
const data = files.value const data = files.value
.filter((f) => f.in.trim() && f.out && !f.error) .filter((f) => f.in.trim() && f.out && !f.error)
.flatMap((f, i) => [ .flatMap((f, i) => [
{ name: `${i + 1}.in`, content: f.in }, { name: `${i + 1}.in`, input: f.in, lastModified: now },
{ name: `${i + 1}.out`, content: f.out }, { name: `${i + 1}.out`, input: f.out, lastModified: now },
]) ])
const blob = createZipBlob(data) const blob = await downloadZip(data).blob()
const file = new File([blob], "testcase.zip", { type: "application/zip" }) const file = new File([blob], "testcase.zip", { type: "application/zip" })
const res = await uploadTestcases(file) const res = await uploadTestcases(file)
+4 -76
View File
@@ -1,18 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { NFlex, NSwitch, NTag, NTooltip } from "naive-ui" import { NFlex, NSwitch, NTag } from "naive-ui"
import { Icon } from "@iconify/vue" import { Icon } from "@iconify/vue"
import Pagination from "shared/components/Pagination.vue" import Pagination from "shared/components/Pagination.vue"
import { usePagination } from "shared/composables/pagination" import { usePagination } from "shared/composables/pagination"
import { getTagColor, parseTime } from "utils/functions" import { getTagColor, parseTime } from "utils/functions"
import type { AdminProblemFiltered } from "utils/types" import type { AdminProblemFiltered } from "utils/types"
import { DIFFICULTY, REACTIONS } from "utils/constants" import { DIFFICULTY } from "utils/constants"
import { getProblemList, toggleProblemVisible } from "../api" import { getProblemList, toggleProblemVisible } from "../api"
import Actions from "./components/Actions.vue" import Actions from "./components/Actions.vue"
import Modal from "./components/Modal.vue" import Modal from "./components/Modal.vue"
import { useRouteQuery } from "@vueuse/router" import { useRouteQuery } from "@vueuse/router"
import AuthorSelect from "shared/components/AuthorSelect.vue" import AuthorSelect from "shared/components/AuthorSelect.vue"
import type { DataTableRowKey } from "naive-ui"
import BatchTagModal from "./components/BatchTagModal.vue"
interface Props { interface Props {
contestID?: string contestID?: string
@@ -38,30 +36,6 @@ const { count, inc } = useCounter(0)
const total = ref(0) const total = ref(0)
const problems = ref<AdminProblemFiltered[]>([]) const problems = ref<AdminProblemFiltered[]>([])
const selectedRowKeys = ref<DataTableRowKey[]>([])
const batchTagAction = ref<"add" | "remove">("add")
const [showBatchTag, toggleBatchTag] = useToggle(false)
const selectedProblemIds = computed(() =>
selectedRowKeys.value.map((key) => Number(key)),
)
const rowKey = (row: AdminProblemFiltered) => row.id
function chooseProblems(rowKeys: DataTableRowKey[]) {
selectedRowKeys.value = rowKeys
}
function openBatchTag(action: "add" | "remove") {
batchTagAction.value = action
toggleBatchTag(true)
}
function onBatchTagDone() {
selectedRowKeys.value = []
listProblems()
}
const nextDisplayID = computed(() => { const nextDisplayID = computed(() => {
if (!isContestProblemList.value) return "" if (!isContestProblemList.value) return ""
if (problems.value.length === 0) return "1" if (problems.value.length === 0) return "1"
@@ -83,7 +57,7 @@ const { query, clearQuery } = usePagination<ProblemQuery>({
author: useRouteQuery("author", "").value, author: useRouteQuery("author", "").value,
}) })
const baseColumns: DataTableColumn<AdminProblemFiltered>[] = [ const columns: DataTableColumn<AdminProblemFiltered>[] = [
{ title: "ID", key: "id", width: 100 }, { title: "ID", key: "id", width: 100 },
{ title: "显示编号", key: "_id", width: 100 }, { title: "显示编号", key: "_id", width: 100 },
{ title: "标题", key: "title", minWidth: 200 }, { title: "标题", key: "title", minWidth: 200 },
@@ -135,21 +109,6 @@ const baseColumns: DataTableColumn<AdminProblemFiltered>[] = [
: null, : null,
]), ]),
}, },
{
title: "反馈",
key: "top_reaction",
width: 60,
render: (row) => {
const top = row.top_reaction
if (!top) return null
const reaction = REACTIONS.find((it) => it.key === top.type)
if (!reaction) return null
return h(NTooltip, null, {
trigger: () => h(Icon, { width: 18, icon: reaction.icon }),
default: () => `${reaction.label} ${top.count}`,
})
},
},
{ title: "出题人", key: "username", width: 120 }, { title: "出题人", key: "username", width: 120 },
{ {
title: "创建时间", title: "创建时间",
@@ -182,13 +141,6 @@ const baseColumns: DataTableColumn<AdminProblemFiltered>[] = [
}, },
] ]
// 比赛题目接口不返回 top_reaction,这一列只在普通题目列表里显示
const columns = computed<DataTableColumn<AdminProblemFiltered>[]>(() =>
isContestProblemList.value
? baseColumns.filter((it) => !("key" in it) || it.key !== "top_reaction")
: [{ type: "selection" }, ...baseColumns],
)
async function listProblems() { async function listProblems() {
if (query.page < 1) query.page = 1 if (query.page < 1) query.page = 1
const offset = (query.page - 1) * query.limit const offset = (query.page - 1) * query.limit
@@ -260,20 +212,8 @@ watch(() => [query.page, query.limit, query.author], listProblems)
> >
年度趋势 年度趋势
</n-button> </n-button>
<n-button
v-if="!isContestProblemList"
@click="$router.push({ name: 'admin tag list' })"
>
标签管理
</n-button>
</n-flex> </n-flex>
<n-flex> <n-flex>
<template v-if="!isContestProblemList && selectedProblemIds.length">
<n-button type="primary" @click="openBatchTag('add')">
添加标签{{ selectedProblemIds.length }}
</n-button>
<n-button @click="openBatchTag('remove')">移除标签</n-button>
</template>
<n-button v-if="isContestProblemList" @click="createContestProblem"> <n-button v-if="isContestProblemList" @click="createContestProblem">
新建比赛题目 新建比赛题目
</n-button> </n-button>
@@ -298,13 +238,7 @@ watch(() => [query.page, query.limit, query.author], listProblems)
</div> </div>
</n-flex> </n-flex>
</n-flex> </n-flex>
<n-data-table <n-data-table striped :columns="columns" :data="problems" />
striped
:columns="columns"
:data="problems"
:row-key="rowKey"
@update:checked-row-keys="chooseProblems"
/>
<Pagination <Pagination
:total="total" :total="total"
v-model:limit="query.limit" v-model:limit="query.limit"
@@ -316,12 +250,6 @@ watch(() => [query.page, query.limit, query.author], listProblems)
:next-display-id="nextDisplayID" :next-display-id="nextDisplayID"
@change="listProblems" @change="listProblems"
/> />
<BatchTagModal
v-model:show="showBatchTag"
:problem-ids="selectedProblemIds"
:action="batchTagAction"
@done="onBatchTagDone"
/>
</template> </template>
<style scoped> <style scoped>
-186
View File
@@ -1,186 +0,0 @@
<script setup lang="ts">
import { NButton, NFlex, NInput } from "naive-ui"
import type { AdminTag } from "utils/types"
import { deleteTag, getTagAdminList, renameTag } from "../api"
import TagProblemsModal from "./components/TagProblemsModal.vue"
const message = useMessage()
const dialog = useDialog()
const tags = ref<AdminTag[]>([])
const keyword = ref("")
const editingId = ref<number | null>(null)
const editingName = ref("")
const activeTag = ref<AdminTag | null>(null)
const [showTagProblems, toggleTagProblems] = useToggle(false)
function openTagProblems(tag: AdminTag) {
activeTag.value = tag
toggleTagProblems(true)
}
const columns: DataTableColumn<AdminTag>[] = [
{ title: "ID", key: "id", width: 80 },
{
title: "标签名",
key: "name",
minWidth: 200,
render: (row) =>
editingId.value === row.id
? h(NInput, {
value: editingName.value,
autofocus: true,
size: "small",
style: "max-width: 240px",
onUpdateValue: (v: string) => (editingName.value = v),
onKeyup: (e: KeyboardEvent) => {
if (e.key === "Enter") saveTag(row)
if (e.key === "Escape") cancelEdit()
},
})
: h(
NButton,
{
text: true,
type: "primary",
onClick: () => openTagProblems(row),
},
() => row.name,
),
},
{
title: "题目数",
key: "problem_count",
width: 100,
render: (row) =>
h(
NButton,
{ text: true, type: "primary", onClick: () => openTagProblems(row) },
() => String(row.problem_count),
),
},
{
title: "选项",
key: "actions",
width: 200,
render: (row) =>
h(NFlex, { size: 8 }, () =>
editingId.value === row.id
? [
h(
NButton,
{ size: "small", type: "primary", onClick: () => saveTag(row) },
() => "保存",
),
h(NButton, { size: "small", onClick: cancelEdit }, () => "取消"),
]
: [
h(
NButton,
{ size: "small", onClick: () => startEdit(row) },
() => "重命名",
),
h(
NButton,
{
size: "small",
type: "error",
onClick: () => confirmDelete(row),
},
() => "删除",
),
],
),
},
]
async function listTags() {
const res = await getTagAdminList(keyword.value)
tags.value = res.data
}
function startEdit(tag: AdminTag) {
editingId.value = tag.id
editingName.value = tag.name
}
function cancelEdit() {
editingId.value = null
editingName.value = ""
}
async function saveTag(tag: AdminTag) {
const name = editingName.value.trim()
if (!name) {
message.error("标签名不能为空")
return
}
if (name === tag.name) {
cancelEdit()
return
}
const res = await renameTag(tag.id, name)
if (res.data.merged) {
message.success(
`已合并到「${res.data.name}」,影响 ${res.data.affected_count} 道题`,
)
} else {
message.success("已重命名")
}
cancelEdit()
listTags()
}
function confirmDelete(tag: AdminTag) {
dialog.warning({
title: "删除标签",
content: `确定删除标签「${tag.name}」吗?当前有 ${tag.problem_count} 道题在使用它,删除后这些题目会失去该标签。`,
positiveText: "删除",
negativeText: "取消",
onPositiveClick: async () => {
await deleteTag(tag.id)
message.success("已删除")
listTags()
},
})
}
onMounted(listTags)
watchDebounced(keyword, listTags, { debounce: 500, maxWait: 1000 })
</script>
<template>
<n-flex class="titleWrapper" justify="space-between">
<n-flex align="center">
<h2 class="title">标签管理</h2>
<n-button @click="$router.push({ name: 'admin problem list' })">
返回题目列表
</n-button>
</n-flex>
<n-input
v-model:value="keyword"
style="width: 200px"
placeholder="搜索标签"
clearable
/>
</n-flex>
<n-data-table striped :columns="columns" :data="tags" />
<TagProblemsModal
v-model:show="showTagProblems"
:tag-id="activeTag?.id ?? 0"
:tag-name="activeTag?.name ?? ''"
@changed="listTags"
/>
</template>
<style scoped>
.titleWrapper {
margin-bottom: 16px;
}
.title {
margin: 0;
}
</style>
-25
View File
@@ -1,10 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { NButton, NTag } from "naive-ui" import { NButton, NTag } from "naive-ui"
import {
CLASS_NAME_MAX_DIGITS,
CLASS_NAME_MIN_DIGITS,
CLASS_NAME_RE,
} from "utils/constants"
import { parseTime } from "utils/functions" import { parseTime } from "utils/functions"
import type { Server } from "utils/types" import type { Server } from "utils/types"
import { useConfigStore } from "shared/store/config" import { useConfigStore } from "shared/store/config"
@@ -145,21 +140,7 @@ async function getWebsiteConfig() {
} }
async function saveWebsiteConfig() { async function saveWebsiteConfig() {
// 班级号要和用户名里 ks 后面那段对得上,位数不对登录页会查不到该班学生。
// 后端 CreateEditWebsiteConfigSerializer 也会拦,这里先报更明确的错
const invalid = websiteConfig.class_list.filter((c) => !CLASS_NAME_RE.test(c))
if (invalid.length) {
message.error(
`班级号 ${invalid.join("、")} 必须是 ${CLASS_NAME_MIN_DIGITS}~${CLASS_NAME_MAX_DIGITS} 位数字`,
)
return
}
try {
await editWebsite(websiteConfig) await editWebsite(websiteConfig)
} catch (err: any) {
message.error("保存失败:" + err.data)
return
}
message.success("网站配置保存成功") message.success("网站配置保存成功")
getWebsiteConfig() getWebsiteConfig()
configStore.getConfig() configStore.getConfig()
@@ -233,13 +214,7 @@ onMounted(() => {
</n-form> </n-form>
<n-form label-placement="left"> <n-form label-placement="left">
<n-form-item label="班级列表"> <n-form-item label="班级列表">
<n-flex vertical size="small">
<n-dynamic-tags v-model:value="websiteConfig.class_list" /> <n-dynamic-tags v-model:value="websiteConfig.class_list" />
<n-text depth="3" style="font-size: 12px">
{{ CLASS_NAME_MIN_DIGITS }}~{{ CLASS_NAME_MAX_DIGITS }}
位数字 2512510要和用户名里 ks 后面那段一致
</n-text>
</n-flex>
</n-form-item> </n-form-item>
</n-form> </n-form>
<n-flex align="center"> <n-flex align="center">
-2
View File
@@ -14,7 +14,5 @@ export function toProblemListItem(result: AdminProblem) {
has_ast_rules: result.has_ast_rules, has_ast_rules: result.has_ast_rules,
allow_flowchart: result.allow_flowchart, allow_flowchart: result.allow_flowchart,
show_flowchart: result.show_flowchart, show_flowchart: result.show_flowchart,
// 比赛题目列表接口不返回这个字段
top_reaction: result.top_reaction ?? null,
} }
} }
+2 -19
View File
@@ -1,10 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import {
CLASS_NAME_MAX_DIGITS,
CLASS_NAME_MAX_VALUE,
CLASS_NAME_MIN_DIGITS,
CLASS_NAME_MIN_VALUE,
} from "utils/constants"
import { importUsers } from "../api" import { importUsers } from "../api"
const message = useMessage() const message = useMessage()
@@ -18,17 +12,6 @@ function generateUsers() {
message.info("请填写相关内容") message.info("请填写相关内容")
return false return false
} }
// get_class_name
//
if (
prefix.value &&
(prefix.value < CLASS_NAME_MIN_VALUE || prefix.value > CLASS_NAME_MAX_VALUE)
) {
message.error(
`班级号 ${prefix.value} 必须是 ${CLASS_NAME_MIN_DIGITS}~${CLASS_NAME_MAX_DIGITS} 位数字`,
)
return false
}
let className = !!prefix.value ? `ks${prefix.value}` : "" let className = !!prefix.value ? `ks${prefix.value}` : ""
rawInput.value = rawInput.value.trim() rawInput.value = rawInput.value.trim()
const inputs = rawInput.value.split("\n") const inputs = rawInput.value.split("\n")
@@ -81,9 +64,9 @@ async function submit() {
style="width: 170px" style="width: 170px"
v-model:value="prefix" v-model:value="prefix"
clearable clearable
:max="CLASS_NAME_MAX_VALUE" :max="9999"
:min="0" :min="0"
:placeholder="`班级号${CLASS_NAME_MIN_DIGITS}~${CLASS_NAME_MAX_DIGITS} 位)`" placeholder="班级号"
/> />
</n-flex> </n-flex>
<n-input <n-input
+2 -10
View File
@@ -172,6 +172,7 @@ function createNewUser() {
problem_permission: "None", problem_permission: "None",
create_time: new Date(), create_time: new Date(),
last_login: new Date(), last_login: new Date(),
two_factor_auth: false,
open_api: false, open_api: false,
is_disabled: false, is_disabled: false,
password: "", password: "",
@@ -196,9 +197,6 @@ async function handleEditUser() {
message.error("密码长度不得小于 6") message.error("密码长度不得小于 6")
return return
} }
// http login-required / permission-denied
//
try {
if (create.value) { if (create.value) {
const newUser = [ const newUser = [
[ [
@@ -211,15 +209,9 @@ async function handleEditUser() {
await importUsers(newUser) await importUsers(newUser)
listUsers() listUsers()
} else { } else {
const user = Object.assign(userEditing.value, { const user = Object.assign(userEditing.value, { password: password.value })
password: password.value,
})
await editUser(user) await editUser(user)
} }
} catch (err: any) {
message.error("保存失败:" + err.data)
return
}
userEditing.value = null userEditing.value = null
password.value = "" password.value = ""
toggleCreate(false) toggleCreate(false)
-27
View File
@@ -1,27 +0,0 @@
import http from "utils/http"
import type {
Achievement,
AchievementSummary,
PendingAchievement,
} from "utils/types"
export function getAchievements(name?: string) {
return http.get<{ username: string; achievements: Achievement[] }>(
"achievements",
{ params: name ? { name } : {} },
)
}
export function getAchievementSummary(name?: string) {
return http.get<AchievementSummary>("achievements/summary", {
params: name ? { name } : {},
})
}
export function getPendingAchievements() {
return http.get<PendingAchievement[]>("achievements/pending")
}
export function markAchievementsRead(ids: number[]) {
return http.post("achievements/pending", { ids })
}
@@ -1,134 +0,0 @@
<script setup lang="ts">
import AchievementIcon from "shared/components/AchievementIcon.vue"
import { useRarityColor } from "shared/composables/rarity"
import { RARITY_COLOR, RARITY_LABEL } from "utils/constants"
import type { Achievement } from "utils/types"
const props = defineProps<{ achievement: Achievement }>()
// tag
const rarityTextColor = useRarityColor()
// // ??? null
// null
const masked = computed(
() => props.achievement.hidden && !props.achievement.unlocked,
)
// 5%
const isRare = computed(
() => props.achievement.unlock_rate > 0 && props.achievement.unlock_rate < 5,
)
// ""lte AC 50
//
const showProgressBar = computed(
() =>
!masked.value &&
!props.achievement.unlocked &&
props.achievement.operator === "gte" &&
props.achievement.threshold !== null,
)
const showBestSoFar = computed(
() =>
!masked.value &&
!props.achievement.unlocked &&
props.achievement.operator === "lte" &&
props.achievement.threshold !== null,
)
const percent = computed(() => {
const { progress, threshold } = props.achievement
if (threshold === null || threshold <= 0) return 100
return Math.min(100, Math.round(((progress ?? 0) / threshold) * 100))
})
const unlockDate = computed(() => {
const { unlock_time, backfilled } = props.achievement
//
if (backfilled || !unlock_time) return "已获得"
return `${new Date(unlock_time).toLocaleDateString()} 获得`
})
</script>
<template>
<n-card
size="small"
:class="{ locked: !achievement.unlocked, rare: isRare }"
:style="{ borderColor: RARITY_COLOR[achievement.rarity] }"
>
<n-thing>
<template #avatar>
<AchievementIcon :icon="achievement.icon" :size="32" />
</template>
<template #header>
<n-flex align="center" :size="8">
<n-text strong>{{ achievement.name }}</n-text>
<n-tag
size="tiny"
:color="{
borderColor: RARITY_COLOR[achievement.rarity],
textColor: rarityTextColor[achievement.rarity],
}"
>
{{ RARITY_LABEL[achievement.rarity] }}
</n-tag>
</n-flex>
</template>
<template #description>
<n-text depth="3">{{ achievement.description }}</n-text>
</template>
<n-flex align="center" :size="8" :wrap="false">
<template v-if="achievement.unlocked">
<n-text depth="3" class="nowrap">{{ unlockDate }}</n-text>
<n-text depth="3" class="nowrap">
{{ achievement.unlock_rate }}% 的人获得
</n-text>
</template>
<template v-else-if="showProgressBar">
<n-progress
style="flex: 1"
type="line"
:percentage="percent"
:height="6"
:show-indicator="false"
/>
<n-text depth="3" class="nowrap">
{{ achievement.progress ?? 0 }} / {{ achievement.threshold }}
</n-text>
</template>
<template v-else-if="showBestSoFar">
<n-text depth="3" class="nowrap">
目标 {{ achievement.threshold }}
</n-text>
<n-text v-if="achievement.progress !== null" depth="3" class="nowrap">
当前最好 {{ achievement.progress }}
</n-text>
</template>
<n-text v-else depth="3" class="nowrap">
{{ achievement.unlock_rate }}% 的人获得
</n-text>
</n-flex>
</n-thing>
</n-card>
</template>
<style scoped>
.nowrap {
white-space: nowrap;
}
.locked {
filter: grayscale(1);
opacity: 0.55;
}
.rare {
box-shadow: 0 0 12px rgba(125, 211, 252, 0.55);
}
</style>
-227
View File
@@ -1,227 +0,0 @@
<script setup lang="ts">
import { getAchievements, getAchievementSummary } from "oj/achievement/api"
import { getUserBadges } from "oj/api"
import { useBreakpoints } from "shared/composables/breakpoints"
import { useRarityColor } from "shared/composables/rarity"
import type {
Achievement,
AchievementRarity,
AchievementSummary,
} from "utils/types"
import AchievementCard from "./components/AchievementCard.vue"
interface UserBadge {
id: number
earned_time: string
badge: {
id: number
name: string
description: string
icon: string
}
// UserBadgeSerializer
problemset: {
id: number
title: string
} | null
}
const route = useRoute()
const name = computed(() => (route.query.name as string) || undefined)
//
const rarityColor = useRarityColor()
const achievements = ref<Achievement[]>([])
const summary = ref<AchievementSummary | null>(null)
//
const RARITY_RANK: Record<AchievementRarity, number> = {
platinum: 0,
gold: 1,
silver: 2,
bronze: 3,
}
const rarities = computed(() =>
[...(summary.value?.rarity ?? [])].sort(
(a, b) => RARITY_RANK[a.rarity] - RARITY_RANK[b.rarity],
),
)
const badges = ref<UserBadge[]>([])
const { isDesktop } = useBreakpoints()
const tab = ref("all")
const loading = ref(true)
const filtered = computed(() => {
if (tab.value === "unlocked")
return achievements.value.filter((a) => a.unlocked)
if (tab.value === "locked")
return achievements.value.filter((a) => !a.unlocked)
return achievements.value
})
async function load() {
loading.value = true
try {
const [list, sum, badgeRes] = await Promise.all([
getAchievements(name.value),
getAchievementSummary(name.value),
getUserBadges(name.value),
])
// http ApiResponse<T> .data
achievements.value = list.data.achievements
summary.value = sum.data
badges.value = (badgeRes.data ?? []) as UserBadge[]
} finally {
loading.value = false
}
}
onMounted(load)
watch(name, load)
</script>
<template>
<div class="hall">
<!-- delay 50ms缓存命中时数据几乎立刻回来不闪一下转圈 -->
<n-spin :show="loading" :delay="50" style="min-height: 240px">
<n-card v-if="summary">
<n-flex align="center" :wrap="false" :size="isDesktop ? 32 : 16">
<n-flex vertical align="center" :size="6">
<n-progress
type="circle"
:percentage="summary.percent"
:stroke-width="8"
>
<n-text strong :style="{ fontSize: isDesktop ? '20px' : '14px' }">
{{ summary.percent }}%
</n-text>
</n-progress>
<n-text depth="3" class="nowrap">
已获得 {{ summary.unlocked }} / {{ summary.total }}
</n-text>
</n-flex>
<n-flex vertical :size="8" class="rarity">
<n-flex
v-for="r in rarities"
:key="r.rarity"
align="center"
:wrap="false"
:size="10"
>
<n-text strong :style="{ color: rarityColor[r.rarity] }">
{{ r.label }}
</n-text>
<n-progress
style="flex: 1"
type="line"
:percentage="r.total ? (r.unlocked / r.total) * 100 : 0"
:height="6"
:border-radius="3"
:fill-border-radius="3"
:color="rarityColor[r.rarity]"
:show-indicator="false"
/>
<n-text depth="3" class="nowrap">
{{ r.unlocked }} / {{ r.total }}
</n-text>
</n-flex>
</n-flex>
</n-flex>
</n-card>
<n-tabs v-model:value="tab" type="line" class="tabs">
<n-tab name="all">全部</n-tab>
<n-tab name="unlocked">已获得</n-tab>
<n-tab name="locked">未获得</n-tab>
<n-tab name="badges">题单奖章</n-tab>
</n-tabs>
<template v-if="tab !== 'badges'">
<n-grid
v-if="filtered.length"
responsive="screen"
cols="1 s:2 l:3"
:x-gap="12"
:y-gap="12"
>
<n-gi v-for="a in filtered" :key="a.id">
<AchievementCard :achievement="a" />
</n-gi>
</n-grid>
<!-- 加载中不显示空态不然首屏会闪一下"什么都没有" -->
<n-empty v-else-if="!loading" description="这里还什么都没有" />
</template>
<template v-else>
<n-grid
v-if="badges.length"
responsive="screen"
cols="1 s:2 l:3"
:x-gap="12"
:y-gap="12"
>
<n-gi v-for="b in badges" :key="b.id">
<n-card size="small">
<n-thing
:title="b.badge?.name"
:description="b.badge?.description"
>
<template #avatar v-if="b.badge?.icon">
<n-avatar
:size="40"
:src="b.badge.icon"
color="transparent"
object-fit="contain"
/>
</template>
<n-text v-if="b.problemset" depth="3" class="source">
来自题单
<router-link
:to="{
name: 'problemset',
params: { problemSetId: b.problemset.id },
}"
>
{{ b.problemset.title }}
</router-link>
</n-text>
</n-thing>
</n-card>
</n-gi>
</n-grid>
<n-empty v-else-if="!loading" description="还没有获得任何题单奖章" />
</template>
</n-spin>
</div>
</template>
<style scoped>
.hall {
max-width: 1100px;
margin: 0 auto;
padding: 16px;
}
.rarity {
flex: 1;
max-width: 420px;
}
.nowrap {
white-space: nowrap;
}
.tabs {
margin: 16px 0;
}
.source {
display: block;
margin-top: 6px;
font-size: 13px;
}
.source a {
color: inherit;
text-decoration: underline;
text-underline-offset: 2px;
}
</style>
+16 -13
View File
@@ -3,8 +3,6 @@ import { filterResult } from "oj/transforms"
import type { import type {
Exercise, Exercise,
Problem, Problem,
ReactionKey,
ReactionState,
Submission, Submission,
SubmissionListPayload, SubmissionListPayload,
SubmitCodePayload, SubmitCodePayload,
@@ -225,17 +223,22 @@ export function getMessageList(offset = 0, limit = 10) {
return http.get("message", { params: { limit, offset } }) return http.get("message", { params: { limit, offset } })
} }
export function getReaction(problemID: number) { export function createComment(data: {
return http.get<ReactionState>("reaction", { problem_id: number
params: { problem_id: problemID }, description_rating: number
}) difficulty_rating: number
comprehensive_rating: number
content: string
}) {
return http.post("comment", data)
} }
export function setReaction(problemID: number, type: ReactionKey) { export function getComment(problemID: number) {
return http.post<ReactionState>("reaction", { return http.get("comment", { params: { problem_id: problemID } })
problem_id: problemID, }
type,
}) export function getCommentStatistics(problemID: number) {
return http.get("comment/statistics", { params: { problem_id: problemID } })
} }
// TODO: 这个API有问题 // TODO: 这个API有问题
@@ -251,8 +254,8 @@ export function getTutorial(id: number) {
return http.get("tutorial", { params: { id } }) return http.get("tutorial", { params: { id } })
} }
export function getTutorials(type: "python" | "c") { export function getTutorials() {
return http.get("tutorials", { params: { type } }) return http.get("tutorials")
} }
export function getAIDetailData(start: string, end: string, username?: string) { export function getAIDetailData(start: string, end: string, username?: string) {
+22 -3
View File
@@ -1,7 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import hljs from "highlight.js/lib/core"
import python from "highlight.js/lib/languages/python"
import c from "highlight.js/lib/languages/c"
import type { Exercise, ExerciseFillData } from "utils/types" import type { Exercise, ExerciseFillData } from "utils/types"
import { highlight } from "../composables/useCodeHighlight"
import "./exercise-highlight.css" hljs.registerLanguage("python", python)
hljs.registerLanguage("c", c)
const props = defineProps<{ exercise: Exercise; lang?: string }>() const props = defineProps<{ exercise: Exercise; lang?: string }>()
const data = computed(() => props.exercise.data as ExerciseFillData) const data = computed(() => props.exercise.data as ExerciseFillData)
@@ -10,6 +14,10 @@ type CodeSeg = { type: "code"; html: string }
type BlankSeg = { type: "blank"; answers: string[]; index: number } type BlankSeg = { type: "blank"; answers: string[]; index: number }
type Segment = CodeSeg | BlankSeg type Segment = CodeSeg | BlankSeg
function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
}
const segments = computed<Segment[]>(() => { const segments = computed<Segment[]>(() => {
const blanks: string[][] = [] const blanks: string[][] = []
const markedCode = data.value.code.replace(/\{\{([^}]+)\}\}/g, (_, inner) => { const markedCode = data.value.code.replace(/\{\{([^}]+)\}\}/g, (_, inner) => {
@@ -17,7 +25,18 @@ const segments = computed<Segment[]>(() => {
return `____${blanks.length - 1}____` return `____${blanks.length - 1}____`
}) })
const highlighted = highlight(markedCode, props.lang) const lang =
props.lang === "python" ? "python" : props.lang === "c" ? "c" : null
let highlighted: string
if (lang) {
try {
highlighted = hljs.highlight(markedCode, { language: lang }).value
} catch {
highlighted = escapeHtml(markedCode)
}
} else {
highlighted = escapeHtml(markedCode)
}
const parts = highlighted.split(/____(\d+)____/) const parts = highlighted.split(/____(\d+)____/)
const result: Segment[] = [] const result: Segment[] = []
+9 -1
View File
@@ -1,6 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Exercise, ExerciseGroupData } from "utils/types" import type { Exercise, ExerciseGroupData } from "utils/types"
import { shuffle } from "../composables/useShuffle"
const props = defineProps<{ exercise: Exercise; lang?: string }>() const props = defineProps<{ exercise: Exercise; lang?: string }>()
const data = computed(() => props.exercise.data as ExerciseGroupData) const data = computed(() => props.exercise.data as ExerciseGroupData)
@@ -10,6 +9,15 @@ const placement = ref<number[]>([]) // placement[itemIdx] = 桶下标,-1 表
const dragIdx = ref<number | null>(null) const dragIdx = ref<number | null>(null)
const submitted = ref(false) const submitted = ref(false)
function shuffle<T>(arr: T[]): T[] {
const a = [...arr]
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[a[i], a[j]] = [a[j], a[i]]
}
return a
}
function init() { function init() {
order.value = shuffle(data.value.items.map((_, i) => i)) order.value = shuffle(data.value.items.map((_, i) => i))
placement.value = Array(data.value.items.length).fill(-1) placement.value = Array(data.value.items.length).fill(-1)
+9 -1
View File
@@ -1,6 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Exercise, ExerciseMatchData } from "utils/types" import type { Exercise, ExerciseMatchData } from "utils/types"
import { shuffle } from "../composables/useShuffle"
const props = defineProps<{ exercise: Exercise; lang?: string }>() const props = defineProps<{ exercise: Exercise; lang?: string }>()
const data = computed(() => props.exercise.data as ExerciseMatchData) const data = computed(() => props.exercise.data as ExerciseMatchData)
@@ -21,6 +20,15 @@ const pairs = ref<(number | null)[]>([]) // pairs[leftIdx] = 配对的 right 原
const selectedLeft = ref<number | null>(null) const selectedLeft = ref<number | null>(null)
const submitted = ref(false) const submitted = ref(false)
function shuffle<T>(arr: T[]): T[] {
const a = [...arr]
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[a[i], a[j]] = [a[j], a[i]]
}
return a
}
function init() { function init() {
const n = data.value.right.length const n = data.value.right.length
rightOrder.value = shuffle(Array.from({ length: n }, (_, i) => i)) rightOrder.value = shuffle(Array.from({ length: n }, (_, i) => i))
+100 -14
View File
@@ -1,8 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import hljs from "highlight.js/lib/core"
import python from "highlight.js/lib/languages/python"
import c from "highlight.js/lib/languages/c"
import type { Exercise, ExerciseSortData } from "utils/types" import type { Exercise, ExerciseSortData } from "utils/types"
import { shuffle } from "../composables/useShuffle"
import { highlightLines } from "../composables/useCodeHighlight" hljs.registerLanguage("python", python)
import "./exercise-highlight.css" hljs.registerLanguage("c", c)
const props = defineProps<{ exercise: Exercise; lang?: string }>() const props = defineProps<{ exercise: Exercise; lang?: string }>()
const data = computed(() => props.exercise.data as ExerciseSortData) const data = computed(() => props.exercise.data as ExerciseSortData)
@@ -12,16 +15,21 @@ type LineItem = { originalIdx: number; text: string }
const lines = ref<LineItem[]>([]) const lines = ref<LineItem[]>([])
const submitted = ref(false) const submitted = ref(false)
function shuffle(arr: LineItem[]): LineItem[] {
const a = [...arr]
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[a[i], a[j]] = [a[j], a[i]]
}
const isCorrect = a.every((item, i) => item.originalIdx === i)
if (isCorrect && a.length > 1) [a[0], a[1]] = [a[1], a[0]]
return a
}
function init() { function init() {
const shuffled = shuffle( lines.value = shuffle(
data.value.lines.map((text, idx) => ({ originalIdx: idx, text })), data.value.lines.map((text, idx) => ({ originalIdx: idx, text })),
) )
//
const isCorrect = shuffled.every((item, i) => item.originalIdx === i)
if (isCorrect && shuffled.length > 1) {
;[shuffled[0], shuffled[1]] = [shuffled[1], shuffled[0]]
}
lines.value = shuffled
submitted.value = false submitted.value = false
} }
@@ -61,9 +69,35 @@ function reset() {
init() init()
} }
const lineHtml = computed<string[]>(() => function escapeHtml(text: string): string {
highlightLines(data.value.lines, props.lang), return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
) }
const lineHtmlMap = computed<Record<number, string>>(() => {
const rawLines = data.value.lines
const map: Record<number, string> = {}
const lang =
props.lang === "python" ? "python" : props.lang === "c" ? "c" : null
if (lang) {
try {
const result = hljs.highlight(rawLines.join("\n"), {
language: lang,
}).value
result.split("\n").forEach((html, i) => {
map[i] = html
})
} catch {
// fall through
}
}
rawLines.forEach((text, i) => {
if (map[i] === undefined) map[i] = escapeHtml(text)
})
return map
})
</script> </script>
<template> <template>
@@ -109,7 +143,7 @@ const lineHtml = computed<string[]>(() =>
@drop="onDrop(idx)" @drop="onDrop(idx)"
> >
<span style="color: #bbb; cursor: grab"></span> <span style="color: #bbb; cursor: grab"></span>
<span v-html="lineHtml[line.originalIdx]" style="white-space: pre" /> <span v-html="lineHtmlMap[line.originalIdx]" style="white-space: pre" />
</div> </div>
</n-space> </n-space>
@@ -128,3 +162,55 @@ const lineHtml = computed<string[]>(() =>
</n-space> </n-space>
</n-card> </n-card>
</template> </template>
<style>
.hljs-keyword,
.hljs-operator,
.hljs-selector-tag {
color: #d73a49;
}
.hljs-string,
.hljs-regexp,
.hljs-template-literal {
color: #032f62;
}
.hljs-comment,
.hljs-quote {
color: #6a737d;
font-style: italic;
}
.hljs-number,
.hljs-literal {
color: #005cc5;
}
.hljs-built_in,
.hljs-title.function_,
.hljs-class .hljs-title {
color: #6f42c1;
}
.dark .hljs-keyword,
.dark .hljs-operator,
.dark .hljs-selector-tag {
color: #c678dd;
}
.dark .hljs-string,
.dark .hljs-regexp,
.dark .hljs-template-literal {
color: #98c379;
}
.dark .hljs-comment,
.dark .hljs-quote {
color: #7f848e;
font-style: italic;
}
.dark .hljs-number,
.dark .hljs-literal {
color: #e5c07b;
}
.dark .hljs-built_in,
.dark .hljs-title.function_,
.dark .hljs-class .hljs-title {
color: #61afef;
}
</style>
-9
View File
@@ -1,9 +0,0 @@
// Fisher–Yates 洗牌,返回新数组,不修改原数组
export function shuffle<T>(arr: T[]): T[] {
const a = [...arr]
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[a[i], a[j]] = [a[j], a[i]]
}
return a
}
+9 -31
View File
@@ -57,7 +57,7 @@
content-style="height: calc(100% - 44px); padding: 0;" content-style="height: calc(100% - 44px); padding: 0;"
> >
<CodeEditor <CodeEditor
:language="editorLanguage" language="Python3"
v-model="tutorial.code" v-model="tutorial.code"
height="100%" height="100%"
/> />
@@ -102,7 +102,7 @@
</n-tab-pane> </n-tab-pane>
<n-tab-pane name="code" tab="示例代码" v-if="tutorial.code"> <n-tab-pane name="code" tab="示例代码" v-if="tutorial.code">
<CodeEditor :language="editorLanguage" v-model="tutorial.code" /> <CodeEditor language="Python3" v-model="tutorial.code" />
</n-tab-pane> </n-tab-pane>
</n-tabs> </n-tabs>
@@ -128,23 +128,16 @@
</n-button> </n-button>
</n-flex> </n-flex>
</template> </template>
<n-empty
v-if="isEmpty"
description="该教程还没有公开"
style="margin-top: 80px"
/>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { MdPreview } from "md-editor-v3" import { MdPreview } from "md-editor-v3"
import "md-editor-v3/lib/preview.css" import "md-editor-v3/lib/preview.css"
import type { Tutorial, Exercise, LANGUAGE } from "utils/types" import type { Tutorial, Exercise } from "utils/types"
import { getTutorial, getTutorials, getExercises } from "../api" import { getTutorial, getTutorials, getExercises } from "../api"
import { parseExercises } from "./composables/useExerciseParse" import { parseExercises } from "./composables/useExerciseParse"
import { useBreakpoints } from "shared/composables/breakpoints" import { useBreakpoints } from "shared/composables/breakpoints"
import { useLearnProgress } from "shared/composables/learnProgress"
const ExerciseWidget = defineAsyncComponent( const ExerciseWidget = defineAsyncComponent(
() => import("./components/ExerciseWidget.vue"), () => import("./components/ExerciseWidget.vue"),
@@ -157,32 +150,21 @@ const isDark = useDark()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const { isDesktop } = useBreakpoints() const { isDesktop } = useBreakpoints()
const { learnStep } = useLearnProgress()
const step = computed(() => { const step = computed(() => {
const value = route.params.step as string | undefined if (!route.params.step || !route.params.step.length) return 1
if (!value) return 1 return parseInt(route.params.step[0])
return parseInt(value)
}) })
const type = computed<"python" | "c">(() =>
route.params.type === "c" ? "c" : "python",
)
const tutorial = ref<Partial<Tutorial>>({ const tutorial = ref<Partial<Tutorial>>({
id: 0, id: 0,
title: "", title: "",
content: "", content: "",
code: "", code: "",
}) })
const editorLanguage = computed<LANGUAGE>(() =>
tutorial.value.type === "c" ? "C" : "Python3",
)
const titles = ref<{ id: number; title: string }[]>([]) const titles = ref<{ id: number; title: string }[]>([])
const exercises = ref<Exercise[]>([]) const exercises = ref<Exercise[]>([])
const activeTab = ref("content") const activeTab = ref("content")
const isEmpty = ref(false)
const segments = computed(() => const segments = computed(() =>
parseExercises(tutorial.value.content ?? "", exercises.value), parseExercises(tutorial.value.content ?? "", exercises.value),
@@ -193,9 +175,7 @@ const isLastLesson = computed(() => step.value === titles.value.length)
function goToLesson(lessonNumber: number) { function goToLesson(lessonNumber: number) {
activeTab.value = "content" activeTab.value = "content"
router.push( router.push("/learn/" + lessonNumber.toString().padStart(2, "0"))
`/learn/${type.value}/${lessonNumber.toString().padStart(2, "0")}`,
)
} }
function goToPrevLesson() { function goToPrevLesson() {
if (step.value > 1) goToLesson(step.value - 1) if (step.value > 1) goToLesson(step.value - 1)
@@ -205,10 +185,9 @@ function goToNextLesson() {
} }
async function init() { async function init() {
const res1 = await getTutorials(type.value) const res1 = await getTutorials()
titles.value = res1.data titles.value = res1.data
isEmpty.value = titles.value.length === 0 if (titles.value.length === 0) return
if (isEmpty.value) return
const id = titles.value[step.value - 1].id const id = titles.value[step.value - 1].id
const [res2, exs] = await Promise.allSettled([ const [res2, exs] = await Promise.allSettled([
getTutorial(id), getTutorial(id),
@@ -216,11 +195,10 @@ async function init() {
]) ])
if (res2.status === "fulfilled") tutorial.value = res2.value.data if (res2.status === "fulfilled") tutorial.value = res2.value.data
exercises.value = exs.status === "fulfilled" ? exs.value : [] exercises.value = exs.status === "fulfilled" ? exs.value : []
learnStep.value[type.value] = step.value
} }
watch( watch(
() => [route.params.type, route.params.step], () => route.params.step,
async () => { async () => {
if (route.name === "learn") init() if (route.name === "learn") init()
}, },
+37 -32
View File
@@ -71,10 +71,8 @@ const showGoSubmissionButton = computed(() => {
else return false else return false
}) })
const menuOptions = computed<DropdownOption[]>(() => { const mobileMenuOptions = computed<DropdownOption[]>(() => {
const options: DropdownOption[] = [] const options: DropdownOption[] = []
//
if (!isDesktop.value) {
if (showGoSubmissionButton.value) { if (showGoSubmissionButton.value) {
options.push({ options.push({
label: "提交信息", label: "提交信息",
@@ -83,18 +81,11 @@ const menuOptions = computed<DropdownOption[]>(() => {
} }
if (userStore.isTeacherOrAbove) { if (userStore.isTeacherOrAbove) {
options.push({ options.push({
label: "课堂统计", label: "统计信息",
key: "statistics", key: "statistics",
}) })
} }
}
if (codeStore.code.language !== "Flowchart") { if (codeStore.code.language !== "Flowchart") {
if (codeStore.code.language !== "SQL") {
options.push({
label: "去自测猫",
key: "testcat",
})
}
options.push({ options.push({
label: "复制代码", label: "复制代码",
key: "copy", key: "copy",
@@ -104,16 +95,10 @@ const menuOptions = computed<DropdownOption[]>(() => {
key: "reset", key: "reset",
}) })
} }
if (isDesktop.value && userStore.isSuperAdmin) {
options.push({
label: "编辑题目",
key: "edit",
})
}
return options return options
}) })
const handleMenuSelect = (key: string) => { const handleMobileSelect = (key: string) => {
switch (key) { switch (key) {
case "submissions": case "submissions":
goSubmissions() goSubmissions()
@@ -121,18 +106,12 @@ const handleMenuSelect = (key: string) => {
case "statistics": case "statistics":
statisticPanel.value = true statisticPanel.value = true
break break
case "testcat":
goTestCat()
break
case "copy": case "copy":
copy() copy()
break break
case "reset": case "reset":
reset() reset()
break break
case "edit":
goEdit()
break
} }
} }
@@ -237,17 +216,35 @@ onMounted(() => {
:size="buttonSize" :size="buttonSize"
@click="statisticPanel = true" @click="statisticPanel = true"
> >
课堂统计 统计信息
</n-button> </n-button>
<!-- 自测猫 / 复制代码 / 重置代码 / 编辑题目 收进下拉菜单移动端再加上提交信息 / 课堂统计 --> <template v-if="codeStore.code.language !== 'Flowchart'">
<n-dropdown <n-button
v-if="menuOptions.length" v-if="codeStore.code.language !== 'SQL'"
trigger="click" :size="buttonSize"
:options="menuOptions" @click="goTestCat"
@select="handleMenuSelect"
> >
<n-button :size="buttonSize">更多操作</n-button> 自测猫
</n-button>
<n-button v-if="isDesktop" :size="buttonSize" @click="copy">
复制代码
</n-button>
<n-button v-if="isDesktop" :size="buttonSize" @click="reset">
重置代码
</n-button>
</template>
<!-- 移动端提交信息 / 统计信息 / 复制代码 / 重置代码 收进下拉菜单 -->
<n-dropdown
v-if="!isDesktop && mobileMenuOptions.length"
trigger="click"
:options="mobileMenuOptions"
@select="handleMobileSelect"
>
<n-button size="small">更多</n-button>
</n-dropdown> </n-dropdown>
<template v-if="showSyncFeature"> <template v-if="showSyncFeature">
@@ -276,6 +273,14 @@ onMounted(() => {
</n-tag> </n-tag>
</template> </template>
</template> </template>
<n-button
v-if="isDesktop && userStore.isSuperAdmin"
:size="buttonSize"
@click="goEdit"
>
编辑题目
</n-button>
</n-flex> </n-flex>
<n-modal <n-modal
@@ -0,0 +1,197 @@
<template>
<n-alert type="error" v-if="!userStore.isAuthed" title="请先登录" />
<div v-else>
<n-alert
v-if="problem?.my_status !== 0"
class="title"
type="error"
title="请先完成该题"
></n-alert>
<div v-else>
<n-alert class="title" type="success" :title="title"></n-alert>
<n-form>
<n-form-item
label-width="220"
label-align="left"
label="题目是否描述清楚"
label-placement="left"
>
<Icon
v-if="hasCommented"
icon="fluent-emoji:star"
:width="24"
v-for="(_, i) in description_rating"
:key="i"
/>
<n-rate v-else size="large" v-model:value="description_rating" />
</n-form-item>
<n-form-item
label-width="220"
label-align="left"
:label="
'难度是否匹配(此题是' + DIFFICULTY[problem.difficulty] + '的)'
"
label-placement="left"
>
<Icon
v-if="hasCommented"
icon="fluent-emoji:star"
:width="24"
v-for="(_, i) in difficulty_rating"
:key="i"
/>
<n-rate v-else size="large" v-model:value="difficulty_rating" />
</n-form-item>
<n-form-item
label-width="220"
label-align="left"
label="综合评分"
label-placement="left"
>
<Icon
v-if="hasCommented"
icon="fluent-emoji:star"
:width="24"
v-for="(_, i) in difficulty_rating"
:key="i"
/>
<n-rate v-else size="large" v-model:value="comprehensive_rating" />
</n-form-item>
<n-form-item
v-if="!hasCommented"
label="对这道题的评价(可选,注意文明用语)"
>
<n-input v-model:value="content" type="textarea" />
</n-form-item>
<n-form-item v-if="hasCommented && content" label="你对这道题的评价:">
{{ content }}
</n-form-item>
<n-button
v-if="hasCommented && showStatistics"
type="primary"
@click="getComments"
>
查看统计
</n-button>
<n-button v-if="!hasCommented" type="primary" @click="submit">
提交
</n-button>
</n-form>
<div v-if="showStatistics">
<n-descriptions
class="list"
v-if="count"
:column="4"
bordered
label-placement="left"
>
<n-descriptions-item label="评论">
{{ count }}
</n-descriptions-item>
<n-descriptions-item label="描述">
{{ rating.description }}
</n-descriptions-item>
<n-descriptions-item label="难度">
{{ rating.difficulty }}
</n-descriptions-item>
<n-descriptions-item label="综合">
{{ rating.comprehensive }}
</n-descriptions-item>
</n-descriptions>
<n-empty class="list" v-if="show && count === 0">
暂无记录快去评论吧
</n-empty>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { Icon } from "@iconify/vue"
import { storeToRefs } from "pinia"
import { useProblemStore } from "oj/store/problem"
import { DIFFICULTY } from "utils/constants"
import { createComment, getComment, getCommentStatistics } from "oj/api"
import { useUserStore } from "shared/store/user"
interface Props {
showStatistics?: boolean
}
const { showStatistics = true } = defineProps<Props>()
const userStore = useUserStore()
const problemStore = useProblemStore()
const { problem } = storeToRefs(problemStore)
const message = useMessage()
const content = ref("")
const description_rating = ref(0)
const difficulty_rating = ref(0)
const comprehensive_rating = ref(0)
const [show, toggleShow] = useToggle()
const [hasCommented, toggleHasCommented] = useToggle()
const count = ref(0)
const rating = reactive({
description: 0,
difficulty: 0,
comprehensive: 0,
})
const title = computed(() => {
if (hasCommented.value) return "这道题你已经打过分了,你的评分如下:"
return "你已经完成了这道题,请给这道题打分吧!"
})
async function submit() {
if (
description_rating.value === 0 ||
difficulty_rating.value === 0 ||
comprehensive_rating.value === 0
) {
message.error("请完成打分")
return
}
const data = {
problem_id: problem.value!.id,
content: content.value,
description_rating: description_rating.value,
difficulty_rating: difficulty_rating.value,
comprehensive_rating: comprehensive_rating.value,
}
await createComment(data)
toggleHasCommented(true)
message.success("提交成功")
}
async function getComments() {
const res = await getCommentStatistics(problem.value!.id)
toggleShow(true)
if (!res.data) return
count.value = res.data.count
rating.description = res.data.rating.description
rating.difficulty = res.data.rating.difficulty
rating.comprehensive = res.data.rating.comprehensive
}
async function getMyComment() {
const res = await getComment(problem.value!.id)
if (!res.data) return
content.value = res.data.content
description_rating.value = res.data.description_rating
difficulty_rating.value = res.data.difficulty_rating
comprehensive_rating.value = res.data.comprehensive_rating
toggleHasCommented(true)
}
onMounted(getMyComment)
</script>
<style scoped>
.title {
margin-bottom: 24px;
}
.list {
margin-top: 24px;
}
</style>
@@ -1,698 +0,0 @@
<script lang="ts" setup>
import { Icon } from "@iconify/vue"
import { useThemeVars } from "naive-ui"
import { storeToRefs } from "pinia"
import type { CSSProperties } from "vue"
import { getReaction, setReaction } from "oj/api"
import { useProblemStore } from "oj/store/problem"
import { useUserStore } from "shared/store/user"
import { REACTIONS } from "utils/constants"
import type { ReactionCounts, ReactionKey } from "utils/types"
const emit = defineEmits<{ submitted: [] }>()
const userStore = useUserStore()
const problemStore = useProblemStore()
const { problem } = storeToRefs(problemStore)
const message = useMessage()
const theme = useThemeVars()
const mine = ref<ReactionKey | null>(null)
const counts = ref<ReactionCounts | null>(null)
const loading = ref(false)
// key
const submitting = ref<ReactionKey | null>(null)
const activeIndex = ref<number | null>(null)
const keyboardActive = ref(false)
const wheelRef = ref<HTMLElement | null>(null)
let loadSequence = 0
const wheelGeometry = {
startAngle: -90,
contentRadius: 35,
pushRadius: 4,
outerRadius: 50,
arcPointCount: 9,
hitInnerRadius: 0.18,
hitOuterRadius: 0.49,
} as const
const sliceAngle = 360 / REACTIONS.length
function pointOnCircle(angle: number, radius: number) {
const radians = (angle * Math.PI) / 180
return {
x: 50 + Math.cos(radians) * radius,
y: 50 + Math.sin(radians) * radius,
}
}
function getWheelItemStyle(index: number): CSSProperties {
const centerAngle = wheelGeometry.startAngle + index * sliceAngle
const startAngle = centerAngle - sliceAngle / 2
const endAngle = centerAngle + sliceAngle / 2
const dividerAngle = index * sliceAngle - sliceAngle / 2
const position = pointOnCircle(centerAngle, wheelGeometry.contentRadius)
const push = pointOnCircle(centerAngle, wheelGeometry.pushRadius)
const arcPoints = Array.from(
{ length: wheelGeometry.arcPointCount },
(_, pointIndex) => {
const progress = pointIndex / (wheelGeometry.arcPointCount - 1)
const angle = startAngle + (endAngle - startAngle) * progress
const point = pointOnCircle(angle, wheelGeometry.outerRadius)
return `${point.x.toFixed(3)}% ${point.y.toFixed(3)}%`
},
)
return {
"--segment-path": `polygon(50% 50%, ${arcPoints.join(", ")})`,
"--content-x": `${position.x}%`,
"--content-y": `${position.y}%`,
"--push-x": `${push.x - 50}px`,
"--push-y": `${push.y - 50}px`,
"--divider-angle": `${dividerAngle}deg`,
}
}
const wheelItems = REACTIONS.map((item, index) => ({
...item,
index,
style: getWheelItemStyle(index),
}))
const solved = computed(() => problem.value?.my_status === 0)
const locked = computed(() => mine.value !== null)
const canInteract = computed(
() =>
userStore.isAuthed &&
!!problem.value &&
solved.value &&
!locked.value &&
!loading.value &&
!submitting.value,
)
const wheelCenter = computed(() => {
if (loading.value) {
return {
icon: "ph:spinner-gap-bold",
eyebrow: "正在读取",
label: "题目点评",
spinning: true,
}
}
if (submitting.value) {
const item = REACTIONS.find((reaction) => reaction.key === submitting.value)
return {
icon: "svg-spinners:180-ring-with-bg",
eyebrow: "正在记录",
label: item?.label ?? "提交点评",
spinning: false,
}
}
if (mine.value) {
const item = REACTIONS.find((reaction) => reaction.key === mine.value)
return {
icon: "ph:check-bold",
eyebrow: "你的选择",
label: item?.label ?? "已提交",
spinning: false,
}
}
if (!userStore.isAuthed) {
return {
icon: "ph:user-circle-dashed",
eyebrow: "登录后开放",
label: "登录后点评",
spinning: false,
}
}
if (activeIndex.value !== null) {
const item = wheelItems[activeIndex.value]
const count = counts.value?.[item.key]
return {
icon: item.icon,
eyebrow: count === undefined ? "选择这项" : `${count} 人选择`,
label: item.label,
spinning: false,
}
}
if (!solved.value) {
return {
icon: "ph:lock-simple-bold",
eyebrow: "完成后开放",
label: "通关后点评",
spinning: false,
}
}
return {
icon: "ph:cursor-click-bold",
eyebrow: "移动到扇区",
label: "选择点评",
spinning: false,
}
})
const reactionStyle = computed(() => ({
"--reaction-accent": theme.value.primaryColor,
"--reaction-card": theme.value.cardColor,
"--reaction-border": theme.value.borderColor,
"--reaction-text": theme.value.textColor1,
"--reaction-text-faint": theme.value.textColor3,
}))
function optionAriaLabel(key: ReactionKey, label: string) {
const count = counts.value?.[key]
const countText = count === undefined ? "" : `${count} 人选择`
const selectedText = mine.value === key ? ",你的选择" : ""
return `${label}${countText}${selectedText}`
}
function getPointerIndex(event: PointerEvent | MouseEvent) {
const wheel = wheelRef.value
if (!wheel) return null
const bounds = wheel.getBoundingClientRect()
const x = event.clientX - (bounds.left + bounds.width / 2)
const y = event.clientY - (bounds.top + bounds.height / 2)
const distance = Math.hypot(x, y)
if (
distance < bounds.width * wheelGeometry.hitInnerRadius ||
distance > bounds.width * wheelGeometry.hitOuterRadius
) {
return null
}
const angle = (Math.atan2(y, x) * 180) / Math.PI
const rawIndex = Math.round((angle - wheelGeometry.startAngle) / sliceAngle)
return (
((rawIndex % wheelItems.length) + wheelItems.length) % wheelItems.length
)
}
function preview(index: number, fromKeyboard = false) {
if (!canInteract.value) return
keyboardActive.value = fromKeyboard
activeIndex.value = index
}
function clearPreview() {
if (locked.value) return
activeIndex.value = null
keyboardActive.value = false
}
function onWheelPointerMove(event: PointerEvent) {
if (!canInteract.value) return
keyboardActive.value = false
activeIndex.value = getPointerIndex(event)
}
function onWheelClick(event: MouseEvent) {
if (!canInteract.value) return
const target = event.target
if (target instanceof Element && target.closest(".reaction-option")) return
const index = getPointerIndex(event)
if (index !== null) pick(wheelItems[index].key)
}
async function pick(key: ReactionKey) {
if (
!problem.value ||
!solved.value ||
locked.value ||
loading.value ||
submitting.value
)
return
activeIndex.value = null
keyboardActive.value = false
submitting.value = key
try {
const res = await setReaction(problem.value.id, key)
mine.value = res.data.mine
counts.value = res.data.counts
emit("submitted")
} catch {
message.error("提交失败,请重试")
} finally {
submitting.value = null
}
}
async function load(problemId: number) {
const sequence = ++loadSequence
loading.value = true
try {
const res = await getReaction(problemId)
if (sequence !== loadSequence) return
mine.value = res.data.mine
counts.value = res.data.counts
} catch {
if (sequence === loadSequence) message.error("暂时无法读取题目点评")
} finally {
if (sequence === loadSequence) loading.value = false
}
}
watch(
[() => userStore.isAuthed, () => problem.value?.id],
([isAuthed, problemId]) => {
mine.value = null
counts.value = null
submitting.value = null
activeIndex.value = null
keyboardActive.value = false
if (!isAuthed || problemId === undefined) {
loadSequence += 1
loading.value = false
return
}
load(problemId)
},
{ immediate: true },
)
</script>
<template>
<section class="reaction-panel" :style="reactionStyle" aria-label="题目点评">
<div class="wheel-stage">
<div
ref="wheelRef"
class="reaction-wheel"
:class="{
'has-selection': locked,
'is-disabled': !canInteract,
'is-keyboard-active': keyboardActive,
}"
role="group"
aria-label="选择一项题目点评,点击后立即提交"
:aria-busy="loading || !!submitting"
@pointermove="onWheelPointerMove"
@pointerleave="clearPreview"
@click="onWheelClick"
>
<span
v-for="item in wheelItems"
:key="`${item.key}-face`"
class="segment-face"
:class="{
'is-active': activeIndex === item.index,
'is-selected': mine === item.key,
'is-submitting': submitting === item.key,
}"
:style="item.style"
aria-hidden="true"
/>
<span
v-for="item in wheelItems"
:key="`${item.key}-divider`"
class="segment-divider"
:style="item.style"
aria-hidden="true"
/>
<button
v-for="item in wheelItems"
:key="item.key"
type="button"
class="reaction-option"
:class="{
'is-active': activeIndex === item.index,
'is-selected': mine === item.key,
'is-submitting': submitting === item.key,
'is-muted': locked && mine !== item.key,
'is-unavailable': !userStore.isAuthed || !solved || loading,
}"
:style="item.style"
:disabled="!canInteract"
:aria-pressed="mine === item.key"
:aria-label="optionAriaLabel(item.key, item.label)"
@focus="preview(item.index, true)"
@blur="clearPreview"
@click.stop="pick(item.key)"
>
<span class="option-content">
<span class="option-icon" aria-hidden="true">
<Icon
:icon="
submitting === item.key
? 'svg-spinners:180-ring-with-bg'
: item.icon
"
/>
</span>
<span class="option-label">{{ item.label }}</span>
<span v-if="counts" class="option-count">
{{ counts[item.key] }}
</span>
</span>
</button>
<div class="wheel-core" aria-hidden="true">
<div class="core-content">
<Icon
class="core-icon"
:class="{ 'is-spinning': wheelCenter.spinning }"
:icon="wheelCenter.icon"
/>
<span class="core-eyebrow">{{ wheelCenter.eyebrow }}</span>
<strong class="core-label">{{ wheelCenter.label }}</strong>
</div>
</div>
</div>
</div>
</section>
</template>
<style scoped>
.reaction-panel {
width: min(100%, 720px);
box-sizing: border-box;
container-type: inline-size;
margin: 0 auto;
padding: clamp(12px, 3vw, 24px);
color: var(--reaction-text);
}
.wheel-stage {
display: grid;
place-items: center;
padding: 4px 0;
}
.reaction-wheel {
position: relative;
width: min(100%, 400px);
box-sizing: border-box;
aspect-ratio: 1;
overflow: hidden;
border: 1px solid var(--reaction-border);
border-radius: 50%;
background: var(--reaction-card);
isolation: isolate;
}
.segment-face {
position: absolute;
inset: 1px;
overflow: hidden;
background: color-mix(in srgb, var(--reaction-text) 4%, var(--reaction-card));
clip-path: var(--segment-path);
transform: translate(0, 0) scale(1);
transform-origin: center;
pointer-events: none;
transition:
transform 140ms cubic-bezier(0, 0, 0.2, 1),
background-color 140ms ease-out;
}
.segment-face:is(.is-active, .is-submitting) {
z-index: 2;
background: color-mix(
in srgb,
var(--reaction-accent) 10%,
var(--reaction-card)
);
transform: translate(calc(var(--push-x) * 0.7), calc(var(--push-y) * 0.7))
scale(1.045);
}
.segment-face.is-selected {
z-index: 3;
background: color-mix(
in srgb,
var(--reaction-accent) 18%,
var(--reaction-card)
);
transform: translate(var(--push-x), var(--push-y)) scale(1.075);
}
.segment-divider {
position: absolute;
z-index: 8;
top: 1px;
left: calc(50% - 1px);
width: 2px;
height: calc(50% - 1px);
background: var(--reaction-border);
transform: rotate(var(--divider-angle));
transform-origin: 50% 100%;
pointer-events: none;
}
.reaction-option {
position: absolute;
z-index: 9;
top: var(--content-y);
left: var(--content-x);
display: flex;
width: clamp(66px, 20%, 80px);
min-height: clamp(52px, 16%, 64px);
align-items: stretch;
justify-content: stretch;
padding: 0;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--reaction-text);
font: inherit;
transform: translate(-50%, -50%);
touch-action: manipulation;
cursor: pointer;
}
.reaction-option:disabled {
cursor: not-allowed;
}
.reaction-option.is-unavailable {
opacity: 0.58;
}
.reaction-option.is-muted {
opacity: 0.48;
}
.reaction-option:is(.is-selected, .is-submitting) {
opacity: 1;
}
.reaction-option:focus-visible {
outline: 2px solid color-mix(in srgb, var(--reaction-accent) 55%, transparent);
outline-offset: 2px;
}
.option-content {
display: flex;
width: 100%;
min-height: 100%;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 2px;
border-radius: 6px;
transform: translate(0, 0) scale(1);
transition: transform 140ms cubic-bezier(0, 0, 0.2, 1);
}
.reaction-option:active:not(:disabled) .option-content {
transform: scale(0.96);
}
.reaction-option:is(.is-active, .is-submitting) .option-content {
transform: translate(calc(var(--push-x) * 0.8), calc(var(--push-y) * 0.8))
scale(1.18);
}
.reaction-option:is(.is-active, .is-submitting):active:not(:disabled)
.option-content {
transform: translate(calc(var(--push-x) * 0.8), calc(var(--push-y) * 0.8))
scale(1.1);
}
.reaction-option.is-selected .option-content {
transform: translate(var(--push-x), var(--push-y)) scale(1.24);
}
.option-icon {
display: grid;
width: 28px;
height: 28px;
place-items: center;
font-size: 24px;
line-height: 1;
}
.option-icon svg {
width: 24px;
height: 24px;
}
.option-label {
font-size: 13px;
font-weight: 600;
line-height: 1.25;
white-space: nowrap;
}
.option-count {
color: var(--reaction-text-faint);
font-size: 12px;
font-weight: 400;
font-variant-numeric: tabular-nums;
line-height: 1.2;
}
.reaction-option.is-selected .option-count {
color: var(--reaction-text);
}
.wheel-core {
position: absolute;
z-index: 12;
top: 50%;
left: 50%;
display: grid;
width: 31%;
aspect-ratio: 1;
place-items: center;
padding: 12px;
border: 1px solid var(--reaction-border);
border-radius: 50%;
background: var(--reaction-card);
color: var(--reaction-text);
text-align: center;
transform: translate(-50%, -50%);
pointer-events: none;
}
.core-content {
display: grid;
justify-items: center;
gap: 3px;
}
.core-icon {
width: clamp(20px, 5cqi, 26px);
height: clamp(20px, 5cqi, 26px);
font-size: clamp(20px, 5cqi, 26px);
color: var(--reaction-text-faint);
}
.core-eyebrow {
color: var(--reaction-text-faint);
font-size: clamp(9px, 2.3cqi, 11px);
font-weight: 400;
}
.core-label {
font-size: clamp(12px, 3cqi, 15px);
font-weight: 600;
line-height: 1.2;
text-wrap: balance;
}
.reaction-wheel.is-keyboard-active :is(.segment-face, .option-content) {
transition-duration: 0.01ms;
}
.is-spinning {
animation: reaction-spin 850ms linear infinite;
}
@container (max-width: 440px) {
.wheel-stage {
padding-top: 0;
}
.reaction-option {
width: clamp(58px, 20%, 70px);
min-height: clamp(48px, 16%, 56px);
}
.segment-face:is(.is-active, .is-submitting) {
transform: translate(calc(var(--push-x) * 0.5), calc(var(--push-y) * 0.5))
scale(1.03);
}
.segment-face.is-selected {
transform: translate(calc(var(--push-x) * 0.75), calc(var(--push-y) * 0.75))
scale(1.05);
}
.option-content {
gap: 2px;
}
.reaction-option:is(.is-active, .is-submitting) .option-content {
transform: translate(calc(var(--push-x) * 0.55), calc(var(--push-y) * 0.55))
scale(1.12);
}
.reaction-option:is(.is-active, .is-submitting):active:not(:disabled)
.option-content {
transform: translate(calc(var(--push-x) * 0.55), calc(var(--push-y) * 0.55))
scale(1.06);
}
.reaction-option.is-selected .option-content {
transform: translate(calc(var(--push-x) * 0.75), calc(var(--push-y) * 0.75))
scale(1.17);
}
.option-icon {
width: 24px;
height: 24px;
font-size: 21px;
}
.option-icon svg {
width: 21px;
height: 21px;
}
.option-label {
font-size: 11px;
}
.option-count {
font-size: 11px;
}
.wheel-core {
padding: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.segment-face,
.option-content,
.option-icon,
.option-label,
.option-count {
transition-duration: 0.01ms;
}
.is-spinning {
animation-duration: 1.8s;
}
}
@keyframes reaction-spin {
to {
transform: rotate(360deg);
}
}
</style>
+9 -28
View File
@@ -3,7 +3,7 @@ import { Icon } from "@iconify/vue"
import { storeToRefs } from "pinia" import { storeToRefs } from "pinia"
import { import {
formatCode, formatCode,
getReaction, getComment,
submitCode, submitCode,
updateProblemSetProgress, updateProblemSetProgress,
} from "oj/api" } from "oj/api"
@@ -17,14 +17,11 @@ import SubmissionResult from "./SubmissionResult.vue"
import { getSubmitButtonState } from "./submitButtonState" import { getSubmitButtonState } from "./submitButtonState"
import { useBreakpoints } from "shared/composables/breakpoints" import { useBreakpoints } from "shared/composables/breakpoints"
import { useUserStore } from "shared/store/user" import { useUserStore } from "shared/store/user"
import { import { checkPythonSyntax } from "oj/problem/utils/pythonSyntaxCheck"
checkPythonSyntax,
prefetchPythonSyntaxChecker,
} from "oj/problem/utils/pythonSyntaxCheck"
// ==================== ==================== // ==================== ====================
const ProblemReaction = defineAsyncComponent( const ProblemComment = defineAsyncComponent(
() => import("./ProblemReaction.vue"), () => import("./ProblemComment.vue"),
) )
// ==================== ==================== // ==================== ====================
@@ -40,10 +37,6 @@ const router = useRouter()
const [commentPanel] = useToggle() const [commentPanel] = useToggle()
const message = useMessage() const message = useMessage()
function closeCommentPanel() {
commentPanel.value = false
}
const { isDesktop } = useBreakpoints() const { isDesktop } = useBreakpoints()
// ==================== ==================== // ==================== ====================
@@ -57,16 +50,6 @@ const showResult = ref(false)
const isFormatting = ref(false) const isFormatting = ref(false)
const isSubmittingRequest = ref(false) const isSubmittingRequest = ref(false)
// ==================== Python ====================
// Python3 Skulpt
watch(
() => codeStore.code.language,
(language) => {
if (language === "Python3") prefetchPythonSyntaxChecker()
},
{ immediate: true },
)
// ==================== ==================== // ==================== ====================
const { start: startCooldown, isPending: isCooldown } = useTimeout(5000, { const { start: startCooldown, isPending: isCooldown } = useTimeout(5000, {
controls: true, controls: true,
@@ -76,8 +59,8 @@ const { start: startCooldown, isPending: isCooldown } = useTimeout(5000, {
// ==================== AC ==================== // ==================== AC ====================
const { start: showCommentPanelDelayed } = useTimeoutFn( const { start: showCommentPanelDelayed } = useTimeoutFn(
async () => { async () => {
const res = await getReaction(problem.value!.id) const res = await getComment(problem.value!.id)
if (res.data.mine === null) { if (!res.data) {
commentPanel.value = true commentPanel.value = true
} }
}, },
@@ -116,7 +99,7 @@ async function submit() {
// 0. Python3 // 0. Python3
if (codeStore.code.language === "Python3") { if (codeStore.code.language === "Python3") {
const syntaxError = await checkPythonSyntax(codeStore.code.value) const syntaxError = checkPythonSyntax(codeStore.code.value)
if (syntaxError) { if (syntaxError) {
message.warning(`${syntaxError.line} 行存在语法错误,请修正后再提交`) message.warning(`${syntaxError.line} 行存在语法错误,请修正后再提交`)
return return
@@ -263,13 +246,11 @@ watch(
<!-- 评价弹窗 --> <!-- 评价弹窗 -->
<n-modal <n-modal
preset="card" preset="card"
title="恭喜你成功提交,说说你对这道题的感受吧" title="恭喜你成功提交,请对该题进行评价(一星差评,五星好评)"
:mask-closable="false" :mask-closable="false"
:closable="false"
:close-on-esc="false"
:style="{ maxWidth: isDesktop && '50vw', maxHeight: '80vh' }" :style="{ maxWidth: isDesktop && '50vw', maxHeight: '80vh' }"
v-model:show="commentPanel" v-model:show="commentPanel"
> >
<ProblemReaction @submitted="closeCommentPanel" /> <ProblemComment :showStatistics="false" />
</n-modal> </n-modal>
</template> </template>
+30 -47
View File
@@ -138,7 +138,6 @@ export function useFireworks() {
confetti({ confetti({
particleCount: 12, particleCount: 12,
angle: 270,
spread: 100, spread: 100,
startVelocity: 12, startVelocity: 12,
gravity: 0.6, gravity: 0.6,
@@ -146,7 +145,7 @@ export function useFireworks() {
ticks: 200, ticks: 200,
scalar: 1.2, scalar: 1.2,
shapes: ["star"], shapes: ["star"],
origin: { x: Math.random(), y: 0 }, origin: { x: Math.random(), y: -0.1 },
colors: ["#FFD700", "#FFA500", "#FFFF00", "#FF69B4", "#00CED1"], colors: ["#FFD700", "#FFA500", "#FFFF00", "#FF69B4", "#00CED1"],
}) })
}, 150) }, 150)
@@ -286,32 +285,29 @@ export function useFireworks() {
// 效果7: 螺旋上升 // 效果7: 螺旋上升
() => { () => {
const steps = 16 const defaults = {
const colors = ["#a864fd", "#29cdff", "#78ff44", "#ff718d", "#fdff6a"] spread: 360,
for (let step = 0; step < steps; step++) {
runLater(() => {
const progress = step / (steps - 1)
const phase = step * (Math.PI / 4)
confetti({
particleCount: 7,
angle: 90 + Math.cos(phase) * 18,
spread: 25,
startVelocity: 16,
gravity: 0.25,
decay: 0.94,
ticks: 100, ticks: 100,
scalar: 0.9, gravity: 0,
shapes: ["circle", "square"], decay: 0.94,
origin: { startVelocity: 30,
x: 0.5 + Math.sin(phase) * 0.14,
y: 0.85 - progress * 0.55,
},
colors,
})
}, step * 65)
} }
function shoot() {
confetti({
...defaults,
particleCount: 50,
scalar: 1.2,
shapes: ["circle", "square"],
colors: ["#a864fd", "#29cdff", "#78ff44", "#ff718d", "#fdff6a"],
})
}
runLater(shoot, 0)
runLater(shoot, 100)
runLater(shoot, 200)
runLater(shoot, 300)
runLater(shoot, 400)
}, },
// 效果8: 表情包烟花 // 效果8: 表情包烟花
@@ -319,7 +315,6 @@ export function useFireworks() {
loadEmojiShapes().then((emojiShapes) => { loadEmojiShapes().then((emojiShapes) => {
confetti({ confetti({
shapes: emojiShapes, shapes: emojiShapes,
flat: true,
scalar: 6, scalar: 6,
particleCount: 18, particleCount: 18,
spread: 360, spread: 360,
@@ -354,28 +349,16 @@ export function useFireworks() {
// 效果10: 礼花瀑布 // 效果10: 礼花瀑布
() => { () => {
const animationEnd = Date.now() + 1800
const colors = ["#f6d365", "#fda085", "#fbc2eb", "#a6c1ee", "#84fab0"]
const interval = setInterval(() => {
if (Date.now() >= animationEnd) return stopTimer(interval)
for (let column = 0; column < 6; column++) {
confetti({ confetti({
particleCount: 2, particleCount: 150,
angle: 270, spread: 180,
spread: 30, startVelocity: 0,
startVelocity: 5, gravity: 1.2,
gravity: 1, decay: 0.95,
decay: 0.96, ticks: 300,
ticks: 240, origin: { x: 0.5, y: 0 },
scalar: 1.1, colors: ["#f6d365", "#fda085", "#fbc2eb", "#a6c1ee", "#84fab0"],
origin: { x: (column + Math.random()) / 6, y: 0 },
colors,
}) })
}
}, 120)
timers.add(interval)
}, },
] ]
@@ -2,8 +2,7 @@ import { ref, computed, watch, onUnmounted } from "vue"
import { useIntervalFn, useTimeoutFn } from "@vueuse/core" import { useIntervalFn, useTimeoutFn } from "@vueuse/core"
import { getSubmission } from "oj/api" import { getSubmission } from "oj/api"
import { SubmissionStatus } from "utils/constants" import { SubmissionStatus } from "utils/constants"
import type { PendingAchievement, Submission } from "utils/types" import type { Submission } from "utils/types"
import { useAchievementStore } from "shared/store/achievement"
import { import {
useSubmissionWebSocket, useSubmissionWebSocket,
type SubmissionUpdate, type SubmissionUpdate,
@@ -46,20 +45,6 @@ export function useSubmissionMonitor() {
// ==================== WebSocket 处理 ==================== // ==================== WebSocket 处理 ====================
const handleSubmissionUpdate = (data: SubmissionUpdate) => { const handleSubmissionUpdate = (data: SubmissionUpdate) => {
// push_to_user 复用了 submission_update 这个 channel handler
// 其他类型的消息会走同一条 WebSocket 帧进来,必须先分流
const frame = data as unknown as {
type: string
achievements?: PendingAchievement[]
}
if (frame.type === "achievement_unlocked") {
useAchievementStore().enqueue(frame.achievements ?? [])
return
}
if (frame.type !== "submission_update") {
return
}
console.log("[SubmissionMonitor] 收到WebSocket更新:", data) console.log("[SubmissionMonitor] 收到WebSocket更新:", data)
if (data.submission_id !== submissionId.value) { if (data.submission_id !== submissionId.value) {
+5 -5
View File
@@ -23,8 +23,8 @@ const ProblemInfo = defineAsyncComponent(
const ProblemSubmission = defineAsyncComponent( const ProblemSubmission = defineAsyncComponent(
() => import("./components/ProblemSubmission.vue"), () => import("./components/ProblemSubmission.vue"),
) )
const ProblemReaction = defineAsyncComponent( const ProblemComment = defineAsyncComponent(
() => import("./components/ProblemReaction.vue"), () => import("./components/ProblemComment.vue"),
) )
const ProblemFlowchart = defineAsyncComponent( const ProblemFlowchart = defineAsyncComponent(
() => import("./components/ProblemFlowchart.vue"), () => import("./components/ProblemFlowchart.vue"),
@@ -174,7 +174,7 @@ watch(
tab="题目点评" tab="题目点评"
:disabled="!!problemSetId" :disabled="!!problemSetId"
> >
<ProblemReaction /> <ProblemComment />
</n-tab-pane> </n-tab-pane>
<n-tab-pane <n-tab-pane
v-if="myFlowchartStore.showing" v-if="myFlowchartStore.showing"
@@ -226,7 +226,7 @@ watch(
tab="题目点评" tab="题目点评"
:disabled="!!problemSetId" :disabled="!!problemSetId"
> >
<ProblemReaction /> <ProblemComment />
</n-tab-pane> </n-tab-pane>
<n-tab-pane <n-tab-pane
v-if="myFlowchartStore.showing" v-if="myFlowchartStore.showing"
@@ -266,7 +266,7 @@ watch(
tab="点评" tab="点评"
:disabled="!!problemSetId" :disabled="!!problemSetId"
> >
<ProblemReaction /> <ProblemComment />
</n-tab-pane> </n-tab-pane>
<n-tab-pane <n-tab-pane
v-if="myFlowchartStore.showing" v-if="myFlowchartStore.showing"
+4 -36
View File
@@ -1,47 +1,15 @@
// @ts-ignore - skulpt has no type definitions
import Sk from "skulpt"
export interface PythonSyntaxError { export interface PythonSyntaxError {
line: number line: number
} }
let skulptPromise: Promise<any> | null = null
/**
* Skulpt 233KB gzip Python3
*
*/
function loadSkulpt(): Promise<any> {
if (!skulptPromise) {
// @ts-ignore - skulpt has no type definitions
skulptPromise = import("skulpt").then((m) => m.default ?? m)
}
return skulptPromise
}
/**
* Skulpt
*
*/
export function prefetchPythonSyntaxChecker() {
loadSkulpt().catch(() => {
skulptPromise = null
})
}
/** /**
* Skulpt Python * Skulpt Python
* input() IO * input() IO
* null
*/ */
export async function checkPythonSyntax( export function checkPythonSyntax(code: string): PythonSyntaxError | null {
code: string,
): Promise<PythonSyntaxError | null> {
let Sk: any
try {
Sk = await loadSkulpt()
} catch {
skulptPromise = null
return null
}
Sk.configure({ output: () => {} }) Sk.configure({ output: () => {} })
try { try {
Sk.compile(code, "prog.py", "exec") Sk.compile(code, "prog.py", "exec")
+2 -27
View File
@@ -198,9 +198,8 @@ const columns: DataTableColumn<Rank>[] = [
"streamline-emojis:smiling-face-with-sunglasses", "streamline-emojis:smiling-face-with-sunglasses",
), ),
key: "username", key: "username",
width: 240, width: 200,
render: (row) => render: (row) =>
h("div", { style: "display:flex;align-items:center;gap:6px" }, [
h( h(
NButton, NButton,
{ {
@@ -210,18 +209,6 @@ const columns: DataTableColumn<Rank>[] = [
}, },
() => row.user.username, () => row.user.username,
), ),
h(
NButton,
{
text: true,
size: "tiny",
title: "查看成就",
onClick: () =>
router.push("/achievement?name=" + row.user.username),
},
() => "🏆",
),
]),
}, },
{ {
title: renderTableTitle( title: renderTableTitle(
@@ -391,9 +378,8 @@ const myClassColumns: DataTableColumn<UserRank>[] = [
{ {
title: "用户名", title: "用户名",
key: "username", key: "username",
width: 240, width: 200,
render: (row) => render: (row) =>
h("div", { style: "display:flex;align-items:center;gap:6px" }, [
h( h(
NButton, NButton,
{ {
@@ -418,17 +404,6 @@ const myClassColumns: DataTableColumn<UserRank>[] = [
) )
: row.username, : row.username,
), ),
h(
NButton,
{
text: true,
size: "tiny",
title: "查看成就",
onClick: () => router.push("/achievement?name=" + row.username),
},
() => "🏆",
),
]),
}, },
{ {
title: "已解决", title: "已解决",
@@ -1,6 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import { Icon } from "@iconify/vue" import { Icon } from "@iconify/vue"
import { USERNAME_CLASS_RE } from "utils/constants"
interface Props { interface Props {
type: "题目" | "用户" type: "题目" | "用户"
@@ -11,14 +10,12 @@ const props = defineProps<Props>()
const emits = defineEmits(["click", "search", "filterClass"]) const emits = defineEmits(["click", "search", "filterClass"])
// kstest ks
//
const showFilterClass = computed(() => { const showFilterClass = computed(() => {
return props.type === "用户" && USERNAME_CLASS_RE.test(props.username ?? "") return props.type === "用户" && props.username?.startsWith("ks")
}) })
function filterClass() { function filterClass() {
const match = props.username!.match(USERNAME_CLASS_RE) const match = props.username!.match(/^ks\d{3,4}/)
const classname = match ? match[0] : "" const classname = match ? match[0] : ""
if (!classname) return if (!classname) return
emits("filterClass", classname) emits("filterClass", classname)
+69 -76
View File
@@ -4,10 +4,9 @@ import { NH2, NH3 } from "naive-ui"
import { getProfile } from "shared/api" import { getProfile } from "shared/api"
import { useBreakpoints } from "shared/composables/breakpoints" import { useBreakpoints } from "shared/composables/breakpoints"
import { durationToDays, parseTime } from "utils/functions" import { durationToDays, parseTime } from "utils/functions"
import type { AchievementSummary, Profile } from "utils/types" import type { Profile, UserBadge as UserBadgeType } from "utils/types"
import { getAchievementSummary } from "oj/achievement/api" import { getMetrics, getUserBadges } from "../api"
import { getMetrics } from "../api" import GroupedUserBadge from "shared/components/GroupedUserBadge.vue"
import AchievementIcon from "shared/components/AchievementIcon.vue"
import { useUserStore } from "shared/store/user" import { useUserStore } from "shared/store/user"
const route = useRoute() const route = useRoute()
@@ -19,7 +18,7 @@ const firstSubmissionAt = ref("")
const latestSubmissionAt = ref("") const latestSubmissionAt = ref("")
const toLatestAt = ref("") const toLatestAt = ref("")
const learnDuration = ref("") const learnDuration = ref("")
const achievementSummary = ref<AchievementSummary | null>(null) const userBadges = ref<GroupedBadge[]>([])
const [loading, toggle] = useToggle() const [loading, toggle] = useToggle()
const [show, toggleShow] = useToggle(false) const [show, toggleShow] = useToggle(false)
@@ -59,6 +58,40 @@ const hasMoreProblems = computed(
() => problems.value.length > itemsPerRow.value * 3, () => problems.value.length > itemsPerRow.value * 3,
) )
//
interface GroupedBadge {
icon: string
count: number
badges: UserBadgeType[]
latestEarnedTime: Date
}
//
function groupBadgesByIcon(badges: UserBadgeType[]): GroupedBadge[] {
const grouped = new Map<string, UserBadgeType[]>()
//
badges.forEach((badge) => {
const icon = badge.badge.icon
if (!grouped.has(icon)) {
grouped.set(icon, [])
}
grouped.get(icon)!.push(badge)
})
//
return Array.from(grouped.entries())
.map(([icon, badgeList]) => ({
icon,
count: badgeList.length,
badges: badgeList,
latestEarnedTime: new Date(
Math.max(...badgeList.map((b) => new Date(b.earned_time).getTime())),
),
}))
.sort((a, b) => a.icon.localeCompare(b.icon))
}
async function init() { async function init() {
toggle(true) toggle(true)
try { try {
@@ -73,9 +106,23 @@ async function init() {
}) })
ac.sort() ac.sort()
problems.value = ac problems.value = ac
const promises: Promise<{ data: any }>[] = []
if (profile.value.submission_number > 0) { if (profile.value.submission_number > 0) {
const metricsRes = await getMetrics(profile.value.user.id) promises.push(getMetrics(profile.value.user.id))
}
if (route.query.name) {
promises.push(getUserBadges(route.query.name as string))
} else {
promises.push(getUserBadges())
}
const results = await Promise.all(promises)
// metrics
if (profile.value.submission_number > 0) {
const metricsRes = results[0]
firstSubmissionAt.value = parseTime(metricsRes.data.first) firstSubmissionAt.value = parseTime(metricsRes.data.first)
latestSubmissionAt.value = parseTime(metricsRes.data.latest) latestSubmissionAt.value = parseTime(metricsRes.data.latest)
toLatestAt.value = durationToDays( toLatestAt.value = durationToDays(
@@ -87,24 +134,14 @@ async function init() {
metricsRes.data.latest, metricsRes.data.latest,
) )
} }
// badges
userBadges.value = groupBadgesByIcon(results[1].data)
} finally { } finally {
toggle(false) toggle(false)
} }
} }
// promises results[0]/[1]
//
async function loadAchievementSummary() {
try {
const res = await getAchievementSummary(
(route.query.name as string) || undefined,
)
achievementSummary.value = res.data
} catch {
achievementSummary.value = null
}
}
const metrics = computed(() => { const metrics = computed(() => {
if (loading.value) return [] if (loading.value) return []
return [ return [
@@ -143,10 +180,7 @@ const metrics = computed(() => {
] ]
}) })
onMounted(() => { onMounted(init)
init()
loadAchievementSummary()
})
</script> </script>
<template> <template>
<n-flex <n-flex
@@ -188,14 +222,14 @@ onMounted(() => {
<n-grid <n-grid
v-if="profile && profile.submission_number > 0" v-if="profile && profile.submission_number > 0"
class="wrapper" class="wrapper"
:cols="isDesktop ? 2 : 1" :cols="2"
:x-gap="10" :x-gap="10"
:y-gap="10" :y-gap="10"
> >
<n-gi v-for="item in metrics" :key="item.content"> <n-gi v-for="item in metrics" :key="item.content">
<n-card hoverable> <n-card hoverable>
<n-flex align="center"> <n-flex align="center">
<Icon :icon="item.icon" :width="isDesktop ? 50 : 40" /> <Icon v-if="isDesktop" :icon="item.icon" width="50" />
<div> <div>
<Component :is="isDesktop ? NH2 : NH3" class="number"> <Component :is="isDesktop ? NH2 : NH3" class="number">
<n-number-animation v-if="item.animate" :to="item.title" /> <n-number-animation v-if="item.animate" :to="item.title" />
@@ -210,50 +244,21 @@ onMounted(() => {
</n-gi> </n-gi>
</n-grid> </n-grid>
<!-- 成就摘要 --> <!-- 徽章展示卡片 -->
<n-card <n-card
v-if="!loading && profile && achievementSummary" v-if="!loading && profile && userBadges.length > 0"
class="wrapper" class="wrapper"
hoverable hoverable
> >
<n-flex align="center" justify="space-between"> <template #header>
<n-flex align="center" :size="12"> <n-h4 style="margin: 0">获得的徽章</n-h4>
<span class="achievement-title">
成就 {{ achievementSummary.unlocked }} /
{{ achievementSummary.total }}
</span>
<n-tag size="small" type="info">
{{ achievementSummary.percent }}%
</n-tag>
</n-flex>
<n-button
text
type="primary"
@click="
router.push({
path: '/achievement',
query: route.query.name ? { name: route.query.name } : {},
})
"
>
查看全部
</n-button>
</n-flex>
<n-flex align="center" :size="10" class="achievement-recent">
<n-text v-if="achievementSummary.recent.length" depth="3">
最近获得
</n-text>
<n-tooltip v-for="a in achievementSummary.recent" :key="a.id">
<template #trigger>
<span class="achievement-icon">
<AchievementIcon :icon="a.icon" :size="24" />
</span>
</template> </template>
{{ a.name }} <n-flex wrap>
</n-tooltip> <GroupedUserBadge
<n-text v-if="!achievementSummary.recent.length" depth="3"> v-for="groupedBadge in userBadges"
还没有获得成就 :key="groupedBadge.icon"
</n-text> :grouped-badge="groupedBadge"
/>
</n-flex> </n-flex>
</n-card> </n-card>
@@ -316,16 +321,4 @@ h2 {
word-wrap: break-word; word-wrap: break-word;
max-width: 100%; max-width: 100%;
} }
.achievement-title {
font-weight: 600;
}
.achievement-recent {
margin-top: 10px;
}
.achievement-icon {
display: inline-flex;
align-items: center;
font-size: 24px;
cursor: default;
}
</style> </style>
+7 -21
View File
@@ -82,12 +82,6 @@ export const ojs: RouteRecordRaw = {
component: () => import("oj/user/index.vue"), component: () => import("oj/user/index.vue"),
meta: { requiresAuth: true }, meta: { requiresAuth: true },
}, },
{
path: "achievement",
name: "achievement",
component: () => import("oj/achievement/index.vue"),
meta: { requiresAuth: true },
},
{ {
path: "setting", path: "setting",
component: () => import("oj/user/setting.vue"), component: () => import("oj/user/setting.vue"),
@@ -99,7 +93,7 @@ export const ojs: RouteRecordRaw = {
meta: { requiresAuth: true }, meta: { requiresAuth: true },
}, },
{ {
path: "learn/:type/:step", path: "learn/:step+",
name: "learn", name: "learn",
component: () => import("oj/learn/index.vue"), component: () => import("oj/learn/index.vue"),
props: true, props: true,
@@ -171,12 +165,6 @@ export const admins: RouteRecordRaw = {
component: () => import("admin/problem/list.vue"), component: () => import("admin/problem/list.vue"),
meta: { requiresProblemPermission: true }, meta: { requiresProblemPermission: true },
}, },
{
path: "problem/tags",
name: "admin tag list",
component: () => import("admin/problem/tags.vue"),
meta: { requiresProblemPermission: true },
},
{ {
path: "problem/create", path: "problem/create",
name: "admin problem create", name: "admin problem create",
@@ -257,6 +245,12 @@ export const admins: RouteRecordRaw = {
props: true, props: true,
meta: { requiresSuperAdmin: true }, meta: { requiresSuperAdmin: true },
}, },
{
path: "comment/list",
name: "admin comment list",
component: () => import("admin/communication/comments.vue"),
meta: { requiresSuperAdmin: true },
},
{ {
path: "message/list", path: "message/list",
name: "admin message list", name: "admin message list",
@@ -301,14 +295,6 @@ export const admins: RouteRecordRaw = {
component: () => import("admin/problemset/list.vue"), component: () => import("admin/problemset/list.vue"),
meta: { requiresTeacherAdmin: true }, meta: { requiresTeacherAdmin: true },
}, },
{
path: "achievement/list",
name: "admin achievement list",
// 后端 admin 接口是 super_admin_required,这里必须跟着用
// requiresSuperAdmin,否则教师能进页面但每个请求都 403
component: () => import("admin/achievement/list.vue"),
meta: { requiresSuperAdmin: true },
},
{ {
path: "problemset/create", path: "problemset/create",
name: "admin problemset create", name: "admin problemset create",
+5
View File
@@ -9,6 +9,7 @@ export function signup(data: {
username: string username: string
email: string email: string
password: string password: string
captcha: string
}) { }) {
return http.post("register", data) return http.post("register", data)
} }
@@ -25,6 +26,10 @@ export function getProblemTagList() {
return http.get<Tag[]>("problem/tags") return http.get<Tag[]>("problem/tags")
} }
export function getCaptcha() {
return http.get("captcha")
}
export function getHitokoto() { export function getHitokoto() {
return http.get("hitokoto") return http.get("hitokoto")
} }
-50
View File
@@ -1,50 +0,0 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
// iconifynoto emoji SVG
// emoji
//
// icon URL
// https:// iconify URL
// icon emoji
const props = withDefaults(defineProps<{ icon: string; size?: number }>(), {
size: 32,
})
const kind = computed(() => {
const v = props.icon ?? ""
if (/^(https?:\/\/|\/|data:)/.test(v)) return "image"
if (v.includes(":")) return "iconify"
return "text"
})
</script>
<template>
<img
v-if="kind === 'image'"
:src="icon"
:width="size"
:height="size"
class="image"
alt=""
/>
<Icon
v-else-if="kind === 'iconify'"
:icon="icon"
:width="size"
:height="size"
/>
<span v-else class="fallback" :style="{ fontSize: `${size}px` }">
{{ icon }}
</span>
</template>
<style scoped>
.image {
object-fit: contain;
border-radius: 4px;
}
.fallback {
line-height: 1;
}
</style>
-101
View File
@@ -1,101 +0,0 @@
<script setup lang="ts">
import AchievementIcon from "shared/components/AchievementIcon.vue"
import { useAchievementStore } from "shared/store/achievement"
import { RARITY_COLOR } from "utils/constants"
const store = useAchievementStore()
const { current, queue } = storeToRefs(store)
const visible = ref(false)
let timer: ReturnType<typeof setTimeout> | null = null
let gapTimer: ReturnType<typeof setTimeout> | null = null
//
function playNext() {
const item = store.next()
if (!item) return
visible.value = true
timer = setTimeout(async () => {
visible.value = false
await store.markRead(item)
gapTimer = setTimeout(playNext, 400)
}, 3000)
}
watch(
() => queue.value.length,
(len) => {
if (len > 0 && !visible.value) playNext()
},
)
onUnmounted(() => {
if (timer) clearTimeout(timer)
if (gapTimer) clearTimeout(gapTimer)
})
</script>
<template>
<Transition name="slide">
<div
v-if="visible && current"
class="toast"
:style="{ borderColor: RARITY_COLOR[current.rarity] }"
>
<div class="icon">
<AchievementIcon :icon="current.icon" :size="34" />
</div>
<div class="body">
<div class="label">
{{ current.kind === "badge" ? "获得奖章" : "成就解锁" }}
</div>
<div class="name">{{ current.name }}</div>
<div class="desc">{{ current.description }}</div>
</div>
</div>
</Transition>
</template>
<style scoped>
.toast {
position: fixed;
right: 24px;
bottom: 24px;
z-index: 3000;
display: flex;
gap: 12px;
align-items: center;
padding: 14px 18px;
border-radius: 10px;
border: 2px solid;
background: var(--n-color, rgba(24, 24, 28, 0.95));
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
min-width: 260px;
}
.icon {
font-size: 34px;
}
.label {
font-size: 11px;
letter-spacing: 2px;
opacity: 0.6;
}
.name {
font-weight: 700;
margin-top: 2px;
}
.desc {
font-size: 12px;
opacity: 0.7;
margin-top: 2px;
}
.slide-enter-active,
.slide-leave-active {
transition: all 0.35s ease;
}
.slide-enter-from,
.slide-leave-to {
opacity: 0;
transform: translateX(40px);
}
</style>
@@ -0,0 +1,76 @@
<template>
<div class="badge-item">
<img
:src="groupedBadge.icon"
:alt="groupedBadge.badges[0].badge.name"
class="badge-icon"
@error="handleImageError"
/>
<div v-if="groupedBadge.count > 1" class="badge-count">
×{{ groupedBadge.count }}
</div>
</div>
</template>
<script setup lang="ts">
interface GroupedBadge {
icon: string
count: number
badges: any[]
latestEarnedTime: Date
}
interface Props {
groupedBadge: GroupedBadge
}
defineProps<Props>()
function handleImageError(event: Event) {
const img = event.target as HTMLImageElement
img.src = "/badge-1.png"
}
</script>
<style scoped>
.badge-item {
position: relative;
display: inline-block;
cursor: pointer;
width: 44px;
height: 44px;
}
.badge-icon {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
border: 2px solid #e0e0e0;
transition: all 0.3s ease;
}
.badge-icon:hover {
transform: scale(1.1);
border-color: #1890ff;
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.3);
}
.badge-count {
position: absolute;
top: -5px;
right: -5px;
background: #ff4d4f;
color: white;
border-radius: 50%;
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: bold;
border: 2px solid white;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
</style>
+7 -27
View File
@@ -2,7 +2,6 @@
import { Icon } from "@iconify/vue" import { Icon } from "@iconify/vue"
import { RouterLink } from "vue-router" import { RouterLink } from "vue-router"
import { useBreakpoints } from "shared/composables/breakpoints" import { useBreakpoints } from "shared/composables/breakpoints"
import { useLearnProgress } from "shared/composables/learnProgress"
import { useAuthModalStore } from "shared/store/authModal" import { useAuthModalStore } from "shared/store/authModal"
import { useScreenModeStore } from "shared/store/screenMode" import { useScreenModeStore } from "shared/store/screenMode"
import { logout } from "../api" import { logout } from "../api"
@@ -18,14 +17,15 @@ const route = useRoute()
const router = useRouter() const router = useRouter()
const { isMobile, isDesktop } = useBreakpoints() const { isMobile, isDesktop } = useBreakpoints()
const { learnStep } = useLearnProgress()
const isDark = useDark() const isDark = useDark()
function toggleDark() { function toggleDark(event: MouseEvent) {
const x = window.innerWidth / 2 const { clientX: x, clientY: y } = event
const y = window.innerHeight / 2 const radius = Math.hypot(
const radius = Math.hypot(x, y) Math.max(x, window.innerWidth - x),
Math.max(y, window.innerHeight - y),
)
if (!document.startViewTransition) { if (!document.startViewTransition) {
isDark.value = !isDark.value isDark.value = !isDark.value
return return
@@ -125,31 +125,11 @@ function renderIcon(icon: string) {
return () => h(Icon, { icon, width: 20 }) return () => h(Icon, { icon, width: 20 })
} }
function learnLink(type: "python" | "c") {
return `/learn/${type}/${learnStep.value[type].toString().padStart(2, "0")}`
}
const menus = computed<MenuOption[]>(() => [ const menus = computed<MenuOption[]>(() => [
{ {
label: "自学", label: () => h(RouterLink, { to: "/learn/01" }, { default: () => "自学" }),
key: "learn", key: "learn",
icon: renderIcon("fluent-emoji:books"), icon: renderIcon("fluent-emoji:books"),
children: [
{
label: () =>
h(
RouterLink,
{ to: learnLink("python") },
{ default: () => "Python" },
),
key: "learn-python",
},
{
label: () =>
h(RouterLink, { to: learnLink("c") }, { default: () => "C语言" }),
key: "learn-c",
},
],
}, },
{ {
label: () => h(RouterLink, { to: "/" }, { default: () => "题目" }), label: () => h(RouterLink, { to: "/" }, { default: () => "题目" }),
+2 -14
View File
@@ -21,20 +21,8 @@ const limit = ref(initialLimit)
const page = ref(initialPage) const page = ref(initialPage)
const sizes = [10, 30, 50] const sizes = [10, 30, 50]
// emit emit ref ref watch(limit, () => emit("update:limit", limit))
// emit watch(page, () => emit("update:page", page))
watch(limit, (value) => emit("update:limit", value))
watch(page, (value) => emit("update:page", value))
// /
watch(
() => initialLimit,
(value) => (limit.value = value),
)
watch(
() => initialPage,
(value) => (page.value = value),
)
</script> </script>
<template> <template>
+38 -2
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { signup } from "../api" import { getCaptcha, signup } from "../api"
import { storeToRefs } from "pinia" import { storeToRefs } from "pinia"
import { useAuthModalStore } from "../store/authModal" import { useAuthModalStore } from "../store/authModal"
@@ -10,6 +10,7 @@ const {
signupForm: form, signupForm: form,
signupLoading: isLoading, signupLoading: isLoading,
signupError: msg, signupError: msg,
captchaSrc,
} = storeToRefs(authStore) } = storeToRefs(authStore)
const signupRef = useTemplateRef("signupRef") const signupRef = useTemplateRef("signupRef")
@@ -30,6 +31,9 @@ const rules: FormRules = {
trigger: "blur", trigger: "blur",
}, },
], ],
captcha: [
{ required: true, message: "验证码必填", trigger: "blur", min: 1, max: 10 },
],
} }
function goLogin() { function goLogin() {
@@ -46,15 +50,20 @@ function submit() {
username: form.value.username, username: form.value.username,
email: form.value.email, email: form.value.email,
password: form.value.password, password: form.value.password,
captcha: form.value.captcha,
}) })
} catch (err: any) { } catch (err: any) {
if (err.data === "Username already exists") { if (err.data === "Invalid captcha") {
authStore.setSignupError("验证码不正确")
} else if (err.data === "Username already exists") {
authStore.setSignupError("用户名已存在") authStore.setSignupError("用户名已存在")
} else if (err.data === "Email already exists") { } else if (err.data === "Email already exists") {
authStore.setSignupError("邮箱已存在") authStore.setSignupError("邮箱已存在")
} else { } else {
authStore.setSignupError("无法注册") authStore.setSignupError("无法注册")
} }
getCaptchaSrc()
form.value.captcha = ""
} finally { } finally {
authStore.setSignupLoading(false) authStore.setSignupLoading(false)
} }
@@ -65,6 +74,14 @@ function submit() {
}) })
} }
async function getCaptchaSrc() {
const res = await getCaptcha()
authStore.setCaptchaSrc(res.data)
}
watch(signupModalOpen, (v) => {
if (v) getCaptchaSrc()
})
</script> </script>
<template> <template>
@@ -117,6 +134,18 @@ function submit() {
autocomplete="new-password" autocomplete="new-password"
/> />
</n-form-item> </n-form-item>
<n-form-item label="验证码" path="captcha">
<n-space>
<n-input
v-model:value="form.captcha"
clearable
name="captcha"
id="signup-captcha"
autocomplete="off"
/>
<img class="captcha" :src="captchaSrc" @click="getCaptchaSrc" />
</n-space>
</n-form-item>
<n-alert v-if="msg" type="error" :show-icon="false"> {{ msg }}</n-alert> <n-alert v-if="msg" type="error" :show-icon="false"> {{ msg }}</n-alert>
<n-form-item> <n-form-item>
<n-space> <n-space>
@@ -129,3 +158,10 @@ function submit() {
</n-form> </n-form>
</n-modal> </n-modal>
</template> </template>
<style scoped>
.captcha {
height: 34px;
cursor: pointer;
}
</style>
-15
View File
@@ -1,15 +0,0 @@
import { useStorage } from "@vueuse/core"
import { STORAGE_KEY } from "utils/constants"
export type TutorialType = "python" | "c"
// 模块级单例:学习页写入、导航栏读取,必须共用同一个响应式引用,
// 否则导航栏的链接不会随学习进度更新
const learnStep = useStorage<Record<TutorialType, number>>(
STORAGE_KEY.LEARN_CURRENT_STEP,
{ python: 1, c: 1 },
)
export function useLearnProgress() {
return { learnStep }
}
-15
View File
@@ -1,15 +0,0 @@
import { useDark } from "@vueuse/core"
import { computed } from "vue"
import { RARITY_TEXT_COLOR } from "utils/constants"
/**
*
* 4.5:1
* RARITY_COLOR
*/
export function useRarityColor() {
const isDark = useDark()
return computed(() =>
isDark.value ? RARITY_TEXT_COLOR.dark : RARITY_TEXT_COLOR.light,
)
}
+10 -10
View File
@@ -114,6 +114,15 @@ const options = computed<MenuOption[]>(() => {
h(RouterLink, { to: "/admin/user/list" }, { default: () => "用户" }), h(RouterLink, { to: "/admin/user/list" }, { default: () => "用户" }),
key: "admin user list", key: "admin user list",
}, },
{
label: () =>
h(
RouterLink,
{ to: "/admin/comment/list" },
{ default: () => "评论" },
),
key: "admin comment list",
},
{ {
label: () => label: () =>
h( h(
@@ -132,15 +141,6 @@ const options = computed<MenuOption[]>(() => {
), ),
key: "admin tutorial list", key: "admin tutorial list",
}, },
{
label: () =>
h(
RouterLink,
{ to: "/admin/achievement/list" },
{ default: () => "成就" },
),
key: "admin achievement list",
},
{ {
label: () => label: () =>
h( h(
@@ -164,10 +164,10 @@ const active = computed(() => {
if (path.startsWith("/admin/config")) return "admin config" if (path.startsWith("/admin/config")) return "admin config"
if (path.startsWith("/admin/problemset")) return "admin problemset list" if (path.startsWith("/admin/problemset")) return "admin problemset list"
if (path.startsWith("/admin/problem/stuck")) return "admin stuck problems" if (path.startsWith("/admin/problem/stuck")) return "admin stuck problems"
if (path.startsWith("/admin/problem/tags")) return "admin problem list"
if (path.startsWith("/admin/problem")) return "admin problem list" if (path.startsWith("/admin/problem")) return "admin problem list"
if (path.startsWith("/admin/contest")) return "admin contest list" if (path.startsWith("/admin/contest")) return "admin contest list"
if (path.startsWith("/admin/user")) return "admin user list" if (path.startsWith("/admin/user")) return "admin user list"
if (path.startsWith("/admin/comment")) return "admin comment list"
if (path.startsWith("/admin/announcement")) return "admin announcement list" if (path.startsWith("/admin/announcement")) return "admin announcement list"
if (path.startsWith("/admin/tutorial")) return "admin tutorial list" if (path.startsWith("/admin/tutorial")) return "admin tutorial list"
if (path.startsWith("/admin/ai")) return "admin ai reports" if (path.startsWith("/admin/ai")) return "admin ai reports"
-18
View File
@@ -4,23 +4,6 @@ import Header from "../components/Header.vue"
import Login from "../components/Login.vue" import Login from "../components/Login.vue"
import Signup from "../components/Signup.vue" import Signup from "../components/Signup.vue"
import LoginSummaryModal from "../components/LoginSummaryModal.vue" import LoginSummaryModal from "../components/LoginSummaryModal.vue"
import AchievementToast from "../components/AchievementToast.vue"
import { useAchievementStore } from "shared/store/achievement"
import { useUserStore } from "shared/store/user"
const achievementStore = useAchievementStore()
const userStore = useUserStore()
const route = useRoute()
// WebSocket
//
watch(
() => route.path,
() => {
if (userStore.isAuthed) achievementStore.fetchPending()
},
{ immediate: true },
)
</script> </script>
<template> <template>
@@ -36,7 +19,6 @@ watch(
<Login /> <Login />
<Signup /> <Signup />
<LoginSummaryModal /> <LoginSummaryModal />
<AchievementToast />
<Beian /> <Beian />
</n-layout> </n-layout>
</template> </template>
-64
View File
@@ -1,64 +0,0 @@
import {
getPendingAchievements,
markAchievementsRead,
} from "oj/achievement/api"
import type { PendingAchievement } from "utils/types"
/**
*
*
* UserAchievement.notified
* - pending
* - WebSocket
*
* WebSocket
*
*/
export const useAchievementStore = defineStore("achievement", () => {
const queue = ref<PendingAchievement[]>([])
const current = ref<PendingAchievement | null>(null)
// 成就和题单奖章的 id 来自两张不同的表,数值会重叠,
// 只按 id 去重会让奖章 5 把成就 5 挤掉
function keyOf(item: PendingAchievement) {
return `${item.kind ?? "achievement"}:${item.id}`
}
function enqueue(items: PendingAchievement[]) {
if (!items?.length) return
// 去重:WebSocket 推来的和 pending 拉来的可能是同一批
const known = new Set([
...queue.value.map(keyOf),
...(current.value ? [keyOf(current.value)] : []),
])
queue.value.push(...items.filter((i) => !known.has(keyOf(i))))
}
async function fetchPending() {
try {
// http 客户端返回 ApiResponse<T>,真实载荷在 .data 里
const res = await getPendingAchievements()
enqueue(res.data ?? [])
} catch {
// 拉取失败静默处理,下次路由切换会再拉
}
}
function next() {
current.value = queue.value.shift() ?? null
return current.value
}
async function markRead(item: PendingAchievement) {
// 奖章不在 UserAchievement 表里,它的 id 传给标记接口会被当成成就 id,
// 把一个恰好同号、还没弹过的成就静默标记为已弹——那个奖杯就再也不会出现
if (item.kind === "badge") return
try {
await markAchievementsRead([item.id])
} catch {
// 标记失败下次会重复弹一次,可接受
}
}
return { queue, current, enqueue, fetchPending, next, markRead }
})
+15
View File
@@ -25,11 +25,15 @@ export const useAuthModalStore = defineStore("authModal", () => {
email: "", email: "",
password: "", password: "",
passwordAgain: "", passwordAgain: "",
captcha: "",
}) })
const signupLoading = ref(false) const signupLoading = ref(false)
const signupError = ref("") const signupError = ref("")
// ==================== 验证码 ====================
const captchaSrc = ref("")
// ==================== 模态框操作 ==================== // ==================== 模态框操作 ====================
/** /**
* *
@@ -119,6 +123,13 @@ export const useAuthModalStore = defineStore("authModal", () => {
signupError.value = "" signupError.value = ""
} }
/**
*
*/
function setCaptchaSrc(src: string) {
captchaSrc.value = src
}
return { return {
// 模态框状态 // 模态框状态
loginModalOpen, loginModalOpen,
@@ -134,6 +145,9 @@ export const useAuthModalStore = defineStore("authModal", () => {
signupLoading, signupLoading,
signupError, signupError,
// 验证码
captchaSrc,
// 模态框操作 // 模态框操作
openLoginModal, openLoginModal,
closeLoginModal, closeLoginModal,
@@ -151,5 +165,6 @@ export const useAuthModalStore = defineStore("authModal", () => {
setSignupLoading, setSignupLoading,
setSignupError, setSignupError,
clearSignupError, clearSignupError,
setCaptchaSrc,
} }
}) })
+1 -82
View File
@@ -1,4 +1,4 @@
import type { AchievementRarity, SUBMISSION_RESULT, ReactionKey } from "./types" import type { SUBMISSION_RESULT } from "./types"
export enum SubmissionStatus { export enum SubmissionStatus {
compile_error = -2, compile_error = -2,
@@ -276,44 +276,6 @@ export enum ChartType {
Activity, Activity,
} }
// 成就稀有度
export const RARITY_LABEL: Record<AchievementRarity, string> = {
bronze: "青铜",
silver: "白银",
gold: "黄金",
platinum: "白金",
}
// 边框、色块用的原色:饱和度够高,明暗底上都醒目。
// 青铜往红偏(色相 23),黄金往纯黄推(色相 47),中间隔开两档亮度,
// 不然两个都落在橙棕区,扫一眼分不出来
export const RARITY_COLOR: Record<AchievementRarity, string> = {
bronze: "#c2703d",
silver: "#9fa6b2",
gold: "#f2c012",
platinum: "#7dd3fc",
}
// 文字要另配一套。上面那组是照深色底调的,搬到白底上白金只有 1.7:1、
// 黄金 2.2:1,12px 的稀有度标签根本看不清。取色见 useRarityColor
export const RARITY_TEXT_COLOR: Record<
"dark" | "light",
Record<AchievementRarity, string>
> = {
dark: {
bronze: "#e08d63",
silver: "#b6bcc7",
gold: "#f2c012",
platinum: "#7dd3fc",
},
light: {
bronze: "#a13d1e",
silver: "#6b7280",
gold: "#8f6f00",
platinum: "#0369a1",
},
}
// 时间范围配置 // 时间范围配置
export const DURATION_OPTIONS = [ export const DURATION_OPTIONS = [
{ label: "本节课内", value: "hours:1" }, { label: "本节课内", value: "hours:1" },
@@ -325,46 +287,3 @@ export const DURATION_OPTIONS = [
{ label: "半年内", value: "months:6" }, { label: "半年内", value: "months:6" },
{ label: "一年内", value: "years:1" }, { label: "一年内", value: "years:1" },
] as const ] as const
// 班级号的位数范围。学生用户名形如 ks<班级号><姓名>,班级号还要跟
// 网站配置里的班级列表对得上。
// 后端 OnlineJudge/utils/shortcuts.py 的 CLASS_NAME_MIN/MAX_DIGITS
// 是同一条规则的另一份,改这里必须同时改那边。
export const CLASS_NAME_MIN_DIGITS = 3
export const CLASS_NAME_MAX_DIGITS = 4
/** 合法班级号:3~4 位纯数字 */
export const CLASS_NAME_RE = new RegExp(
`^\\d{${CLASS_NAME_MIN_DIGITS},${CLASS_NAME_MAX_DIGITS}}$`,
)
/** 用户名开头的 ks<班级号>,用于从用户名里认出班级 */
export const USERNAME_CLASS_RE = new RegExp(
`^ks\\d{${CLASS_NAME_MIN_DIGITS},${CLASS_NAME_MAX_DIGITS}}`,
)
/** 班级号作为数字时的上下界,给 n-input-number 用 */
export const CLASS_NAME_MIN_VALUE = 10 ** (CLASS_NAME_MIN_DIGITS - 1)
export const CLASS_NAME_MAX_VALUE = 10 ** CLASS_NAME_MAX_DIGITS - 1
export const REACTIONS: {
key: ReactionKey
label: string
icon: string
}[] = [
{
key: "too_easy",
label: "太简单",
icon: "fluent-emoji:smiling-face-with-sunglasses",
},
{ key: "too_hard", label: "太难了", icon: "fluent-emoji:exploding-head" },
{
key: "confusing",
label: "没看懂",
icon: "fluent-emoji:face-with-spiral-eyes",
},
{ key: "buggy", label: "题目有错", icon: "fluent-emoji:bug" },
{ key: "learned", label: "学到了", icon: "fluent-emoji:light-bulb" },
{ key: "interesting", label: "有意思", icon: "fluent-emoji:star-struck" },
{ key: "want_explain", label: "想听讲解", icon: "fluent-emoji:books" },
]
+1 -24
View File
@@ -1,14 +1,7 @@
import { getTime, intervalToDuration, parseISO, type Duration } from "date-fns" import { getTime, intervalToDuration, parseISO, type Duration } from "date-fns"
import { User } from "./types" import { User } from "./types"
import { USER_TYPE } from "./constants" import { USER_TYPE } from "./constants"
import { import { strFromU8, strToU8, unzlibSync, zlibSync } from "fflate"
strFromU8,
strToU8,
unzlibSync,
zipSync,
zlibSync,
type Zippable,
} from "fflate"
import copyTextFallback from "copy-text-to-clipboard" import copyTextFallback from "copy-text-to-clipboard"
import { customAlphabet } from "nanoid" import { customAlphabet } from "nanoid"
@@ -215,22 +208,6 @@ export function atou(base64: string): string {
return strFromU8(unzipped) return strFromU8(unzipped)
} }
/**
* zip Blob
* @param files
* @param mtime
*/
export function createZipBlob(
files: { name: string; content: string }[],
mtime: Date = new Date(),
): Blob {
const entries: Zippable = {}
for (const f of files) {
entries[f.name] = strToU8(f.content)
}
return new Blob([zipSync(entries, { mtime })], { type: "application/zip" })
}
/** /**
* *
* 使 Clipboard API modal 使退 copy-text-to-clipboard * 使 Clipboard API modal 使退 copy-text-to-clipboard
+2
View File
@@ -12,6 +12,7 @@ export function usePermissions() {
canManageUsers: computed(() => userStore.isSuperAdmin), canManageUsers: computed(() => userStore.isSuperAdmin),
canManageAnnouncements: computed(() => userStore.isSuperAdmin), canManageAnnouncements: computed(() => userStore.isSuperAdmin),
canManageComments: computed(() => userStore.isSuperAdmin),
canManageTutorials: computed(() => userStore.isSuperAdmin), canManageTutorials: computed(() => userStore.isSuperAdmin),
canManageSystemConfig: computed(() => userStore.isSuperAdmin), canManageSystemConfig: computed(() => userStore.isSuperAdmin),
canSendMessages: computed(() => userStore.isSuperAdmin), canSendMessages: computed(() => userStore.isSuperAdmin),
@@ -64,6 +65,7 @@ export function checkRoutePermission(routeName: string): boolean {
"admin announcement list", "admin announcement list",
"admin announcement create", "admin announcement create",
"admin announcement edit", "admin announcement edit",
"admin comment list",
"admin message list", "admin message list",
"admin tutorial list", "admin tutorial list",
"admin tutorial create", "admin tutorial create",
+12 -77
View File
@@ -35,6 +35,7 @@ export interface User {
problem_permission: string problem_permission: string
create_time: Date create_time: Date
last_login: Date last_login: Date
two_factor_auth: boolean
open_api: boolean open_api: boolean
is_disabled: boolean is_disabled: boolean
password?: string password?: string
@@ -103,12 +104,6 @@ export interface Tag {
name: string name: string
} }
export interface AdminTag {
id: number
name: string
problem_count: number
}
export interface TestcaseUploadedReturns { export interface TestcaseUploadedReturns {
id: string id: string
info: Testcase[] info: Testcase[]
@@ -177,11 +172,7 @@ export interface Problem {
sql_display?: SQLDisplay | null sql_display?: SQLDisplay | null
} }
export type AdminProblem = Problem & export type AdminProblem = Problem & AlterProblem
AlterProblem & {
// 后台题目列表接口附带的最高票评价,比赛题目列表不返回
top_reaction?: { type: ReactionKey; count: number } | null
}
interface AlterProblem { interface AlterProblem {
test_case_id: string test_case_id: string
@@ -230,8 +221,6 @@ export interface AdminProblemFiltered {
has_ast_rules: boolean has_ast_rules: boolean
allow_flowchart: boolean allow_flowchart: boolean
show_flowchart: boolean show_flowchart: boolean
// 比赛题目列表接口不返回这个字段
top_reaction?: { type: ReactionKey; count: number } | null
} }
// 题单相关类型 // 题单相关类型
@@ -600,20 +589,16 @@ export interface CreateMessage {
message: string message: string
} }
export type ReactionKey = export interface Comment {
| "too_easy" id: number
| "too_hard" problem: string
| "confusing" submission: string
| "buggy" content: string
| "learned" description_rating: 1 | 2 | 3 | 4 | 5
| "interesting" difficulty_rating: 1 | 2 | 3 | 4 | 5
| "want_explain" comprehensive_rating: 1 | 2 | 3 | 4 | 5
create_time: Date
export type ReactionCounts = Record<ReactionKey, number> user: SampleUser
export interface ReactionState {
mine: ReactionKey | null
counts: ReactionCounts | null
} }
export interface Tutorial { export interface Tutorial {
@@ -738,53 +723,3 @@ export interface DetailsData {
} }
export type Grade = "S" | "A" | "B" | "C" export type Grade = "S" | "A" | "B" | "C"
// ==================== 成就相关类型 ====================
export type AchievementRarity = "bronze" | "silver" | "gold" | "platinum"
export interface Achievement {
id: number
name: string
description: string
icon: string
rarity: AchievementRarity
hidden: boolean
// 隐藏成就未解锁时,后端已做掩码处理,以下四个字段为 null
metric: string | null
operator: "gte" | "lte" | null
threshold: number | null
unlocked: boolean
unlock_time: string | null
backfilled: boolean
progress: number | null
unlock_rate: number
}
export interface AchievementRarityStat {
rarity: AchievementRarity
label: string
total: number
unlocked: number
}
export interface PendingAchievement {
id: number
name: string
description: string
icon: string
rarity: AchievementRarity
// 弹窗队列里混着两种东西:全站成就和题单奖章。它们的 id 来自两张不同的表,
// 数值会重叠,所以去重和标记已读都必须带上 kind 一起判断。
// pending 接口只返回成就,不带这个字段,缺省按 achievement 处理。
kind?: "achievement" | "badge"
}
export interface AchievementSummary {
username: string
total: number
unlocked: number
percent: number
rarity: AchievementRarityStat[]
recent: PendingAchievement[]
}
+1 -64
View File
@@ -6,69 +6,6 @@ import AutoImport from "unplugin-auto-import/vite"
import Components from "unplugin-vue-components/vite" import Components from "unplugin-vue-components/vite"
import { NaiveUiResolver } from "unplugin-vue-components/resolvers" import { NaiveUiResolver } from "unplugin-vue-components/resolvers"
// 显式保留 Chrome 90 所需的运行时兼容项,避免 plugin-legacy 对每个产物执行 Babel 扫描。
// 升级前端依赖后需要重新审计此列表。
const polyfills = [
"es.aggregate-error.cause",
"es.array-buffer.detached",
"es.array-buffer.transfer-to-fixed-length",
"es.array-buffer.transfer",
"es.array.at",
"es.array.find-last-index",
"es.array.push",
"es.array.to-reversed",
"es.array.to-sorted",
"es.array.to-spliced",
"es.array.with",
"es.error.cause",
"es.iterator.constructor",
"es.iterator.drop",
"es.iterator.every",
"es.iterator.filter",
"es.iterator.find",
"es.iterator.flat-map",
"es.iterator.for-each",
"es.iterator.map",
"es.iterator.reduce",
"es.iterator.some",
"es.iterator.to-array",
"es.json.parse",
"es.json.stringify",
"es.map.get-or-insert-computed",
"es.map.get-or-insert",
"es.object.has-own",
"es.regexp.flags",
"es.set.difference.v2",
"es.set.intersection.v2",
"es.set.is-disjoint-from.v2",
"es.set.is-subset-of.v2",
"es.set.is-superset-of.v2",
"es.set.symmetric-difference.v2",
"es.set.union.v2",
"es.string.at-alternative",
"es.typed-array.at",
"es.typed-array.find-last-index",
"es.typed-array.find-last",
"es.typed-array.set",
"es.typed-array.to-reversed",
"es.typed-array.to-sorted",
"es.typed-array.with",
"es.uint8-array.set-from-base64",
"es.uint8-array.set-from-hex",
"es.uint8-array.to-base64",
"es.uint8-array.to-hex",
"es.weak-map.get-or-insert-computed",
"es.weak-map.get-or-insert",
"esnext.array.group",
"web.dom-exception.stack",
"web.immediate",
"web.structured-clone",
"web.url-search-params.delete",
"web.url-search-params.has",
"web.url-search-params.size",
"web.url.can-parse",
]
// index.html 里按需注入 MaxKB 脚本(原 Rsbuild EJS 模板的等价实现) // index.html 里按需注入 MaxKB 脚本(原 Rsbuild EJS 模板的等价实现)
function injectMaxkb(maxkbUrl: string | undefined): Plugin { function injectMaxkb(maxkbUrl: string | undefined): Plugin {
return { return {
@@ -111,7 +48,7 @@ export default defineConfig(({ mode }) => {
legacy({ legacy({
renderLegacyChunks: false, renderLegacyChunks: false,
modernTargets: "chrome>=90", modernTargets: "chrome>=90",
modernPolyfills: polyfills, modernPolyfills: true,
}), }),
AutoImport({ AutoImport({
imports: [ imports: [