You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

637 lines
17 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<script setup lang="ts">
/**
* 与原型 frontend/prototype/pages/admin-reviewers.htmllayout.html role=admin一致
* 左侧赛道配置 Tab + 右侧当前赛道评审员表(姓名/电话/账户/密码/修改|删除)。
* 数据来源:后端 reviewers + reviewer_scopes每场赛事由顶栏切换器选定
*/
import { computed, ref, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { listTracks } from '../../../api/admin/competitions'
import { createReviewer, deleteReviewer, updateReviewer } from '../../../api/admin/reviewers'
import {
createReviewerScope,
deleteReviewerScope,
fetchReviewerScopeTrackCounts,
listReviewerScopes,
} from '../../../api/admin/reviewerScopes'
import type { CompetitionTrackRow, ReviewerScopeRow } from '../../../api/admin/types'
import { useAdminCompetitionStore } from '../../../stores/adminCompetition'
const competitionStore = useAdminCompetitionStore()
const { selectedCompetitionId } = storeToRefs(competitionStore)
const tracks = ref<CompetitionTrackRow[]>([])
const trackCounts = ref<Record<string, number>>({})
const currentTrackCode = ref<string | null>(null)
const scopeRows = ref<ReviewerScopeRow[]>([])
const shellLoading = ref(false)
const tableLoading = ref(false)
const editDialog = ref(false)
const editMode = ref<'create' | 'edit'>('create')
const saving = ref(false)
const editingReviewerId = ref<number | null>(null)
const formRef = ref<FormInstance>()
const form = ref({
name: '',
mobile: '',
username: '',
password: '',
})
const rules: FormRules = {
name: [{ required: true, message: '请填写姓名', trigger: 'blur' }],
username: [
{ required: true, message: '请填写账户', trigger: 'blur' },
{ pattern: /^[A-Za-z0-9._-]+$/, message: '仅字母、数字、点、横线或下划线', trigger: 'blur' },
],
password: [
{
validator: (_rule, val, cb) => {
if (editMode.value === 'create' && (!val || String(val).trim() === '')) {
cb(new Error('请填写密码'))
return
}
if (val && String(val).trim().length > 0 && String(val).length < 6) {
cb(new Error('密码不少于 6 位'))
return
}
cb()
},
trigger: 'blur',
},
],
}
const currentTrackTitle = computed(
() => tracks.value.find((t) => t.track_code === currentTrackCode.value)?.title ?? '',
)
const currentMeta = computed(() => {
const n = currentTrackCode.value ? (trackCounts.value[currentTrackCode.value] ?? 0) : 0
return `已配置 ${n} 名评审员`
})
async function loadTracks() {
const cid = selectedCompetitionId.value
shellLoading.value = true
if (!cid) {
tracks.value = []
trackCounts.value = {}
currentTrackCode.value = null
shellLoading.value = false
return
}
try {
tracks.value = (await listTracks(cid)).filter((t) => t.is_enabled).sort((a, b) => a.sort - b.sort)
if (!tracks.value.length) {
currentTrackCode.value = null
trackCounts.value = {}
return
}
if (!currentTrackCode.value || !tracks.value.some((t) => t.track_code === currentTrackCode.value)) {
currentTrackCode.value = tracks.value[0]?.track_code ?? null
}
trackCounts.value = await fetchReviewerScopeTrackCounts(cid)
} catch (e) {
tracks.value = []
ElMessage.warning(e instanceof Error ? e.message : '赛道加载失败')
} finally {
shellLoading.value = false
}
}
async function loadTable() {
const cid = selectedCompetitionId.value
const tc = currentTrackCode.value
if (!cid || !tc) {
scopeRows.value = []
return
}
tableLoading.value = true
try {
const res = await listReviewerScopes({
competition_id: cid,
track_code: tc,
page: 1,
per_page: 500,
})
scopeRows.value = res.data
trackCounts.value = await fetchReviewerScopeTrackCounts(cid)
} catch (e) {
scopeRows.value = []
ElMessage.error(e instanceof Error ? e.message : '加载失败')
} finally {
tableLoading.value = false
}
}
function selectTab(trackCode: string) {
currentTrackCode.value = trackCode
}
watch(
selectedCompetitionId,
() => {
currentTrackCode.value = null
void loadTracks().then(() => loadTable())
},
{ immediate: true },
)
watch(currentTrackCode, () => {
void loadTable()
})
function pwdDisplay(row: ReviewerScopeRow): string {
return row.reviewer?.password_display ?? '—'
}
function telDisplay(row: ReviewerScopeRow): string {
const m = row.reviewer?.mobile
return m && String(m).trim() !== '' ? String(m) : '—'
}
function openCreate() {
editMode.value = 'create'
editingReviewerId.value = null
form.value = { name: '', mobile: '', username: '', password: '' }
editDialog.value = true
}
function openEdit(row: ReviewerScopeRow) {
editMode.value = 'edit'
editingReviewerId.value = row.reviewer_id
const r = row.reviewer
form.value = {
name: r?.name ?? '',
mobile: r?.mobile ?? '',
username: r?.username ?? '',
password: '',
}
editDialog.value = true
}
async function submitEdit() {
if (!formRef.value) return
const cid = selectedCompetitionId.value
const tc = currentTrackCode.value
if (!cid || !tc) return
await formRef.value.validate(async (ok) => {
if (!ok) return
saving.value = true
try {
if (editMode.value === 'create') {
let reviewerId = 0
try {
const created = await createReviewer({
name: form.value.name.trim(),
username: form.value.username.trim(),
password: form.value.password,
mobile: form.value.mobile.trim() || null,
})
reviewerId = created.id
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : '创建评审员失败')
saving.value = false
return
}
try {
await createReviewerScope({
reviewer_id: reviewerId,
competition_id: cid,
track_code: tc,
})
} catch (e) {
try {
await deleteReviewer(reviewerId)
} catch {
/* 回滚失败时仍提示绑定错误 */
}
ElMessage.error(e instanceof Error ? e.message : '绑定赛道失败')
saving.value = false
return
}
ElMessage.success('评审员信息已保存')
} else {
const rid = editingReviewerId.value!
await updateReviewer(rid, {
name: form.value.name.trim(),
username: form.value.username.trim(),
mobile: form.value.mobile.trim() || null,
...(form.value.password.trim() ? { password: form.value.password.trim() } : {}),
})
ElMessage.success('评审员信息已更新')
}
editDialog.value = false
await loadTable()
if (cid) {
trackCounts.value = await fetchReviewerScopeTrackCounts(cid)
}
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : '保存失败')
} finally {
saving.value = false
}
})
}
async function onDelete(row: ReviewerScopeRow) {
const name = row.reviewer?.name ?? '该评审员'
try {
await ElMessageBox.confirm(`确定移除「${name}」对本赛道的评审权限吗?(不删除全局账号)`, '确认删除', {
type: 'warning',
confirmButtonText: '确定删除',
cancelButtonText: '取消',
})
} catch {
return
}
try {
await deleteReviewerScope(row.id)
ElMessage.success('已移除')
await loadTable()
const cid = selectedCompetitionId.value
if (cid) {
trackCounts.value = await fetchReviewerScopeTrackCounts(cid)
}
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : '删除失败')
}
}
</script>
<template>
<div class="proto-reviewer-page admin-desktop-page">
<div class="rev-head">
<h5 class="section-title mb-0">评审员管理</h5>
</div>
<el-alert
v-if="!selectedCompetitionId"
type="warning"
show-icon
:closable="false"
class="rev-alert"
title="请先在顶栏「赛事切换」中选择一场赛事,再按赛道维护评审员。"
/>
<template v-else>
<div v-loading="shellLoading" class="reviewer-manage-shell">
<aside class="reviewer-track-panel">
<div class="reviewer-track-panel-title">赛道配置</div>
<div class="reviewer-track-tabs">
<button
v-for="t in tracks"
:key="t.id"
type="button"
class="reviewer-track-tab"
:class="{ active: currentTrackCode === t.track_code }"
@click="selectTab(t.track_code)"
>
<span class="reviewer-track-tab-name">{{ t.title }}</span>
<span class="reviewer-track-tab-count">{{ trackCounts[t.track_code] ?? 0 }} 人</span>
</button>
</div>
</aside>
<section class="reviewer-editor-panel">
<div class="reviewer-editor-head">
<div>
<h6 class="reviewer-track-title">{{ currentTrackTitle || '—' }}</h6>
<p class="reviewer-track-meta mb-0">{{ currentMeta }}</p>
</div>
<div class="reviewer-editor-actions">
<button type="button" class="prm-btn-outline" :disabled="!currentTrackCode" @click="openCreate">
新增评审员
</button>
</div>
</div>
<div v-loading="tableLoading" class="table-responsive">
<table v-if="currentTrackCode" class="table table-sm align-middle mb-0 reviewer-manage-table">
<thead>
<tr>
<th style="width: 18%">姓名</th>
<th style="width: 22%">电话</th>
<th style="width: 24%">账户</th>
<th style="width: 24%">密码</th>
<th class="text-end" style="width: 12%">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="row in scopeRows" :key="row.id">
<td>{{ row.reviewer?.name ?? '—' }}</td>
<td>{{ telDisplay(row) }}</td>
<td>{{ row.reviewer?.username ?? '—' }}</td>
<td>{{ pwdDisplay(row) }}</td>
<td class="text-end reviewer-row-actions">
<button type="button" class="prm-btn-outline sm" @click="openEdit(row)">修改</button>
<button type="button" class="prm-btn-light sm" @click="onDelete(row)">删除</button>
</td>
</tr>
</tbody>
</table>
<div v-if="!currentTrackCode" class="empty-hint">
{{
tracks.length === 0
? '本场暂无启用中的赛道,请先在「赛事中心」配置赛道后再维护评审员。'
: ''
}}
</div>
</div>
<p class="footnote">
正式环境仅存密码摘要;列表中为占位符而非明文密码。
</p>
</section>
</div>
</template>
<el-dialog
v-model="editDialog"
:title="editMode === 'create' ? '新增评审员' : '修改评审员'"
width="520px"
destroy-on-close
class="rev-dialog"
>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="rev-form">
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="姓名" prop="name" required>
<el-input v-model="form.name" maxlength="64" placeholder="必填" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="电话" prop="mobile">
<el-input v-model="form.mobile" maxlength="20" placeholder="可选" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="账户" prop="username" required>
<el-input v-model="form.username" maxlength="64" autocomplete="off" placeholder="必填" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="editMode === 'create' ? '密码' : '密码(不修改请留空)'" prop="password">
<el-input
v-model="form.password"
type="password"
show-password
maxlength="255"
autocomplete="new-password"
:placeholder="editMode === 'create' ? '必填' : '留空表示不修改'"
/>
</el-form-item>
</el-col>
</el-row>
</el-form>
<template #footer>
<el-button @click="editDialog = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="submitEdit"></el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.proto-reviewer-page {
--bg: #f5f3f4;
--text: #1f1f1f;
--primary: #b40010;
--primary-soft: #fbe9eb;
--shadow-soft: 0 4px 14px rgba(46, 24, 26, 0.06);
}
.rev-head {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.section-title {
font-size: 1.35rem;
font-weight: 620;
color: #2f2a2b;
}
.rev-alert {
margin-bottom: 0.75rem;
}
.reviewer-manage-shell {
display: grid;
grid-template-columns: 14.5rem minmax(0, 1fr);
gap: 0.9rem;
align-items: start;
}
.reviewer-track-panel,
.reviewer-editor-panel {
background: #fff;
border: 1px solid #ece2e4;
border-radius: 10px;
box-shadow: var(--shadow-soft);
}
.reviewer-track-panel {
padding: 0.72rem;
}
.reviewer-track-panel-title {
color: #827579;
font-size: 0.78rem;
font-weight: 600;
margin-bottom: 0.55rem;
}
.reviewer-track-tabs {
display: grid;
gap: 0.42rem;
}
.reviewer-track-tab {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
width: 100%;
border: 1px solid transparent;
border-radius: 8px;
background: #fbf7f8;
color: #3e3437;
padding: 0.58rem 0.62rem;
text-align: left;
cursor: pointer;
font: inherit;
}
.reviewer-track-tab.active {
color: var(--primary);
border-color: #e5bdc1;
background: var(--primary-soft);
}
.reviewer-track-tab-name {
font-size: 0.86rem;
font-weight: 600;
}
.reviewer-track-tab-count {
color: #8b7e81;
font-size: 0.78rem;
white-space: nowrap;
}
.reviewer-editor-panel {
padding: 0.8rem;
}
.reviewer-editor-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 0.75rem;
margin-bottom: 0.72rem;
}
.reviewer-editor-actions {
display: flex;
align-items: center;
gap: 0.45rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.reviewer-track-title {
margin: 0 0 0.18rem;
font-size: 1rem;
color: #322b2d;
font-weight: 650;
}
.reviewer-track-meta {
color: #8b7e81;
font-size: 0.78rem;
}
.table-responsive {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.reviewer-manage-table {
min-width: 620px;
width: 100%;
border-collapse: collapse;
}
.reviewer-manage-table th {
color: #4a4043;
background: #faf7f7;
font-size: 0.8rem;
font-weight: 600;
padding: 0.65rem 0.62rem;
border-bottom: 1px solid #ece2e4;
}
.reviewer-manage-table td {
padding: 0.65rem 0.62rem;
border-bottom: 1px solid #f0e8ea;
font-size: 0.88rem;
vertical-align: middle;
}
.reviewer-row-actions {
white-space: nowrap;
}
.reviewer-row-actions .prm-btn-outline + .prm-btn-light {
margin-left: 0.35rem;
}
.prm-btn-outline,
.prm-btn-light {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.8rem;
border-radius: 6px;
padding: 0.22rem 0.55rem;
cursor: pointer;
font-weight: 500;
border: 1px solid #c9b8bb;
background: #fff;
color: var(--primary);
}
.prm-btn-outline:hover:not(:disabled) {
background: var(--primary-soft);
}
.prm-btn-outline:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.prm-btn-outline.sm,
.prm-btn-light.sm {
padding: 0.18rem 0.45rem;
font-size: 0.78rem;
}
.prm-btn-light {
color: #3e3437;
background: #fafafa;
border-color: #ddd5d7;
}
.prm-btn-light:hover {
background: #f3f0f1;
}
.empty-hint {
padding: 1rem;
color: #8b7e81;
font-size: 0.86rem;
}
.footnote {
margin: 0.55rem 0 0;
font-size: 0.72rem;
color: #8b7e81;
}
@media (max-width: 992px) {
.admin-desktop-page .reviewer-manage-shell {
grid-template-columns: 1fr;
}
.admin-desktop-page .reviewer-track-tabs {
display: flex;
gap: 0.5rem;
overflow-x: auto;
padding-bottom: 0.1rem;
-webkit-overflow-scrolling: touch;
}
.admin-desktop-page .reviewer-track-tab {
flex: 0 0 min(13rem, 72vw);
}
.reviewer-editor-head {
flex-direction: column;
align-items: stretch;
}
.reviewer-editor-actions {
justify-content: flex-start;
}
}
</style>