library-picturebook-activity/frontend/src/views/contests/Create.vue
2026-04-03 13:49:19 +08:00

507 lines
23 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="create-contest-page">
<a-spin :spinning="pageLoading">
<!-- 顶部导航 -->
<div class="page-header">
<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>{{ isEdit ? '编辑活动' : '创建活动' }}</a-breadcrumb-item>
</a-breadcrumb>
</div>
<a-form ref="formRef" :model="form" :rules="rules" layout="vertical" class="contest-form">
<!-- 主办信息 -->
<div class="form-section">
<h3 class="section-title">主办信息</h3>
<a-row :gutter="24">
<a-col :span="24">
<a-form-item label="主办单位" name="organizers" required>
<a-input v-model:value="form.organizers" placeholder="请输入主办单位" :maxlength="200" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="协办单位" name="coOrganizers">
<a-input v-model:value="form.coOrganizers" placeholder="请输入协办单位" :maxlength="200" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="赞助单位" name="sponsors">
<a-input v-model:value="form.sponsors" placeholder="请输入赞助单位" :maxlength="200" />
</a-form-item>
</a-col>
</a-row>
</div>
<!-- 活动信息 -->
<div class="form-section">
<h3 class="section-title">活动信息</h3>
<a-row :gutter="24">
<a-col :span="24">
<a-form-item label="活动名称" name="contestName" required>
<a-input v-model:value="form.contestName" placeholder="请输入活动名称" :maxlength="200" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="活动类型" name="contestType" required>
<a-select v-model:value="form.contestType" placeholder="请选择活动类型">
<a-select-option value="individual">个人参与</a-select-option>
<a-select-option value="team">团队参与</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="活动时间" name="timeRange" required>
<a-range-picker v-model:value="timeRange" show-time format="YYYY-MM-DD HH:mm" style="width: 100%" @change="handleTimeRangeChange" />
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="可见范围" name="visibility">
<a-radio-group v-model:value="form.visibility">
<a-radio value="public">公开所有公众用户可见</a-radio>
<a-radio value="targeted">定向推送按城市/年龄筛选</a-radio>
<a-radio value="designated">指定机构</a-radio>
<a-radio value="internal">仅内部</a-radio>
</a-radio-group>
</a-form-item>
</a-col>
<a-col v-if="form.visibility === 'targeted'" :span="12">
<a-form-item label="目标城市">
<a-select v-model:value="form.targetCities" mode="tags" placeholder="输入城市名称后按回车添加" />
<div class="form-hint">留空表示不限城市</div>
</a-form-item>
</a-col>
<a-col v-if="form.visibility === 'public' || form.visibility === 'targeted'" :span="12">
<a-form-item label="年龄限制">
<a-space>
<a-input-number v-model:value="form.ageMin" :min="1" :max="99" placeholder="最小" style="width: 100px" />
<span>至</span>
<a-input-number v-model:value="form.ageMax" :min="1" :max="99" placeholder="最大" style="width: 100px" />
<span class="form-hint">岁(留空不限)</span>
</a-space>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="活动详情" name="content" required>
<RichTextEditor v-model="form.content" placeholder="请输入活动详细说明" :height="300" />
</a-form-item>
</a-col>
</a-row>
</div>
<!-- 图片与附件 -->
<div class="form-section">
<h3 class="section-title">图片与附件</h3>
<a-row :gutter="24">
<a-col :span="12">
<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="form-hint">建议尺寸 16:9小于 30M</div>
</a-form-item>
</a-col>
<a-col :span="12">
<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="form-hint">建议尺寸 16:9小于 30M</div>
</a-form-item>
</a-col>
<a-col :span="24">
<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>
<div class="form-hint">最多 10 个文件,每个不超过 30M</div>
</a-form-item>
</a-col>
</a-row>
</div>
<!-- 时间配置 -->
<div class="form-section">
<h3 class="section-title">时间配置</h3>
<a-row :gutter="24">
<a-col :span="12">
<a-form-item label="报名时间" name="registerTimeRange" required>
<a-range-picker v-model:value="registerTimeRange" show-time format="YYYY-MM-DD HH:mm" style="width: 100%"
:disabled-date="disabledRegisterDate" @change="handleRegisterTimeRangeChange" />
</a-form-item>
</a-col>
<a-col :span="12">
<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-col>
<a-col :span="12">
<a-form-item label="作品提交时间" name="submitTimeRange" required>
<a-range-picker v-model:value="submitTimeRange" show-time format="YYYY-MM-DD HH:mm" style="width: 100%"
:disabled-date="disabledSubmitDate" @change="handleSubmitTimeRangeChange" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="评审规则" name="reviewRuleId">
<a-select v-model:value="form.reviewRuleId" placeholder="请选择评审规则(可选)" :options="reviewRuleOptions" allow-clear />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="评审时间" name="reviewTimeRange" required>
<a-range-picker v-model:value="reviewTimeRange" show-time format="YYYY-MM-DD HH:mm" style="width: 100%"
:disabled-date="disabledReviewDate" @change="handleReviewTimeRangeChange" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="结果公布时间" name="resultPublishTime">
<a-date-picker v-model:value="resultPublishTime" show-time format="YYYY-MM-DD HH:mm" style="width: 100%"
:disabled-date="disabledPublishDate" placeholder="请选择结果公布时间" @change="handleResultPublishTimeChange" />
</a-form-item>
</a-col>
</a-row>
</div>
<!-- 底部操作按钮 -->
<div class="form-actions">
<a-button @click="handleCancel">取消</a-button>
<a-button type="primary" :loading="submitLoading" @click="handleSubmit">保存</a-button>
</div>
</a-form>
</a-spin>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, nextTick, onMounted, computed } 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 dayjs from "dayjs"
import { PlusOutlined, UploadOutlined, ArrowLeftOutlined } from "@ant-design/icons-vue"
import RichTextEditor from "@/components/RichTextEditor.vue"
import { contestsApi, attachmentsApi, reviewRulesApi, type CreateContestForm, type Contest } from "@/api/contests"
import { uploadFile } from "@/api/upload"
const router = useRouter()
const route = useRoute()
const tenantCode = route.params.tenantCode as string
const contestId = computed(() => route.params.id ? Number(route.params.id) : null)
const isEdit = computed(() => !!contestId.value)
const pageLoading = ref(false)
const formRef = ref<FormInstance>()
const submitLoading = ref(false)
const form = reactive<CreateContestForm & { reviewRuleId?: number }>({
contestName: "", contestType: "individual", visibility: "designated",
targetCities: [] as string[], ageMin: undefined as number | undefined, ageMax: undefined as number | undefined,
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 fetchReviewRules = async () => {
try {
const rules = await reviewRulesApi.getForSelect()
reviewRuleOptions.value = rules.map(r => ({ value: r.id, label: r.ruleName }))
} catch { /* */ }
}
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: () => (!timeRange.value || !form.startTime || !form.endTime) ? Promise.reject("请选择活动时间") : Promise.resolve(), trigger: "change" }],
registerTimeRange: [{ required: true, validator: () => (!registerTimeRange.value || !form.registerStartTime || !form.registerEndTime) ? Promise.reject("请选择报名时间") : Promise.resolve(), trigger: "change" }],
submitRule: [{ required: true, message: "请选择提交规则", trigger: "change" }],
submitTimeRange: [{ required: true, validator: () => (!submitTimeRange.value || !form.submitStartTime || !form.submitEndTime) ? Promise.reject("请选择提交时间") : Promise.resolve(), trigger: "change" }],
reviewTimeRange: [{ required: true, validator: () => (!reviewTimeRange.value || !form.reviewStartTime || !form.reviewEndTime) ? Promise.reject("请选择评审时间") : 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 }
return new Promise((resolve) => {
const reader = new FileReader()
reader.onload = (e) => {
const img = new Image()
img.onload = () => {
const ratio = img.width / img.height
if (Math.abs(ratio - 16 / 9) > 0.1) message.warning("图片尺寸建议为16:9")
resolve(true)
}
img.src = e.target?.result as string
}
reader.readAsDataURL(file)
})
}
const beforeFileUpload = (file: File) => {
if (file.size / 1024 / 1024 >= 30) { message.error("文件大小不能超过30M"); return false }
return true
}
const handleCoverUpload = async (options: any) => {
const { file, onSuccess, onError } = options
try {
const result: any = await uploadFile(file)
const url = result.data?.url || result.url
if (url) { form.coverUrl = url; onSuccess(); message.success("封面上传成功") }
else throw new Error("无法获取图片地址")
} catch (e: any) { onError(e); message.error(e?.response?.data?.message || "封面上传失败") }
}
const handlePosterUpload = async (options: any) => {
const { file, onSuccess, onError } = options
try {
const result: any = await uploadFile(file)
const url = result.data?.url || result.url
if (url) { form.posterUrl = url; onSuccess(); message.success("海报上传成功") }
else throw new Error("无法获取图片地址")
} catch (e: any) { onError(e); message.error(e?.response?.data?.message || "海报上传失败") }
}
const handleAttachmentUpload = async (options: any) => {
const { file, onSuccess, onError } = options
try {
const result: any = await uploadFile(file)
const url = result.data?.url || result.url
if (url) {
await nextTick()
const idx = attachmentFileList.value.findIndex(f => f.uid === file.uid || f.name === file.name)
if (idx !== -1) {
attachmentFileList.value[idx] = { ...attachmentFileList.value[idx], url, response: result, status: "done" }
} else {
attachmentFileList.value.push({ uid: file.uid, name: file.name, status: "done", url, response: result })
}
onSuccess(); message.success("附件上传成功")
} else throw new Error("无法获取文件地址")
} catch (e: any) {
const idx = attachmentFileList.value.findIndex(f => f.uid === file.uid || f.name === file.name)
if (idx !== -1) attachmentFileList.value[idx].status = "error"
onError(e); message.error(e?.response?.data?.message || "附件上传失败")
}
}
const handleTimeRangeChange = (dates: [Dayjs, Dayjs] | null) => {
if (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?.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?.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?.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) => {
form.resultPublishTime = date ? date.format("YYYY-MM-DD HH:mm:ss") : ""
}
const disabledRegisterDate = (current: Dayjs | null) => {
if (!timeRange.value || !current) return false
return current < timeRange.value[0].startOf("day") || current > timeRange.value[1].endOf("day")
}
const disabledSubmitDate = (current: Dayjs | null) => {
if (!registerTimeRange.value || !timeRange.value || !current) return false
return current < registerTimeRange.value[1].startOf("day") || current > timeRange.value[1].endOf("day")
}
const disabledReviewDate = (current: Dayjs | null) => {
if (!registerTimeRange.value || !timeRange.value || !current) return false
return current < registerTimeRange.value[1].startOf("day") || current > timeRange.value[1].endOf("day")
}
const disabledPublishDate = (current: Dayjs | null) => {
if (!reviewTimeRange.value || !timeRange.value || !current) return false
return current < reviewTimeRange.value[1].startOf("day") || current > timeRange.value[1].endOf("day")
}
const loadContestData = async () => {
if (!contestId.value) return
pageLoading.value = true
try {
const c = await contestsApi.getDetail(contestId.value)
form.contestName = c.contestName || ""
form.contestType = c.contestType || "individual"
form.startTime = c.startTime || ""
form.endTime = c.endTime || ""
form.content = c.content || ""
form.coverUrl = c.coverUrl || ""
form.posterUrl = c.posterUrl || ""
form.organizers = Array.isArray(c.organizers) ? c.organizers.join("、") : c.organizers || ""
form.coOrganizers = Array.isArray(c.coOrganizers) ? c.coOrganizers.join("、") : c.coOrganizers || ""
form.sponsors = Array.isArray(c.sponsors) ? c.sponsors.join("、") : c.sponsors || ""
form.registerStartTime = c.registerStartTime || ""
form.registerEndTime = c.registerEndTime || ""
form.submitRule = c.submitRule || "once"
form.submitStartTime = c.submitStartTime || ""
form.submitEndTime = c.submitEndTime || ""
form.reviewRuleId = c.reviewRuleId || undefined
form.reviewStartTime = c.reviewStartTime || ""
form.reviewEndTime = c.reviewEndTime || ""
form.resultPublishTime = c.resultPublishTime || ""
if (c.startTime && c.endTime) timeRange.value = [dayjs(c.startTime), dayjs(c.endTime)]
if (c.registerStartTime && c.registerEndTime) registerTimeRange.value = [dayjs(c.registerStartTime), dayjs(c.registerEndTime)]
if (c.submitStartTime && c.submitEndTime) submitTimeRange.value = [dayjs(c.submitStartTime), dayjs(c.submitEndTime)]
if (c.reviewStartTime && c.reviewEndTime) reviewTimeRange.value = [dayjs(c.reviewStartTime), dayjs(c.reviewEndTime)]
if (c.resultPublishTime) resultPublishTime.value = dayjs(c.resultPublishTime)
if (c.coverUrl) coverFileList.value = [{ uid: "-1", name: "cover", status: "done", url: c.coverUrl }]
if (c.posterUrl) posterFileList.value = [{ uid: "-2", name: "poster", status: "done", url: c.posterUrl }]
if (c.attachments?.length) {
attachmentFileList.value = c.attachments.map((att: any, i: number) => ({ uid: `-${i + 3}`, name: att.fileName, status: "done", url: att.fileUrl }))
}
} catch (e: any) { message.error(e?.response?.data?.message || "加载活动数据失败"); router.back() }
finally { pageLoading.value = false }
}
const handleSubmit = async () => {
try {
await formRef.value?.validate()
submitLoading.value = true
const submitData: CreateContestForm = {
contestName: form.contestName, contestType: form.contestType, startTime: form.startTime, endTime: form.endTime,
content: form.content, coverUrl: form.coverUrl, posterUrl: form.posterUrl,
organizers: form.organizers, coOrganizers: form.coOrganizers, sponsors: form.sponsors,
registerStartTime: form.registerStartTime, registerEndTime: form.registerEndTime,
submitRule: form.submitRule, submitStartTime: form.submitStartTime, submitEndTime: form.submitEndTime,
reviewRuleId: form.reviewRuleId || undefined,
reviewStartTime: form.reviewStartTime, reviewEndTime: form.reviewEndTime,
resultPublishTime: form.resultPublishTime || undefined,
}
if (isEdit.value && contestId.value) {
await contestsApi.update(contestId.value, submitData)
message.success("保存成功")
} else {
const contest = await contestsApi.create(submitData)
if (attachmentFileList.value.length > 0) {
try {
await Promise.all(attachmentFileList.value.map(file => {
const fileUrl = file.url || file.response?.url || file.response?.data?.url
if (fileUrl && file.name) {
return attachmentsApi.create({ contestId: contest.id, fileName: file.name, fileUrl, format: file.name.split(".").pop()?.toLowerCase(), size: file.size?.toString() })
}
return Promise.resolve()
}))
} catch { message.warning("活动创建成功,但部分附件记录创建失败") }
}
message.success("创建成功")
}
router.push(`/${tenantCode}/contests/list`)
} catch (e: any) {
if (e?.errorFields) return
message.error(e?.response?.data?.message || (isEdit.value ? "保存失败" : "创建失败"))
} finally { submitLoading.value = false }
}
const handleCancel = () => { router.back() }
onMounted(() => {
fetchReviewRules()
if (isEdit.value) loadContestData()
})
</script>
<style scoped lang="scss">
$primary: #6366f1;
.create-contest-page {
max-width: 960px;
margin: 0 auto;
}
.page-header {
display: flex;
align-items: center;
margin-bottom: 20px;
padding: 16px 20px;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
}
.contest-form {
:deep(.ant-form-item-label > label) {
font-weight: 500;
color: #374151;
}
}
.form-section {
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
padding: 24px;
margin-bottom: 16px;
.section-title {
font-size: 16px;
font-weight: 600;
color: #1e1b4b;
margin: 0 0 20px;
padding-bottom: 12px;
border-bottom: 1px solid #f0ecf9;
}
}
.form-hint {
font-size: 12px;
color: #9ca3af;
margin-top: 4px;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 20px 24px;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
}
</style>