Files
Starlight_Lancher/apps/app-frontend/src/components/ui/skin-site/admin/AdminReviewPage.vue
T
AxTps 227af685d4 fix: 修正管理面板权限组、公告发布人与问卷选项显示
- 用户列表新增权限组列,展示权限标签
- 公告发布人由 UUID 解析为用户名
- 问卷选择题作答由选项 ID 解析为选项文字
- 维基默认打开首页,内部链接标题不再被替换下划线
2026-10-22 22:07:05 +08:00

1622 lines
57 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { SpinnerIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { computed, onMounted, ref } from 'vue'
import {
skinSiteDecideReviewSubmission,
skinSiteRegenerateReviewInvite,
type SkinSiteReviewResult,
type SkinSiteReviewSubmission,
skinSiteReviewPaperAdmin,
skinSiteReviewPaperList,
skinSiteReviewSubmissionCount,
skinSiteReviewSubmissions,
skinSiteCreateReviewPaperAdmin,
skinSiteUpdateReviewPaperAdmin,
type SkinSiteReviewAnswer,
type SkinSiteReviewConfigData,
type SkinSiteReviewQuestion,
type SkinSiteReviewMailConfig,
type SkinSiteReviewPaperSummary,
} from '@/helpers/skin-site-api'
const { formatMessage } = useVIntl()
const { handleError, addNotification } = injectNotificationManager()
const messages = defineMessages({
title: { id: 'app.skin-site.admin.review.title', defaultMessage: 'Player review' },
loading: { id: 'app.skin-site.admin.review.loading', defaultMessage: 'Loading…' },
failed: {
id: 'app.skin-site.admin.review.failed',
defaultMessage: 'Could not load submissions.',
},
empty: { id: 'app.skin-site.admin.review.empty', defaultMessage: 'No submissions found.' },
total: { id: 'app.skin-site.admin.review.total', defaultMessage: '{count} submissions' },
filterAll: { id: 'app.skin-site.admin.review.filter-all', defaultMessage: 'All' },
filterPending: { id: 'app.skin-site.admin.review.filter-pending', defaultMessage: 'Pending' },
filterPassed: { id: 'app.skin-site.admin.review.filter-passed', defaultMessage: 'Passed' },
filterRejected: { id: 'app.skin-site.admin.review.filter-rejected', defaultMessage: 'Rejected' },
resultPending: { id: 'app.skin-site.admin.review.result-pending', defaultMessage: 'Pending' },
resultPassed: { id: 'app.skin-site.admin.review.result-passed', defaultMessage: 'Passed' },
resultRejected: { id: 'app.skin-site.admin.review.result-rejected', defaultMessage: 'Rejected' },
modeAuto: { id: 'app.skin-site.admin.review.mode-auto', defaultMessage: 'Auto' },
modeManual: { id: 'app.skin-site.admin.review.mode-manual', defaultMessage: 'Manual' },
applicant: { id: 'app.skin-site.admin.review.applicant', defaultMessage: 'Applicant' },
submittedAt: { id: 'app.skin-site.admin.review.submitted-at', defaultMessage: 'Submitted' },
score: { id: 'app.skin-site.admin.review.score', defaultMessage: 'Score' },
reviewer: { id: 'app.skin-site.admin.review.reviewer', defaultMessage: 'Reviewer' },
details: { id: 'app.skin-site.admin.review.details', defaultMessage: 'Details' },
hide: { id: 'app.skin-site.admin.review.hide', defaultMessage: 'Hide' },
answers: { id: 'app.skin-site.admin.review.answers', defaultMessage: 'Answers' },
questionNo: {
id: 'app.skin-site.admin.review.question-no',
defaultMessage: 'Question {n}',
},
noAnswers: { id: 'app.skin-site.admin.review.no-answers', defaultMessage: 'No answers.' },
autoChecks: { id: 'app.skin-site.admin.review.auto-checks', defaultMessage: 'Automatic checks' },
context: { id: 'app.skin-site.admin.review.context', defaultMessage: 'Submission context' },
ip: { id: 'app.skin-site.admin.review.ip', defaultMessage: 'IP' },
userAgent: { id: 'app.skin-site.admin.review.user-agent', defaultMessage: 'User agent' },
activity: { id: 'app.skin-site.admin.review.activity', defaultMessage: 'Activity' },
duration: { id: 'app.skin-site.admin.review.duration', defaultMessage: 'Duration' },
hiddenTime: { id: 'app.skin-site.admin.review.hidden-time', defaultMessage: 'Hidden time' },
screenSwitches: {
id: 'app.skin-site.admin.review.screen-switches',
defaultMessage: 'Screen switches',
},
history: { id: 'app.skin-site.admin.review.history', defaultMessage: 'Decision history' },
accept: { id: 'app.skin-site.admin.review.accept', defaultMessage: 'Accept' },
reject: { id: 'app.skin-site.admin.review.reject', defaultMessage: 'Reject' },
reason: { id: 'app.skin-site.admin.review.reason', defaultMessage: 'Reason' },
acceptDone: {
id: 'app.skin-site.admin.review.accept-done',
defaultMessage: 'Submission accepted.',
},
rejectDone: {
id: 'app.skin-site.admin.review.reject-done',
defaultMessage: 'Submission rejected.',
},
regenInvite: {
id: 'app.skin-site.admin.review.regen-invite',
defaultMessage: 'Regenerate invite',
},
inviteRegenerated: {
id: 'app.skin-site.admin.review.invite-regenerated',
defaultMessage: 'Invite code regenerated.',
},
inviteSent: {
id: 'app.skin-site.admin.review.invite-sent',
defaultMessage: 'Email sent.',
},
inviteNotSent: {
id: 'app.skin-site.admin.review.invite-not-sent',
defaultMessage: 'Email not sent.',
},
prev: { id: 'app.skin-site.admin.review.prev', defaultMessage: 'Previous' },
next: { id: 'app.skin-site.admin.review.next', defaultMessage: 'Next' },
page: { id: 'app.skin-site.admin.review.page', defaultMessage: 'Page {page}' },
tabList: { id: 'app.skin-site.admin.review.tab-list', defaultMessage: 'Submissions' },
tabPapers: { id: 'app.skin-site.admin.review.tab-papers', defaultMessage: 'Papers' },
papersEmpty: {
id: 'app.skin-site.admin.review.papers-empty',
defaultMessage: 'No review papers yet.',
},
newPaper: { id: 'app.skin-site.admin.review.new-paper', defaultMessage: 'New paper' },
editPaper: { id: 'app.skin-site.admin.review.edit-paper', defaultMessage: 'Edit' },
paperTitle: { id: 'app.skin-site.admin.review.paper-title', defaultMessage: 'Title' },
paperAnnotation: {
id: 'app.skin-site.admin.review.paper-annotation',
defaultMessage: 'Description',
},
paperEnabled: { id: 'app.skin-site.admin.review.paper-enabled', defaultMessage: 'Enabled' },
paperMode: { id: 'app.skin-site.admin.review.paper-mode', defaultMessage: 'Mode' },
paperPassScore: {
id: 'app.skin-site.admin.review.paper-pass-score',
defaultMessage: 'Pass score',
},
paperTargetPermissions: {
id: 'app.skin-site.admin.review.paper-target-permissions',
defaultMessage: 'Target permissions',
},
paperQuestions: {
id: 'app.skin-site.admin.review.paper-questions',
defaultMessage: 'Questions',
},
addQuestion: { id: 'app.skin-site.admin.review.add-question', defaultMessage: 'Add question' },
questionTitle: {
id: 'app.skin-site.admin.review.question-title',
defaultMessage: 'Question title',
},
questionType: { id: 'app.skin-site.admin.review.question-type', defaultMessage: 'Type' },
questionScore: { id: 'app.skin-site.admin.review.question-score', defaultMessage: 'Score' },
questionAnnotation: {
id: 'app.skin-site.admin.review.question-annotation',
defaultMessage: 'Annotation',
},
addOption: { id: 'app.skin-site.admin.review.add-option', defaultMessage: 'Add option' },
optionDescription: {
id: 'app.skin-site.admin.review.option-description',
defaultMessage: 'Option text',
},
optionCorrect: {
id: 'app.skin-site.admin.review.option-correct',
defaultMessage: 'Correct',
},
removeQuestion: {
id: 'app.skin-site.admin.review.remove-question',
defaultMessage: 'Remove',
},
paperCreated: {
id: 'app.skin-site.admin.review.paper-created',
defaultMessage: 'Paper created.',
},
paperSaved: { id: 'app.skin-site.admin.review.paper-saved', defaultMessage: 'Paper saved.' },
mailTitle: { id: 'app.skin-site.admin.review.mail-title', defaultMessage: 'Pass mail' },
mailEnabled: { id: 'app.skin-site.admin.review.mail-enabled', defaultMessage: 'Send mail on pass' },
mailSubject: { id: 'app.skin-site.admin.review.mail-subject', defaultMessage: 'Subject' },
mailMessage: { id: 'app.skin-site.admin.review.mail-message', defaultMessage: 'Body' },
mailExtra: { id: 'app.skin-site.admin.review.mail-extra', defaultMessage: 'Extra text' },
mailIncludeInvite: {
id: 'app.skin-site.admin.review.mail-include-invite',
defaultMessage: 'Include invite code',
},
preventDuplicate: {
id: 'app.skin-site.admin.review.prevent-duplicate',
defaultMessage: 'Reject duplicate submissions',
},
copyLink: { id: 'app.skin-site.admin.review.copy-link', defaultMessage: 'Copy link' },
linkCopied: { id: 'app.skin-site.admin.review.link-copied', defaultMessage: 'Link copied.' },
moveUp: { id: 'app.skin-site.admin.review.move-up', defaultMessage: 'Move up' },
moveDown: { id: 'app.skin-site.admin.review.move-down', defaultMessage: 'Move down' },
mailSubjectRequired: {
id: 'app.skin-site.admin.review.mail-subject-required',
defaultMessage: 'Subject is required when mail is enabled.',
},
mailMessageRequired: {
id: 'app.skin-site.admin.review.mail-message-required',
defaultMessage: 'Body is required when mail is enabled.',
},
})
const availablePermissions: Array<{ value: string; label: string }> = [
{ value: 'EXTERNAL_MEMBER', label: 'Mod服玩家' },
{ value: 'INTERNAL_MEMBER', label: '原版服玩家' },
{ value: 'SERVER_ADMIN', label: '服务器管理' },
{ value: 'WEB_ADMIN', label: 'Web管理' },
{ value: 'SPONSOR', label: '捐赠者' },
{ value: 'DEVELOPER', label: '开发者' },
{ value: 'SENIOR_MEMBER', label: '老资历' },
]
const prerequisitePermissionOptions: Array<{ value: string; label: string }> = [
{ value: 'GUEST', label: '游客' },
...availablePermissions,
]
const questionTypeOptions: Array<{ value: SkinSiteReviewQuestionType; label: string }> = [
{ value: 'SINGLE', label: '单选题' },
{ value: 'MULTIPLE', label: '多选题' },
{ value: 'TRUE_FALSE', label: '判断题' },
{ value: 'FILL', label: '填空题' },
{ value: 'ESSAY', label: '简答题' },
]
const multipleScoreModeOptions: Array<{ value: 'EXACT' | 'PARTIAL' | 'PARTIAL_WITH_PENALTY'; label: string }> = [
{ value: 'EXACT', label: '多选少选均不给分' },
{ value: 'PARTIAL', label: '少选部分得分' },
{ value: 'PARTIAL_WITH_PENALTY', label: '多选部分扣分' },
]
const PAGE_SIZE = 20
const items = ref<SkinSiteReviewSubmission[]>([])
const total = ref(0)
const offset = ref(0)
const loading = ref(true)
const failed = ref(false)
const filter = ref<'' | SkinSiteReviewResult | 'COMPLETED'>('PENDING')
const expandedId = ref<number | null>(null)
const decisions = ref<Record<number, { reason: string; working: boolean }>>({})
const page = computed(() => Math.floor(offset.value / PAGE_SIZE) + 1)
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE)))
const hasPrev = computed(() => offset.value > 0)
const hasNext = computed(() => offset.value + PAGE_SIZE < total.value)
const FILTERS: Array<{ key: '' | SkinSiteReviewResult | 'COMPLETED'; label: () => string }> = [
{ key: '', label: () => formatMessage(messages.filterAll) },
{ key: 'PENDING', label: () => formatMessage(messages.filterPending) },
{ key: 'PASSED', label: () => formatMessage(messages.filterPassed) },
{ key: 'REJECTED', label: () => formatMessage(messages.filterRejected) },
]
function resultLabel(result: SkinSiteReviewResult): string {
if (result === 'PASSED') return formatMessage(messages.resultPassed)
if (result === 'REJECTED') return formatMessage(messages.resultRejected)
return formatMessage(messages.resultPending)
}
function resultClass(result: SkinSiteReviewResult): string {
if (result === 'PASSED') return 'bg-green text-contrast'
if (result === 'REJECTED') return 'bg-red text-contrast'
return 'bg-button-bg text-secondary'
}
function modeLabel(mode: string): string {
return mode === 'AUTO' ? formatMessage(messages.modeAuto) : formatMessage(messages.modeManual)
}
function formatDateTime(ts?: number | null): string {
if (!ts) return ''
try {
return new Date(ts).toLocaleString()
} catch {
return ''
}
}
function formatDuration(ms?: number | null): string {
if (!ms || ms < 0) return '—'
const totalSeconds = Math.floor(ms / 1000)
const m = Math.floor(totalSeconds / 60)
const s = totalSeconds % 60
return m > 0 ? m + 'm ' + s + 's' : s + 's'
}
function questionFor(submission: SkinSiteReviewSubmission, questionId: string) {
return submission.paper?.questions?.find((q) => q.id === questionId)
}
function answerText(
submission: SkinSiteReviewSubmission,
answer: SkinSiteReviewAnswer,
): string {
if (answer.text) return answer.text
if (Array.isArray(answer.values) && answer.values.length) {
const question = answer.questionId ? questionFor(submission, answer.questionId) : undefined
const options = question?.options ?? []
return answer.values
.map((value) => options.find((o) => o.id === value)?.description ?? value)
.join(', ')
}
return '—'
}
function decisionFor(id: number) {
if (!decisions.value[id]) decisions.value[id] = { reason: '', working: false }
return decisions.value[id]
}
async function load(reset = false) {
if (reset) offset.value = 0
loading.value = true
failed.value = false
try {
const resultFilter = filter.value || undefined
const listResult = await skinSiteReviewSubmissions({
result: resultFilter,
limit: PAGE_SIZE,
offset: offset.value,
})
if (!listResult.success) throw new Error(listResult.errorMessage)
items.value = listResult.payload ?? []
const countResult = await skinSiteReviewSubmissionCount({ result: resultFilter })
if (countResult.success && typeof countResult.payload === 'number') {
total.value = countResult.payload
}
} catch (error) {
failed.value = true
handleError(error)
} finally {
loading.value = false
}
}
async function setFilter(key: '' | SkinSiteReviewResult | 'COMPLETED') {
filter.value = key
await load(true)
}
async function prevPage() {
if (!hasPrev.value) return
offset.value = Math.max(0, offset.value - PAGE_SIZE)
await load()
}
async function nextPage() {
if (!hasNext.value) return
offset.value += PAGE_SIZE
await load()
}
async function decide(submission: SkinSiteReviewSubmission, accepted: boolean) {
const state = decisionFor(submission.id)
if (state.working) return
state.working = true
try {
const result = await skinSiteDecideReviewSubmission(
submission.id,
accepted,
{},
submission.revision,
state.reason,
)
if (!result.success) throw new Error(result.errorMessage)
addNotification({
type: 'success',
title: formatMessage(accepted ? messages.acceptDone : messages.rejectDone),
})
expandedId.value = null
await load()
} catch (error) {
handleError(error)
} finally {
state.working = false
}
}
async function regenerateInvite(submission: SkinSiteReviewSubmission) {
try {
const result = await skinSiteRegenerateReviewInvite(submission.id)
if (!result.success) throw new Error(result.errorMessage)
addNotification({
type: 'success',
title: formatMessage(result.payload.emailSent ? messages.inviteSent : messages.inviteNotSent),
})
await load()
} catch (error) {
handleError(error)
}
}
const detailOpen = ref(false)
const detailSubmission = ref<SkinSiteReviewSubmission | null>(null)
function openDetail(submission: SkinSiteReviewSubmission) {
detailSubmission.value = submission
decisionFor(submission.id)
detailOpen.value = true
}
function closeDetail() {
detailOpen.value = false
detailSubmission.value = null
}
const tab = ref<'list' | 'papers'>('list')
const papers = ref<SkinSiteReviewPaperSummary[]>([])
const papersLoading = ref(false)
const paperEditorOpen = ref(false)
const editingPaperId = ref<string | null>(null)
const paperSaving = ref(false)
const paperForm = ref<SkinSiteReviewConfigData>({
id: '',
enabled: true,
title: '',
annotation: '',
mode: 'MANUAL',
passScore: 0,
targetPermissions: [],
version: 0,
questions: [],
updatedAt: 0,
prerequisiteMode: 'ANY',
prerequisitePermissions: [],
mail: defaultMailConfig(),
})
function newPaper() {
editingPaperId.value = null
paperForm.value = {
id: crypto.randomUUID(),
enabled: true,
title: '',
annotation: '',
mode: 'MANUAL',
passScore: 0,
targetPermissions: [],
version: 0,
questions: [],
updatedAt: Date.now(),
prerequisiteMode: 'ANY',
prerequisitePermissions: [],
mail: defaultMailConfig(),
}
paperEditorOpen.value = true
}
async function loadPapers() {
papersLoading.value = true
try {
const result = await skinSiteReviewPaperList()
if (result.success) papers.value = result.payload ?? []
} catch (error) {
handleError(error)
} finally {
papersLoading.value = false
}
}
async function editPaper(paper: SkinSiteReviewPaperSummary) {
try {
const result = await skinSiteReviewPaperAdmin(paper.id)
if (!result.success) throw new Error(result.errorMessage)
paperForm.value = { ...result.payload, mail: result.payload.mail ?? defaultMailConfig() }
editingPaperId.value = paper.id
paperEditorOpen.value = true
} catch (error) {
handleError(error)
}
}
function removeQuestion(index: number) {
paperForm.value.questions.splice(index, 1)
}
function addOption(question: SkinSiteReviewQuestion) {
question.options = question.options ?? []
question.options.push({
id: crypto.randomUUID(),
description: '',
correct: false,
defaultSelected: false,
score: 0,
})
}
function removeOption(question: SkinSiteReviewQuestion, index: number) {
question.options?.splice(index, 1)
}
function defaultMailConfig(): SkinSiteReviewMailConfig {
return { enabled: false, subject: '', message: '', includeInviteCode: false, extraText: '' }
}
async function copyPaperLink(paperId: string) {
try {
await navigator.clipboard.writeText('https://skin.starlight.cool/review?paper=' + encodeURIComponent(paperId))
addNotification({ type: 'success', title: formatMessage(messages.linkCopied) })
} catch (error) {
handleError(error)
}
}
function moveQuestion(index: number, direction: number) {
const target = index + direction
if (target < 0 || target >= paperForm.value.questions.length) return
const [question] = paperForm.value.questions.splice(index, 1)
paperForm.value.questions.splice(target, 0, question)
}
function permissionLabel(value: string): string {
return (
availablePermissions.find((item) => item.value === value)?.label ??
prerequisitePermissionOptions.find((item) => item.value === value)?.label ??
value
)
}
function isChoice(question: SkinSiteReviewQuestion): boolean {
return ['SINGLE', 'MULTIPLE', 'TRUE_FALSE'].includes(question.type)
}
function supportsAutoReview(question: SkinSiteReviewQuestion): boolean {
return ['SINGLE', 'MULTIPLE', 'TRUE_FALSE', 'ESSAY'].includes(question.type)
}
function supportsFatal(question: SkinSiteReviewQuestion): boolean {
return paperForm.value.mode === 'AUTO'
? question.type !== 'ESSAY'
: supportsAutoReview(question)
}
function isAutomaticallyReviewed(question: SkinSiteReviewQuestion): boolean {
return paperForm.value.mode === 'AUTO' || question.autoReview
}
function showOptionScore(question: SkinSiteReviewQuestion): boolean {
if (
question.type !== 'MULTIPLE' ||
!isAutomaticallyReviewed(question) ||
question.multipleScoreMode === 'EXACT'
) {
return false
}
return true
}
function setCorrectOption(question: SkinSiteReviewQuestion, optionId: string) {
question.options?.forEach((option) => {
option.correct = option.id === optionId
})
}
function setDefaultOption(question: SkinSiteReviewQuestion, optionId: string) {
if (
question.type === 'MULTIPLE' ||
!question.options?.find((option) => option.id === optionId)?.defaultSelected
)
return
question.options?.forEach((option) => {
if (option.id !== optionId) option.defaultSelected = false
})
}
function moveOption(question: SkinSiteReviewQuestion, index: number, direction: number) {
if (!question.options) return
const target = index + direction
if (target < 0 || target >= question.options.length) return
const [option] = question.options.splice(index, 1)
question.options.splice(target, 0, option)
}
function setOptionalQuestion(question: SkinSiteReviewQuestion) {
if (!question.optional) return
question.fatal = false
question.autoReview = false
}
function setFatalQuestion(question: SkinSiteReviewQuestion) {
if (!question.fatal) return
question.optional = false
if (paperForm.value.mode === 'MANUAL') question.autoReview = true
}
function addQuestionOfType(type: SkinSiteReviewQuestionType) {
if (paperForm.value.mode === 'AUTO' && type === 'ESSAY') return
if (paperForm.value.questions.length >= 50) return
const isChoiceType = ['SINGLE', 'MULTIPLE', 'TRUE_FALSE'].includes(type)
const baseOptions = isChoiceType
? type === 'TRUE_FALSE'
? [
{ id: crypto.randomUUID(), description: '正确', correct: false, defaultSelected: false, score: 0 },
{ id: crypto.randomUUID(), description: '错误', correct: false, defaultSelected: false, score: 0 },
]
: [
{ id: crypto.randomUUID(), description: '', correct: false, defaultSelected: false, score: 0 },
{ id: crypto.randomUUID(), description: '', correct: false, defaultSelected: false, score: 0 },
]
: []
paperForm.value.questions.push({
id: crypto.randomUUID(),
type,
title: '',
annotation: '',
score: 1,
optional: false,
fatal: false,
autoReview: false,
multipleScoreMode: 'EXACT',
options: baseOptions,
answer: null,
maxLength: type === 'FILL' ? 50 : type === 'ESSAY' ? 300 : null,
informationQuestion: false,
informationAlias: null,
})
}
function onQuestionTypeSelected(event: Event) {
const el = event.target as HTMLSelectElement
const value = el.value as SkinSiteReviewQuestionType
if (value) addQuestionOfType(value)
el.value = ''
}
function availableQuestionTypes(): SkinSiteReviewQuestionType[] {
const all: SkinSiteReviewQuestionType[] = ['SINGLE', 'MULTIPLE', 'TRUE_FALSE', 'FILL', 'ESSAY']
return paperForm.value.mode === 'AUTO' ? all.filter((t) => t !== 'ESSAY') : all
}
const totalScore = computed(() =>
paperForm.value.questions.reduce((sum, q) => sum + Number(q.score || 0), 0),
)
const prerequisiteEnabled = computed({
get: () => paperForm.value.prerequisitePermissions.length > 0,
set: (enabled: boolean) => {
paperForm.value.prerequisitePermissions = enabled ? ['EXTERNAL_MEMBER'] : []
},
})
function validateBeforeSave(): string | null {
if (!paperForm.value.title.trim() || !paperForm.value.questions.length)
return '请填写试卷标题并至少添加一道题目。'
if (!paperForm.value.targetPermissions.length)
return '请至少选择一个审核通过后玩家可获得的身份。'
if (prerequisiteEnabled.value && !paperForm.value.prerequisitePermissions.length)
return '请至少选择一个前置身份。'
if (paperForm.value.passScore < 1 || paperForm.value.passScore > totalScore.value)
return '通过分数必须在 1 至 ' + totalScore.value + ' 之间。'
for (const [index, q] of paperForm.value.questions.entries()) {
if (!q.title.trim() || !Number.isInteger(q.score) || q.score < 0)
return '请完整填写第 ' + (index + 1) + ' 题的题目与整数分数。'
}
const mail = paperForm.value.mail
if (mail?.enabled) {
if (!mail.subject.trim()) return formatMessage(messages.mailSubjectRequired)
if (!mail.message.trim()) return formatMessage(messages.mailMessageRequired)
if (mail.subject.length > 200) return '邮件标题不能超过 200 字。'
if (mail.message.length > 5000) return '邮件正文不能超过 5000 字。'
if (mail.extraText.length > 3000) return '邮件补充说明不能超过 3000 字。'
}
return null
}
async function savePaper() {
if (paperSaving.value) return
const validationError = validateBeforeSave()
if (validationError) {
addNotification({ type: 'error', title: validationError })
return
}
paperSaving.value = true
try {
const data: SkinSiteReviewConfigData = {
...paperForm.value,
targetPermissions: paperForm.value.targetPermissions.filter(Boolean),
prerequisitePermissions: paperForm.value.prerequisitePermissions.filter(Boolean),
updatedAt: Date.now(),
}
const result =
editingPaperId.value === null
? await skinSiteCreateReviewPaperAdmin(data)
: await skinSiteUpdateReviewPaperAdmin(editingPaperId.value, data)
if (!result.success) throw new Error(result.errorMessage)
addNotification({
type: 'success',
title: formatMessage(
editingPaperId.value === null ? messages.paperCreated : messages.paperSaved,
),
})
paperEditorOpen.value = false
await loadPapers()
} catch (error) {
handleError(error)
} finally {
paperSaving.value = false
}
}
async function switchTab(next: 'list' | 'papers') {
tab.value = next
if (next === 'papers' && !papers.value.length) await loadPapers()
}
onMounted(() => void load())
</script>
<template>
<div class="flex w-full flex-col gap-4">
<div class="flex flex-wrap gap-2 border-b border-solid border-surface-5 pb-2">
<button
type="button"
class="rounded-lg px-4 py-2 text-sm font-semibold transition-colors"
:class="
tab === 'list'
? 'bg-brand text-contrast'
: 'bg-button-bg text-secondary hover:text-contrast'
"
@click="switchTab('list')"
>
{{ formatMessage(messages.tabList) }}
</button>
<button
type="button"
class="rounded-lg px-4 py-2 text-sm font-semibold transition-colors"
:class="
tab === 'papers'
? 'bg-brand text-contrast'
: 'bg-button-bg text-secondary hover:text-contrast'
"
@click="switchTab('papers')"
>
{{ formatMessage(messages.tabPapers) }}
</button>
</div>
<template v-if="tab === 'list'">
<div class="flex flex-wrap items-end justify-between gap-3">
<div>
<h2 class="m-0 text-xl font-bold text-contrast">{{ formatMessage(messages.title) }}</h2>
<p class="m-0 mt-1 text-sm text-secondary">
{{ formatMessage(messages.total, { count: total }) }}
</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<button
v-for="f in FILTERS"
:key="f.key || 'all'"
type="button"
class="rounded-lg px-3 py-1.5 text-xs font-semibold transition-colors"
:class="
filter === f.key
? 'bg-brand text-contrast'
: 'bg-button-bg text-secondary hover:text-contrast'
"
@click="setFilter(f.key)"
>
{{ f.label() }}
</button>
</div>
</div>
<div
v-if="loading"
class="flex min-h-48 items-center justify-center gap-2 text-sm text-secondary"
>
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<p
v-else-if="failed"
class="m-0 rounded-lg border border-solid border-surface-5 bg-bg-raised p-4 text-sm text-secondary"
>
{{ formatMessage(messages.failed) }}
</p>
<p
v-else-if="!items.length"
class="m-0 rounded-lg border border-solid border-surface-5 bg-bg-raised p-4 text-sm text-secondary"
>
{{ formatMessage(messages.empty) }}
</p>
<template v-else>
<div class="overflow-hidden rounded-lg border border-solid border-surface-5">
<table class="w-full border-collapse text-left text-sm">
<thead class="bg-bg-raised">
<tr class="text-xs text-secondary">
<th class="px-3 py-2 font-semibold">#</th>
<th class="px-3 py-2 font-semibold">{{ formatMessage(messages.applicant) }}</th>
<th class="px-3 py-2 font-semibold">{{ formatMessage(messages.resultPending) }}</th>
<th class="px-3 py-2 font-semibold">{{ formatMessage(messages.score) }}</th>
<th class="px-3 py-2 font-semibold">{{ formatMessage(messages.submittedAt) }}</th>
<th class="px-3 py-2 font-semibold">{{ formatMessage(messages.details) }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="submission in items"
:key="submission.id"
class="border-t border-solid border-surface-5 hover:bg-bg-raised"
>
<td class="px-3 py-2 text-xs text-secondary">{{ submission.id }}</td>
<td class="px-3 py-2">
<span class="font-semibold text-contrast">{{ submission.applicantName }}</span>
<span
class="ml-2 rounded-full bg-button-bg px-2 py-0.5 text-[10px] font-semibold text-secondary"
>
{{ modeLabel(submission.mode) }}
</span>
</td>
<td class="px-3 py-2">
<span
class="rounded-full px-2 py-0.5 text-[10px] font-semibold"
:class="resultClass(submission.result)"
>
{{ resultLabel(submission.result) }}
</span>
</td>
<td class="px-3 py-2 text-xs text-contrast">
{{
submission.score !== null && submission.score !== undefined
? submission.score
: '—'
}}
</td>
<td class="px-3 py-2 text-xs text-secondary">
{{ formatDateTime(submission.createdAt) }}
</td>
<td class="px-3 py-2">
<button
type="button"
class="rounded-lg bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="openDetail(submission)"
>
{{ formatMessage(messages.details) }}
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div class="flex items-center justify-center gap-3 pt-1">
<button
type="button"
class="rounded-lg bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="!hasPrev"
@click="prevPage"
>
{{ formatMessage(messages.prev) }}
</button>
<span class="text-xs text-secondary">
{{ formatMessage(messages.page, { page }) }} / {{ pageCount }}
</span>
<button
type="button"
class="rounded-lg bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="!hasNext"
@click="nextPage"
>
{{ formatMessage(messages.next) }}
</button>
</div>
</template>
</template>
<template v-else>
<div class="flex flex-wrap items-center justify-between gap-3">
<p class="m-0 text-sm text-secondary">
{{ formatMessage(messages.tabPapers) }}
</p>
<button
type="button"
class="rounded-lg bg-brand px-4 py-2 text-sm font-semibold text-contrast hover:brightness-110"
@click="newPaper"
>
{{ formatMessage(messages.newPaper) }}
</button>
</div>
<div
v-if="papersLoading"
class="flex min-h-32 items-center justify-center gap-2 text-sm text-secondary"
>
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<p
v-else-if="!papers.length"
class="m-0 rounded-lg border border-solid border-surface-5 bg-bg-raised p-4 text-sm text-secondary"
>
{{ formatMessage(messages.papersEmpty) }}
</p>
<div v-else class="grid gap-4 lg:grid-cols-2">
<article
v-for="paper in papers"
:key="paper.id"
class="flex flex-col gap-3 rounded-lg border border-solid border-surface-5 bg-bg-raised p-4"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<h3 class="m-0 truncate text-base font-bold text-contrast">
{{ paper.title }}
</h3>
<span
class="rounded-full px-2 py-0.5 text-[10px] font-semibold"
:class="paper.enabled ? 'bg-green text-contrast' : 'bg-button-bg text-secondary'"
>
{{ paper.enabled ? '✓' : '—' }}
</span>
</div>
<p
v-if="paper.annotation"
class="m-0 mt-1 line-clamp-2 text-xs whitespace-pre-wrap text-secondary"
>
{{ paper.annotation }}
</p>
</div>
<div class="flex shrink-0 gap-1">
<button
type="button"
class="rounded-lg bg-button-bg px-2 py-1 text-xs font-semibold text-secondary hover:brightness-110"
@click="copyPaperLink(paper.id)"
>
{{ formatMessage(messages.copyLink) }}
</button>
<button
type="button"
class="rounded-lg bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="editPaper(paper)"
>
{{ formatMessage(messages.editPaper) }}
</button>
</div>
</div>
<div class="flex flex-wrap gap-2 text-[11px] text-secondary">
<span class="rounded-full bg-bg px-2 py-0.5">
{{ paper.mode === 'AUTO' ? formatMessage(messages.modeAuto) : formatMessage(messages.modeManual) }}
</span>
<span class="rounded-full bg-bg px-2 py-0.5">{{ paper.questionCount }} 题</span>
<span class="rounded-full bg-bg px-2 py-0.5">总分 {{ paper.totalScore }}</span>
<span class="rounded-full bg-bg px-2 py-0.5">通过分 {{ paper.passScore }}</span>
<span
v-for="perm in paper.targetPermissions"
:key="perm"
class="rounded-full bg-brand/20 px-2 py-0.5 text-contrast"
>
{{ permissionLabel(perm) }}
</span>
</div>
<div class="text-[11px] text-secondary">
{{ formatDateTime(paper.updatedAt) }}
</div>
</article>
</div>
</template>
<div
v-if="detailOpen && detailSubmission"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
@click.self="closeDetail"
>
<div
class="flex max-h-[85vh] w-full max-w-3xl flex-col gap-4 overflow-y-auto rounded-xl bg-bg p-6"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<h3 class="m-0 text-lg font-bold text-contrast">
{{ formatMessage(messages.details) }} · #{{ detailSubmission.id }}
</h3>
<p class="m-0 mt-1 text-sm text-secondary">
{{ detailSubmission.applicantName }}
</p>
</div>
<button
type="button"
class="shrink-0 rounded-lg bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="closeDetail"
>
✕
</button>
</div>
<div class="flex flex-wrap items-center gap-2">
<span
class="rounded-full px-2 py-0.5 text-[10px] font-semibold"
:class="resultClass(detailSubmission.result)"
>
{{ resultLabel(detailSubmission.result) }}
</span>
<span
class="rounded-full bg-button-bg px-2 py-0.5 text-[10px] font-semibold text-secondary"
>
{{ modeLabel(detailSubmission.mode) }}
</span>
<span
v-if="detailSubmission.score !== null && detailSubmission.score !== undefined"
class="text-xs text-contrast"
>
{{ formatMessage(messages.score) }}: {{ detailSubmission.score }}
</span>
</div>
<div
v-if="detailSubmission.context"
class="flex flex-col gap-1 rounded-lg bg-bg-raised p-3 text-xs text-secondary"
>
<span class="font-semibold text-contrast">
{{ formatMessage(messages.context) }}
</span>
<span> {{ formatMessage(messages.ip) }}: {{ detailSubmission.context.ip ?? '—' }} </span>
<span class="break-all">
{{ formatMessage(messages.userAgent) }}:
{{ detailSubmission.context.userAgent }}
</span>
<span v-if="detailSubmission.context.activity">
{{ formatMessage(messages.duration) }}:
{{ formatDuration(detailSubmission.context.activity.durationMs) }} ·
{{ formatMessage(messages.hiddenTime) }}:
{{ formatDuration(detailSubmission.context.activity.hiddenMs) }} ·
{{ formatMessage(messages.screenSwitches) }}:
{{ detailSubmission.context.activity.screenSwitches }}
</span>
</div>
<div class="flex flex-col gap-2">
<span class="text-sm font-semibold text-contrast">
{{ formatMessage(messages.answers) }}
</span>
<p
v-if="!detailSubmission.answers || !detailSubmission.answers.length"
class="m-0 text-xs text-secondary"
>
{{ formatMessage(messages.noAnswers) }}
</p>
<div v-else class="flex flex-col gap-2">
<div
v-for="(answer, idx) in detailSubmission.answers"
:key="answer.questionId || idx"
class="flex flex-col gap-1 rounded-lg border border-solid border-surface-5 bg-bg-raised p-3"
>
<div class="flex flex-wrap items-center gap-2">
<span class="text-sm font-semibold text-contrast">
{{ idx + 1 }}.
{{
questionFor(detailSubmission, answer.questionId)?.title ||
formatMessage(messages.questionNo, { n: idx + 1 })
}}
</span>
<span
v-if="questionFor(detailSubmission, answer.questionId)?.informationQuestion"
class="rounded-full bg-brand px-2 py-0.5 text-[10px] font-semibold text-contrast"
>
{{ questionFor(detailSubmission, answer.questionId)?.informationAlias || 'info' }}
</span>
<span
v-if="questionFor(detailSubmission, answer.questionId)?.score !== undefined"
class="text-xs text-secondary"
>
({{ formatMessage(messages.score) }}:
{{ questionFor(detailSubmission, answer.questionId)?.score }})
</span>
</div>
<span
v-if="questionFor(detailSubmission, answer.questionId)?.annotation"
class="text-[11px] whitespace-pre-wrap break-words text-secondary"
>
{{ questionFor(detailSubmission, answer.questionId)?.annotation }}
</span>
<span
class="mt-1 rounded-lg bg-bg px-3 py-2 text-sm break-words whitespace-pre-wrap text-contrast"
>
{{ answerText(detailSubmission, answer) }}
</span>
<span
v-if="answer.awardedScore !== null && answer.awardedScore !== undefined"
class="text-xs text-secondary"
>
{{ formatMessage(messages.score) }}: {{ answer.awardedScore }}
</span>
</div>
</div>
</div>
<div
v-if="Object.keys(detailSubmission.automaticResults || {}).length"
class="flex flex-col gap-1"
>
<span class="text-sm font-semibold text-contrast">
{{ formatMessage(messages.autoChecks) }}
</span>
<div class="flex flex-wrap gap-2">
<span
v-for="(passed, key) in detailSubmission.automaticResults"
:key="key"
class="rounded-full px-2 py-0.5 text-[10px] font-semibold"
:class="passed ? 'bg-green text-contrast' : 'bg-red text-contrast'"
>
{{ key }}: {{ passed ? '✓' : '✗' }}
</span>
</div>
</div>
<div
v-if="detailSubmission.decisionHistory && detailSubmission.decisionHistory.length"
class="flex flex-col gap-1"
>
<span class="text-sm font-semibold text-contrast">
{{ formatMessage(messages.history) }}
</span>
<div
v-for="(h, idx) in detailSubmission.decisionHistory"
:key="idx"
class="rounded-lg bg-bg-raised p-2 text-xs text-secondary"
>
{{ h.reviewer }} · {{ resultLabel(h.result) }} ·
{{ formatDateTime(h.at) }}
<span v-if="h.reason"> · {{ h.reason }}</span>
</div>
</div>
<div class="flex flex-col gap-2 border-t border-solid border-surface-5 pt-3">
<input
v-model="decisionFor(detailSubmission.id).reason"
type="text"
:placeholder="formatMessage(messages.reason)"
class="rounded-lg border border-solid border-surface-5 bg-bg-raised px-3 py-2 text-sm text-contrast outline-none focus:border-brand"
/>
<div class="flex flex-wrap gap-2">
<button
type="button"
class="rounded-lg bg-green px-4 py-2 text-sm font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="
decisionFor(detailSubmission.id).working || detailSubmission.result === 'PASSED'
"
@click="decide(detailSubmission, true)"
>
{{ formatMessage(messages.accept) }}
</button>
<button
type="button"
class="rounded-lg bg-red px-4 py-2 text-sm font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="
decisionFor(detailSubmission.id).working || detailSubmission.result === 'REJECTED'
"
@click="decide(detailSubmission, false)"
>
{{ formatMessage(messages.reject) }}
</button>
<button
type="button"
class="rounded-lg bg-button-bg px-4 py-2 text-sm font-semibold text-secondary hover:brightness-110"
@click="regenerateInvite(detailSubmission)"
>
{{ formatMessage(messages.regenInvite) }}
</button>
</div>
</div>
</div>
</div>
</div>
<div
v-if="paperEditorOpen"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
@click.self="paperEditorOpen = false"
>
<div
class="flex max-h-[90vh] w-full max-w-4xl flex-col gap-4 overflow-y-auto rounded-xl bg-bg p-6"
>
<div class="flex items-start justify-between gap-3">
<h3 class="m-0 text-lg font-bold text-contrast">
{{ editingPaperId ? formatMessage(messages.editPaper) : formatMessage(messages.newPaper) }}
</h3>
<button
type="button"
class="shrink-0 rounded-lg bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="paperEditorOpen = false"
>
✕
</button>
</div>
<div class="grid gap-4 lg:grid-cols-2">
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-secondary">
{{ formatMessage(messages.paperTitle) }}
</span>
<input
v-model="paperForm.title"
type="text"
class="rounded-lg border border-solid border-surface-5 bg-bg-raised px-3 py-2 text-sm text-contrast outline-none focus:border-brand"
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-secondary">
{{ formatMessage(messages.paperAnnotation) }}
</span>
<textarea
v-model="paperForm.annotation"
rows="2"
class="rounded-lg border border-solid border-surface-5 bg-bg-raised px-3 py-2 text-sm text-contrast outline-none focus:border-brand"
></textarea>
</label>
</div>
<div class="grid gap-4 sm:grid-cols-3">
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-secondary">
{{ formatMessage(messages.paperMode) }}
</span>
<select
v-model="paperForm.mode"
class="rounded-lg border border-solid border-surface-5 bg-bg-raised px-3 py-2 text-sm text-contrast outline-none focus:border-brand"
>
<option value="AUTO">{{ formatMessage(messages.modeAuto) }}</option>
<option value="MANUAL">{{ formatMessage(messages.modeManual) }}</option>
</select>
</label>
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-secondary">
{{ formatMessage(messages.paperPassScore) }}
</span>
<input
v-model.number="paperForm.passScore"
type="number"
min="1"
:max="totalScore || undefined"
class="rounded-lg border border-solid border-surface-5 bg-bg-raised px-3 py-2 text-sm text-contrast outline-none focus:border-brand"
/>
<span class="text-[11px] text-secondary">当前总分 {{ totalScore }}</span>
</label>
<label class="flex items-center gap-2 pt-5">
<input v-model="paperForm.enabled" type="checkbox" class="size-4" />
<span class="text-sm text-contrast">{{ formatMessage(messages.paperEnabled) }}</span>
</label>
</div>
<div class="flex flex-col gap-2">
<span class="text-xs font-semibold text-secondary">
{{ formatMessage(messages.paperTargetPermissions) }}
</span>
<div
class="flex flex-wrap items-center gap-3 rounded-lg border border-solid border-surface-5 bg-bg-raised px-3 py-2"
>
<label
v-for="perm in availablePermissions"
:key="'t-' + perm.value"
class="flex cursor-pointer items-center gap-1.5 text-xs text-contrast"
>
<input v-model="paperForm.targetPermissions" type="checkbox" :value="perm.value" class="size-3.5" />
{{ perm.label }}
</label>
</div>
</div>
<div class="flex flex-col gap-2 rounded-lg border border-solid border-surface-5 p-3">
<label class="flex cursor-pointer items-center gap-2">
<input v-model="prerequisiteEnabled" type="checkbox" class="size-4" />
<span class="text-sm font-semibold text-contrast">设置前置身份</span>
</label>
<div v-if="prerequisiteEnabled" class="flex flex-col gap-2 pt-1">
<div class="flex flex-wrap items-end gap-3">
<label class="flex flex-col gap-1">
<span class="text-[11px] text-secondary">判断方式</span>
<select
v-model="paperForm.prerequisiteMode"
class="rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast outline-none focus:border-brand"
>
<option value="ANY">满足任一</option>
<option value="ALL">满足全部</option>
</select>
</label>
<div
class="flex flex-1 flex-wrap items-center gap-3 rounded-lg border border-solid border-surface-5 bg-bg px-3 py-2"
>
<label
v-for="perm in prerequisitePermissionOptions"
:key="'p-' + perm.value"
class="flex cursor-pointer items-center gap-1.5 text-xs text-contrast"
>
<input
v-model="paperForm.prerequisitePermissions"
type="checkbox"
:value="perm.value"
class="size-3.5"
/>
{{ perm.label }}
</label>
</div>
</div>
</div>
</div>
<label class="flex cursor-pointer items-center gap-2 rounded-lg border border-solid border-surface-5 p-3">
<input v-model="paperForm.preventDuplicate" type="checkbox" class="size-4" />
<span class="text-sm font-semibold text-contrast">
{{ formatMessage(messages.preventDuplicate) }}
</span>
</label>
<div class="flex flex-col gap-2 rounded-lg border border-solid border-surface-5 p-3">
<label class="flex cursor-pointer items-center gap-2">
<input v-model="paperForm.mail!.enabled" type="checkbox" class="size-4" />
<span class="text-sm font-semibold text-contrast">
{{ formatMessage(messages.mailEnabled) }}
</span>
</label>
<div v-if="paperForm.mail!.enabled" class="flex flex-col gap-3 pt-1">
<label class="flex flex-col gap-1">
<span class="text-[11px] text-secondary">
{{ formatMessage(messages.mailSubject) }}
</span>
<input
v-model="paperForm.mail!.subject"
type="text"
class="rounded-lg border border-solid border-surface-5 bg-bg px-3 py-1.5 text-sm text-contrast outline-none focus:border-brand"
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-[11px] text-secondary">
{{ formatMessage(messages.mailMessage) }}
</span>
<textarea
v-model="paperForm.mail!.message"
rows="4"
class="rounded-lg border border-solid border-surface-5 bg-bg px-3 py-1.5 text-sm text-contrast outline-none focus:border-brand"
></textarea>
</label>
<label class="flex flex-col gap-1">
<span class="text-[11px] text-secondary">
{{ formatMessage(messages.mailExtra) }}
</span>
<textarea
v-model="paperForm.mail!.extraText"
rows="2"
class="rounded-lg border border-solid border-surface-5 bg-bg px-3 py-1.5 text-xs text-contrast outline-none focus:border-brand"
></textarea>
</label>
<label class="flex cursor-pointer items-center gap-2 text-xs text-contrast">
<input v-model="paperForm.mail!.includeInviteCode" type="checkbox" class="size-3.5" />
{{ formatMessage(messages.mailIncludeInvite) }}
</label>
</div>
</div>
<div class="flex flex-wrap items-center justify-between gap-2 border-t border-solid border-surface-5 pt-3">
<span class="text-sm font-semibold text-contrast">
{{ formatMessage(messages.paperQuestions) }} ({{ paperForm.questions.length }})
</span>
<select
class="rounded-lg border border-solid border-surface-5 bg-bg-raised px-2 py-1.5 text-xs text-contrast outline-none focus:border-brand"
@change="onQuestionTypeSelected"
>
<option value="">{{ formatMessage(messages.addQuestion) }}…</option>
<option v-for="t in availableQuestionTypes()" :key="t" :value="t">
{{ questionTypeOptions.find((q) => q.value === t)?.label }}
</option>
</select>
</div>
<p
v-if="!paperForm.questions.length"
class="m-0 rounded-lg bg-bg-raised p-3 text-xs text-secondary"
>
还没有题目,请从上方下拉框添加。
</p>
<article
v-for="(question, qIndex) in paperForm.questions"
:key="question.id"
class="flex flex-col gap-3 rounded-lg border border-solid border-surface-5 bg-bg-raised p-4"
>
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex flex-wrap items-center gap-2">
<span class="rounded-full bg-brand px-2 py-0.5 text-[10px] font-semibold text-contrast">
{{ qIndex + 1 }}
</span>
<span class="text-xs font-semibold text-contrast">
{{ questionTypeOptions.find((q) => q.value === question.type)?.label }}
</span>
<span v-if="question.optional" class="rounded-full bg-button-bg px-2 py-0.5 text-[10px] text-secondary">
选答
</span>
<span v-if="question.fatal" class="rounded-full bg-red px-2 py-0.5 text-[10px] text-contrast">
生死题
</span>
</div>
<div class="flex gap-1">
<button
type="button"
class="rounded bg-button-bg px-2 py-1 text-[11px] text-secondary disabled:opacity-40"
:disabled="qIndex === 0"
@click="moveQuestion(qIndex, -1)"
>
↑
</button>
<button
type="button"
class="rounded bg-button-bg px-2 py-1 text-[11px] text-secondary disabled:opacity-40"
:disabled="qIndex === paperForm.questions.length - 1"
@click="moveQuestion(qIndex, 1)"
>
↓
</button>
<button
type="button"
class="rounded-lg bg-red/20 px-2 py-1 text-[11px] font-semibold text-red hover:brightness-110"
@click="removeQuestion(qIndex)"
>
{{ formatMessage(messages.removeQuestion) }}
</button>
</div>
</div>
<div class="grid gap-3 lg:grid-cols-[1fr_7rem]">
<label class="flex flex-col gap-1">
<span class="text-[11px] text-secondary">{{ formatMessage(messages.questionTitle) }}</span>
<textarea
v-model="question.title"
rows="2"
class="rounded-lg border border-solid border-surface-5 bg-bg px-3 py-2 text-sm text-contrast outline-none focus:border-brand"
></textarea>
</label>
<label class="flex flex-col gap-1">
<span class="text-[11px] text-secondary">{{ formatMessage(messages.questionScore) }}</span>
<input
v-model.number="question.score"
type="number"
min="0"
class="rounded-lg border border-solid border-surface-5 bg-bg px-3 py-2 text-sm text-contrast outline-none focus:border-brand"
/>
</label>
</div>
<label class="flex flex-col gap-1">
<span class="text-[11px] text-secondary">{{ formatMessage(messages.questionAnnotation) }}</span>
<textarea
v-model="question.annotation"
rows="1"
class="rounded-lg border border-solid border-surface-5 bg-bg px-3 py-1.5 text-xs text-contrast outline-none focus:border-brand"
></textarea>
</label>
<div class="flex flex-wrap items-center gap-4">
<label class="flex cursor-pointer items-center gap-1.5 text-xs text-contrast">
<input v-model="question.informationQuestion" type="checkbox" class="size-3.5" />
信息题
</label>
<input
v-if="question.informationQuestion"
v-model="question.informationAlias"
type="text"
placeholder="信息代称,如 QQ 号"
class="rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast outline-none focus:border-brand"
/>
<label
v-if="paperForm.mode === 'MANUAL'"
class="flex cursor-pointer items-center gap-1.5 text-xs text-contrast"
>
<input v-model="question.optional" type="checkbox" class="size-3.5" @change="setOptionalQuestion(question)" />
选答题
</label>
<label v-if="supportsFatal(question)" class="flex cursor-pointer items-center gap-1.5 text-xs text-contrast">
<input v-model="question.fatal" type="checkbox" class="size-3.5" @change="setFatalQuestion(question)" />
生死题
</label>
<label
v-if="paperForm.mode === 'MANUAL' && !question.optional && supportsAutoReview(question)"
class="flex cursor-pointer items-center gap-1.5 text-xs text-contrast"
>
<input v-model="question.autoReview" type="checkbox" class="size-3.5" :disabled="question.fatal" />
自动批复
</label>
</div>
<div v-if="isChoice(question)" class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class="text-xs font-semibold text-contrast">选项</span>
<button
v-if="question.type !== 'TRUE_FALSE'"
type="button"
class="rounded-lg bg-button-bg px-2 py-1 text-[11px] font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="(question.options?.length ?? 0) >= 10"
@click="addOption(question)"
>
{{ formatMessage(messages.addOption) }}
</button>
</div>
<label
v-if="question.type === 'MULTIPLE' && isAutomaticallyReviewed(question)"
class="flex flex-col gap-1"
>
<span class="text-[11px] text-secondary">多选题计分模式</span>
<select
v-model="question.multipleScoreMode"
class="rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast outline-none focus:border-brand"
>
<option v-for="m in multipleScoreModeOptions" :key="m.value" :value="m.value">
{{ m.label }}
</option>
</select>
</label>
<div
v-for="(option, optionIndex) in question.options"
:key="option.id"
class="grid items-center gap-2 sm:grid-cols-[auto_1fr_auto]"
>
<div class="flex flex-col gap-1">
<label class="flex items-center gap-1 text-[11px] text-contrast">
<input
v-model="option.defaultSelected"
type="checkbox"
class="size-3"
@change="setDefaultOption(question, option.id)"
/>
默认
</label>
<label
v-if="paperForm.mode === 'AUTO' || question.autoReview"
class="flex items-center gap-1 text-[11px] text-contrast"
>
<input
v-if="question.type === 'MULTIPLE'"
v-model="option.correct"
type="checkbox"
class="size-3"
/>
<input
v-else
type="radio"
class="size-3"
:name="'correct-' + question.id"
:checked="option.correct"
@change="setCorrectOption(question, option.id)"
/>
正确
</label>
<label
v-if="showOptionScore(question)"
class="flex items-center gap-1 text-[11px] text-contrast"
>
<span class="shrink-0">{{ option.correct ? '得分' : '扣分' }}</span>
<input
v-model.number="option.score"
type="number"
min="0"
:max="question.score"
step="0.1"
class="w-16 rounded-lg border border-solid border-surface-5 bg-bg px-1.5 py-0.5 text-xs text-contrast"
/>
</label>
</div>
<input
v-model="option.description"
type="text"
:placeholder="'选项 ' + (optionIndex + 1)"
class="rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast outline-none focus:border-brand"
/>
<div class="flex gap-1">
<button
type="button"
class="rounded bg-button-bg px-1.5 py-0.5 text-[10px] text-secondary disabled:opacity-40"
:disabled="optionIndex === 0"
@click="moveOption(question, optionIndex, -1)"
>
↑
</button>
<button
type="button"
class="rounded bg-button-bg px-1.5 py-0.5 text-[10px] text-secondary disabled:opacity-40"
:disabled="optionIndex === (question.options?.length ?? 0) - 1"
@click="moveOption(question, optionIndex, 1)"
>
↓
</button>
<button
v-if="question.type !== 'TRUE_FALSE'"
type="button"
class="rounded bg-red/20 px-1.5 py-0.5 text-[10px] text-red disabled:opacity-40"
:disabled="(question.options?.length ?? 0) <= 2"
@click="removeOption(question, optionIndex)"
>
✕
</button>
</div>
</div>
</div>
<div
v-if="question.type === 'FILL' || question.type === 'ESSAY'"
class="grid gap-3 sm:grid-cols-[8rem_1fr]"
>
<label class="flex flex-col gap-1">
<span class="text-[11px] text-secondary">作答字数上限</span>
<input
v-model.number="question.maxLength"
type="number"
min="1"
:max="question.type === 'FILL' ? 150 : 300"
class="rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast"
/>
</label>
<label v-if="paperForm.mode === 'AUTO' || question.autoReview" class="flex flex-col gap-1">
<span class="text-[11px] text-secondary">标准答案(完全一致才判正确)</span>
<input
v-model="question.answer"
type="text"
class="rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast outline-none focus:border-brand"
/>
</label>
</div>
</article>
<div class="flex justify-end gap-2 border-t border-solid border-surface-5 pt-3">
<button
type="button"
class="rounded-lg bg-button-bg px-4 py-2 text-sm font-semibold text-secondary hover:brightness-110"
@click="paperEditorOpen = false"
>
取消
</button>
<button
type="button"
class="rounded-lg bg-brand px-4 py-2 text-sm font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="paperSaving"
@click="savePaper"
>
{{ paperSaving ? formatMessage(messages.loading) : '保存' }}
</button>
</div>
</div>
</div>
</template>