|
|
|
|
@ -22,6 +22,7 @@ import {
|
|
|
|
|
} from '../../../api/admin/signupChannels'
|
|
|
|
|
import type {
|
|
|
|
|
CompetitionPayload,
|
|
|
|
|
CompetitionSuccessNoticeSettings,
|
|
|
|
|
CompetitionRow,
|
|
|
|
|
CompetitionTrackPayload,
|
|
|
|
|
CompetitionTrackRow,
|
|
|
|
|
@ -78,9 +79,12 @@ const form = ref({
|
|
|
|
|
signup_open_at: null as string | null,
|
|
|
|
|
signup_close_at: null as string | null,
|
|
|
|
|
pledge_content_html: '',
|
|
|
|
|
success_notice_enabled: false,
|
|
|
|
|
success_notice_message: '',
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const brand = ref<BrandingForm>(emptyBrandingForm())
|
|
|
|
|
const rawSettings = ref<Record<string, unknown>>({})
|
|
|
|
|
const tracks = ref<CompetitionTrackRow[]>([])
|
|
|
|
|
const tracksLoading = ref(false)
|
|
|
|
|
const channels = ref<SignupChannelRow[]>([])
|
|
|
|
|
@ -99,10 +103,12 @@ const trackForm = ref({
|
|
|
|
|
const channelDialogVisible = ref(false)
|
|
|
|
|
const channelEditingId = ref<number | null>(null)
|
|
|
|
|
const channelSaving = ref(false)
|
|
|
|
|
const channelDetail = ref<SignupChannelRow | null>(null)
|
|
|
|
|
const channelForm = ref<SignupChannelPayload>({
|
|
|
|
|
channel_name: '',
|
|
|
|
|
status: 'enabled',
|
|
|
|
|
shared_secret: '',
|
|
|
|
|
entry_encryption_enabled: false,
|
|
|
|
|
success_callback_url: '',
|
|
|
|
|
remark: null,
|
|
|
|
|
})
|
|
|
|
|
@ -147,6 +153,33 @@ const headline = computed(() => {
|
|
|
|
|
/** 切换赛事或新建时重建富文本编辑器,避免内容与实例不同步 */
|
|
|
|
|
const pledgeEditorKey = computed(() => String(competitionId.value ?? 'new'))
|
|
|
|
|
|
|
|
|
|
function successNoticeFromSettings(settings: unknown): CompetitionSuccessNoticeSettings {
|
|
|
|
|
const raw =
|
|
|
|
|
settings && typeof settings === 'object' && !Array.isArray(settings)
|
|
|
|
|
? (settings as Record<string, unknown>).success_notice
|
|
|
|
|
: null
|
|
|
|
|
const notice =
|
|
|
|
|
raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record<string, unknown>) : {}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
enabled: notice.enabled === true,
|
|
|
|
|
message: typeof notice.message === 'string' ? notice.message : '',
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function settingsWithSuccessNotice(): Record<string, unknown> | null {
|
|
|
|
|
const settings = { ...rawSettings.value }
|
|
|
|
|
const enabled = form.value.success_notice_enabled
|
|
|
|
|
const message = form.value.success_notice_message.trim()
|
|
|
|
|
if (enabled && message) {
|
|
|
|
|
settings.success_notice = { enabled: true, message }
|
|
|
|
|
} else {
|
|
|
|
|
delete settings.success_notice
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Object.keys(settings).length > 0 ? settings : null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 当前站点下的绝对 URL(与 `createWebHistory(import.meta.env.BASE_URL)` 一致) */
|
|
|
|
|
function absoluteUrlFromPath(path: string): string {
|
|
|
|
|
const origin = typeof window !== 'undefined' ? window.location.origin : ''
|
|
|
|
|
@ -375,7 +408,10 @@ async function loadDetail() {
|
|
|
|
|
signup_open_at: null,
|
|
|
|
|
signup_close_at: null,
|
|
|
|
|
pledge_content_html: '',
|
|
|
|
|
success_notice_enabled: false,
|
|
|
|
|
success_notice_message: '',
|
|
|
|
|
}
|
|
|
|
|
rawSettings.value = {}
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
const id = resolveCompetitionId()
|
|
|
|
|
@ -401,7 +437,13 @@ async function loadDetail() {
|
|
|
|
|
signup_open_at: toElDateTimeModel(row.signup_open_at),
|
|
|
|
|
signup_close_at: toElDateTimeModel(row.signup_close_at),
|
|
|
|
|
pledge_content_html: row.pledge_content_html ?? '',
|
|
|
|
|
success_notice_enabled: successNoticeFromSettings(row.settings).enabled,
|
|
|
|
|
success_notice_message: successNoticeFromSettings(row.settings).message,
|
|
|
|
|
}
|
|
|
|
|
rawSettings.value =
|
|
|
|
|
row.settings && typeof row.settings === 'object' && !Array.isArray(row.settings)
|
|
|
|
|
? { ...(row.settings as Record<string, unknown>) }
|
|
|
|
|
: {}
|
|
|
|
|
brand.value = brandingFormFromApi(row.branding_json)
|
|
|
|
|
loadScoringFromRow(row)
|
|
|
|
|
await refreshTracks()
|
|
|
|
|
@ -433,6 +475,7 @@ function buildPayload(): CompetitionPayload {
|
|
|
|
|
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,
|
|
|
|
|
settings: settingsWithSuccessNotice(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@ -444,6 +487,10 @@ async function saveBasic() {
|
|
|
|
|
ElMessage.warning('请填写赛事名称和访问地址')
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if (form.value.success_notice_enabled && !form.value.success_notice_message.trim()) {
|
|
|
|
|
ElMessage.warning('请填写报名成功提示语,或关闭该提示')
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const creatingNew = route.name === 'admin-competition-new'
|
|
|
|
|
if (creatingNew) {
|
|
|
|
|
@ -578,11 +625,13 @@ async function openChannelModal(row?: SignupChannelRow) {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
channelEditingId.value = detail?.id ?? null
|
|
|
|
|
channelDetail.value = detail ?? null
|
|
|
|
|
channelForm.value = detail
|
|
|
|
|
? {
|
|
|
|
|
channel_name: detail.channel_name,
|
|
|
|
|
status: detail.status,
|
|
|
|
|
shared_secret: detail.shared_secret ?? '',
|
|
|
|
|
entry_encryption_enabled: detail.entry_encryption_enabled ?? false,
|
|
|
|
|
success_callback_url: detail.success_callback_url,
|
|
|
|
|
remark: detail.remark,
|
|
|
|
|
}
|
|
|
|
|
@ -590,12 +639,37 @@ async function openChannelModal(row?: SignupChannelRow) {
|
|
|
|
|
channel_name: '',
|
|
|
|
|
status: 'enabled',
|
|
|
|
|
shared_secret: '',
|
|
|
|
|
entry_encryption_enabled: false,
|
|
|
|
|
success_callback_url: '',
|
|
|
|
|
remark: null,
|
|
|
|
|
}
|
|
|
|
|
channelDialogVisible.value = true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function copyChannelEncryptionPublicKey() {
|
|
|
|
|
const publicKey = channelDetail.value?.encryption_public_key
|
|
|
|
|
if (!publicKey) return
|
|
|
|
|
try {
|
|
|
|
|
await navigator.clipboard.writeText(publicKey)
|
|
|
|
|
ElMessage.success('加密公钥已复制')
|
|
|
|
|
} catch {
|
|
|
|
|
ElMessage.error('复制失败,请手动复制')
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function generateChannelSecret() {
|
|
|
|
|
const bytes = new Uint8Array(32)
|
|
|
|
|
if (!globalThis.crypto?.getRandomValues) {
|
|
|
|
|
ElMessage.error('当前浏览器不支持安全随机数,请更换浏览器后重试')
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
globalThis.crypto.getRandomValues(bytes)
|
|
|
|
|
channelForm.value.shared_secret = btoa(String.fromCharCode(...bytes))
|
|
|
|
|
.replace(/\+/g, '-')
|
|
|
|
|
.replace(/\//g, '_')
|
|
|
|
|
.replace(/=+$/g, '')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function saveChannel() {
|
|
|
|
|
const cid = competitionId.value
|
|
|
|
|
if (!cid) return
|
|
|
|
|
@ -603,11 +677,12 @@ async function saveChannel() {
|
|
|
|
|
channel_name: channelForm.value.channel_name.trim(),
|
|
|
|
|
status: channelForm.value.status,
|
|
|
|
|
shared_secret: channelForm.value.shared_secret.trim(),
|
|
|
|
|
entry_encryption_enabled: channelForm.value.entry_encryption_enabled,
|
|
|
|
|
success_callback_url: channelForm.value.success_callback_url.trim(),
|
|
|
|
|
remark: channelForm.value.remark?.trim() || null,
|
|
|
|
|
}
|
|
|
|
|
if (!payload.channel_name || !payload.shared_secret || !payload.success_callback_url) {
|
|
|
|
|
ElMessage.warning('请填写渠道名称、共享密钥与成功回跳地址')
|
|
|
|
|
if (!payload.channel_name || !payload.shared_secret) {
|
|
|
|
|
ElMessage.warning('请填写渠道名称与共享密钥')
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
channelSaving.value = true
|
|
|
|
|
@ -935,6 +1010,27 @@ onMounted(() => {
|
|
|
|
|
<PledgeRichTextEditor v-model="form.pledge_content_html" :key="pledgeEditorKey" />
|
|
|
|
|
</el-form-item>
|
|
|
|
|
</el-col>
|
|
|
|
|
<el-col :span="24">
|
|
|
|
|
<el-form-item label="报名成功提示">
|
|
|
|
|
<el-switch
|
|
|
|
|
v-model="form.success_notice_enabled"
|
|
|
|
|
active-text="开启"
|
|
|
|
|
inactive-text="关闭"
|
|
|
|
|
/>
|
|
|
|
|
<p class="form-hint mt-2">
|
|
|
|
|
开启后,选手提交报名成功会看到自定义提示;关闭时保持系统默认提示。
|
|
|
|
|
</p>
|
|
|
|
|
<el-input
|
|
|
|
|
v-if="form.success_notice_enabled"
|
|
|
|
|
v-model="form.success_notice_message"
|
|
|
|
|
type="textarea"
|
|
|
|
|
:rows="4"
|
|
|
|
|
maxlength="1000"
|
|
|
|
|
show-word-limit
|
|
|
|
|
placeholder="请输入报名成功后展示给选手的提示语"
|
|
|
|
|
/>
|
|
|
|
|
</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">
|
|
|
|
|
@ -1365,10 +1461,43 @@ onMounted(() => {
|
|
|
|
|
<el-input v-model="channelForm.channel_name" autocomplete="off" />
|
|
|
|
|
</el-form-item>
|
|
|
|
|
<el-form-item label="共享密钥">
|
|
|
|
|
<el-input v-model="channelForm.shared_secret" autocomplete="off" />
|
|
|
|
|
<el-input v-model="channelForm.shared_secret" autocomplete="off">
|
|
|
|
|
<template #append>
|
|
|
|
|
<el-button @click="generateChannelSecret">生成</el-button>
|
|
|
|
|
</template>
|
|
|
|
|
</el-input>
|
|
|
|
|
</el-form-item>
|
|
|
|
|
<template v-if="channelEditingId">
|
|
|
|
|
<el-form-item label="加密算法">
|
|
|
|
|
<el-input :model-value="channelDetail?.encryption_algorithm || '—'" readonly />
|
|
|
|
|
</el-form-item>
|
|
|
|
|
<el-form-item label="入口敏感字段加密">
|
|
|
|
|
<el-switch
|
|
|
|
|
v-model="channelForm.entry_encryption_enabled"
|
|
|
|
|
active-text="启用"
|
|
|
|
|
inactive-text="关闭"
|
|
|
|
|
/>
|
|
|
|
|
</el-form-item>
|
|
|
|
|
<el-form-item label="加密公钥(PEM)">
|
|
|
|
|
<el-input
|
|
|
|
|
:model-value="channelDetail?.encryption_public_key || ''"
|
|
|
|
|
type="textarea"
|
|
|
|
|
:rows="8"
|
|
|
|
|
readonly
|
|
|
|
|
placeholder="暂无加密公钥"
|
|
|
|
|
class="channel-public-key"
|
|
|
|
|
/>
|
|
|
|
|
<el-button
|
|
|
|
|
class="channel-public-key-copy"
|
|
|
|
|
:disabled="!channelDetail?.encryption_public_key"
|
|
|
|
|
@click="copyChannelEncryptionPublicKey"
|
|
|
|
|
>
|
|
|
|
|
复制公钥
|
|
|
|
|
</el-button>
|
|
|
|
|
</el-form-item>
|
|
|
|
|
</template>
|
|
|
|
|
<el-form-item label="成功回跳地址">
|
|
|
|
|
<el-input v-model="channelForm.success_callback_url" autocomplete="off" placeholder="https://example.com/..." />
|
|
|
|
|
<el-input v-model="channelForm.success_callback_url" autocomplete="off" placeholder="可留空;填写时请输入 https://example.com/..." />
|
|
|
|
|
</el-form-item>
|
|
|
|
|
<el-form-item label="备注">
|
|
|
|
|
<el-input v-model="channelForm.remark" type="textarea" :rows="3" />
|
|
|
|
|
@ -1562,6 +1691,15 @@ onMounted(() => {
|
|
|
|
|
min-height: 120px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.channel-public-key :deep(textarea) {
|
|
|
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.channel-public-key-copy {
|
|
|
|
|
margin-top: 8px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.login-link-preview {
|
|
|
|
|
margin-top: 8px;
|
|
|
|
|
}
|
|
|
|
|
|