library-picturebook-activity/frontend/src/views/contests/reviews/ProgressDetail.vue
2026-01-16 16:35:43 +08:00

728 lines
20 KiB
Vue

<template>
<div class="progress-detail-page">
<a-card class="mb-4">
<template #title>
<a-breadcrumb>
<a-breadcrumb-item>
<router-link :to="`/${tenantCode}/contests/reviews/progress`">评审进度</router-link>
</a-breadcrumb-item>
<a-breadcrumb-item>{{ contestName || '作品评审进度' }}</a-breadcrumb-item>
</a-breadcrumb>
</template>
<template #extra>
<a-space>
<a-button type="primary" @click="handleStartReview">
开始评审
</a-button>
<a-button @click="handleEndReview">
结束评审
</a-button>
<a-button @click="handleNotSubmitted">
{{ contestType === 'team' ? '未提交作品队伍' : '未提交作品选手' }}
</a-button>
<a-button>
<template #icon><DownloadOutlined /></template>
导出
</a-button>
</a-space>
</template>
</a-card>
<!-- 搜索表单 -->
<a-form :model="searchParams" layout="inline" class="search-form" @finish="handleSearch">
<a-form-item label="作品编号">
<a-input
v-model:value="searchParams.workNo"
placeholder="请输入作品编号"
allow-clear
style="width: 150px"
/>
</a-form-item>
<a-form-item label="报名账号">
<a-input
v-model:value="searchParams.username"
placeholder="请输入报名账号"
allow-clear
style="width: 150px"
/>
</a-form-item>
<a-form-item label="评审进度">
<a-select
v-model:value="searchParams.reviewProgress"
placeholder="请选择评审进度"
allow-clear
style="width: 150px"
>
<a-select-option value="not_reviewed">未评审</a-select-option>
<a-select-option value="in_progress">评审中</a-select-option>
<a-select-option value="completed">已完成</a-select-option>
</a-select>
</a-form-item>
<a-form-item>
<a-button type="primary" html-type="submit">
<template #icon><SearchOutlined /></template>
搜索
</a-button>
<a-button style="margin-left: 8px" @click="handleReset">
<template #icon><ReloadOutlined /></template>
重置
</a-button>
</a-form-item>
</a-form>
<!-- 数据表格 -->
<a-table
:columns="columns"
:data-source="dataSource"
:loading="loading"
:pagination="pagination"
row-key="id"
@change="handleTableChange"
>
<template #bodyCell="{ column, record, index }">
<template v-if="column.key === 'index'">
{{ (pagination.current - 1) * pagination.pageSize + index + 1 }}
</template>
<template v-else-if="column.key === 'workNo'">
<a @click="handleViewWorkDetail(record)">{{ record.workNo || "-" }}</a>
</template>
<template v-else-if="column.key === 'username'">
{{ record.submitterAccountNo || record.registration?.user?.username || "-" }}
</template>
<template v-else-if="column.key === 'judgeScore'">
<span v-if="record.averageScore !== undefined && record.averageScore !== null">
{{ record.averageScore.toFixed(2) }}
</span>
<span v-else>-</span>
</template>
<template v-else-if="column.key === 'reviewProgress'">
<a-tag :color="getProgressColor(record)">
{{ getProgressText(record) }}
</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<a-button type="link" size="small" @click="handleViewScores(record)">
查看
</a-button>
</template>
</template>
</a-table>
<!-- 评审详情抽屉 -->
<a-drawer
v-model:open="scoreDrawerVisible"
title="评审详情"
placement="right"
width="700"
>
<a-table
:columns="scoreColumns"
:data-source="scoreList"
:loading="scoreLoading"
:pagination="false"
row-key="id"
size="small"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'judgeName'">
{{ record.judge?.nickname || record.judge?.username || "-" }}
</template>
<template v-else-if="column.key === 'phone'">
{{ record.judge?.phone || "-" }}
</template>
<template v-else-if="column.key === 'tenant'">
{{ record.judge?.tenant?.name || "-" }}
</template>
<template v-else-if="column.key === 'score'">
<span v-if="record.score !== undefined && record.score !== null">
{{ record.score }}
</span>
<span v-else class="text-gray">未评分</span>
</template>
<template v-else-if="column.key === 'scoreTime'">
{{ formatDate(record.scoreTime) }}
</template>
<template v-else-if="column.key === 'action'">
<a-button type="link" size="small" @click="handleReplaceJudge(record)">
替换评委
</a-button>
</template>
</template>
</a-table>
</a-drawer>
<!-- 评委替换抽屉 -->
<a-drawer
v-model:open="replaceJudgeDrawerVisible"
title="评委替换"
placement="right"
width="700"
:footer-style="{ textAlign: 'right' }"
>
<!-- 搜索 -->
<a-form layout="inline" class="mb-3">
<a-form-item label="姓名">
<a-input
v-model:value="judgeSearchParams.nickname"
placeholder="请输入姓名"
allow-clear
style="width: 150px"
/>
</a-form-item>
<a-form-item label="机构信息">
<a-input
v-model:value="judgeSearchParams.tenantName"
placeholder="请输入机构信息"
allow-clear
style="width: 150px"
/>
</a-form-item>
<a-form-item>
<a-button type="primary" @click="handleSearchJudges">搜索</a-button>
<a-button class="ml-2" @click="handleResetJudgeSearch">重置</a-button>
</a-form-item>
</a-form>
<!-- 评委列表 -->
<a-table
:columns="judgeSelectColumns"
:data-source="judgeList"
:loading="judgeListLoading"
:pagination="judgePagination"
:row-selection="judgeRowSelection"
row-key="id"
size="small"
@change="handleJudgeTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'judgeName'">
{{ record.nickname || record.username || "-" }}
</template>
<template v-else-if="column.key === 'assignedCount'">
{{ record.contestJudges?.length || 0 }}
</template>
<template v-else-if="column.key === 'tenant'">
{{ record.tenant?.name || "-" }}
</template>
</template>
</a-table>
<template #footer>
<a-space>
<a-button @click="replaceJudgeDrawerVisible = false">取消</a-button>
<a-button type="primary" :loading="replaceLoading" @click="handleConfirmReplace">
确定
</a-button>
</a-space>
</template>
</a-drawer>
<!-- 作品详情弹框 -->
<WorkDetailModal
v-model:open="workDetailModalVisible"
:work-id="currentWorkId"
/>
<!-- 未提交作品弹框 -->
<a-modal
v-model:open="notSubmittedModalVisible"
:title="contestType === 'team' ? '未提交作品队伍' : '未提交作品选手'"
width="700px"
:footer="null"
>
<a-table
:columns="notSubmittedColumns"
:data-source="notSubmittedList"
:loading="notSubmittedLoading"
:pagination="false"
row-key="id"
size="small"
>
<template #bodyCell="{ column, record, index }">
<template v-if="column.key === 'index'">
{{ index + 1 }}
</template>
<template v-else-if="column.key === 'name'">
{{ contestType === 'team' ? record.teamName : (record.user?.nickname || '-') }}
</template>
<template v-else-if="column.key === 'username'">
{{ record.user?.username || "-" }}
</template>
</template>
</a-table>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from "vue"
import { useRoute, useRouter } from "vue-router"
import { message, Modal } from "ant-design-vue"
import type { TableProps } from "ant-design-vue"
import {
ArrowLeftOutlined,
SearchOutlined,
ReloadOutlined,
DownloadOutlined,
} from "@ant-design/icons-vue"
import dayjs from "dayjs"
import { contestsApi, worksApi, reviewsApi, type ContestWork } from "@/api/contests"
import { judgesManagementApi, type Judge } from "@/api/judges-management"
import WorkDetailModal from "../components/WorkDetailModal.vue"
const route = useRoute()
const router = useRouter()
const tenantCode = route.params.tenantCode as string
const contestId = Number(route.params.id)
const contestType = (route.query.type as string) || "individual"
// 赛事名称
const contestName = ref("")
// 作品详情弹框
const workDetailModalVisible = ref(false)
const currentWorkId = ref<number | null>(null)
// 列表状态
const loading = ref(false)
const dataSource = ref<ContestWork[]>([])
const pagination = reactive({
current: 1,
pageSize: 10,
total: 0,
})
// 搜索参数
const searchParams = reactive({
workNo: "",
username: "",
reviewProgress: undefined as string | undefined,
})
// 表格列定义
const columns = [
{ title: "序号", key: "index", width: 70 },
{ title: "作品编号", key: "workNo", width: 120 },
{ title: "报名账号", key: "username", width: 150 },
{ title: "评委评分", key: "judgeScore", width: 100 },
{ title: "评审进度", key: "reviewProgress", width: 100 },
{ title: "操作", key: "action", width: 80, fixed: "right" as const },
]
// 评分详情抽屉
const scoreDrawerVisible = ref(false)
const scoreLoading = ref(false)
const scoreList = ref<any[]>([])
const currentWork = ref<ContestWork | null>(null)
// 评分详情列
const scoreColumns = [
{ title: "评委姓名", key: "judgeName", width: 100 },
{ title: "联系方式", key: "phone", width: 120 },
{ title: "机构信息", key: "tenant", width: 150 },
{ title: "评分", key: "score", width: 80 },
{ title: "评分时间", key: "scoreTime", width: 150 },
{ title: "操作", key: "action", width: 100 },
]
// 替换评委抽屉
const replaceJudgeDrawerVisible = ref(false)
const replaceLoading = ref(false)
const currentReplaceScore = ref<any>(null)
const judgeList = ref<Judge[]>([])
const judgeListLoading = ref(false)
const judgePagination = reactive({
current: 1,
pageSize: 10,
total: 0,
})
const judgeSearchParams = reactive({
nickname: "",
tenantName: "",
})
const selectedJudgeKeys = ref<number[]>([])
const selectedJudgeRow = ref<Judge | null>(null)
// 评委选择列
const judgeSelectColumns = [
{ title: "评委姓名", key: "judgeName", width: 120 },
{ title: "已分配赛事数", key: "assignedCount", width: 120 },
{ title: "机构信息", key: "tenant", width: 150 },
]
// 评委选择行配置(单选)
const judgeRowSelection = computed<TableProps["rowSelection"]>(() => ({
type: "radio",
selectedRowKeys: selectedJudgeKeys.value,
onChange: (keys: any, rows: Judge[]) => {
selectedJudgeKeys.value = keys
selectedJudgeRow.value = rows[0] || null
},
}))
// 未提交作品弹框
const notSubmittedModalVisible = ref(false)
const notSubmittedLoading = ref(false)
const notSubmittedList = ref<any[]>([])
// 未提交作品列
const notSubmittedColumns = computed(() => {
if (contestType === "team") {
return [
{ title: "序号", key: "index", width: 70 },
{ title: "队伍名称", key: "name", width: 150 },
{ title: "报名账号", key: "username", width: 150 },
]
}
return [
{ title: "序号", key: "index", width: 70 },
{ title: "姓名", key: "name", width: 120 },
{ title: "报名账号", key: "username", width: 150 },
]
})
// 获取评审进度颜色
const getProgressColor = (record: ContestWork) => {
if (!record.reviewedCount || record.reviewedCount === 0) return "default"
if (record.reviewedCount >= (record.totalJudgesCount || 1)) return "success"
return "processing"
}
// 获取评审进度文本
const getProgressText = (record: ContestWork) => {
if (!record.reviewedCount || record.reviewedCount === 0) return "未评审"
if (record.reviewedCount >= (record.totalJudgesCount || 1)) return "已完成"
return `${record.reviewedCount}/${record.totalJudgesCount || 0}`
}
// 格式化日期
const formatDate = (dateStr?: string) => {
if (!dateStr) return "-"
return dayjs(dateStr).format("YYYY-MM-DD HH:mm")
}
// 获取赛事信息
const fetchContestInfo = async () => {
try {
const contest = await contestsApi.getDetail(contestId)
contestName.value = contest.contestName
} catch (error) {
console.error("获取赛事信息失败", error)
}
}
// 获取作品列表
const fetchList = async () => {
loading.value = true
try {
const response = await worksApi.getList({
page: pagination.current,
pageSize: pagination.pageSize,
contestId,
workNo: searchParams.workNo || undefined,
username: searchParams.username || undefined,
})
dataSource.value = response.list
pagination.total = response.total
} catch (error: any) {
message.error(error?.response?.data?.message || "获取作品列表失败")
} finally {
loading.value = false
}
}
// 获取评委列表(全部评委)
const fetchJudgeList = async () => {
judgeListLoading.value = true
try {
const response = await judgesManagementApi.getList({
page: judgePagination.current,
pageSize: judgePagination.pageSize,
nickname: judgeSearchParams.nickname || undefined,
})
judgeList.value = response.list
judgePagination.total = response.total
} catch (error) {
message.error("获取评委列表失败")
} finally {
judgeListLoading.value = false
}
}
// 搜索
const handleSearch = () => {
pagination.current = 1
fetchList()
}
// 重置
const handleReset = () => {
searchParams.workNo = ""
searchParams.username = ""
searchParams.reviewProgress = undefined
pagination.current = 1
fetchList()
}
// 表格分页变化
const handleTableChange = (pag: any) => {
pagination.current = pag.current
pagination.pageSize = pag.pageSize
fetchList()
}
// 返回
const handleBack = () => {
router.back()
}
// 查看作品详情
const handleViewWorkDetail = (record: ContestWork) => {
currentWorkId.value = record.id
workDetailModalVisible.value = true
}
// 开始评审
const handleStartReview = () => {
Modal.confirm({
title: "确认开始评审",
content: "确定要开始评审吗?开始后评委可以进行评分操作。",
okText: "确定",
cancelText: "取消",
async onOk() {
try {
await contestsApi.update(contestId, {
reviewStartTime: new Date().toISOString(),
})
message.success("评审已开始")
} catch (error: any) {
message.error(error?.response?.data?.message || "操作失败")
}
},
})
}
// 结束评审
const handleEndReview = () => {
Modal.confirm({
title: "确认结束评审",
content: "确定要结束评审吗?结束后评委将无法继续评分。",
okText: "确定",
cancelText: "取消",
async onOk() {
try {
await contestsApi.update(contestId, {
reviewEndTime: new Date().toISOString(),
})
message.success("评审已结束")
} catch (error: any) {
message.error(error?.response?.data?.message || "操作失败")
}
},
})
}
// 查看未提交作品
const handleNotSubmitted = async () => {
notSubmittedModalVisible.value = true
notSubmittedLoading.value = true
try {
// TODO: 调用获取未提交作品的API
notSubmittedList.value = []
} catch (error) {
message.error("获取未提交作品列表失败")
} finally {
notSubmittedLoading.value = false
}
}
// 查看评分详情
const handleViewScores = async (record: ContestWork) => {
currentWork.value = record
scoreDrawerVisible.value = true
scoreLoading.value = true
try {
// TODO: 调用获取作品评分列表的API
const scores = await reviewsApi.getWorkScores(record.id)
scoreList.value = scores
} catch (error) {
message.error("获取评分详情失败")
scoreList.value = []
} finally {
scoreLoading.value = false
}
}
// 替换评委
const handleReplaceJudge = (record: any) => {
currentReplaceScore.value = record
selectedJudgeKeys.value = []
selectedJudgeRow.value = null
replaceJudgeDrawerVisible.value = true
fetchJudgeList()
}
// 搜索评委
const handleSearchJudges = () => {
judgePagination.current = 1
fetchJudgeList()
}
// 重置评委搜索
const handleResetJudgeSearch = () => {
judgeSearchParams.nickname = ""
judgeSearchParams.tenantName = ""
judgePagination.current = 1
fetchJudgeList()
}
// 评委表格分页变化
const handleJudgeTableChange = (pag: any) => {
judgePagination.current = pag.current
judgePagination.pageSize = pag.pageSize
fetchJudgeList()
}
// 确认替换评委
const handleConfirmReplace = async () => {
if (!selectedJudgeRow.value) {
message.warning("请选择评委")
return
}
replaceLoading.value = true
try {
// TODO: 调用替换评委的API
await reviewsApi.replaceJudge(currentReplaceScore.value.id, selectedJudgeRow.value.id)
message.success("替换成功")
replaceJudgeDrawerVisible.value = false
// 刷新评分列表
if (currentWork.value) {
handleViewScores(currentWork.value)
}
} catch (error: any) {
message.error(error?.response?.data?.message || "替换失败")
} finally {
replaceLoading.value = false
}
}
onMounted(() => {
fetchContestInfo()
fetchList()
})
</script>
<style scoped lang="scss">
$primary: #1890ff;
$primary-dark: #0958d9;
$gradient-primary: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
.progress-detail-page {
:deep(.ant-card) {
border: none;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
margin-bottom: 16px;
.ant-card-head {
border-bottom: none;
padding: 16px 24px;
.ant-card-head-title {
font-size: 18px;
font-weight: 600;
color: rgba(0, 0, 0, 0.85);
}
}
.ant-card-body {
padding: 0;
}
}
:deep(.ant-btn-primary) {
background: $gradient-primary;
border: none;
box-shadow: 0 4px 12px rgba($primary, 0.35);
transition: all 0.3s ease;
&:hover {
background: linear-gradient(135deg, $primary-dark 0%, darken($primary-dark, 8%) 100%);
box-shadow: 0 6px 16px rgba($primary, 0.45);
transform: translateY(-1px);
}
&:active {
transform: translateY(0);
}
}
:deep(.ant-table-wrapper) {
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
overflow: hidden;
.ant-table {
.ant-table-thead > tr > th {
background: #fafafa;
font-weight: 600;
color: rgba(0, 0, 0, 0.85);
border-bottom: 1px solid #f0f0f0;
}
.ant-table-tbody > tr {
transition: all 0.2s ease;
&:hover > td {
background: rgba($primary, 0.04);
}
> td {
border-bottom: 1px solid #f5f5f5;
}
}
}
.ant-table-pagination {
padding: 16px;
margin: 0;
}
}
}
.search-form {
margin-bottom: 16px;
padding: 20px 24px;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 16px 24px;
:deep(.ant-form-item) {
margin-bottom: 0;
margin-right: 0;
}
}
.mb-3 {
margin-bottom: 12px;
}
.mb-4 {
margin-bottom: 16px;
}
.ml-2 {
margin-left: 8px;
}
.text-gray {
color: #999;
}
</style>