Compare commits
27 Commits
8cfa90ca16
...
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 |
1037
package-lock.json
generated
1037
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
28
package.json
28
package.json
@@ -20,11 +20,11 @@
|
||||
"@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",
|
||||
@@ -33,33 +33,33 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,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
|
||||
|
||||
@@ -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>
|
||||
@@ -1,11 +1,11 @@
|
||||
<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"
|
||||
@@ -135,6 +135,21 @@ const baseColumns: 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: "创建时间",
|
||||
@@ -167,9 +182,10 @@ const baseColumns: DataTableColumn<AdminProblemFiltered>[] = [
|
||||
},
|
||||
]
|
||||
|
||||
// 比赛题目接口不返回 top_reaction,这一列只在普通题目列表里显示
|
||||
const columns = computed<DataTableColumn<AdminProblemFiltered>[]>(() =>
|
||||
isContestProblemList.value
|
||||
? baseColumns
|
||||
? baseColumns.filter((it) => !("key" in it) || it.key !== "top_reaction")
|
||||
: [{ type: "selection" }, ...baseColumns],
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -48,17 +48,6 @@ const rarities = computed(() =>
|
||||
(a, b) => RARITY_RANK[a.rarity] - RARITY_RANK[b.rarity],
|
||||
),
|
||||
)
|
||||
|
||||
// 数据是异步来的,卡片挂上去时百分比就是终值,CSS 过渡不会触发。
|
||||
// 先按 0 渲染一帧,下一帧再填真值,圆环和进度条才有"长出来"的动画
|
||||
const grown = ref(false)
|
||||
const percent = computed(() =>
|
||||
grown.value ? (summary.value?.percent ?? 0) : 0,
|
||||
)
|
||||
function rarityPercent(r: { unlocked: number; total: number }) {
|
||||
if (!grown.value || !r.total) return 0
|
||||
return (r.unlocked / r.total) * 100
|
||||
}
|
||||
const badges = ref<UserBadge[]>([])
|
||||
const { isDesktop } = useBreakpoints()
|
||||
const tab = ref("all")
|
||||
@@ -74,7 +63,6 @@ const filtered = computed(() => {
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
grown.value = false
|
||||
try {
|
||||
const [list, sum, badgeRes] = await Promise.all([
|
||||
getAchievements(name.value),
|
||||
@@ -88,9 +76,6 @@ async function load() {
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
// 等卡片以 0 渲染完这一帧,再切到真值
|
||||
await nextTick()
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => (grown.value = true)))
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
@@ -99,117 +84,117 @@ watch(name, load)
|
||||
|
||||
<template>
|
||||
<div class="hall">
|
||||
<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="percent"
|
||||
:stroke-width="8"
|
||||
class="grow"
|
||||
>
|
||||
<n-text strong>
|
||||
<n-number-animation
|
||||
:from="0"
|
||||
:to="summary.percent"
|
||||
:duration="900"
|
||||
/>%
|
||||
</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>
|
||||
<!-- 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
|
||||
style="flex: 1"
|
||||
class="grow"
|
||||
type="line"
|
||||
:percentage="rarityPercent(r)"
|
||||
:height="6"
|
||||
:border-radius="3"
|
||||
:fill-border-radius="3"
|
||||
:color="rarityColor[r.rarity]"
|
||||
:show-indicator="false"
|
||||
/>
|
||||
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">
|
||||
{{ r.unlocked }} / {{ r.total }}
|
||||
已获得 {{ summary.unlocked }} / {{ summary.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-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-thing>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
<n-empty v-else-if="!loading" description="还没有获得任何题单奖章" />
|
||||
</template>
|
||||
<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>
|
||||
|
||||
@@ -239,27 +224,4 @@ watch(name, load)
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
.source a:hover {
|
||||
color: var(--n-text-color);
|
||||
}
|
||||
|
||||
/* naive 自带过渡,但圆环 stroke-dasharray 只有 .3s、线条 max-width 只有 .2s,
|
||||
配合 0 → 真值那一下太快看不出来,这里拉长 */
|
||||
.grow :deep(.n-progress-graph-circle-fill) {
|
||||
transition:
|
||||
opacity 0.3s var(--n-bezier),
|
||||
stroke 0.3s var(--n-bezier),
|
||||
stroke-dasharray 0.9s cubic-bezier(0.25, 1, 0.5, 1);
|
||||
}
|
||||
.grow :deep(.n-progress-graph-line-fill) {
|
||||
transition:
|
||||
background-color 0.3s var(--n-bezier),
|
||||
max-width 0.9s cubic-bezier(0.25, 1, 0.5, 1);
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.grow :deep(.n-progress-graph-circle-fill),
|
||||
.grow :deep(.n-progress-graph-line-fill) {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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有问题
|
||||
|
||||
@@ -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>
|
||||
698
src/oj/problem/components/ProblemReaction.vue
Normal file
698
src/oj/problem/components/ProblemReaction.vue
Normal file
@@ -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"
|
||||
@@ -23,8 +23,8 @@ import {
|
||||
} from "oj/problem/utils/pythonSyntaxCheck"
|
||||
|
||||
// ==================== 异步组件 ====================
|
||||
const ProblemComment = defineAsyncComponent(
|
||||
() => import("./ProblemComment.vue"),
|
||||
const ProblemReaction = defineAsyncComponent(
|
||||
() => import("./ProblemReaction.vue"),
|
||||
)
|
||||
|
||||
// ==================== 基础状态 ====================
|
||||
@@ -40,6 +40,10 @@ const router = useRouter()
|
||||
const [commentPanel] = useToggle()
|
||||
const message = useMessage()
|
||||
|
||||
function closeCommentPanel() {
|
||||
commentPanel.value = false
|
||||
}
|
||||
|
||||
const { isDesktop } = useBreakpoints()
|
||||
|
||||
// ==================== 烟花效果 ====================
|
||||
@@ -72,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
|
||||
}
|
||||
},
|
||||
@@ -259,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)
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -257,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",
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
@@ -177,7 +168,6 @@ const active = computed(() => {
|
||||
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"
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AchievementRarity, SUBMISSION_RESULT } from "./types"
|
||||
import type { AchievementRarity, SUBMISSION_RESULT, ReactionKey } from "./types"
|
||||
|
||||
export enum SubmissionStatus {
|
||||
compile_error = -2,
|
||||
@@ -325,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" },
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -178,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
|
||||
@@ -227,6 +230,8 @@ export interface AdminProblemFiltered {
|
||||
has_ast_rules: boolean
|
||||
allow_flowchart: boolean
|
||||
show_flowchart: boolean
|
||||
// 比赛题目列表接口不返回这个字段
|
||||
top_reaction?: { type: ReactionKey; count: number } | null
|
||||
}
|
||||
|
||||
// 题单相关类型
|
||||
@@ -595,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 {
|
||||
|
||||
@@ -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