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.

1376 lines
46 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { storeToRefs } from 'pinia'
import {
createCompetition,
deleteTrack,
getCompetition,
listTracks,
updateCompetition,
patchCompetition,
createTrack,
updateTrack,
} from '../../../api/admin/competitions'
import * as formSchemaApi from '../../../api/admin/formSchemas'
import type {
CompetitionPayload,
CompetitionRow,
CompetitionTrackPayload,
CompetitionTrackRow,
FormSchemaRow,
} from '../../../api/admin/types'
import {
brandingFormFromApi,
brandingJsonFromForm,
emptyBrandingForm,
type BrandingForm,
} from '../../../utils/competitionBranding'
import { adminUseMock } from '../../../config/api'
import { useAdminCompetitionStore } from '../../../stores/adminCompetition'
import FormSchemaVisualEditor from '../../../components/admin/FormSchemaVisualEditor.vue'
import PledgeRichTextEditor from '../../../components/admin/PledgeRichTextEditor.vue'
import {
editorItemsToSchemaJson,
schemaJsonToEditorItems,
validateEditorItems,
type FormSchemaEditorItem,
type FormSchemaPurpose,
} from '../../../utils/formSchemaEditor'
type TabKey = 'basic' | 'tracks' | 'signupForm' | 'review' | 'brand'
const TAB_ITEMS: { tab: TabKey; label: string }[] = [
{ tab: 'basic', label: '基础信息' },
{ tab: 'tracks', label: '赛道管理' },
{ tab: 'signupForm', label: '报名表配置' },
{ tab: 'review', label: '评审与计分' },
{ tab: 'brand', label: '品牌与文案' },
]
const router = useRouter()
const route = useRoute()
const competitionStore = useAdminCompetitionStore()
const { selectedCompetitionId } = storeToRefs(competitionStore)
const isCreate = () => route.name === 'admin-competition-new'
const competitionId = ref<number | null>(null)
const noContext = ref(false)
const detailRow = ref<CompetitionRow | null>(null)
const form = ref({
slug: '',
name: '',
description: '',
status: 'draft',
published: false,
signup_open_at: null as string | null,
signup_close_at: null as string | null,
pledge_content_html: '',
})
const brand = ref<BrandingForm>(emptyBrandingForm())
const tracks = ref<CompetitionTrackRow[]>([])
const tracksLoading = ref(false)
const trackDialogVisible = ref(false)
const trackEditingId = ref<number | null>(null)
const trackForm = ref({
track_code: '',
title: '',
description: '',
sort: 0,
is_enabled: true,
})
const saving = ref(false)
const tabKey = ref<TabKey>('basic')
const signupSchemas = ref<FormSchemaRow[]>([])
const reviewSchemas = ref<FormSchemaRow[]>([])
const schemaBusy = ref(false)
const scoringJsonText = ref('')
const scoringError = ref('')
type ScoringDimRow = { key: string; weight: number; min: number; max: number }
function emptyScoringDimRow(): ScoringDimRow {
return { key: '', weight: 1, min: 0, max: 100 }
}
const scoringDimRows = ref<ScoringDimRow[]>([emptyScoringDimRow()])
const schemaDialogVisible = ref(false)
const schemaModalPurpose = ref<'signup' | 'review'>('signup')
const schemaNewName = ref('')
const editSchemaDialogVisible = ref(false)
const editSchemaId = ref<number | null>(null)
const editSchemaName = ref('')
const editSchemaJsonText = ref('[]')
const editSchemaError = ref('')
const editSchemaPurpose = ref<FormSchemaPurpose>('signup')
const editSchemaVisualItems = ref<FormSchemaEditorItem[]>([])
const editSchemaTab = ref<'visual' | 'json'>('visual')
const headline = computed(() => {
if (isCreate()) return '新建赛事'
const sub = TAB_ITEMS.find((x) => x.tab === tabKey.value)?.label
return sub ? `赛事中心 · ${sub}` : '赛事中心'
})
/** 切换赛事或新建时重建富文本编辑器,避免内容与实例不同步 */
const pledgeEditorKey = computed(() => String(competitionId.value ?? 'new'))
/** 当前站点下的绝对 URL与 `createWebHistory(import.meta.env.BASE_URL)` 一致) */
function absoluteUrlFromPath(path: string): string {
const origin = typeof window !== 'undefined' ? window.location.origin : ''
const basePath = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
const normalized = path.startsWith('/') ? path : `/${path}`
return `${origin}${basePath}${normalized}`
}
/** 访问地址 slug 非空时:选手登录、评审端;并始终附管理端入口(与文档 `/c/{slug}/…`、`/admin/…` 一致) */
const portalLinkRows = computed(() => {
const slug = form.value.slug.trim()
const rows: { label: string; href: string }[] = []
if (slug) {
const enc = encodeURIComponent(slug)
rows.push({
label: '选手登录',
href: absoluteUrlFromPath(`/c/${enc}/login`),
})
rows.push({
label: '评审端入口',
href: absoluteUrlFromPath(`/c/${enc}/review`),
})
}
rows.push({
label: '管理后台登录',
href: absoluteUrlFromPath('/admin/login'),
})
rows.push({
label: '赛事工作台(需先登录管理端)',
href: absoluteUrlFromPath('/admin/competition/workspace'),
})
return rows
})
watch(editSchemaTab, (t) => {
if (t === 'json') {
syncEditVisualToJson()
} else {
syncEditJsonToVisual()
}
})
function syncEditVisualToJson() {
editSchemaJsonText.value = JSON.stringify(
editorItemsToSchemaJson(editSchemaVisualItems.value, editSchemaPurpose.value),
null,
2,
)
}
function syncEditJsonToVisual() {
try {
const raw = JSON.parse(editSchemaJsonText.value)
if (Array.isArray(raw)) {
editSchemaVisualItems.value = schemaJsonToEditorItems(raw, editSchemaPurpose.value)
}
} catch {
/* 保留当前可视化内容 */
}
}
watch(
() => route.name,
(n) => {
if (n === 'admin-competition-new' || n === 'admin-competition-edit') {
tabKey.value = 'basic'
}
},
{ immediate: true },
)
watch(tabKey, (t) => {
const cid = competitionId.value
if (!cid) return
if (t === 'signupForm') void refreshSignupSchemas()
if (t === 'review') {
void refreshReviewSchemas()
}
})
function resolveCompetitionId(): number | null {
if (isCreate()) return null
const raw = route.params.id
if (raw != null && String(raw) !== '') {
const n = Number(raw)
if (Number.isFinite(n)) return n
}
const sid = selectedCompetitionId.value
return sid && sid > 0 ? sid : null
}
/** API ISO → Element Plus 日期时间选择器value-format `YYYY-MM-DD HH:mm:ss`,本地时区) */
function toElDateTimeModel(iso: string | null | undefined): string | null {
if (!iso) return null
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return null
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
function fromDatetimePickerValue(s: string | null | undefined): string | null {
const t = (s ?? '').trim()
if (!t) return null
const forParse = t.includes('T') ? t : t.replace(/^(\d{4}-\d{2}-\d{2})\s+/, '$1T')
const d = new Date(forParse)
if (Number.isNaN(d.getTime())) return null
return d.toISOString()
}
async function refreshSignupSchemas() {
const cid = competitionId.value
if (!cid) return
schemaBusy.value = true
try {
signupSchemas.value = await formSchemaApi.listFormSchemas(cid, 'signup')
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : '加载报名表列表失败')
} finally {
schemaBusy.value = false
}
}
async function refreshReviewSchemas() {
const cid = competitionId.value
if (!cid) return
schemaBusy.value = true
try {
reviewSchemas.value = await formSchemaApi.listFormSchemas(cid, 'review')
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : '加载评审表列表失败')
} finally {
schemaBusy.value = false
}
}
function loadScoringFromRow(row: CompetitionRow) {
scoringJsonText.value =
row.scoring_rules_json != null ? JSON.stringify(row.scoring_rules_json, null, 2) : ''
syncScoringDimRowsFromJson()
}
function syncScoringDimRowsFromJson() {
const t = scoringJsonText.value.trim()
if (!t) {
scoringDimRows.value = [emptyScoringDimRow()]
return
}
try {
const o = JSON.parse(t) as {
dimensions?: Array<{ key?: string; weight?: number; min?: number; max?: number }>
}
if (o && Array.isArray(o.dimensions) && o.dimensions.length > 0) {
scoringDimRows.value = o.dimensions.map((d) => ({
key: String(d.key ?? ''),
weight: Number(d.weight) || 0,
min: d.min != null ? Number(d.min) : 0,
max: d.max != null ? Number(d.max) : 100,
}))
return
}
} catch {
/* 结构不符合快捷表时保持默认一行 */
}
scoringDimRows.value = [emptyScoringDimRow()]
}
function addScoringDimRow() {
scoringDimRows.value = [...scoringDimRows.value, emptyScoringDimRow()]
}
function removeScoringDimRow(i: number) {
if (scoringDimRows.value.length <= 1) {
scoringDimRows.value = [emptyScoringDimRow()]
return
}
scoringDimRows.value = scoringDimRows.value.filter((_, idx) => idx !== i)
}
function buildScoringRulesFromSimpleDims(dims: ScoringDimRow[]): Record<string, unknown> {
const dimensions = dims
.filter((r) => r.key.trim())
.map((r) => ({
key: r.key.trim(),
weight: Number(r.weight) || 0,
min: Number(r.min) || 0,
max: Number(r.max) || 0,
}))
return {
version: 1,
source: 'custom',
dimensions,
line_total_formula: 'weighted_sum',
team_aggregate: {
sum: { on: 'line_total' },
average: { on: 'line_total', denominator: 'submitted_review_count' },
},
constraints: { min_reviewers_for_avg: 1 },
tie_break: 'submitted_at_asc',
}
}
function applySimpleScoringToJson() {
const withKey = scoringDimRows.value.filter((r) => r.key.trim())
if (!withKey.length) {
ElMessage.warning('请至少填写一行「维度 key」须与评审表 number 类字段的 name/key 一致)')
return
}
const obj = buildScoringRulesFromSimpleDims(scoringDimRows.value)
scoringJsonText.value = JSON.stringify(obj, null, 2)
scoringError.value = ''
}
async function loadDetail() {
if (isCreate()) {
competitionId.value = null
noContext.value = false
detailRow.value = null
brand.value = emptyBrandingForm()
form.value = {
slug: '',
name: '',
description: '',
status: 'draft',
published: false,
signup_open_at: null,
signup_close_at: null,
pledge_content_html: '',
}
return
}
const id = resolveCompetitionId()
if (!id) {
competitionId.value = null
detailRow.value = null
noContext.value = true
return
}
noContext.value = false
competitionId.value = id
competitionStore.selectCompetition(id)
try {
const row = await getCompetition(id)
detailRow.value = row
form.value = {
slug: row.slug,
name: row.name,
description: row.description ?? '',
status: row.status,
published: row.published,
signup_open_at: toElDateTimeModel(row.signup_open_at),
signup_close_at: toElDateTimeModel(row.signup_close_at),
pledge_content_html: row.pledge_content_html ?? '',
}
brand.value = brandingFormFromApi(row.branding_json)
loadScoringFromRow(row)
await refreshTracks()
if (tabKey.value === 'signupForm') await refreshSignupSchemas()
if (tabKey.value === 'review') await refreshReviewSchemas()
} catch (e) {
detailRow.value = null
ElMessage.error(e instanceof Error ? e.message : '加载赛事详情失败')
}
}
watch(
() => [route.fullPath, selectedCompetitionId.value] as const,
() => {
void loadDetail()
},
)
function buildPayload(): CompetitionPayload {
const branding_json = brandingJsonFromForm(brand.value) as CompetitionPayload['branding_json']
return {
slug: form.value.slug.trim(),
name: form.value.name.trim(),
description: form.value.description.trim() || null,
status: form.value.status,
published: form.value.published,
signup_open_at: fromDatetimePickerValue(form.value.signup_open_at),
signup_close_at: fromDatetimePickerValue(form.value.signup_close_at),
branding_json: branding_json ?? null,
pledge_content_html: form.value.pledge_content_html?.trim() ? form.value.pledge_content_html : null,
}
}
async function saveBasic() {
saving.value = true
try {
const payload = buildPayload()
if (!payload.slug || !payload.name) {
ElMessage.warning('请填写赛事名称和访问地址')
return
}
const creatingNew = route.name === 'admin-competition-new'
if (creatingNew) {
const row = await createCompetition(payload)
competitionId.value = row.id
competitionStore.selectCompetition(row.id)
await router.replace({ name: 'admin-competition-workspace' })
await loadDetail()
if (adminUseMock()) {
ElMessage.success(
'已创建(当前为 Mock 模式,浏览器不会发 HTTP联调请设 VITE_ADMIN_USE_MOCK=false 并重启 dev',
)
} else {
ElMessage.success('已创建,请在本页顶部标签切换「赛道 / 报名表」等模块继续配置')
}
return
}
if (competitionId.value) {
await updateCompetition(competitionId.value, payload)
ElMessage.success('已保存')
await loadDetail()
} else {
ElMessage.warning('无法保存:请先选择一场赛事,或从「新建赛事」进入后再试')
}
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : '保存失败')
} finally {
saving.value = false
}
}
function openTrackModal(row?: CompetitionTrackRow) {
if (!competitionId.value) {
ElMessage.warning('请先保存赛事基础信息')
return
}
trackEditingId.value = row?.id ?? null
trackForm.value = row
? {
track_code: row.track_code,
title: row.title,
description: row.description ?? '',
sort: row.sort,
is_enabled: row.is_enabled,
}
: {
track_code: '',
title: '',
description: '',
sort: (tracks.value[tracks.value.length - 1]?.sort ?? 0) + 10,
is_enabled: true,
}
trackDialogVisible.value = true
}
async function refreshTracks() {
const cid = competitionId.value
if (!cid) {
tracks.value = []
return
}
tracksLoading.value = true
try {
tracks.value = await listTracks(cid)
} finally {
tracksLoading.value = false
}
}
async function saveTrack() {
const cid = competitionId.value
if (!cid) return
const payload: CompetitionTrackPayload = {
track_code: trackForm.value.track_code.trim(),
title: trackForm.value.title.trim(),
description: trackForm.value.description.trim() || null,
sort: Number(trackForm.value.sort) || 0,
is_enabled: trackForm.value.is_enabled,
}
if (!payload.track_code || !payload.title) {
ElMessage.warning('请填写赛道编码与标题')
return
}
try {
if (trackEditingId.value) {
await updateTrack(cid, trackEditingId.value, payload)
} else {
await createTrack(cid, payload)
}
trackDialogVisible.value = false
await refreshTracks()
ElMessage.success('赛道已保存')
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : '保存赛道失败')
}
}
async function removeTrack(row: CompetitionTrackRow) {
const cid = competitionId.value
if (!cid) return
try {
await ElMessageBox.confirm(`确定删除赛道「${row.title}」?`, '确认删除', {
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
try {
await deleteTrack(cid, row.id)
await refreshTracks()
ElMessage.success('已删除')
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : '删除失败')
}
}
function previewSchemaLabels(fieldLabels: string[] | undefined, json: unknown): string {
if (fieldLabels?.length) return fieldLabels.join('、')
if (!Array.isArray(json)) return '—'
const parts = json
.map((item) =>
item !== null && typeof item === 'object' && 'label' in item
? String((item as { label?: string }).label || '')
: '',
)
.filter(Boolean)
return parts.length ? parts.join('、') : '—'
}
function openNewSchemaModal(purpose: 'signup' | 'review') {
if (!competitionId.value) {
ElMessage.warning('请先选择或创建赛事')
return
}
schemaModalPurpose.value = purpose
schemaNewName.value = purpose === 'signup' ? '报名表新版本' : '评审表新版本'
schemaDialogVisible.value = true
}
async function confirmNewSchema() {
const cid = competitionId.value
if (!cid) return
const name = schemaNewName.value.trim()
if (!name) {
ElMessage.warning('请填写版本名称')
return
}
try {
await formSchemaApi.createFormSchema(cid, {
purpose: schemaModalPurpose.value,
name,
schema_json: [],
is_published: false,
})
schemaDialogVisible.value = false
if (schemaModalPurpose.value === 'signup') await refreshSignupSchemas()
else await refreshReviewSchemas()
ElMessage.success('已创建表单版本')
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : '创建失败')
}
}
async function bindSignup(id: number) {
const cid = competitionId.value
if (!cid) return
try {
await patchCompetition(cid, { form_schema_id: id })
ElMessage.success('已设为当前报名表')
await loadDetail()
await refreshSignupSchemas()
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : '绑定失败')
}
}
async function bindReview(id: number) {
const cid = competitionId.value
if (!cid) return
try {
await patchCompetition(cid, { review_form_schema_id: id })
ElMessage.success('已设为当前评审表')
await loadDetail()
await refreshReviewSchemas()
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : '绑定失败')
}
}
async function togglePublish(row: FormSchemaRow, next: boolean) {
const cid = competitionId.value
if (!cid) return
try {
await formSchemaApi.updateFormSchema(cid, row.id, { is_published: next })
if (row.purpose === 'signup') await refreshSignupSchemas()
else await refreshReviewSchemas()
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : '更新失败')
}
}
async function openEditSchema(row: FormSchemaRow) {
const cid = competitionId.value
if (!cid) return
editSchemaError.value = ''
try {
const full = await formSchemaApi.getFormSchema(cid, row.id)
editSchemaId.value = full.id
editSchemaName.value = full.name
editSchemaPurpose.value = full.purpose
editSchemaVisualItems.value = schemaJsonToEditorItems(full.schema_json ?? [], full.purpose)
editSchemaJsonText.value = JSON.stringify(full.schema_json ?? [], null, 2)
editSchemaTab.value = 'visual'
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : '加载表单详情失败')
return
}
editSchemaDialogVisible.value = true
}
async function saveEditedSchema() {
const cid = competitionId.value
const sid = editSchemaId.value
if (!cid || !sid) return
editSchemaError.value = ''
let parsed: unknown[] = []
if (editSchemaTab.value === 'visual') {
const err = validateEditorItems(editSchemaVisualItems.value, editSchemaPurpose.value)
if (err) {
editSchemaError.value = err
return
}
parsed = editorItemsToSchemaJson(editSchemaVisualItems.value, editSchemaPurpose.value) as unknown[]
} else {
try {
const raw = JSON.parse(editSchemaJsonText.value)
if (!Array.isArray(raw)) {
editSchemaError.value = '表单定义须为 JSON 数组'
return
}
parsed = raw as unknown[]
} catch {
editSchemaError.value = 'JSON 格式不正确'
return
}
}
try {
await formSchemaApi.updateFormSchema(cid, sid, {
name: editSchemaName.value.trim() || undefined,
schema_json: parsed,
})
editSchemaDialogVisible.value = false
await refreshSignupSchemas()
await refreshReviewSchemas()
ElMessage.success('表单已保存')
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : '保存失败')
}
}
async function removeSchema(row: FormSchemaRow) {
const cid = competitionId.value
if (!cid) return
try {
await ElMessageBox.confirm(`确定删除表单版本「${row.name}」?`, '确认删除', {
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
try {
await formSchemaApi.deleteFormSchema(cid, row.id)
if (row.purpose === 'signup') await refreshSignupSchemas()
else await refreshReviewSchemas()
await loadDetail()
ElMessage.success('已删除')
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : '删除失败')
}
}
async function saveScoringRules() {
const cid = competitionId.value
if (!cid) return
scoringError.value = ''
let parsed: unknown = null
const t = scoringJsonText.value.trim()
if (t) {
try {
parsed = JSON.parse(t) as unknown
if (parsed !== null && typeof parsed !== 'object') {
scoringError.value = '计分规则须为 JSON 对象'
return
}
} catch {
scoringError.value = 'JSON 格式不正确'
return
}
}
try {
await patchCompetition(cid, { scoring_rules_json: parsed })
ElMessage.success('计分规则已保存(留空表示使用系统默认)')
await loadDetail()
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : '保存失败')
}
}
async function clearReviewBinding() {
const cid = competitionId.value
if (!cid) return
try {
await patchCompetition(cid, { review_form_schema_id: null })
ElMessage.success('已取消评审表绑定,将使用系统默认评审项')
await loadDetail()
await refreshReviewSchemas()
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : '操作失败')
}
}
onMounted(() => {
void loadDetail()
})
</script>
<template>
<div class="competition-workspace">
<div class="workspace-header">
<h2 class="workspace-title">{{ headline }}</h2>
<el-button class="workspace-back" @click="router.push({ name: 'admin-competitions-list' })">
返回赛事列表
</el-button>
</div>
<el-alert
v-if="noContext && !isCreate()"
title="请选择赛事"
type="warning"
:closable="false"
show-icon
class="workspace-alert"
>
请先在顶部<strong>赛事切换</strong>中选择一场赛事,或从「赛事列表」点击「进入赛事中心」。
</el-alert>
<el-tabs
v-else
v-model="tabKey"
type="border-card"
class="workspace-tabs"
:class="{ 'workspace-tabs--create': isCreate() }"
>
<el-tab-pane label="基础信息" name="basic">
<el-form label-position="top" class="workspace-form">
<el-row :gutter="20">
<el-col :xs="24" :md="12">
<el-form-item label="赛事名称">
<el-input v-model="form.name" autocomplete="off" />
</el-form-item>
</el-col>
<el-col :xs="24" :md="12">
<el-form-item label="访问地址">
<el-input v-model="form.slug" autocomplete="off" />
<p class="form-hint">用于选手端网址识别,可使用中文、数字、横线等,请勿含空格。</p>
<div class="login-links-stack">
<p v-for="(row, idx) in portalLinkRows" :key="idx" class="form-hint login-link-preview">
<span class="login-link-label">{{ row.label }}</span>
<a :href="row.href" target="_blank" rel="noopener noreferrer" class="login-link-url">{{
row.href
}}</a>
</p>
</div>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="描述">
<el-input v-model="form.description" type="textarea" :rows="3" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="参赛承诺书">
<p class="form-hint pledge-hint">
以下为选手端签署弹窗中的正文(支持富文本)。若留空,将使用系统内置默认条款。
</p>
<PledgeRichTextEditor v-model="form.pledge_content_html" :key="pledgeEditorKey" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="8">
<el-form-item label="状态">
<el-select v-model="form.status" class="w-100">
<el-option label="草稿" value="draft" />
<el-option label="已发布" value="published" />
<el-option label="报名进行中" value="signup_open" />
<el-option label="报名已截止" value="signup_closed" />
<el-option label="评审中" value="reviewing" />
<el-option label="已结束" value="ended" />
</el-select>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="8">
<el-form-item label="对外展示">
<el-checkbox v-model="form.published">在选手端展示</el-checkbox>
</el-form-item>
</el-col>
<el-col :xs="24" :md="12">
<el-form-item label="报名开始">
<el-date-picker
v-model="form.signup_open_at"
type="datetime"
placeholder="选择报名开始时间"
format="YYYY-MM-DD HH:mm"
value-format="YYYY-MM-DD HH:mm:ss"
class="w-100"
clearable
/>
</el-form-item>
</el-col>
<el-col :xs="24" :md="12">
<el-form-item label="报名截止">
<el-date-picker
v-model="form.signup_close_at"
type="datetime"
placeholder="选择报名截止时间"
format="YYYY-MM-DD HH:mm"
value-format="YYYY-MM-DD HH:mm:ss"
class="w-100"
clearable
/>
</el-form-item>
</el-col>
</el-row>
<el-form-item>
<el-button native-type="button" type="primary" :loading="saving" @click="saveBasic">
{{ saving ? '保存中…' : '保存基础信息' }}
</el-button>
</el-form-item>
</el-form>
</el-tab-pane>
<el-tab-pane v-if="!isCreate()" label="赛道管理" name="tracks" lazy>
<div v-loading="tracksLoading" class="tracks-pane">
<div class="pane-toolbar pane-toolbar--split">
<p class="form-hint pane-toolbar-tip">维护本场主题赛道。新建赛事请先保存基础信息。</p>
<el-button type="primary" @click="openTrackModal()">新增赛道</el-button>
</div>
<el-table :data="tracks" stripe border empty-text="暂无赛道" class="workspace-table">
<el-table-column prop="track_code" label="赛道编码" min-width="120" />
<el-table-column prop="title" label="标题" min-width="140" />
<el-table-column prop="description" label="简介" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
<span class="cell-muted">{{ row.description || '—' }}</span>
</template>
</el-table-column>
<el-table-column prop="sort" label="排序" width="80" />
<el-table-column label="启用" width="80">
<template #default="{ row }">{{ row.is_enabled ? '是' : '否' }}</template>
</el-table-column>
<el-table-column label="操作" width="140" fixed="right" align="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openTrackModal(row)">编辑</el-button>
<el-button link type="danger" size="small" @click="removeTrack(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-tab-pane>
<el-tab-pane v-if="!isCreate()" label="报名表配置" name="signupForm" lazy>
<p class="form-hint">
管理选手报名表字段定义JSON 数组)。可创建多个版本,设为当前报名表后选手端生效。当前绑定编号:<strong>{{
detailRow?.form_schema_id ?? '未绑定'
}}</strong>
</p>
<div class="pane-toolbar">
<el-button type="primary" :disabled="!competitionId" @click="openNewSchemaModal('signup')">
新建报名表版本
</el-button>
<el-button :loading="schemaBusy" @click="refreshSignupSchemas">刷新列表</el-button>
</div>
<el-table :data="signupSchemas" stripe border empty-text="暂无版本,请先新建" class="workspace-table">
<el-table-column prop="name" label="名称" min-width="120" />
<el-table-column prop="version" label="版本" width="80" />
<el-table-column label="发布" width="72">
<template #default="{ row }">{{ row.is_published ? '是' : '否' }}</template>
</el-table-column>
<el-table-column label="当前" width="88">
<template #default="{ row }">{{ row.is_current_signup ? '当前' : '—' }}</template>
</el-table-column>
<el-table-column label="字段预览" min-width="200">
<template #default="{ row }">
<span class="cell-muted">{{ previewSchemaLabels(row.field_labels, row.schema_json) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="260" fixed="right" align="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="bindSignup(row.id)">设为当前</el-button>
<el-button link type="primary" size="small" @click="openEditSchema(row)">编辑</el-button>
<el-button link size="small" @click="togglePublish(row, !row.is_published)">
{{ row.is_published ? '取消发布' : '发布' }}
</el-button>
<el-button link type="danger" size="small" @click="removeSchema(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane v-if="!isCreate()" label="评审与计分" name="review" lazy>
<div class="section-block">
<h3 class="section-title">评审打分表</h3>
<p class="form-hint">
配置评审端打分项(与报名表独立)。当前绑定编号:<strong>{{
detailRow?.review_form_schema_id ?? '未绑定(系统默认)'
}}</strong>
</p>
<div class="pane-toolbar">
<el-button type="primary" :disabled="!competitionId" @click="openNewSchemaModal('review')">
新建评审表版本
</el-button>
<el-button @click="clearReviewBinding">取消绑定(用默认)</el-button>
<el-button :loading="schemaBusy" @click="refreshReviewSchemas">刷新列表</el-button>
</div>
<el-table :data="reviewSchemas" stripe border empty-text="暂无版本" class="workspace-table workspace-table--mb">
<el-table-column prop="name" label="名称" min-width="120" />
<el-table-column prop="version" label="版本" width="80" />
<el-table-column label="发布" width="72">
<template #default="{ row }">{{ row.is_published ? '是' : '否' }}</template>
</el-table-column>
<el-table-column label="当前" width="88">
<template #default="{ row }">{{ row.is_current_review ? '当前' : '—' }}</template>
</el-table-column>
<el-table-column label="字段预览" min-width="200">
<template #default="{ row }">
<span class="cell-muted">{{ previewSchemaLabels(row.field_labels, row.schema_json) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="260" fixed="right" align="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="bindReview(row.id)">设为当前</el-button>
<el-button link type="primary" size="small" @click="openEditSchema(row)">编辑</el-button>
<el-button link size="small" @click="togglePublish(row, !row.is_published)">
{{ row.is_published ? '取消发布' : '发布' }}
</el-button>
<el-button link type="danger" size="small" @click="removeSchema(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="section-block">
<h3 class="section-title">计分规则</h3>
<p class="form-hint">
数据库中按 JSON 对象存储。留空并保存表示使用系统默认规则。多数情况只需填写各评分维度的 key 与权重,再点「生成 JSON」。
</p>
<p class="subsection-label">快捷配置(维度加权求和 → line_total</p>
<p class="form-hint">「维度 key」须与评审 Schema 里数字打分项的字段名一致。</p>
<el-table :data="scoringDimRows" border size="small" class="workspace-table workspace-table--mb">
<el-table-column label="维度 key" min-width="160">
<template #default="{ row }">
<el-input v-model="row.key" size="small" placeholder="如 dim_innovation" />
</template>
</el-table-column>
<el-table-column label="权重" width="120">
<template #default="{ row }">
<el-input-number v-model="row.weight" size="small" :controls="false" class="w-100" />
</template>
</el-table-column>
<el-table-column label="最低分" width="110">
<template #default="{ row }">
<el-input-number v-model="row.min" size="small" :controls="false" class="w-100" />
</template>
</el-table-column>
<el-table-column label="最高分" width="110">
<template #default="{ row }">
<el-input-number v-model="row.max" size="small" :controls="false" class="w-100" />
</template>
</el-table-column>
<el-table-column label="操作" width="88" align="right">
<template #default="{ $index }">
<el-button link type="danger" size="small" @click="removeScoringDimRow($index)">删行</el-button>
</template>
</el-table-column>
</el-table>
<div class="pane-toolbar pane-toolbar--wrap">
<el-button @click="addScoringDimRow">增加一行</el-button>
<el-button @click="syncScoringDimRowsFromJson">从下方 JSON 解析到表格</el-button>
<el-button type="primary" @click="applySimpleScoringToJson">根据表格生成 JSON</el-button>
</div>
<p class="subsection-label">高级:直接编辑完整规则 JSON</p>
<el-input v-model="scoringJsonText" type="textarea" :rows="8" class="json-area" spellcheck="false" />
<el-alert v-if="scoringError" type="error" :closable="false" class="mt-2" :description="scoringError" />
<el-button type="primary" class="mt-2" @click="saveScoringRules">保存计分规则</el-button>
</div>
</el-tab-pane>
<el-tab-pane v-if="!isCreate()" label="品牌与文案" name="brand" lazy>
<p class="form-hint">按页面分类填写对外展示文案。留空的项不会保存。</p>
<h3 class="section-title">整站</h3>
<el-form label-position="top" class="workspace-form">
<el-form-item label="浏览器标签标题">
<el-input v-model="brand.documentTitle" />
</el-form-item>
</el-form>
<h3 class="section-title">登录页</h3>
<el-form label-position="top" class="workspace-form">
<el-row :gutter="16">
<el-col :xs="24" :md="12">
<el-form-item label="品牌辅助行"><el-input v-model="brand.login.markLine" /></el-form-item>
</el-col>
<el-col :xs="24" :md="12">
<el-form-item label="主标题"><el-input v-model="brand.login.headline" /></el-form-item>
</el-col>
<el-col :xs="24" :md="12">
<el-form-item label="标语"><el-input v-model="brand.login.slogan" /></el-form-item>
</el-col>
<el-col :xs="24" :md="12">
<el-form-item label="登录卡片欢迎语"><el-input v-model="brand.login.cardWelcome" /></el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="页脚版权"><el-input v-model="brand.login.footerCopyright" /></el-form-item>
</el-col>
<el-col :xs="24" :md="12">
<el-form-item label="标志图网址">
<el-input v-model="brand.login.logoUrl" placeholder="图片网址,以 https 开头" />
</el-form-item>
</el-col>
<el-col :xs="24" :md="12">
<el-form-item label="网站图标网址">
<el-input v-model="brand.login.faviconUrl" placeholder="图标网址,以 https 开头" />
</el-form-item>
</el-col>
<el-col :xs="24" :md="12">
<el-form-item label="主题色(如 #b40010"><el-input v-model="brand.login.themePrimary" /></el-form-item>
</el-col>
</el-row>
</el-form>
<h3 class="section-title">报名填报页</h3>
<el-form label-position="top" class="workspace-form">
<el-row :gutter="16">
<el-col :xs="24" :md="12">
<el-form-item label="页面标题"><el-input v-model="brand.apply.pageTitle" /></el-form-item>
</el-col>
<el-col :xs="24" :md="12">
<el-form-item label="副标题(整页填报说明)">
<el-input v-model="brand.apply.headerSubtitle" placeholder="展示在页面标题下方,可选用" />
</el-form-item>
</el-col>
</el-row>
</el-form>
<h3 class="section-title">评审端</h3>
<el-form label-position="top" class="workspace-form">
<el-row :gutter="16">
<el-col :xs="24" :md="12">
<el-form-item label="页面标题"><el-input v-model="brand.review.pageTitle" /></el-form-item>
</el-col>
<el-col :xs="24" :md="12">
<el-form-item label="页脚版权"><el-input v-model="brand.review.footerCopyright" /></el-form-item>
</el-col>
</el-row>
</el-form>
<h3 class="section-title">运营后台</h3>
<el-form label-position="top" class="workspace-form">
<el-row :gutter="16">
<el-col :xs="24" :md="12">
<el-form-item label="登录页标题"><el-input v-model="brand.admin.loginTitle" /></el-form-item>
</el-col>
<el-col :xs="24" :md="12">
<el-form-item label="页脚版权"><el-input v-model="brand.admin.footerCopyright" /></el-form-item>
</el-col>
<el-col :xs="24" :md="12">
<el-form-item label="侧栏产品名称"><el-input v-model="brand.admin.sidebarProductName" /></el-form-item>
</el-col>
</el-row>
</el-form>
<el-button native-type="button" type="primary" class="mt-compact" :loading="saving" @click="saveBasic">保存(含品牌文案)</el-button>
</el-tab-pane>
</el-tabs>
<el-dialog
v-model="schemaDialogVisible"
:title="schemaModalPurpose === 'signup' ? '新建报名表版本' : '新建评审表版本'"
width="480px"
destroy-on-close
align-center
>
<el-form label-position="top">
<el-form-item label="版本名称">
<el-input v-model="schemaNewName" autocomplete="off" />
<p class="text-muted small mt-2 mb-0">将创建空字段列表,保存后可在「编辑」中配置。</p>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="schemaDialogVisible = false">取消</el-button>
<el-button type="primary" @click="confirmNewSchema">创建</el-button>
</template>
</el-dialog>
<el-dialog
v-model="editSchemaDialogVisible"
title="编辑表单定义"
width="min(960px, 96vw)"
top="4vh"
destroy-on-close
align-center
>
<el-form label-position="top">
<el-form-item label="版本名称">
<el-input v-model="editSchemaName" autocomplete="off" />
</el-form-item>
</el-form>
<el-tabs v-model="editSchemaTab">
<el-tab-pane label="可视化设计" name="visual">
<FormSchemaVisualEditor v-model="editSchemaVisualItems" :purpose="editSchemaPurpose" />
</el-tab-pane>
<el-tab-pane label="JSON 源码" name="json">
<p class="text-muted small mb-2">
适合粘贴备份或高级字段;切换回「可视化」时会尽量同步(仅识别常用属性)。
</p>
<el-input
v-model="editSchemaJsonText"
type="textarea"
:rows="16"
class="font-monospace"
spellcheck="false"
@blur="syncEditJsonToVisual"
/>
</el-tab-pane>
</el-tabs>
<p v-if="editSchemaError" class="text-danger small mt-2 mb-0">{{ editSchemaError }}</p>
<template #footer>
<el-button @click="editSchemaDialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveEditedSchema">保存</el-button>
</template>
</el-dialog>
<el-dialog
v-model="trackDialogVisible"
:title="trackEditingId ? '编辑赛道' : '新增赛道'"
width="520px"
destroy-on-close
align-center
>
<el-form label-position="top">
<el-form-item label="赛道编码">
<el-input v-model="trackForm.track_code" :disabled="Boolean(trackEditingId)" autocomplete="off" />
</el-form-item>
<el-form-item label="标题">
<el-input v-model="trackForm.title" autocomplete="off" />
</el-form-item>
<el-form-item label="简介">
<el-input v-model="trackForm.description" type="textarea" :rows="2" />
</el-form-item>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="排序">
<el-input-number v-model="trackForm.sort" :step="1" controls-position="right" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="状态">
<el-checkbox v-model="trackForm.is_enabled"></el-checkbox>
</el-form-item>
</el-col>
</el-row>
</el-form>
<template #footer>
<el-button @click="trackDialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveTrack"></el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.competition-workspace {
max-width: 1200px;
}
.workspace-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.workspace-title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.workspace-alert {
margin-bottom: 16px;
}
.workspace-tabs {
margin-top: 4px;
}
.workspace-tabs--create :deep(.el-tabs__header) {
display: none;
}
.workspace-tabs :deep(.el-tabs__content) {
padding: 16px;
background: var(--el-bg-color);
}
.workspace-form {
max-width: 960px;
}
.form-hint {
margin: 0 0 8px;
font-size: 13px;
line-height: 1.5;
color: var(--el-text-color-secondary);
}
.form-hint.pledge-hint {
margin-bottom: 10px;
}
.pane-toolbar {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 8px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.pane-toolbar--split {
justify-content: space-between;
}
.pane-toolbar--wrap {
flex-wrap: wrap;
}
.pane-toolbar-tip {
flex: 1;
margin: 0 12px 0 0;
min-width: 200px;
}
.workspace-back {
flex-shrink: 0;
}
.workspace-table {
width: 100%;
}
.workspace-table--mb {
margin-bottom: 16px;
}
.cell-muted {
color: var(--el-text-color-secondary);
font-size: 13px;
}
.section-block + .section-block {
margin-top: 24px;
padding-top: 8px;
border-top: 1px solid var(--el-border-color-lighter);
}
.section-title {
margin: 0 0 8px;
font-size: 15px;
font-weight: 600;
color: var(--el-text-color-regular);
}
.subsection-label {
margin: 12px 0 8px;
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-regular);
}
.json-area :deep(textarea) {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 13px;
}
.mt-compact {
margin-top: 16px;
}
.mt-2 {
margin-top: 8px;
}
.w-100 {
width: 100%;
}
.tracks-pane {
min-height: 120px;
}
.login-link-preview {
margin-top: 8px;
}
.login-links-stack {
margin-top: 10px;
padding: 10px 12px;
border-radius: var(--el-border-radius-base);
background: var(--el-fill-color-light);
border: 1px solid var(--el-border-color-lighter);
}
.login-links-stack .login-link-preview:first-of-type {
margin-top: 0;
}
.login-link-label {
display: block;
margin-bottom: 4px;
font-weight: 500;
color: var(--el-text-color-regular);
}
.login-link-url {
display: inline-block;
max-width: 100%;
word-break: break-all;
color: var(--el-color-primary);
text-decoration: none;
}
.login-link-url:hover {
text-decoration: underline;
}
</style>