library-picturebook-activity/frontend/src/views/contests/judges/Index.vue
2026-01-08 09:17:46 +08:00

642 lines
17 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.

<template>
<div class="judges-page">
<a-card class="mb-4">
<template #title>
<a-breadcrumb>
<a-breadcrumb-item>
<router-link :to="`/${tenantCode}/contests`">赛事管理</router-link>
</a-breadcrumb-item>
<a-breadcrumb-item v-if="isValidContestId">
<router-link :to="`/${tenantCode}/contests/${contestId}`">{{
contestName || "赛事详情"
}}</router-link>
</a-breadcrumb-item>
<a-breadcrumb-item>评委管理</a-breadcrumb-item>
</a-breadcrumb>
</template>
<template #extra>
<a-space>
<a-button
v-permission="'judge:create'"
type="primary"
@click="handleAdd"
>
<template #icon><PlusOutlined /></template>
新增
</a-button>
<a-button v-permission="'judge:create'">
<template #icon><UploadOutlined /></template>
导入
</a-button>
<a-button v-permission="'judge:read'">
<template #icon><DownloadOutlined /></template>
导出
</a-button>
<a-popconfirm
v-permission="'judge:delete'"
title="确定要删除选中的评委吗?"
:disabled="selectedRowKeys.length === 0"
@confirm="handleBatchDelete"
>
<a-button danger :disabled="selectedRowKeys.length === 0">
<template #icon><DeleteOutlined /></template>
删除
</a-button>
</a-popconfirm>
</a-space>
</template>
</a-card>
<!-- 搜索表单 -->
<a-form
:model="searchParams"
layout="inline"
class="search-form"
@finish="handleSearch"
>
<a-form-item v-if="hasTenantReadPermission" label="所属单位">
<a-select
v-model:value="searchParams.tenantId"
placeholder="请选择所属单位"
allow-clear
style="width: 200px"
:loading="tenantsLoading"
>
<a-select-option
v-for="tenant in tenantsList"
:key="tenant.id"
:value="tenant.id"
>
{{ tenant.name }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="姓名">
<a-input
v-model:value="searchParams.nickname"
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.status"
placeholder="请选择状态"
allow-clear
style="width: 120px"
>
<a-select-option value="disabled">停用</a-select-option>
<a-select-option value="enabled">启用</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-selection="rowSelection"
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 === 'tenant'">
{{ record.tenant?.name || "-" }}
</template>
<template v-else-if="column.key === 'gender'">
<span v-if="record.gender === 'male'">男</span>
<span v-else-if="record.gender === 'female'">女</span>
<span v-else>-</span>
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="record.status === 'enabled' ? 'success' : 'error'">
{{ record.status === "enabled" ? "启用" : "停用" }}
</a-tag>
</template>
<template v-else-if="column.key === 'ongoingContests'">
<template
v-if="record.contestJudges && record.contestJudges.length > 0"
>
<a-tooltip>
<template #title>
<div v-for="cj in record.contestJudges" :key="cj.contest.id">
{{ cj.contest.contestName }}
</div>
</template>
<a-tag color="processing">
{{ record.contestJudges.length }}个赛事
</a-tag>
</a-tooltip>
</template>
<span v-else>-</span>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button
v-permission="'judge:update'"
type="link"
size="small"
@click="handleToggleStatus(record)"
>
{{ record.status === "enabled" ? "冻结" : "解冻" }}
</a-button>
<a-button
v-permission="'judge:update'"
type="link"
size="small"
@click="handleEdit(record)"
>
编辑
</a-button>
<a-popconfirm
v-permission="'judge:delete'"
title="确定要删除这个评委吗?"
@confirm="handleDelete(record.id)"
>
<a-button type="link" danger size="small">删除</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
<!-- 新增/编辑评委抽屉 -->
<a-drawer
v-model:open="drawerVisible"
:title="isEditing ? '编辑评委' : '新增评委'"
placement="right"
width="500px"
:footer-style="{ textAlign: 'right' }"
>
<a-form
ref="formRef"
:model="form"
:rules="rules"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 18 }"
>
<a-form-item label="姓名" name="nickname">
<a-input
v-model:value="form.nickname"
placeholder="请输入姓名"
:maxlength="50"
/>
</a-form-item>
<a-form-item label="性别" name="gender">
<a-radio-group v-model:value="form.gender">
<a-radio value="male">男</a-radio>
<a-radio value="female">女</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item
v-if="hasTenantReadPermission"
label="所属单位"
name="tenantId"
>
<a-select
v-model:value="form.tenantId"
placeholder="请选择所属单位"
:loading="tenantsLoading"
:disabled="isEditing"
show-search
:filter-option="(input: string, option: any) => {
const label = option?.children?.[0]?.children || option?.label || ''
return String(label).toLowerCase().includes(input.toLowerCase())
}"
>
<a-select-option
v-for="tenant in tenantsList"
:key="tenant.id"
:value="tenant.id"
>
{{ tenant.name }}
</a-select-option>
</a-select>
<div
v-if="
!isEditing &&
form.tenantId &&
form.tenantId !== authStore.user?.tenantId
"
class="tenant-warning"
>
<a-alert
message="注意"
description="选择的租户与当前租户不同,创建的用户将属于当前租户"
type="warning"
show-icon
:closable="false"
/>
</div>
</a-form-item>
<a-form-item v-else label="所属单位">
<a-input
:value="
tenantsList.find((t) => t.id === authStore.user?.tenantId)
?.name || '当前租户'
"
disabled
/>
</a-form-item>
<a-form-item label="联系方式" name="phone">
<a-input
v-model:value="form.phone"
placeholder="请输入联系方式"
:maxlength="20"
/>
</a-form-item>
<a-form-item label="初始密码" name="password">
<a-input-password
v-model:value="form.password"
:placeholder="isEditing ? '请输入新密码' : '请输入初始密码'"
:maxlength="50"
/>
</a-form-item>
</a-form>
<template #footer>
<a-button style="margin-right: 8px" @click="handleCancel"
>取消</a-button
>
<a-button type="primary" :loading="submitLoading" @click="handleSubmit">
确定
</a-button>
</template>
</a-drawer>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from "vue"
import { useRoute } from "vue-router"
import { message } from "ant-design-vue"
import type { FormInstance, TableProps } from "ant-design-vue"
import {
PlusOutlined,
UploadOutlined,
DownloadOutlined,
DeleteOutlined,
SearchOutlined,
ReloadOutlined,
} from "@ant-design/icons-vue"
import { useListRequest } from "@/composables/useListRequest"
import { contestsApi } from "@/api/contests"
import { tenantsApi, type Tenant } from "@/api/tenants"
import {
judgesManagementApi,
type Judge,
type QueryJudgeParams,
} from "@/api/judges-management"
import { useAuthStore } from "@/stores/auth"
const route = useRoute()
const contestId = route.params.id ? Number(route.params.id) : null
const tenantCode = route.params.tenantCode as string
const authStore = useAuthStore()
// 检查 contestId 是否有效
const isValidContestId =
contestId !== null && !isNaN(contestId) && contestId > 0
// 获取评委列表
const fetchJudgesList = async (params: QueryJudgeParams) => {
const response = await judgesManagementApi.getList(params)
return response
}
// 使用列表请求组合函数
const {
loading,
dataSource,
pagination,
searchParams,
fetchList,
resetSearch,
search,
handleTableChange,
} = useListRequest<Judge, QueryJudgeParams>({
requestFn: fetchJudgesList,
defaultSearchParams: {} as QueryJudgeParams,
defaultPageSize: 10,
errorMessage: "获取评委列表失败",
})
// 租户列表
const tenantsList = ref<Tenant[]>([])
const tenantsLoading = ref(false)
// 比赛信息
const contestName = ref("")
// 表格选择
const selectedRowKeys = ref<number[]>([])
const rowSelection: TableProps["rowSelection"] = {
selectedRowKeys: selectedRowKeys.value,
onChange: (keys: any) => {
selectedRowKeys.value = keys
},
}
// 抽屉相关
const drawerVisible = ref(false)
const isEditing = ref(false)
const editingId = ref<number | null>(null)
const submitLoading = ref(false)
const formRef = ref<FormInstance>()
const form = reactive<{
nickname: string
gender: "male" | "female" | undefined
tenantId: number | undefined
phone: string
password: string
}>({
nickname: "",
gender: undefined,
tenantId: undefined,
phone: "",
password: "",
})
// 表单验证规则
const rules = computed(() => ({
nickname: [{ required: true, message: "请输入姓名", trigger: "blur" }],
gender: [{ required: true, message: "请选择性别", trigger: "change" }],
tenantId: [{ required: true, message: "请选择所属单位", trigger: "change" }],
phone: [{ required: true, message: "请输入联系方式", trigger: "blur" }],
password: isEditing.value
? []
: [{ required: true, message: "请输入初始密码", trigger: "blur" }],
}))
// 表格列定义
const columns = [
{
title: "序号",
key: "index",
width: 70,
},
{
title: "所属单位",
key: "tenant",
width: 150,
},
{
title: "姓名",
dataIndex: "nickname",
key: "nickname",
width: 120,
},
{
title: "性别",
key: "gender",
width: 80,
},
{
title: "账号",
dataIndex: "username",
key: "username",
width: 150,
},
{
title: "联系方式",
dataIndex: "phone",
key: "phone",
width: 130,
},
{
title: "关联进行中的赛事",
key: "ongoingContests",
width: 150,
},
{
title: "状态",
key: "status",
width: 80,
},
{
title: "操作",
key: "action",
width: 180,
fixed: "right" as const,
},
]
// 是否有租户读取权限
const hasTenantReadPermission = computed(() => {
return authStore.hasPermission("tenant:read")
})
// 加载租户列表
const loadTenants = async () => {
// 如果没有权限,跳过加载
if (!hasTenantReadPermission.value) {
return
}
tenantsLoading.value = true
try {
// 使用较大的 pageSize 确保获取所有租户
// 如果租户数量很多,可以考虑分页加载或使用后端提供的"获取全部"接口
const res = await tenantsApi.getList({ page: 1, pageSize: 10000 })
tenantsList.value = res.list
// 如果返回的数据量等于 pageSize可能还有更多数据
if (res.list.length === 10000 && res.total > 10000) {
console.warn(`租户数量较多(${res.total})当前仅显示前10000个`)
}
} catch (error: any) {
// 如果是权限错误,静默处理(不显示错误消息,因为 request.ts 已经显示了)
if (error?.response?.status === 403) {
console.warn("缺少租户读取权限,租户筛选功能不可用")
} else {
console.error("加载租户列表失败", error)
message.error("加载租户列表失败,请稍后重试")
}
} finally {
tenantsLoading.value = false
}
}
// 加载比赛信息
const loadContestInfo = async () => {
// 如果 contestId 无效,跳过加载
if (!isValidContestId) {
return
}
try {
const contest = await contestsApi.getDetail(contestId!)
contestName.value = contest.contestName
} catch (error) {
console.error("获取比赛信息失败", error)
}
}
// 搜索
const handleSearch = () => {
search()
}
// 重置搜索
const handleReset = () => {
resetSearch()
}
// 新增
const handleAdd = () => {
isEditing.value = false
editingId.value = null
drawerVisible.value = true
form.nickname = ""
form.gender = undefined
// 默认使用当前用户的租户ID
form.tenantId = authStore.user?.tenantId
form.phone = ""
form.password = ""
// 如果有权限但租户列表为空,尝试加载
if (hasTenantReadPermission.value && tenantsList.value.length === 0) {
loadTenants()
}
}
// 编辑
const handleEdit = (record: Judge) => {
isEditing.value = true
editingId.value = record.id
drawerVisible.value = true
form.nickname = record.nickname || ""
form.gender = record.gender as "male" | "female" | undefined
form.tenantId = record.tenantId
form.phone = record.phone || ""
form.password = ""
}
// 切换状态(冻结/解冻)
const handleToggleStatus = async (record: Judge) => {
try {
if (record.status === "enabled") {
await judgesManagementApi.freeze(record.id)
message.success("冻结成功")
} else {
await judgesManagementApi.unfreeze(record.id)
message.success("解冻成功")
}
fetchList()
} catch (error: any) {
message.error(error?.response?.data?.message || "操作失败")
}
}
// 删除
const handleDelete = async (id: number) => {
try {
await judgesManagementApi.delete(id)
message.success("删除成功")
fetchList()
} catch (error: any) {
message.error(error?.response?.data?.message || "删除失败")
}
}
// 批量删除
const handleBatchDelete = async () => {
if (selectedRowKeys.value.length === 0) return
try {
await judgesManagementApi.batchDelete(selectedRowKeys.value)
message.success("批量删除成功")
selectedRowKeys.value = []
fetchList()
} catch (error: any) {
message.error(error?.response?.data?.message || "批量删除失败")
}
}
// 提交表单
const handleSubmit = async () => {
try {
await formRef.value?.validate()
submitLoading.value = true
if (isEditing.value && editingId.value) {
// 编辑评委
await judgesManagementApi.update(editingId.value, {
nickname: form.nickname,
gender: form.gender,
phone: form.phone,
...(form.password && { password: form.password }),
})
message.success("编辑成功")
} else {
// 新增评委
await judgesManagementApi.create({
nickname: form.nickname,
gender: form.gender!,
tenantId: form.tenantId!,
phone: form.phone,
password: form.password,
status: "enabled",
})
message.success("创建成功")
}
drawerVisible.value = false
fetchList()
} catch (error: any) {
if (error?.errorFields) {
return
}
message.error(
error?.response?.data?.message ||
(isEditing.value ? "编辑失败" : "创建失败")
)
} finally {
submitLoading.value = false
}
}
// 取消
const handleCancel = () => {
drawerVisible.value = false
formRef.value?.resetFields()
}
onMounted(() => {
loadTenants()
loadContestInfo()
})
</script>
<style scoped>
.search-form {
margin-bottom: 16px;
}
.tenant-warning {
margin-top: 8px;
}
</style>