|
|
<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>
|