library-picturebook-activity/frontend/src/views/contests/Create.vue

826 lines
25 KiB
Vue
Raw Normal View History

2026-01-08 09:17:46 +08:00
<template>
<div class="create-contest-page">
<a-spin :spinning="loading">
<a-card>
<template #title>
<a-space>
<a-button
type="text"
@click="handleCancel"
style="padding: 0; margin-right: 8px"
>
<template #icon><ArrowLeftOutlined /></template>
</a-button>
<a-breadcrumb>
<a-breadcrumb-item>
<router-link :to="`/${tenantCode}/contests`"
>赛事管理</router-link
>
</a-breadcrumb-item>
<a-breadcrumb-item>{{
isEditMode ? "编辑比赛" : "创建比赛"
}}</a-breadcrumb-item>
</a-breadcrumb>
</a-space>
</template>
<a-form
ref="formRef"
:model="form"
:rules="rules"
:label-col="{ span: 5 }"
:wrapper-col="{ span: 19 }"
layout="vertical"
>
<!-- 主办信息 -->
<a-card title="主办信息" size="small" class="section-card">
<a-form-item label="主办单位" name="organizers" required>
<a-input
v-model:value="form.organizers"
placeholder="请输入主办单位"
:maxlength="200"
style="width: 600px"
/>
</a-form-item>
<a-form-item label="协办单位" name="coOrganizers">
<a-input
v-model:value="form.coOrganizers"
placeholder="请输入协办单位"
:maxlength="200"
style="width: 600px"
/>
</a-form-item>
<a-form-item label="赞助单位" name="sponsors">
<a-input
v-model:value="form.sponsors"
placeholder="请输入赞助单位"
:maxlength="200"
style="width: 600px"
/>
</a-form-item>
</a-card>
<!-- 赛事信息 -->
<a-card title="赛事信息" size="small" class="section-card">
<a-form-item label="赛事名称" name="contestName" required>
<a-input
v-model:value="form.contestName"
placeholder="请输入赛事名称"
:maxlength="200"
style="width: 600px"
/>
</a-form-item>
<a-form-item label="赛事时间" name="timeRange" required>
<a-range-picker
v-model:value="timeRange"
show-time
format="YYYY-MM-DD HH:mm"
style="width: 600px"
@change="handleTimeRangeChange"
/>
</a-form-item>
<a-form-item label="赛事类型" name="contestType" required>
<a-select
v-model:value="form.contestType"
placeholder="请选择赛事类型"
style="width: 200px"
>
<a-select-option value="individual">个人赛</a-select-option>
<a-select-option value="team">团队赛</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="赛事详情" name="content" required>
<a-textarea
v-model:value="form.content"
placeholder="请输入赛事详细说明"
:rows="8"
:maxlength="5000"
show-count
style="width: 600px"
/>
</a-form-item>
<a-form-item label="赛事封面" name="coverUrl" required>
<a-upload
v-model:file-list="coverFileList"
list-type="picture-card"
:before-upload="beforeUpload"
:custom-request="handleCoverUpload"
accept="image/*"
:max-count="1"
>
<div v-if="coverFileList.length < 1">
<plus-outlined />
<div style="margin-top: 8px">上传封面</div>
</div>
</a-upload>
<div class="upload-tip">
<a-alert
message="图片要求尺寸16:9文件大小小于30M"
type="info"
show-icon
style="margin-top: 8px; width: fit-content"
/>
</div>
</a-form-item>
<a-form-item label="赛事海报" name="posterUrl" required>
<a-upload
v-model:file-list="posterFileList"
list-type="picture-card"
:before-upload="beforeUpload"
:custom-request="handlePosterUpload"
accept="image/*"
:max-count="1"
>
<div v-if="posterFileList.length < 1">
<plus-outlined />
<div style="margin-top: 8px">上传海报</div>
</div>
</a-upload>
<div class="upload-tip">
<a-alert
message="图片要求尺寸16:9文件大小小于30M"
type="info"
show-icon
style="margin-top: 8px; width: fit-content"
/>
</div>
</a-form-item>
<a-form-item label="赛事附件" name="attachments">
<a-upload
v-model:file-list="attachmentFileList"
:before-upload="beforeFileUpload"
:custom-request="handleAttachmentUpload"
:max-count="10"
>
<a-button>
<upload-outlined />
上传附件
</a-button>
</a-upload>
</a-form-item>
</a-card>
<!-- 报名设置 -->
<a-card title="报名设置" size="small" class="section-card">
<a-form-item label="报名时间" name="registerTimeRange" required>
<a-range-picker
v-model:value="registerTimeRange"
show-time
format="YYYY-MM-DD HH:mm"
style="width: 600px"
:disabled-date="disabledRegisterDate"
@change="handleRegisterTimeRangeChange"
/>
</a-form-item>
</a-card>
<!-- 作品提交设置 -->
<a-card title="作品提交设置" size="small" class="section-card">
<a-form-item label="提交规则" name="submitRule" required>
<a-radio-group v-model:value="form.submitRule">
<a-radio value="once">仅允许提交一次</a-radio>
<a-radio value="resubmit">时间范围内允许重复提交</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item label="提交时间" name="submitTimeRange" required>
<a-range-picker
v-model:value="submitTimeRange"
show-time
format="YYYY-MM-DD HH:mm"
style="width: 600px"
:disabled-date="disabledSubmitDate"
@change="handleSubmitTimeRangeChange"
/>
</a-form-item>
</a-card>
<!-- 评审规则 -->
<a-card title="评审规则" size="small" class="section-card">
<a-form-item label="评审规则" name="reviewRuleId">
<a-select
v-model:value="form.reviewRuleId"
placeholder="请选择评审规则(可选)"
style="width: 600px"
:options="reviewRuleOptions"
:loading="reviewRuleLoading"
allow-clear
>
<template
v-if="reviewRuleOptions.length === 0 && !reviewRuleLoading"
#notFoundContent
>
<div style="padding: 8px; text-align: center; color: #999">
<div>暂无评审规则</div>
<div style="margin-top: 4px; font-size: 12px">
请先前往
<a @click="goToReviewRules" style="color: #1890ff"
>评审规则列表</a
>
新增规则
</div>
</div>
</template>
</a-select>
</a-form-item>
<a-form-item label="评审时间" name="reviewTimeRange" required>
<a-range-picker
v-model:value="reviewTimeRange"
show-time
format="YYYY-MM-DD HH:mm"
style="width: 600px"
:disabled-date="disabledReviewDate"
@change="handleReviewTimeRangeChange"
/>
</a-form-item>
</a-card>
<!-- 结果公布 -->
<a-card title="结果公布" size="small" class="section-card">
<a-form-item label="公布时间" name="resultPublishTime">
<a-date-picker
v-model:value="resultPublishTime"
show-time
format="YYYY-MM-DD HH:mm"
style="width: 600px"
:disabled-date="disabledPublishDate"
placeholder="请选择结果公布时间"
@change="handleResultPublishTimeChange"
/>
</a-form-item>
</a-card>
</a-form>
<!-- 底部操作按钮 -->
<div class="form-actions">
<a-space>
<a-button @click="handleCancel">取消</a-button>
<a-button
type="primary"
:loading="submitLoading"
@click="handleSubmit"
>
{{ isEditMode ? "更新" : "保存" }}
</a-button>
</a-space>
</div>
</a-card>
</a-spin>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from "vue"
import { useRouter, useRoute } from "vue-router"
import { message } from "ant-design-vue"
import type { FormInstance, UploadFile } from "ant-design-vue"
import type { Dayjs } from "dayjs"
import {
PlusOutlined,
UploadOutlined,
ArrowLeftOutlined,
} from "@ant-design/icons-vue"
import {
contestsApi,
reviewRulesApi,
type CreateContestForm,
type UpdateContestForm,
type ReviewRule,
} from "@/api/contests"
import dayjs from "dayjs"
const router = useRouter()
const route = useRoute()
const tenantCode = route.params.tenantCode as string
const contestId = route.params.id ? parseInt(route.params.id as string) : null
const isEditMode = ref(!!contestId)
const formRef = ref<FormInstance>()
const submitLoading = ref(false)
const loading = ref(false)
// 表单数据
const form = reactive<
CreateContestForm & {
reviewRuleId?: number
}
>({
contestName: "",
contestType: "individual",
startTime: "",
endTime: "",
content: "",
coverUrl: "",
posterUrl: "",
organizers: "",
coOrganizers: "",
sponsors: "",
registerStartTime: "",
registerEndTime: "",
submitRule: "once",
submitStartTime: "",
submitEndTime: "",
reviewStartTime: "",
reviewEndTime: "",
resultPublishTime: "",
})
// 时间范围
const timeRange = ref<[Dayjs, Dayjs] | null>(null)
const registerTimeRange = ref<[Dayjs, Dayjs] | null>(null)
const submitTimeRange = ref<[Dayjs, Dayjs] | null>(null)
const reviewTimeRange = ref<[Dayjs, Dayjs] | null>(null)
const resultPublishTime = ref<Dayjs | null>(null)
// 文件列表
const coverFileList = ref<UploadFile[]>([])
const posterFileList = ref<UploadFile[]>([])
const attachmentFileList = ref<UploadFile[]>([])
// 评审规则选项
const reviewRuleOptions = ref<{ value: number; label: string }[]>([])
const reviewRuleLoading = ref(false)
// 表单验证规则
const rules = {
contestName: [{ required: true, message: "请输入赛事名称", trigger: "blur" }],
contestType: [
{ required: true, message: "请选择赛事类型", trigger: "change" },
],
organizers: [
{ required: true, message: "请输入主办单位", trigger: "change" },
],
content: [{ required: true, message: "请输入赛事详情", trigger: "blur" }],
coverUrl: [{ required: true, message: "请上传赛事封面", trigger: "change" }],
posterUrl: [{ required: true, message: "请上传赛事海报", trigger: "change" }],
timeRange: [
{
required: true,
validator: () => {
if (!timeRange.value || !form.startTime || !form.endTime) {
return Promise.reject(new Error("请选择赛事时间"))
}
return Promise.resolve()
},
trigger: "change",
},
],
registerTimeRange: [
{
required: true,
validator: () => {
if (
!registerTimeRange.value ||
!form.registerStartTime ||
!form.registerEndTime
) {
return Promise.reject(new Error("请选择报名时间"))
}
return Promise.resolve()
},
trigger: "change",
},
],
submitRule: [
{ required: true, message: "请选择提交规则", trigger: "change" },
],
submitTimeRange: [
{
required: true,
validator: () => {
if (
!submitTimeRange.value ||
!form.submitStartTime ||
!form.submitEndTime
) {
return Promise.reject(new Error("请选择提交时间"))
}
return Promise.resolve()
},
trigger: "change",
},
],
reviewTimeRange: [
{
required: true,
validator: () => {
if (
!reviewTimeRange.value ||
!form.reviewStartTime ||
!form.reviewEndTime
) {
return Promise.reject(new Error("请选择评审时间"))
}
return Promise.resolve()
},
trigger: "change",
},
],
}
// 图片上传前验证
const beforeUpload = (file: File) => {
const isImage = file.type.startsWith("image/")
if (!isImage) {
message.error("只能上传图片文件!")
return false
}
const isLt30M = file.size / 1024 / 1024 < 30
if (!isLt30M) {
message.error("图片大小不能超过30M")
return false
}
// 验证图片尺寸16:9
return new Promise((resolve) => {
const reader = new FileReader()
reader.onload = (e) => {
const img = new Image()
img.onload = () => {
const aspectRatio = img.width / img.height
const targetRatio = 16 / 9
const tolerance = 0.1 // 允许10%的误差
if (Math.abs(aspectRatio - targetRatio) > tolerance) {
message.warning("图片尺寸应为16:9当前尺寸可能不符合要求")
}
resolve(true)
}
img.src = e.target?.result as string
}
reader.readAsDataURL(file)
})
}
// 文件上传前验证
const beforeFileUpload = (file: File) => {
const isLt30M = file.size / 1024 / 1024 < 30
if (!isLt30M) {
message.error("文件大小不能超过30M")
return false
}
return true
}
// 封面图片上传
const handleCoverUpload = async (options: any) => {
const { file, onSuccess, onError } = options
try {
// TODO: 调用实际上传接口
// 注意这里需要实现实际上传逻辑上传成功后返回文件URL
// 示例const response = await uploadFile(file)
// form.coverUrl = response.url
// 临时方案使用本地URL仅用于开发测试
const fileUrl = URL.createObjectURL(file)
form.coverUrl = fileUrl
onSuccess()
message.success("封面上传成功")
} catch (error) {
onError(error)
message.error("封面上传失败")
}
}
// 海报图片上传
const handlePosterUpload = async (options: any) => {
const { file, onSuccess, onError } = options
try {
// TODO: 调用实际上传接口
// 注意这里需要实现实际上传逻辑上传成功后返回文件URL
// 示例const response = await uploadFile(file)
// form.posterUrl = response.url
// 临时方案使用本地URL仅用于开发测试
const fileUrl = URL.createObjectURL(file)
form.posterUrl = fileUrl
onSuccess()
message.success("海报上传成功")
} catch (error) {
onError(error)
message.error("海报上传失败")
}
}
// 附件上传
const handleAttachmentUpload = async (options: any) => {
const { onSuccess, onError } = options
try {
// TODO: 调用实际上传接口
// 注意:附件上传后需要保存文件信息,但此时比赛还未创建,所以需要在创建比赛后再关联附件
// 临时方案:先保存文件信息到 attachmentFileList创建比赛后再上传
onSuccess()
message.success("附件上传成功(将在创建比赛后保存)")
} catch (error) {
onError(error)
message.error("附件上传失败")
}
}
// 时间范围变化处理
const handleTimeRangeChange = (dates: [Dayjs, Dayjs] | null) => {
if (dates && dates.length === 2) {
form.startTime = dates[0].format("YYYY-MM-DD HH:mm:ss")
form.endTime = dates[1].format("YYYY-MM-DD HH:mm:ss")
} else {
form.startTime = ""
form.endTime = ""
}
// 触发验证
formRef.value?.validateFields(["timeRange"])
}
const handleRegisterTimeRangeChange = (dates: [Dayjs, Dayjs] | null) => {
if (dates && dates.length === 2) {
form.registerStartTime = dates[0].format("YYYY-MM-DD HH:mm:ss")
form.registerEndTime = dates[1].format("YYYY-MM-DD HH:mm:ss")
} else {
form.registerStartTime = ""
form.registerEndTime = ""
}
// 触发验证
formRef.value?.validateFields(["registerTimeRange"])
}
const handleSubmitTimeRangeChange = (dates: [Dayjs, Dayjs] | null) => {
if (dates && dates.length === 2) {
form.submitStartTime = dates[0].format("YYYY-MM-DD HH:mm:ss")
form.submitEndTime = dates[1].format("YYYY-MM-DD HH:mm:ss")
} else {
form.submitStartTime = ""
form.submitEndTime = ""
}
// 触发验证
formRef.value?.validateFields(["submitTimeRange"])
}
const handleReviewTimeRangeChange = (dates: [Dayjs, Dayjs] | null) => {
if (dates && dates.length === 2) {
form.reviewStartTime = dates[0].format("YYYY-MM-DD HH:mm:ss")
form.reviewEndTime = dates[1].format("YYYY-MM-DD HH:mm:ss")
} else {
form.reviewStartTime = ""
form.reviewEndTime = ""
}
// 触发验证
formRef.value?.validateFields(["reviewTimeRange"])
}
const handleResultPublishTimeChange = (date: Dayjs | null) => {
if (date) {
form.resultPublishTime = date.format("YYYY-MM-DD HH:mm:ss")
} else {
form.resultPublishTime = ""
}
}
// 禁用报名日期(不早于赛事开始时间,不晚于赛事结束时间)
const disabledRegisterDate = (current: Dayjs | null) => {
if (!timeRange.value || !current) return false
const [startTime, endTime] = timeRange.value
return current < startTime.startOf("day") || current > endTime.endOf("day")
}
// 禁用提交日期(不早于报名结束时间,不晚于赛事结束时间)
const disabledSubmitDate = (current: Dayjs | null) => {
if (!registerTimeRange.value || !timeRange.value || !current) return false
const registerEndTime = registerTimeRange.value[1]
const [, endTime] = timeRange.value
return (
current < registerEndTime.startOf("day") || current > endTime.endOf("day")
)
}
// 禁用评审日期(不早于报名结束时间,不晚于赛事结束时间)
const disabledReviewDate = (current: Dayjs | null) => {
if (!registerTimeRange.value || !timeRange.value || !current) return false
const registerEndTime = registerTimeRange.value[1]
const [, endTime] = timeRange.value
return (
current < registerEndTime.startOf("day") || current > endTime.endOf("day")
)
}
// 禁用公布日期(不早于评审结束时间,不晚于赛事结束时间)
const disabledPublishDate = (current: Dayjs | null) => {
if (!reviewTimeRange.value || !timeRange.value || !current) return false
const reviewEndTime = reviewTimeRange.value[1]
const [, endTime] = timeRange.value
return (
current < reviewEndTime.startOf("day") || current > endTime.endOf("day")
)
}
// 加载比赛详情(编辑模式)
const loadContestDetail = async () => {
if (!contestId) return
try {
loading.value = true
const contest = await contestsApi.getDetail(contestId)
// 填充表单数据
form.contestName = contest.contestName || ""
form.contestType = contest.contestType || "individual"
form.startTime = contest.startTime || ""
form.endTime = contest.endTime || ""
form.content = contest.content || ""
form.coverUrl = contest.coverUrl || ""
form.posterUrl = contest.posterUrl || ""
form.organizers = Array.isArray(contest.organizers)
? contest.organizers.join(",")
: contest.organizers || ""
form.coOrganizers = Array.isArray(contest.coOrganizers)
? contest.coOrganizers.join(",")
: contest.coOrganizers || ""
form.sponsors = Array.isArray(contest.sponsors)
? contest.sponsors.join(",")
: contest.sponsors || ""
form.registerStartTime = contest.registerStartTime || ""
form.registerEndTime = contest.registerEndTime || ""
form.submitRule = contest.submitRule || "once"
form.submitStartTime = contest.submitStartTime || ""
form.submitEndTime = contest.submitEndTime || ""
form.reviewStartTime = contest.reviewStartTime || ""
form.reviewEndTime = contest.reviewEndTime || ""
form.resultPublishTime = contest.resultPublishTime || ""
form.reviewRuleId = contest.reviewRule?.id
// 设置时间范围
if (contest.startTime && contest.endTime) {
timeRange.value = [dayjs(contest.startTime), dayjs(contest.endTime)]
}
if (contest.registerStartTime && contest.registerEndTime) {
registerTimeRange.value = [
dayjs(contest.registerStartTime),
dayjs(contest.registerEndTime),
]
}
if (contest.submitStartTime && contest.submitEndTime) {
submitTimeRange.value = [
dayjs(contest.submitStartTime),
dayjs(contest.submitEndTime),
]
}
if (contest.reviewStartTime && contest.reviewEndTime) {
reviewTimeRange.value = [
dayjs(contest.reviewStartTime),
dayjs(contest.reviewEndTime),
]
}
if (contest.resultPublishTime) {
resultPublishTime.value = dayjs(contest.resultPublishTime)
}
// 设置文件列表(如果有)
if (contest.coverUrl) {
coverFileList.value = [
{
uid: "-1",
name: "cover.jpg",
status: "done",
url: contest.coverUrl,
},
]
}
if (contest.posterUrl) {
posterFileList.value = [
{
uid: "-2",
name: "poster.jpg",
status: "done",
url: contest.posterUrl,
},
]
}
} catch (error: any) {
message.error(error?.response?.data?.message || "加载比赛详情失败")
router.push(`/${tenantCode}/contests`)
} finally {
loading.value = false
}
}
// 提交表单
const handleSubmit = async () => {
try {
await formRef.value?.validate()
submitLoading.value = true
if (isEditMode.value && contestId) {
// 更新比赛
await contestsApi.update(contestId, form as UpdateContestForm)
message.success("更新成功")
} else {
// 创建比赛
await contestsApi.create(form as CreateContestForm)
message.success("创建成功")
}
// 跳转到比赛列表
router.push(`/${tenantCode}/contests`)
} catch (error: any) {
if (error?.errorFields) {
// 表单验证错误
return
}
message.error(
error?.response?.data?.message ||
(isEditMode.value ? "更新失败" : "创建失败")
)
} finally {
submitLoading.value = false
}
}
// 取消
const handleCancel = () => {
router.back()
}
// 加载评审规则列表
const loadReviewRules = async () => {
try {
reviewRuleLoading.value = true
2026-01-09 18:14:35 +08:00
const result = await reviewRulesApi.getList({ page: 1, pageSize: 100 })
2026-01-08 09:17:46 +08:00
reviewRuleOptions.value = (result.list || []).map((rule: ReviewRule) => ({
value: rule.id,
label: rule.ruleName,
}))
} catch (error) {
console.error("加载评审规则列表失败:", error)
// 静默失败,不影响表单提交
} finally {
reviewRuleLoading.value = false
}
}
// 跳转到评审规则列表
const goToReviewRules = () => {
router.push(`/${tenantCode}/contests/reviews`)
}
// 组件挂载时加载数据
onMounted(async () => {
await loadReviewRules()
if (isEditMode.value) {
await loadContestDetail()
}
})
</script>
<style scoped>
.create-contest-page {
padding: 16px 24px;
max-width: 1200px;
}
.create-contest-page :deep(.ant-card) {
border: none;
box-shadow: none;
}
.create-contest-page :deep(.ant-card-head) {
padding: 12px 0;
min-height: auto;
}
.create-contest-page :deep(.ant-card-body) {
padding: 16px 0;
}
.section-card {
margin-bottom: 24px;
border: none;
box-shadow: none;
background: transparent;
}
.section-card :deep(.ant-card-head) {
border-bottom: none;
padding-bottom: 8px;
background: transparent;
}
.section-card :deep(.ant-card-body) {
padding-top: 0;
background: transparent;
}
.section-card :deep(.ant-card-head-title) {
font-weight: bold;
font-size: 16px;
}
.upload-tip {
margin-top: 8px;
}
.form-actions {
margin-top: 24px;
padding-top: 24px;
border-top: 1px solid #f0f0f0;
text-align: right;
}
</style>