Compare commits
76
Commits
f928e79c1f
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8cf98ed087 | ||
|
|
235d11ecdd | ||
|
|
8be96e178e | ||
|
|
ec54fb430e | ||
|
|
56b5dc2e36 | ||
|
|
39f9bb9d38 | ||
|
|
34aff29048 | ||
|
|
d7b57074d3 | ||
|
|
280e360506 | ||
|
|
5b8e0f866b | ||
|
|
9f89be3876 | ||
|
|
1fb93e6b16 | ||
|
|
b3d0c3fcc7 | ||
|
|
c4d55bb88e | ||
|
|
c91c923fd6 | ||
|
|
9c6c6e1784 | ||
|
|
a8cc7c855a | ||
|
|
e153064d87 | ||
|
|
0af91b1e20 | ||
|
|
d16e3dddb8 | ||
|
|
8a46bae27d | ||
|
|
0e2cc28235 | ||
|
|
e777a94568 | ||
|
|
56f1431a10 | ||
|
|
7b2a4a9094 | ||
|
|
b559c93476 | ||
|
|
42de79ee60 | ||
|
|
8cfa90ca16 | ||
|
|
4b796650ff | ||
|
|
ade6034316 | ||
|
|
a1bea7182f | ||
|
|
425bab1c15 | ||
|
|
17dbd7a4a7 | ||
|
|
8db26ef7a4 | ||
|
|
508267bb59 | ||
|
|
a4fdd377f2 | ||
|
|
da2334ed3a | ||
|
|
a626160f29 | ||
|
|
bf028b3fb1 | ||
|
|
b7f82adb7c | ||
|
|
05ce9cbb9e | ||
|
|
4072abe199 | ||
|
|
0d7336bce8 | ||
|
|
4db0dc7e83 | ||
|
|
56d06eb456 | ||
|
|
b79dd6c721 | ||
|
|
15d823aa20 | ||
|
|
4ae25b09c0 | ||
|
|
ea691834b0 | ||
|
|
344e9734b6 | ||
|
|
7694db7d41 | ||
|
|
a191bd78d4 | ||
|
|
842c4796c0 | ||
|
|
15aaec4384 | ||
|
|
52ac8d205d | ||
|
|
30ea5c8191 | ||
|
|
58c67240b7 | ||
|
|
b4b96b0da3 | ||
|
|
4ed8014c30 | ||
|
|
40bd331a1c | ||
|
|
4ec6b1d738 | ||
|
|
c88753ba78 | ||
|
|
0932a05850 | ||
|
|
0ba25dc4e3 | ||
|
|
7e498a1c1c | ||
|
|
700559557d | ||
|
|
a9278589c9 | ||
|
|
d31bfd8b8f | ||
|
|
64a770ff62 | ||
|
|
d7d80e1c1b | ||
|
|
fe7095bd74 | ||
|
|
d81e00ee14 | ||
|
|
71186f0ca5 | ||
|
|
8d06fafbae | ||
|
|
dc853cd61f | ||
|
|
ccbb961d64 |
Generated
+450
-571
File diff suppressed because it is too large
Load Diff
+14
-15
@@ -20,47 +20,46 @@
|
||||
"@vue-flow/minimap": "^1.5.4",
|
||||
"@vue-flow/node-resizer": "^1.5.1",
|
||||
"@vue-flow/node-toolbar": "^1.1.1",
|
||||
"@vueuse/core": "^14.3.0",
|
||||
"@vueuse/router": "^14.3.0",
|
||||
"@wangeditor-next/editor": "^6.0.2",
|
||||
"@wangeditor-next/editor-for-vue": "^5.1.14",
|
||||
"axios": "^1.18.1",
|
||||
"@vueuse/core": "^14.4.0",
|
||||
"@vueuse/router": "^14.4.0",
|
||||
"@wangeditor-next/editor": "^6.2.0",
|
||||
"@wangeditor-next/editor-for-vue": "^6.2.0",
|
||||
"axios": "^1.19.0",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"chart.js": "^4.5.1",
|
||||
"chartjs-chart-wordcloud": "^4.4.5",
|
||||
"client-zip": "^2.5.0",
|
||||
"codemirror": "^6.0.2",
|
||||
"copy-text-to-clipboard": "^3.2.2",
|
||||
"date-fns": "^4.4.0",
|
||||
"fflate": "^0.8.3",
|
||||
"highlight.js": "^11.11.1",
|
||||
"md-editor-v3": "^6.5.4",
|
||||
"mermaid": "^11.16.0",
|
||||
"md-editor-v3": "^6.5.6",
|
||||
"mermaid": "^11.16.1",
|
||||
"mermaid-legacy": "npm:mermaid@^9.4.3",
|
||||
"naive-ui": "^2.44.1",
|
||||
"nanoid": "^6.0.0",
|
||||
"nanoid": "^6.0.1",
|
||||
"normalize.css": "^8.0.1",
|
||||
"pinia": "^4.0.2",
|
||||
"skulpt": "^1.2.0",
|
||||
"vue": "^3.5.40",
|
||||
"vue": "^3.5.41",
|
||||
"vue-chartjs": "^5.3.4",
|
||||
"vue-codemirror": "^6.1.1",
|
||||
"vue-router": "^5.2.0",
|
||||
"y-codemirror.next": "^0.3.5",
|
||||
"y-webrtc": "^10.3.0",
|
||||
"yjs": "^13.6.31"
|
||||
"yjs": "^13.6.32"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify/vue": "^5.0.1",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/node": "^26.1.1",
|
||||
"@vitejs/plugin-legacy": "^8.2.2",
|
||||
"@types/node": "^26.1.2",
|
||||
"@vitejs/plugin-legacy": "^8.2.3",
|
||||
"@vitejs/plugin-vue": "^6.0.8",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"prettier": "^3.9.6",
|
||||
"typescript": "^7.0.2",
|
||||
"unplugin-auto-import": "^21.0.0",
|
||||
"unplugin-auto-import": "^21.1.0",
|
||||
"unplugin-vue-components": "^32.1.0",
|
||||
"vite": "^8.1.5"
|
||||
"vite": "^8.2.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
<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>
|
||||
@@ -0,0 +1,144 @@
|
||||
<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>
|
||||
+77
-10
@@ -2,6 +2,7 @@ import http from "utils/http"
|
||||
import { toProblemListItem } from "admin/transforms"
|
||||
import type {
|
||||
AdminProblem,
|
||||
AdminTag,
|
||||
Announcement,
|
||||
AnnouncementEdit,
|
||||
BlankContest,
|
||||
@@ -31,6 +32,7 @@ export async function getProblemList(
|
||||
keyword: string,
|
||||
author?: string,
|
||||
contestID?: string,
|
||||
tagId?: number,
|
||||
) {
|
||||
const endpoint = !!contestID ? "admin/contest/problem" : "admin/problem"
|
||||
const res = await http.get<{ results: AdminProblem[]; total: number }>(
|
||||
@@ -43,6 +45,7 @@ export async function getProblemList(
|
||||
keyword,
|
||||
author,
|
||||
contest_id: contestID,
|
||||
tag_id: tagId,
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -84,6 +87,35 @@ export function getContestProblem(id: number) {
|
||||
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(
|
||||
offset = 0,
|
||||
@@ -255,16 +287,6 @@ export function createAnnouncement(announcement: AnnouncementEdit) {
|
||||
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() {
|
||||
const res = await http.get<Tutorial[]>("admin/tutorial")
|
||||
return res.data
|
||||
@@ -531,3 +553,48 @@ export function pinAIReport(id: number) {
|
||||
export function getPinnedAIReports() {
|
||||
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 } })
|
||||
}
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
<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>
|
||||
@@ -1,22 +0,0 @@
|
||||
<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>
|
||||
@@ -0,0 +1,109 @@
|
||||
<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">
|
||||
import { downloadZip } from "client-zip"
|
||||
import type { LANGUAGE, SQLDisplay, Testcase } from "utils/types"
|
||||
import { createZipBlob } from "utils/functions"
|
||||
import SQLDataTable from "oj/problem/components/SQLDataTable.vue"
|
||||
import {
|
||||
generateSQLTestcase,
|
||||
@@ -156,15 +156,13 @@ async function preview() {
|
||||
async function upload() {
|
||||
isUploading.value = true
|
||||
try {
|
||||
const now = new Date()
|
||||
const data = scripts.value
|
||||
.filter((s) => s.sql.trim())
|
||||
.map((s, i) => ({
|
||||
name: `${i + 1}.sql`,
|
||||
input: s.sql,
|
||||
lastModified: now,
|
||||
content: s.sql,
|
||||
}))
|
||||
const blob = await downloadZip(data).blob()
|
||||
const blob = createZipBlob(data)
|
||||
const file = new File([blob], "testcase.zip", { type: "application/zip" })
|
||||
|
||||
const res = await uploadTestcases(file, { sql: true })
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
<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">
|
||||
import { downloadZip } from "client-zip"
|
||||
import type { LANGUAGE, Testcase } from "utils/types"
|
||||
import { createZipBlob } from "utils/functions"
|
||||
import { createTestSubmission } from "utils/judge"
|
||||
import { uploadTestcases } from "../../api"
|
||||
|
||||
@@ -158,15 +158,14 @@ async function run() {
|
||||
async function upload() {
|
||||
isUploading.value = true
|
||||
try {
|
||||
const now = new Date()
|
||||
const data = files.value
|
||||
.filter((f) => f.in.trim() && f.out && !f.error)
|
||||
.flatMap((f, i) => [
|
||||
{ name: `${i + 1}.in`, input: f.in, lastModified: now },
|
||||
{ name: `${i + 1}.out`, input: f.out, lastModified: now },
|
||||
{ name: `${i + 1}.in`, content: f.in },
|
||||
{ name: `${i + 1}.out`, content: f.out },
|
||||
])
|
||||
|
||||
const blob = await downloadZip(data).blob()
|
||||
const blob = createZipBlob(data)
|
||||
const file = new File([blob], "testcase.zip", { type: "application/zip" })
|
||||
|
||||
const res = await uploadTestcases(file)
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { NFlex, NSwitch, NTag } from "naive-ui"
|
||||
import { NFlex, NSwitch, NTag, NTooltip } from "naive-ui"
|
||||
import { Icon } from "@iconify/vue"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import { usePagination } from "shared/composables/pagination"
|
||||
import { getTagColor, parseTime } from "utils/functions"
|
||||
import type { AdminProblemFiltered } from "utils/types"
|
||||
import { DIFFICULTY } from "utils/constants"
|
||||
import { DIFFICULTY, REACTIONS } from "utils/constants"
|
||||
import { getProblemList, toggleProblemVisible } from "../api"
|
||||
import Actions from "./components/Actions.vue"
|
||||
import Modal from "./components/Modal.vue"
|
||||
import { useRouteQuery } from "@vueuse/router"
|
||||
import AuthorSelect from "shared/components/AuthorSelect.vue"
|
||||
import type { DataTableRowKey } from "naive-ui"
|
||||
import BatchTagModal from "./components/BatchTagModal.vue"
|
||||
|
||||
interface Props {
|
||||
contestID?: string
|
||||
@@ -36,6 +38,30 @@ const { count, inc } = useCounter(0)
|
||||
const total = ref(0)
|
||||
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(() => {
|
||||
if (!isContestProblemList.value) return ""
|
||||
if (problems.value.length === 0) return "1"
|
||||
@@ -57,7 +83,7 @@ const { query, clearQuery } = usePagination<ProblemQuery>({
|
||||
author: useRouteQuery("author", "").value,
|
||||
})
|
||||
|
||||
const columns: DataTableColumn<AdminProblemFiltered>[] = [
|
||||
const baseColumns: DataTableColumn<AdminProblemFiltered>[] = [
|
||||
{ title: "ID", key: "id", width: 100 },
|
||||
{ title: "显示编号", key: "_id", width: 100 },
|
||||
{ title: "标题", key: "title", minWidth: 200 },
|
||||
@@ -109,6 +135,21 @@ const columns: DataTableColumn<AdminProblemFiltered>[] = [
|
||||
: 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: "创建时间",
|
||||
@@ -141,6 +182,13 @@ const columns: 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() {
|
||||
if (query.page < 1) query.page = 1
|
||||
const offset = (query.page - 1) * query.limit
|
||||
@@ -212,8 +260,20 @@ watch(() => [query.page, query.limit, query.author], listProblems)
|
||||
>
|
||||
年度趋势
|
||||
</n-button>
|
||||
<n-button
|
||||
v-if="!isContestProblemList"
|
||||
@click="$router.push({ name: 'admin tag list' })"
|
||||
>
|
||||
标签管理
|
||||
</n-button>
|
||||
</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>
|
||||
@@ -238,7 +298,13 @@ watch(() => [query.page, query.limit, query.author], listProblems)
|
||||
</div>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
<n-data-table striped :columns="columns" :data="problems" />
|
||||
<n-data-table
|
||||
striped
|
||||
:columns="columns"
|
||||
:data="problems"
|
||||
:row-key="rowKey"
|
||||
@update:checked-row-keys="chooseProblems"
|
||||
/>
|
||||
<Pagination
|
||||
:total="total"
|
||||
v-model:limit="query.limit"
|
||||
@@ -250,6 +316,12 @@ watch(() => [query.page, query.limit, query.author], listProblems)
|
||||
:next-display-id="nextDisplayID"
|
||||
@change="listProblems"
|
||||
/>
|
||||
<BatchTagModal
|
||||
v-model:show="showBatchTag"
|
||||
:problem-ids="selectedProblemIds"
|
||||
:action="batchTagAction"
|
||||
@done="onBatchTagDone"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
<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>
|
||||
@@ -1,5 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
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 type { Server } from "utils/types"
|
||||
import { useConfigStore } from "shared/store/config"
|
||||
@@ -140,7 +145,21 @@ async function getWebsiteConfig() {
|
||||
}
|
||||
|
||||
async function saveWebsiteConfig() {
|
||||
await editWebsite(websiteConfig)
|
||||
// 班级号要和用户名里 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)
|
||||
} catch (err: any) {
|
||||
message.error("保存失败:" + err.data)
|
||||
return
|
||||
}
|
||||
message.success("网站配置保存成功")
|
||||
getWebsiteConfig()
|
||||
configStore.getConfig()
|
||||
@@ -214,7 +233,13 @@ onMounted(() => {
|
||||
</n-form>
|
||||
<n-form label-placement="left">
|
||||
<n-form-item label="班级列表">
|
||||
<n-dynamic-tags v-model:value="websiteConfig.class_list" />
|
||||
<n-flex vertical size="small">
|
||||
<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 }}
|
||||
位数字,如 251、2510,要和用户名里 ks 后面那段一致
|
||||
</n-text>
|
||||
</n-flex>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<n-flex align="center">
|
||||
|
||||
@@ -14,5 +14,7 @@ export function toProblemListItem(result: AdminProblem) {
|
||||
has_ast_rules: result.has_ast_rules,
|
||||
allow_flowchart: result.allow_flowchart,
|
||||
show_flowchart: result.show_flowchart,
|
||||
// 比赛题目列表接口不返回这个字段
|
||||
top_reaction: result.top_reaction ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
<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"
|
||||
|
||||
const message = useMessage()
|
||||
@@ -12,6 +18,17 @@ function generateUsers() {
|
||||
message.info("请填写相关内容")
|
||||
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}` : ""
|
||||
rawInput.value = rawInput.value.trim()
|
||||
const inputs = rawInput.value.split("\n")
|
||||
@@ -64,9 +81,9 @@ async function submit() {
|
||||
style="width: 170px"
|
||||
v-model:value="prefix"
|
||||
clearable
|
||||
:max="9999"
|
||||
:max="CLASS_NAME_MAX_VALUE"
|
||||
:min="0"
|
||||
placeholder="班级号"
|
||||
:placeholder="`班级号(${CLASS_NAME_MIN_DIGITS}~${CLASS_NAME_MAX_DIGITS} 位)`"
|
||||
/>
|
||||
</n-flex>
|
||||
<n-input
|
||||
|
||||
+23
-15
@@ -172,7 +172,6 @@ function createNewUser() {
|
||||
problem_permission: "None",
|
||||
create_time: new Date(),
|
||||
last_login: new Date(),
|
||||
two_factor_auth: false,
|
||||
open_api: false,
|
||||
is_disabled: false,
|
||||
password: "",
|
||||
@@ -197,20 +196,29 @@ async function handleEditUser() {
|
||||
message.error("密码长度不得小于 6")
|
||||
return
|
||||
}
|
||||
if (create.value) {
|
||||
const newUser = [
|
||||
[
|
||||
userEditing.value.username,
|
||||
password.value,
|
||||
userEditing.value.email,
|
||||
userEditing.value.real_name,
|
||||
],
|
||||
]
|
||||
await importUsers(newUser)
|
||||
listUsers()
|
||||
} else {
|
||||
const user = Object.assign(userEditing.value, { password: password.value })
|
||||
await editUser(user)
|
||||
// http 拦截器只对 login-required / permission-denied 自动弹提示,
|
||||
// 其余业务错误(比如班级号位数不对)不接住就什么都不显示
|
||||
try {
|
||||
if (create.value) {
|
||||
const newUser = [
|
||||
[
|
||||
userEditing.value.username,
|
||||
password.value,
|
||||
userEditing.value.email,
|
||||
userEditing.value.real_name,
|
||||
],
|
||||
]
|
||||
await importUsers(newUser)
|
||||
listUsers()
|
||||
} else {
|
||||
const user = Object.assign(userEditing.value, {
|
||||
password: password.value,
|
||||
})
|
||||
await editUser(user)
|
||||
}
|
||||
} catch (err: any) {
|
||||
message.error("保存失败:" + err.data)
|
||||
return
|
||||
}
|
||||
userEditing.value = null
|
||||
password.value = ""
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
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 })
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
<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>
|
||||
@@ -0,0 +1,227 @@
|
||||
<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>
|
||||
+13
-16
@@ -3,6 +3,8 @@ import { filterResult } from "oj/transforms"
|
||||
import type {
|
||||
Exercise,
|
||||
Problem,
|
||||
ReactionKey,
|
||||
ReactionState,
|
||||
Submission,
|
||||
SubmissionListPayload,
|
||||
SubmitCodePayload,
|
||||
@@ -223,22 +225,17 @@ export function getMessageList(offset = 0, limit = 10) {
|
||||
return http.get("message", { params: { limit, offset } })
|
||||
}
|
||||
|
||||
export function createComment(data: {
|
||||
problem_id: number
|
||||
description_rating: number
|
||||
difficulty_rating: number
|
||||
comprehensive_rating: number
|
||||
content: string
|
||||
}) {
|
||||
return http.post("comment", data)
|
||||
export function getReaction(problemID: number) {
|
||||
return http.get<ReactionState>("reaction", {
|
||||
params: { problem_id: problemID },
|
||||
})
|
||||
}
|
||||
|
||||
export function getComment(problemID: number) {
|
||||
return http.get("comment", { params: { problem_id: problemID } })
|
||||
}
|
||||
|
||||
export function getCommentStatistics(problemID: number) {
|
||||
return http.get("comment/statistics", { params: { problem_id: problemID } })
|
||||
export function setReaction(problemID: number, type: ReactionKey) {
|
||||
return http.post<ReactionState>("reaction", {
|
||||
problem_id: problemID,
|
||||
type,
|
||||
})
|
||||
}
|
||||
|
||||
// TODO: 这个API有问题
|
||||
@@ -254,8 +251,8 @@ export function getTutorial(id: number) {
|
||||
return http.get("tutorial", { params: { id } })
|
||||
}
|
||||
|
||||
export function getTutorials() {
|
||||
return http.get("tutorials")
|
||||
export function getTutorials(type: "python" | "c") {
|
||||
return http.get("tutorials", { params: { type } })
|
||||
}
|
||||
|
||||
export function getAIDetailData(start: string, end: string, username?: string) {
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
<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"
|
||||
|
||||
hljs.registerLanguage("python", python)
|
||||
hljs.registerLanguage("c", c)
|
||||
import { highlight } from "../composables/useCodeHighlight"
|
||||
import "./exercise-highlight.css"
|
||||
|
||||
const props = defineProps<{ exercise: Exercise; lang?: string }>()
|
||||
const data = computed(() => props.exercise.data as ExerciseFillData)
|
||||
@@ -14,10 +10,6 @@ type CodeSeg = { type: "code"; html: string }
|
||||
type BlankSeg = { type: "blank"; answers: string[]; index: number }
|
||||
type Segment = CodeSeg | BlankSeg
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
||||
}
|
||||
|
||||
const segments = computed<Segment[]>(() => {
|
||||
const blanks: string[][] = []
|
||||
const markedCode = data.value.code.replace(/\{\{([^}]+)\}\}/g, (_, inner) => {
|
||||
@@ -25,18 +17,7 @@ const segments = computed<Segment[]>(() => {
|
||||
return `____${blanks.length - 1}____`
|
||||
})
|
||||
|
||||
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 highlighted = highlight(markedCode, props.lang)
|
||||
|
||||
const parts = highlighted.split(/____(\d+)____/)
|
||||
const result: Segment[] = []
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { Exercise, ExerciseGroupData } from "utils/types"
|
||||
import { shuffle } from "../composables/useShuffle"
|
||||
|
||||
const props = defineProps<{ exercise: Exercise; lang?: string }>()
|
||||
const data = computed(() => props.exercise.data as ExerciseGroupData)
|
||||
@@ -9,15 +10,6 @@ const placement = ref<number[]>([]) // placement[itemIdx] = 桶下标,-1 表
|
||||
const dragIdx = ref<number | null>(null)
|
||||
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() {
|
||||
order.value = shuffle(data.value.items.map((_, i) => i))
|
||||
placement.value = Array(data.value.items.length).fill(-1)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { Exercise, ExerciseMatchData } from "utils/types"
|
||||
import { shuffle } from "../composables/useShuffle"
|
||||
|
||||
const props = defineProps<{ exercise: Exercise; lang?: string }>()
|
||||
const data = computed(() => props.exercise.data as ExerciseMatchData)
|
||||
@@ -20,15 +21,6 @@ const pairs = ref<(number | null)[]>([]) // pairs[leftIdx] = 配对的 right 原
|
||||
const selectedLeft = ref<number | null>(null)
|
||||
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() {
|
||||
const n = data.value.right.length
|
||||
rightOrder.value = shuffle(Array.from({ length: n }, (_, i) => i))
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
<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"
|
||||
|
||||
hljs.registerLanguage("python", python)
|
||||
hljs.registerLanguage("c", c)
|
||||
import { shuffle } from "../composables/useShuffle"
|
||||
import { highlightLines } from "../composables/useCodeHighlight"
|
||||
import "./exercise-highlight.css"
|
||||
|
||||
const props = defineProps<{ exercise: Exercise; lang?: string }>()
|
||||
const data = computed(() => props.exercise.data as ExerciseSortData)
|
||||
@@ -15,21 +12,16 @@ type LineItem = { originalIdx: number; text: string }
|
||||
const lines = ref<LineItem[]>([])
|
||||
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() {
|
||||
lines.value = shuffle(
|
||||
const shuffled = shuffle(
|
||||
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
|
||||
}
|
||||
|
||||
@@ -69,35 +61,9 @@ function reset() {
|
||||
init()
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
const lineHtml = computed<string[]>(() =>
|
||||
highlightLines(data.value.lines, props.lang),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -143,7 +109,7 @@ const lineHtmlMap = computed<Record<number, string>>(() => {
|
||||
@drop="onDrop(idx)"
|
||||
>
|
||||
<span style="color: #bbb; cursor: grab">⠿</span>
|
||||
<span v-html="lineHtmlMap[line.originalIdx]" style="white-space: pre" />
|
||||
<span v-html="lineHtml[line.originalIdx]" style="white-space: pre" />
|
||||
</div>
|
||||
</n-space>
|
||||
|
||||
@@ -162,55 +128,3 @@ const lineHtmlMap = computed<Record<number, string>>(() => {
|
||||
</n-space>
|
||||
</n-card>
|
||||
</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>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// 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
|
||||
}
|
||||
+31
-9
@@ -57,7 +57,7 @@
|
||||
content-style="height: calc(100% - 44px); padding: 0;"
|
||||
>
|
||||
<CodeEditor
|
||||
language="Python3"
|
||||
:language="editorLanguage"
|
||||
v-model="tutorial.code"
|
||||
height="100%"
|
||||
/>
|
||||
@@ -102,7 +102,7 @@
|
||||
</n-tab-pane>
|
||||
|
||||
<n-tab-pane name="code" tab="示例代码" v-if="tutorial.code">
|
||||
<CodeEditor language="Python3" v-model="tutorial.code" />
|
||||
<CodeEditor :language="editorLanguage" v-model="tutorial.code" />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
|
||||
@@ -128,16 +128,23 @@
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
|
||||
<n-empty
|
||||
v-if="isEmpty"
|
||||
description="该教程还没有公开"
|
||||
style="margin-top: 80px"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { MdPreview } from "md-editor-v3"
|
||||
import "md-editor-v3/lib/preview.css"
|
||||
import type { Tutorial, Exercise } from "utils/types"
|
||||
import type { Tutorial, Exercise, LANGUAGE } from "utils/types"
|
||||
import { getTutorial, getTutorials, getExercises } from "../api"
|
||||
import { parseExercises } from "./composables/useExerciseParse"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { useLearnProgress } from "shared/composables/learnProgress"
|
||||
|
||||
const ExerciseWidget = defineAsyncComponent(
|
||||
() => import("./components/ExerciseWidget.vue"),
|
||||
@@ -150,21 +157,32 @@ const isDark = useDark()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { isDesktop } = useBreakpoints()
|
||||
const { learnStep } = useLearnProgress()
|
||||
|
||||
const step = computed(() => {
|
||||
if (!route.params.step || !route.params.step.length) return 1
|
||||
return parseInt(route.params.step[0])
|
||||
const value = route.params.step as string | undefined
|
||||
if (!value) return 1
|
||||
return parseInt(value)
|
||||
})
|
||||
|
||||
const type = computed<"python" | "c">(() =>
|
||||
route.params.type === "c" ? "c" : "python",
|
||||
)
|
||||
|
||||
const tutorial = ref<Partial<Tutorial>>({
|
||||
id: 0,
|
||||
title: "",
|
||||
content: "",
|
||||
code: "",
|
||||
})
|
||||
|
||||
const editorLanguage = computed<LANGUAGE>(() =>
|
||||
tutorial.value.type === "c" ? "C" : "Python3",
|
||||
)
|
||||
const titles = ref<{ id: number; title: string }[]>([])
|
||||
const exercises = ref<Exercise[]>([])
|
||||
const activeTab = ref("content")
|
||||
const isEmpty = ref(false)
|
||||
|
||||
const segments = computed(() =>
|
||||
parseExercises(tutorial.value.content ?? "", exercises.value),
|
||||
@@ -175,7 +193,9 @@ const isLastLesson = computed(() => step.value === titles.value.length)
|
||||
|
||||
function goToLesson(lessonNumber: number) {
|
||||
activeTab.value = "content"
|
||||
router.push("/learn/" + lessonNumber.toString().padStart(2, "0"))
|
||||
router.push(
|
||||
`/learn/${type.value}/${lessonNumber.toString().padStart(2, "0")}`,
|
||||
)
|
||||
}
|
||||
function goToPrevLesson() {
|
||||
if (step.value > 1) goToLesson(step.value - 1)
|
||||
@@ -185,9 +205,10 @@ function goToNextLesson() {
|
||||
}
|
||||
|
||||
async function init() {
|
||||
const res1 = await getTutorials()
|
||||
const res1 = await getTutorials(type.value)
|
||||
titles.value = res1.data
|
||||
if (titles.value.length === 0) return
|
||||
isEmpty.value = titles.value.length === 0
|
||||
if (isEmpty.value) return
|
||||
const id = titles.value[step.value - 1].id
|
||||
const [res2, exs] = await Promise.allSettled([
|
||||
getTutorial(id),
|
||||
@@ -195,10 +216,11 @@ async function init() {
|
||||
])
|
||||
if (res2.status === "fulfilled") tutorial.value = res2.value.data
|
||||
exercises.value = exs.status === "fulfilled" ? exs.value : []
|
||||
learnStep.value[type.value] = step.value
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.params.step,
|
||||
() => [route.params.type, route.params.step],
|
||||
async () => {
|
||||
if (route.name === "learn") init()
|
||||
},
|
||||
|
||||
@@ -71,21 +71,30 @@ const showGoSubmissionButton = computed(() => {
|
||||
else return false
|
||||
})
|
||||
|
||||
const mobileMenuOptions = computed<DropdownOption[]>(() => {
|
||||
const menuOptions = computed<DropdownOption[]>(() => {
|
||||
const options: DropdownOption[] = []
|
||||
if (showGoSubmissionButton.value) {
|
||||
options.push({
|
||||
label: "提交信息",
|
||||
key: "submissions",
|
||||
})
|
||||
}
|
||||
if (userStore.isTeacherOrAbove) {
|
||||
options.push({
|
||||
label: "统计信息",
|
||||
key: "statistics",
|
||||
})
|
||||
// 移动端额外收纳桌面端常驻的两项
|
||||
if (!isDesktop.value) {
|
||||
if (showGoSubmissionButton.value) {
|
||||
options.push({
|
||||
label: "提交信息",
|
||||
key: "submissions",
|
||||
})
|
||||
}
|
||||
if (userStore.isTeacherOrAbove) {
|
||||
options.push({
|
||||
label: "课堂统计",
|
||||
key: "statistics",
|
||||
})
|
||||
}
|
||||
}
|
||||
if (codeStore.code.language !== "Flowchart") {
|
||||
if (codeStore.code.language !== "SQL") {
|
||||
options.push({
|
||||
label: "去自测猫",
|
||||
key: "testcat",
|
||||
})
|
||||
}
|
||||
options.push({
|
||||
label: "复制代码",
|
||||
key: "copy",
|
||||
@@ -95,10 +104,16 @@ const mobileMenuOptions = computed<DropdownOption[]>(() => {
|
||||
key: "reset",
|
||||
})
|
||||
}
|
||||
if (isDesktop.value && userStore.isSuperAdmin) {
|
||||
options.push({
|
||||
label: "编辑题目",
|
||||
key: "edit",
|
||||
})
|
||||
}
|
||||
return options
|
||||
})
|
||||
|
||||
const handleMobileSelect = (key: string) => {
|
||||
const handleMenuSelect = (key: string) => {
|
||||
switch (key) {
|
||||
case "submissions":
|
||||
goSubmissions()
|
||||
@@ -106,12 +121,18 @@ const handleMobileSelect = (key: string) => {
|
||||
case "statistics":
|
||||
statisticPanel.value = true
|
||||
break
|
||||
case "testcat":
|
||||
goTestCat()
|
||||
break
|
||||
case "copy":
|
||||
copy()
|
||||
break
|
||||
case "reset":
|
||||
reset()
|
||||
break
|
||||
case "edit":
|
||||
goEdit()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,35 +237,17 @@ onMounted(() => {
|
||||
:size="buttonSize"
|
||||
@click="statisticPanel = true"
|
||||
>
|
||||
统计信息
|
||||
课堂统计
|
||||
</n-button>
|
||||
|
||||
<template v-if="codeStore.code.language !== 'Flowchart'">
|
||||
<n-button
|
||||
v-if="codeStore.code.language !== 'SQL'"
|
||||
:size="buttonSize"
|
||||
@click="goTestCat"
|
||||
>
|
||||
自测猫
|
||||
</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"
|
||||
v-if="menuOptions.length"
|
||||
trigger="click"
|
||||
:options="mobileMenuOptions"
|
||||
@select="handleMobileSelect"
|
||||
:options="menuOptions"
|
||||
@select="handleMenuSelect"
|
||||
>
|
||||
<n-button size="small">更多</n-button>
|
||||
<n-button :size="buttonSize">更多操作</n-button>
|
||||
</n-dropdown>
|
||||
|
||||
<template v-if="showSyncFeature">
|
||||
@@ -273,14 +276,6 @@ onMounted(() => {
|
||||
</n-tag>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<n-button
|
||||
v-if="isDesktop && userStore.isSuperAdmin"
|
||||
:size="buttonSize"
|
||||
@click="goEdit"
|
||||
>
|
||||
编辑题目
|
||||
</n-button>
|
||||
</n-flex>
|
||||
|
||||
<n-modal
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
<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>
|
||||
@@ -0,0 +1,698 @@
|
||||
<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>
|
||||
@@ -3,7 +3,7 @@ import { Icon } from "@iconify/vue"
|
||||
import { storeToRefs } from "pinia"
|
||||
import {
|
||||
formatCode,
|
||||
getComment,
|
||||
getReaction,
|
||||
submitCode,
|
||||
updateProblemSetProgress,
|
||||
} from "oj/api"
|
||||
@@ -17,11 +17,14 @@ import SubmissionResult from "./SubmissionResult.vue"
|
||||
import { getSubmitButtonState } from "./submitButtonState"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
import { checkPythonSyntax } from "oj/problem/utils/pythonSyntaxCheck"
|
||||
import {
|
||||
checkPythonSyntax,
|
||||
prefetchPythonSyntaxChecker,
|
||||
} from "oj/problem/utils/pythonSyntaxCheck"
|
||||
|
||||
// ==================== 异步组件 ====================
|
||||
const ProblemComment = defineAsyncComponent(
|
||||
() => import("./ProblemComment.vue"),
|
||||
const ProblemReaction = defineAsyncComponent(
|
||||
() => import("./ProblemReaction.vue"),
|
||||
)
|
||||
|
||||
// ==================== 基础状态 ====================
|
||||
@@ -37,6 +40,10 @@ const router = useRouter()
|
||||
const [commentPanel] = useToggle()
|
||||
const message = useMessage()
|
||||
|
||||
function closeCommentPanel() {
|
||||
commentPanel.value = false
|
||||
}
|
||||
|
||||
const { isDesktop } = useBreakpoints()
|
||||
|
||||
// ==================== 烟花效果 ====================
|
||||
@@ -50,6 +57,16 @@ const showResult = ref(false)
|
||||
const isFormatting = 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, {
|
||||
controls: true,
|
||||
@@ -59,8 +76,8 @@ const { start: startCooldown, isPending: isCooldown } = useTimeout(5000, {
|
||||
// ==================== AC后显示评论框 ====================
|
||||
const { start: showCommentPanelDelayed } = useTimeoutFn(
|
||||
async () => {
|
||||
const res = await getComment(problem.value!.id)
|
||||
if (!res.data) {
|
||||
const res = await getReaction(problem.value!.id)
|
||||
if (res.data.mine === null) {
|
||||
commentPanel.value = true
|
||||
}
|
||||
},
|
||||
@@ -99,7 +116,7 @@ async function submit() {
|
||||
|
||||
// 0. Python3 语法检测
|
||||
if (codeStore.code.language === "Python3") {
|
||||
const syntaxError = checkPythonSyntax(codeStore.code.value)
|
||||
const syntaxError = await checkPythonSyntax(codeStore.code.value)
|
||||
if (syntaxError) {
|
||||
message.warning(`第 ${syntaxError.line} 行存在语法错误,请修正后再提交`)
|
||||
return
|
||||
@@ -246,11 +263,13 @@ watch(
|
||||
<!-- 评价弹窗 -->
|
||||
<n-modal
|
||||
preset="card"
|
||||
title="恭喜你成功提交,请对该题进行评价(一星差评,五星好评)"
|
||||
title="恭喜你成功提交,说说你对这道题的感受吧"
|
||||
:mask-closable="false"
|
||||
:closable="false"
|
||||
:close-on-esc="false"
|
||||
:style="{ maxWidth: isDesktop && '50vw', maxHeight: '80vh' }"
|
||||
v-model:show="commentPanel"
|
||||
>
|
||||
<ProblemComment :showStatistics="false" />
|
||||
<ProblemReaction @submitted="closeCommentPanel" />
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
@@ -138,6 +138,7 @@ export function useFireworks() {
|
||||
|
||||
confetti({
|
||||
particleCount: 12,
|
||||
angle: 270,
|
||||
spread: 100,
|
||||
startVelocity: 12,
|
||||
gravity: 0.6,
|
||||
@@ -145,7 +146,7 @@ export function useFireworks() {
|
||||
ticks: 200,
|
||||
scalar: 1.2,
|
||||
shapes: ["star"],
|
||||
origin: { x: Math.random(), y: -0.1 },
|
||||
origin: { x: Math.random(), y: 0 },
|
||||
colors: ["#FFD700", "#FFA500", "#FFFF00", "#FF69B4", "#00CED1"],
|
||||
})
|
||||
}, 150)
|
||||
@@ -285,29 +286,32 @@ export function useFireworks() {
|
||||
|
||||
// 效果7: 螺旋上升
|
||||
() => {
|
||||
const defaults = {
|
||||
spread: 360,
|
||||
ticks: 100,
|
||||
gravity: 0,
|
||||
decay: 0.94,
|
||||
startVelocity: 30,
|
||||
}
|
||||
const steps = 16
|
||||
const colors = ["#a864fd", "#29cdff", "#78ff44", "#ff718d", "#fdff6a"]
|
||||
|
||||
function shoot() {
|
||||
confetti({
|
||||
...defaults,
|
||||
particleCount: 50,
|
||||
scalar: 1.2,
|
||||
shapes: ["circle", "square"],
|
||||
colors: ["#a864fd", "#29cdff", "#78ff44", "#ff718d", "#fdff6a"],
|
||||
})
|
||||
}
|
||||
for (let step = 0; step < steps; step++) {
|
||||
runLater(() => {
|
||||
const progress = step / (steps - 1)
|
||||
const phase = step * (Math.PI / 4)
|
||||
|
||||
runLater(shoot, 0)
|
||||
runLater(shoot, 100)
|
||||
runLater(shoot, 200)
|
||||
runLater(shoot, 300)
|
||||
runLater(shoot, 400)
|
||||
confetti({
|
||||
particleCount: 7,
|
||||
angle: 90 + Math.cos(phase) * 18,
|
||||
spread: 25,
|
||||
startVelocity: 16,
|
||||
gravity: 0.25,
|
||||
decay: 0.94,
|
||||
ticks: 100,
|
||||
scalar: 0.9,
|
||||
shapes: ["circle", "square"],
|
||||
origin: {
|
||||
x: 0.5 + Math.sin(phase) * 0.14,
|
||||
y: 0.85 - progress * 0.55,
|
||||
},
|
||||
colors,
|
||||
})
|
||||
}, step * 65)
|
||||
}
|
||||
},
|
||||
|
||||
// 效果8: 表情包烟花
|
||||
@@ -315,6 +319,7 @@ export function useFireworks() {
|
||||
loadEmojiShapes().then((emojiShapes) => {
|
||||
confetti({
|
||||
shapes: emojiShapes,
|
||||
flat: true,
|
||||
scalar: 6,
|
||||
particleCount: 18,
|
||||
spread: 360,
|
||||
@@ -349,16 +354,28 @@ export function useFireworks() {
|
||||
|
||||
// 效果10: 礼花瀑布
|
||||
() => {
|
||||
confetti({
|
||||
particleCount: 150,
|
||||
spread: 180,
|
||||
startVelocity: 0,
|
||||
gravity: 1.2,
|
||||
decay: 0.95,
|
||||
ticks: 300,
|
||||
origin: { x: 0.5, y: 0 },
|
||||
colors: ["#f6d365", "#fda085", "#fbc2eb", "#a6c1ee", "#84fab0"],
|
||||
})
|
||||
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({
|
||||
particleCount: 2,
|
||||
angle: 270,
|
||||
spread: 30,
|
||||
startVelocity: 5,
|
||||
gravity: 1,
|
||||
decay: 0.96,
|
||||
ticks: 240,
|
||||
scalar: 1.1,
|
||||
origin: { x: (column + Math.random()) / 6, y: 0 },
|
||||
colors,
|
||||
})
|
||||
}
|
||||
}, 120)
|
||||
timers.add(interval)
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@ import { ref, computed, watch, onUnmounted } from "vue"
|
||||
import { useIntervalFn, useTimeoutFn } from "@vueuse/core"
|
||||
import { getSubmission } from "oj/api"
|
||||
import { SubmissionStatus } from "utils/constants"
|
||||
import type { Submission } from "utils/types"
|
||||
import type { PendingAchievement, Submission } from "utils/types"
|
||||
import { useAchievementStore } from "shared/store/achievement"
|
||||
import {
|
||||
useSubmissionWebSocket,
|
||||
type SubmissionUpdate,
|
||||
@@ -45,6 +46,20 @@ export function useSubmissionMonitor() {
|
||||
|
||||
// ==================== WebSocket 处理 ====================
|
||||
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)
|
||||
|
||||
if (data.submission_id !== submissionId.value) {
|
||||
|
||||
@@ -23,8 +23,8 @@ const ProblemInfo = defineAsyncComponent(
|
||||
const ProblemSubmission = defineAsyncComponent(
|
||||
() => import("./components/ProblemSubmission.vue"),
|
||||
)
|
||||
const ProblemComment = defineAsyncComponent(
|
||||
() => import("./components/ProblemComment.vue"),
|
||||
const ProblemReaction = defineAsyncComponent(
|
||||
() => import("./components/ProblemReaction.vue"),
|
||||
)
|
||||
const ProblemFlowchart = defineAsyncComponent(
|
||||
() => import("./components/ProblemFlowchart.vue"),
|
||||
@@ -174,7 +174,7 @@ watch(
|
||||
tab="题目点评"
|
||||
:disabled="!!problemSetId"
|
||||
>
|
||||
<ProblemComment />
|
||||
<ProblemReaction />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane
|
||||
v-if="myFlowchartStore.showing"
|
||||
@@ -226,7 +226,7 @@ watch(
|
||||
tab="题目点评"
|
||||
:disabled="!!problemSetId"
|
||||
>
|
||||
<ProblemComment />
|
||||
<ProblemReaction />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane
|
||||
v-if="myFlowchartStore.showing"
|
||||
@@ -266,7 +266,7 @@ watch(
|
||||
tab="点评"
|
||||
:disabled="!!problemSetId"
|
||||
>
|
||||
<ProblemComment />
|
||||
<ProblemReaction />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane
|
||||
v-if="myFlowchartStore.showing"
|
||||
|
||||
@@ -1,15 +1,47 @@
|
||||
// @ts-ignore - skulpt has no type definitions
|
||||
import Sk from "skulpt"
|
||||
|
||||
export interface PythonSyntaxError {
|
||||
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 代码中的语法错误。
|
||||
* 只编译不执行,不受 input() 等 IO 调用影响。
|
||||
* 加载失败时返回 null(放行提交),交给后端判题兜底。
|
||||
*/
|
||||
export function checkPythonSyntax(code: string): PythonSyntaxError | null {
|
||||
export async function checkPythonSyntax(
|
||||
code: string,
|
||||
): Promise<PythonSyntaxError | null> {
|
||||
let Sk: any
|
||||
try {
|
||||
Sk = await loadSkulpt()
|
||||
} catch {
|
||||
skulptPromise = null
|
||||
return null
|
||||
}
|
||||
|
||||
Sk.configure({ output: () => {} })
|
||||
try {
|
||||
Sk.compile(code, "prog.py", "exec")
|
||||
|
||||
+60
-35
@@ -198,17 +198,30 @@ const columns: DataTableColumn<Rank>[] = [
|
||||
"streamline-emojis:smiling-face-with-sunglasses",
|
||||
),
|
||||
key: "username",
|
||||
width: 200,
|
||||
width: 240,
|
||||
render: (row) =>
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
text: true,
|
||||
type: "info",
|
||||
onClick: () => router.push("/user?name=" + row.user.username),
|
||||
},
|
||||
() => row.user.username,
|
||||
),
|
||||
h("div", { style: "display:flex;align-items:center;gap:6px" }, [
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
text: true,
|
||||
type: "info",
|
||||
onClick: () => router.push("/user?name=" + row.user.username),
|
||||
},
|
||||
() => row.user.username,
|
||||
),
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
text: true,
|
||||
size: "tiny",
|
||||
title: "查看成就",
|
||||
onClick: () =>
|
||||
router.push("/achievement?name=" + row.user.username),
|
||||
},
|
||||
() => "🏆",
|
||||
),
|
||||
]),
|
||||
},
|
||||
{
|
||||
title: renderTableTitle(
|
||||
@@ -378,32 +391,44 @@ const myClassColumns: DataTableColumn<UserRank>[] = [
|
||||
{
|
||||
title: "用户名",
|
||||
key: "username",
|
||||
width: 200,
|
||||
width: 240,
|
||||
render: (row) =>
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
text: true,
|
||||
type: "info",
|
||||
onClick: () => router.push("/user?name=" + row.username),
|
||||
},
|
||||
() =>
|
||||
row.rank === myRank.value
|
||||
? h(
|
||||
NFlex,
|
||||
{ align: "flex-end" },
|
||||
{
|
||||
default: () => [
|
||||
h("span", {}, row.username),
|
||||
h(Icon, {
|
||||
width: 20,
|
||||
icon: "fluent-emoji:person-raising-hand",
|
||||
}),
|
||||
],
|
||||
},
|
||||
)
|
||||
: row.username,
|
||||
),
|
||||
h("div", { style: "display:flex;align-items:center;gap:6px" }, [
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
text: true,
|
||||
type: "info",
|
||||
onClick: () => router.push("/user?name=" + row.username),
|
||||
},
|
||||
() =>
|
||||
row.rank === myRank.value
|
||||
? h(
|
||||
NFlex,
|
||||
{ align: "flex-end" },
|
||||
{
|
||||
default: () => [
|
||||
h("span", {}, row.username),
|
||||
h(Icon, {
|
||||
width: 20,
|
||||
icon: "fluent-emoji:person-raising-hand",
|
||||
}),
|
||||
],
|
||||
},
|
||||
)
|
||||
: row.username,
|
||||
),
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
text: true,
|
||||
size: "tiny",
|
||||
title: "查看成就",
|
||||
onClick: () => router.push("/achievement?name=" + row.username),
|
||||
},
|
||||
() => "🏆",
|
||||
),
|
||||
]),
|
||||
},
|
||||
{
|
||||
title: "已解决",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { USERNAME_CLASS_RE } from "utils/constants"
|
||||
|
||||
interface Props {
|
||||
type: "题目" | "用户"
|
||||
@@ -10,12 +11,14 @@ const props = defineProps<Props>()
|
||||
|
||||
const emits = defineEmits(["click", "search", "filterClass"])
|
||||
|
||||
// 用同一个正则判断,避免 kstest 这类 ks 开头但没有班级号的用户名
|
||||
// 也显示出按钮,点了却什么都不发生
|
||||
const showFilterClass = computed(() => {
|
||||
return props.type === "用户" && props.username?.startsWith("ks")
|
||||
return props.type === "用户" && USERNAME_CLASS_RE.test(props.username ?? "")
|
||||
})
|
||||
|
||||
function filterClass() {
|
||||
const match = props.username!.match(/^ks\d{3,4}/)
|
||||
const match = props.username!.match(USERNAME_CLASS_RE)
|
||||
const classname = match ? match[0] : ""
|
||||
if (!classname) return
|
||||
emits("filterClass", classname)
|
||||
|
||||
+77
-70
@@ -4,9 +4,10 @@ import { NH2, NH3 } from "naive-ui"
|
||||
import { getProfile } from "shared/api"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { durationToDays, parseTime } from "utils/functions"
|
||||
import type { Profile, UserBadge as UserBadgeType } from "utils/types"
|
||||
import { getMetrics, getUserBadges } from "../api"
|
||||
import GroupedUserBadge from "shared/components/GroupedUserBadge.vue"
|
||||
import type { AchievementSummary, Profile } from "utils/types"
|
||||
import { getAchievementSummary } from "oj/achievement/api"
|
||||
import { getMetrics } from "../api"
|
||||
import AchievementIcon from "shared/components/AchievementIcon.vue"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
|
||||
const route = useRoute()
|
||||
@@ -18,7 +19,7 @@ const firstSubmissionAt = ref("")
|
||||
const latestSubmissionAt = ref("")
|
||||
const toLatestAt = ref("")
|
||||
const learnDuration = ref("")
|
||||
const userBadges = ref<GroupedBadge[]>([])
|
||||
const achievementSummary = ref<AchievementSummary | null>(null)
|
||||
const [loading, toggle] = useToggle()
|
||||
const [show, toggleShow] = useToggle(false)
|
||||
|
||||
@@ -58,40 +59,6 @@ const hasMoreProblems = computed(
|
||||
() => 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() {
|
||||
toggle(true)
|
||||
try {
|
||||
@@ -106,23 +73,9 @@ async function init() {
|
||||
})
|
||||
ac.sort()
|
||||
problems.value = ac
|
||||
const promises: Promise<{ data: any }>[] = []
|
||||
|
||||
if (profile.value.submission_number > 0) {
|
||||
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]
|
||||
const metricsRes = await getMetrics(profile.value.user.id)
|
||||
firstSubmissionAt.value = parseTime(metricsRes.data.first)
|
||||
latestSubmissionAt.value = parseTime(metricsRes.data.latest)
|
||||
toLatestAt.value = durationToDays(
|
||||
@@ -134,14 +87,24 @@ async function init() {
|
||||
metricsRes.data.latest,
|
||||
)
|
||||
}
|
||||
|
||||
// 处理 badges 结果
|
||||
userBadges.value = groupBadgesByIcon(results[1].data)
|
||||
} finally {
|
||||
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(() => {
|
||||
if (loading.value) return []
|
||||
return [
|
||||
@@ -180,7 +143,10 @@ const metrics = computed(() => {
|
||||
]
|
||||
})
|
||||
|
||||
onMounted(init)
|
||||
onMounted(() => {
|
||||
init()
|
||||
loadAchievementSummary()
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<n-flex
|
||||
@@ -222,14 +188,14 @@ onMounted(init)
|
||||
<n-grid
|
||||
v-if="profile && profile.submission_number > 0"
|
||||
class="wrapper"
|
||||
:cols="2"
|
||||
:cols="isDesktop ? 2 : 1"
|
||||
:x-gap="10"
|
||||
:y-gap="10"
|
||||
>
|
||||
<n-gi v-for="item in metrics" :key="item.content">
|
||||
<n-card hoverable>
|
||||
<n-flex align="center">
|
||||
<Icon v-if="isDesktop" :icon="item.icon" width="50" />
|
||||
<Icon :icon="item.icon" :width="isDesktop ? 50 : 40" />
|
||||
<div>
|
||||
<Component :is="isDesktop ? NH2 : NH3" class="number">
|
||||
<n-number-animation v-if="item.animate" :to="item.title" />
|
||||
@@ -244,21 +210,50 @@ onMounted(init)
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
|
||||
<!-- 徽章展示卡片 -->
|
||||
<!-- 成就摘要 -->
|
||||
<n-card
|
||||
v-if="!loading && profile && userBadges.length > 0"
|
||||
v-if="!loading && profile && achievementSummary"
|
||||
class="wrapper"
|
||||
hoverable
|
||||
>
|
||||
<template #header>
|
||||
<n-h4 style="margin: 0">获得的徽章</n-h4>
|
||||
</template>
|
||||
<n-flex wrap>
|
||||
<GroupedUserBadge
|
||||
v-for="groupedBadge in userBadges"
|
||||
:key="groupedBadge.icon"
|
||||
:grouped-badge="groupedBadge"
|
||||
/>
|
||||
<n-flex align="center" justify="space-between">
|
||||
<n-flex align="center" :size="12">
|
||||
<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>
|
||||
{{ a.name }}
|
||||
</n-tooltip>
|
||||
<n-text v-if="!achievementSummary.recent.length" depth="3">
|
||||
还没有获得成就
|
||||
</n-text>
|
||||
</n-flex>
|
||||
</n-card>
|
||||
|
||||
@@ -321,4 +316,16 @@ h2 {
|
||||
word-wrap: break-word;
|
||||
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>
|
||||
|
||||
+21
-7
@@ -82,6 +82,12 @@ export const ojs: RouteRecordRaw = {
|
||||
component: () => import("oj/user/index.vue"),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "achievement",
|
||||
name: "achievement",
|
||||
component: () => import("oj/achievement/index.vue"),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "setting",
|
||||
component: () => import("oj/user/setting.vue"),
|
||||
@@ -93,7 +99,7 @@ export const ojs: RouteRecordRaw = {
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "learn/:step+",
|
||||
path: "learn/:type/:step",
|
||||
name: "learn",
|
||||
component: () => import("oj/learn/index.vue"),
|
||||
props: true,
|
||||
@@ -165,6 +171,12 @@ export const admins: RouteRecordRaw = {
|
||||
component: () => import("admin/problem/list.vue"),
|
||||
meta: { requiresProblemPermission: true },
|
||||
},
|
||||
{
|
||||
path: "problem/tags",
|
||||
name: "admin tag list",
|
||||
component: () => import("admin/problem/tags.vue"),
|
||||
meta: { requiresProblemPermission: true },
|
||||
},
|
||||
{
|
||||
path: "problem/create",
|
||||
name: "admin problem create",
|
||||
@@ -245,12 +257,6 @@ export const admins: RouteRecordRaw = {
|
||||
props: true,
|
||||
meta: { requiresSuperAdmin: true },
|
||||
},
|
||||
{
|
||||
path: "comment/list",
|
||||
name: "admin comment list",
|
||||
component: () => import("admin/communication/comments.vue"),
|
||||
meta: { requiresSuperAdmin: true },
|
||||
},
|
||||
{
|
||||
path: "message/list",
|
||||
name: "admin message list",
|
||||
@@ -295,6 +301,14 @@ export const admins: RouteRecordRaw = {
|
||||
component: () => import("admin/problemset/list.vue"),
|
||||
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",
|
||||
name: "admin problemset create",
|
||||
|
||||
@@ -9,7 +9,6 @@ export function signup(data: {
|
||||
username: string
|
||||
email: string
|
||||
password: string
|
||||
captcha: string
|
||||
}) {
|
||||
return http.post("register", data)
|
||||
}
|
||||
@@ -26,10 +25,6 @@ export function getProblemTagList() {
|
||||
return http.get<Tag[]>("problem/tags")
|
||||
}
|
||||
|
||||
export function getCaptcha() {
|
||||
return http.get("captcha")
|
||||
}
|
||||
|
||||
export function getHitokoto() {
|
||||
return http.get("hitokoto")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from "@iconify/vue"
|
||||
|
||||
// 成就图标统一走 iconify(noto 彩色 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>
|
||||
@@ -0,0 +1,101 @@
|
||||
<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>
|
||||
@@ -1,76 +0,0 @@
|
||||
<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>
|
||||
@@ -2,6 +2,7 @@
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { RouterLink } from "vue-router"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { useLearnProgress } from "shared/composables/learnProgress"
|
||||
import { useAuthModalStore } from "shared/store/authModal"
|
||||
import { useScreenModeStore } from "shared/store/screenMode"
|
||||
import { logout } from "../api"
|
||||
@@ -17,15 +18,14 @@ const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const { isMobile, isDesktop } = useBreakpoints()
|
||||
const { learnStep } = useLearnProgress()
|
||||
|
||||
const isDark = useDark()
|
||||
|
||||
function toggleDark(event: MouseEvent) {
|
||||
const { clientX: x, clientY: y } = event
|
||||
const radius = Math.hypot(
|
||||
Math.max(x, window.innerWidth - x),
|
||||
Math.max(y, window.innerHeight - y),
|
||||
)
|
||||
function toggleDark() {
|
||||
const x = window.innerWidth / 2
|
||||
const y = window.innerHeight / 2
|
||||
const radius = Math.hypot(x, y)
|
||||
if (!document.startViewTransition) {
|
||||
isDark.value = !isDark.value
|
||||
return
|
||||
@@ -125,11 +125,31 @@ function renderIcon(icon: string) {
|
||||
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[]>(() => [
|
||||
{
|
||||
label: () => h(RouterLink, { to: "/learn/01" }, { default: () => "自学" }),
|
||||
label: "自学",
|
||||
key: "learn",
|
||||
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: () => "题目" }),
|
||||
|
||||
@@ -21,8 +21,20 @@ const limit = ref(initialLimit)
|
||||
const page = ref(initialPage)
|
||||
const sizes = [10, 30, 50]
|
||||
|
||||
watch(limit, () => emit("update:limit", limit))
|
||||
watch(page, () => emit("update:page", page))
|
||||
// 必须 emit 数值:emit ref 对象时,父组件用普通 ref 接会拿不到数字,
|
||||
// 而且每次 emit 的都是同一个对象,父组件那边察觉不到变化
|
||||
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>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { getCaptcha, signup } from "../api"
|
||||
import { signup } from "../api"
|
||||
import { storeToRefs } from "pinia"
|
||||
import { useAuthModalStore } from "../store/authModal"
|
||||
|
||||
@@ -10,7 +10,6 @@ const {
|
||||
signupForm: form,
|
||||
signupLoading: isLoading,
|
||||
signupError: msg,
|
||||
captchaSrc,
|
||||
} = storeToRefs(authStore)
|
||||
const signupRef = useTemplateRef("signupRef")
|
||||
|
||||
@@ -31,9 +30,6 @@ const rules: FormRules = {
|
||||
trigger: "blur",
|
||||
},
|
||||
],
|
||||
captcha: [
|
||||
{ required: true, message: "验证码必填", trigger: "blur", min: 1, max: 10 },
|
||||
],
|
||||
}
|
||||
|
||||
function goLogin() {
|
||||
@@ -50,20 +46,15 @@ function submit() {
|
||||
username: form.value.username,
|
||||
email: form.value.email,
|
||||
password: form.value.password,
|
||||
captcha: form.value.captcha,
|
||||
})
|
||||
} catch (err: any) {
|
||||
if (err.data === "Invalid captcha") {
|
||||
authStore.setSignupError("验证码不正确")
|
||||
} else if (err.data === "Username already exists") {
|
||||
if (err.data === "Username already exists") {
|
||||
authStore.setSignupError("用户名已存在")
|
||||
} else if (err.data === "Email already exists") {
|
||||
authStore.setSignupError("邮箱已存在")
|
||||
} else {
|
||||
authStore.setSignupError("无法注册")
|
||||
}
|
||||
getCaptchaSrc()
|
||||
form.value.captcha = ""
|
||||
} finally {
|
||||
authStore.setSignupLoading(false)
|
||||
}
|
||||
@@ -74,14 +65,6 @@ function submit() {
|
||||
})
|
||||
}
|
||||
|
||||
async function getCaptchaSrc() {
|
||||
const res = await getCaptcha()
|
||||
authStore.setCaptchaSrc(res.data)
|
||||
}
|
||||
|
||||
watch(signupModalOpen, (v) => {
|
||||
if (v) getCaptchaSrc()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -134,18 +117,6 @@ watch(signupModalOpen, (v) => {
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</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-form-item>
|
||||
<n-space>
|
||||
@@ -158,10 +129,3 @@ watch(signupModalOpen, (v) => {
|
||||
</n-form>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.captcha {
|
||||
height: 34px;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
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 }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
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
@@ -114,15 +114,6 @@ const options = computed<MenuOption[]>(() => {
|
||||
h(RouterLink, { to: "/admin/user/list" }, { default: () => "用户" }),
|
||||
key: "admin user list",
|
||||
},
|
||||
{
|
||||
label: () =>
|
||||
h(
|
||||
RouterLink,
|
||||
{ to: "/admin/comment/list" },
|
||||
{ default: () => "评论" },
|
||||
),
|
||||
key: "admin comment list",
|
||||
},
|
||||
{
|
||||
label: () =>
|
||||
h(
|
||||
@@ -141,6 +132,15 @@ const options = computed<MenuOption[]>(() => {
|
||||
),
|
||||
key: "admin tutorial list",
|
||||
},
|
||||
{
|
||||
label: () =>
|
||||
h(
|
||||
RouterLink,
|
||||
{ to: "/admin/achievement/list" },
|
||||
{ default: () => "成就" },
|
||||
),
|
||||
key: "admin achievement list",
|
||||
},
|
||||
{
|
||||
label: () =>
|
||||
h(
|
||||
@@ -164,10 +164,10 @@ const active = computed(() => {
|
||||
if (path.startsWith("/admin/config")) return "admin config"
|
||||
if (path.startsWith("/admin/problemset")) return "admin problemset list"
|
||||
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/contest")) return "admin contest 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/tutorial")) return "admin tutorial list"
|
||||
if (path.startsWith("/admin/ai")) return "admin ai reports"
|
||||
|
||||
@@ -4,6 +4,23 @@ import Header from "../components/Header.vue"
|
||||
import Login from "../components/Login.vue"
|
||||
import Signup from "../components/Signup.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>
|
||||
|
||||
<template>
|
||||
@@ -19,6 +36,7 @@ import LoginSummaryModal from "../components/LoginSummaryModal.vue"
|
||||
<Login />
|
||||
<Signup />
|
||||
<LoginSummaryModal />
|
||||
<AchievementToast />
|
||||
<Beian />
|
||||
</n-layout>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
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 }
|
||||
})
|
||||
@@ -25,15 +25,11 @@ export const useAuthModalStore = defineStore("authModal", () => {
|
||||
email: "",
|
||||
password: "",
|
||||
passwordAgain: "",
|
||||
captcha: "",
|
||||
})
|
||||
|
||||
const signupLoading = ref(false)
|
||||
const signupError = ref("")
|
||||
|
||||
// ==================== 验证码 ====================
|
||||
const captchaSrc = ref("")
|
||||
|
||||
// ==================== 模态框操作 ====================
|
||||
/**
|
||||
* 打开登录模态框
|
||||
@@ -123,13 +119,6 @@ export const useAuthModalStore = defineStore("authModal", () => {
|
||||
signupError.value = ""
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置验证码图片地址
|
||||
*/
|
||||
function setCaptchaSrc(src: string) {
|
||||
captchaSrc.value = src
|
||||
}
|
||||
|
||||
return {
|
||||
// 模态框状态
|
||||
loginModalOpen,
|
||||
@@ -145,9 +134,6 @@ export const useAuthModalStore = defineStore("authModal", () => {
|
||||
signupLoading,
|
||||
signupError,
|
||||
|
||||
// 验证码
|
||||
captchaSrc,
|
||||
|
||||
// 模态框操作
|
||||
openLoginModal,
|
||||
closeLoginModal,
|
||||
@@ -165,6 +151,5 @@ export const useAuthModalStore = defineStore("authModal", () => {
|
||||
setSignupLoading,
|
||||
setSignupError,
|
||||
clearSignupError,
|
||||
setCaptchaSrc,
|
||||
}
|
||||
})
|
||||
|
||||
+82
-1
@@ -1,4 +1,4 @@
|
||||
import type { SUBMISSION_RESULT } from "./types"
|
||||
import type { AchievementRarity, SUBMISSION_RESULT, ReactionKey } from "./types"
|
||||
|
||||
export enum SubmissionStatus {
|
||||
compile_error = -2,
|
||||
@@ -276,6 +276,44 @@ export enum ChartType {
|
||||
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 = [
|
||||
{ label: "本节课内", value: "hours:1" },
|
||||
@@ -287,3 +325,46 @@ export const DURATION_OPTIONS = [
|
||||
{ label: "半年内", value: "months:6" },
|
||||
{ label: "一年内", value: "years:1" },
|
||||
] 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" },
|
||||
]
|
||||
|
||||
+24
-1
@@ -1,7 +1,14 @@
|
||||
import { getTime, intervalToDuration, parseISO, type Duration } from "date-fns"
|
||||
import { User } from "./types"
|
||||
import { USER_TYPE } from "./constants"
|
||||
import { strFromU8, strToU8, unzlibSync, zlibSync } from "fflate"
|
||||
import {
|
||||
strFromU8,
|
||||
strToU8,
|
||||
unzlibSync,
|
||||
zipSync,
|
||||
zlibSync,
|
||||
type Zippable,
|
||||
} from "fflate"
|
||||
import copyTextFallback from "copy-text-to-clipboard"
|
||||
import { customAlphabet } from "nanoid"
|
||||
|
||||
@@ -208,6 +215,22 @@ export function atou(base64: string): string {
|
||||
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
|
||||
|
||||
@@ -12,7 +12,6 @@ export function usePermissions() {
|
||||
|
||||
canManageUsers: computed(() => userStore.isSuperAdmin),
|
||||
canManageAnnouncements: computed(() => userStore.isSuperAdmin),
|
||||
canManageComments: computed(() => userStore.isSuperAdmin),
|
||||
canManageTutorials: computed(() => userStore.isSuperAdmin),
|
||||
canManageSystemConfig: computed(() => userStore.isSuperAdmin),
|
||||
canSendMessages: computed(() => userStore.isSuperAdmin),
|
||||
@@ -65,7 +64,6 @@ export function checkRoutePermission(routeName: string): boolean {
|
||||
"admin announcement list",
|
||||
"admin announcement create",
|
||||
"admin announcement edit",
|
||||
"admin comment list",
|
||||
"admin message list",
|
||||
"admin tutorial list",
|
||||
"admin tutorial create",
|
||||
|
||||
+77
-12
@@ -35,7 +35,6 @@ export interface User {
|
||||
problem_permission: string
|
||||
create_time: Date
|
||||
last_login: Date
|
||||
two_factor_auth: boolean
|
||||
open_api: boolean
|
||||
is_disabled: boolean
|
||||
password?: string
|
||||
@@ -104,6 +103,12 @@ export interface Tag {
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface AdminTag {
|
||||
id: number
|
||||
name: string
|
||||
problem_count: number
|
||||
}
|
||||
|
||||
export interface TestcaseUploadedReturns {
|
||||
id: string
|
||||
info: Testcase[]
|
||||
@@ -172,7 +177,11 @@ export interface Problem {
|
||||
sql_display?: SQLDisplay | null
|
||||
}
|
||||
|
||||
export type AdminProblem = Problem & AlterProblem
|
||||
export type AdminProblem = Problem &
|
||||
AlterProblem & {
|
||||
// 后台题目列表接口附带的最高票评价,比赛题目列表不返回
|
||||
top_reaction?: { type: ReactionKey; count: number } | null
|
||||
}
|
||||
|
||||
interface AlterProblem {
|
||||
test_case_id: string
|
||||
@@ -221,6 +230,8 @@ export interface AdminProblemFiltered {
|
||||
has_ast_rules: boolean
|
||||
allow_flowchart: boolean
|
||||
show_flowchart: boolean
|
||||
// 比赛题目列表接口不返回这个字段
|
||||
top_reaction?: { type: ReactionKey; count: number } | null
|
||||
}
|
||||
|
||||
// 题单相关类型
|
||||
@@ -589,16 +600,20 @@ export interface CreateMessage {
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface Comment {
|
||||
id: number
|
||||
problem: string
|
||||
submission: string
|
||||
content: string
|
||||
description_rating: 1 | 2 | 3 | 4 | 5
|
||||
difficulty_rating: 1 | 2 | 3 | 4 | 5
|
||||
comprehensive_rating: 1 | 2 | 3 | 4 | 5
|
||||
create_time: Date
|
||||
user: SampleUser
|
||||
export type ReactionKey =
|
||||
| "too_easy"
|
||||
| "too_hard"
|
||||
| "confusing"
|
||||
| "buggy"
|
||||
| "learned"
|
||||
| "interesting"
|
||||
| "want_explain"
|
||||
|
||||
export type ReactionCounts = Record<ReactionKey, number>
|
||||
|
||||
export interface ReactionState {
|
||||
mine: ReactionKey | null
|
||||
counts: ReactionCounts | null
|
||||
}
|
||||
|
||||
export interface Tutorial {
|
||||
@@ -723,3 +738,53 @@ export interface DetailsData {
|
||||
}
|
||||
|
||||
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[]
|
||||
}
|
||||
|
||||
+64
-1
@@ -6,6 +6,69 @@ import AutoImport from "unplugin-auto-import/vite"
|
||||
import Components from "unplugin-vue-components/vite"
|
||||
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 模板的等价实现)
|
||||
function injectMaxkb(maxkbUrl: string | undefined): Plugin {
|
||||
return {
|
||||
@@ -48,7 +111,7 @@ export default defineConfig(({ mode }) => {
|
||||
legacy({
|
||||
renderLegacyChunks: false,
|
||||
modernTargets: "chrome>=90",
|
||||
modernPolyfills: true,
|
||||
modernPolyfills: polyfills,
|
||||
}),
|
||||
AutoImport({
|
||||
imports: [
|
||||
|
||||
Reference in New Issue
Block a user