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

2 months ago
<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">
正式环境仅存密码摘要列表中为占位符而非明文密码
2 months ago
</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>