weizong song 1 week ago
parent 3d37a1b276
commit 40411e614c

@ -31,6 +31,11 @@ export interface CompetitionRow {
updated_at?: string
}
export interface CompetitionSuccessNoticeSettings {
enabled: boolean
message: string
}
export interface CompetitionPayload {
slug: string
name: string
@ -41,6 +46,7 @@ export interface CompetitionPayload {
signup_close_at: string | null
branding_json?: unknown | null
pledge_content_html?: string | null
settings?: unknown | null
form_schema_id?: number | null
review_form_schema_id?: number | null
scoring_rules_json?: unknown | null
@ -101,6 +107,11 @@ export interface SignupChannelRow {
status: SignupChannelStatus
/** 列表接口隐藏密钥,详情接口才返回 */
shared_secret?: string
/** 详情接口返回,供渠道方加密报名数据 */
encryption_public_key?: string | null
/** 详情接口返回,标识公钥对应的加密算法 */
encryption_algorithm?: string | null
entry_encryption_enabled: boolean
success_callback_url: string
remark: string | null
created_at?: string
@ -111,6 +122,7 @@ export interface SignupChannelPayload {
channel_name: string
status: SignupChannelStatus
shared_secret: string
entry_encryption_enabled: boolean
success_callback_url: string
remark: string | null
}

@ -386,13 +386,20 @@ const noticeTitle = ref('提示')
const noticeBody = ref('')
const noticeIsSuccess = ref(false)
function showNotice(message: string, title = '提示', type: 'success' | 'warning' = 'warning') {
function showNotice(message: string, title = '提示', type: 'success' | 'warning' = 'warning'): Promise<void> {
noticeTitle.value = title
noticeBody.value = message
noticeIsSuccess.value = type === 'success'
void nextTick(() => {
const el = noticeModalEl.value
if (el) Modal.getOrCreateInstance(el).show()
return new Promise((resolve) => {
void nextTick(() => {
const el = noticeModalEl.value
if (!el) {
resolve()
return
}
el.addEventListener('hidden.bs.modal', () => resolve(), { once: true })
Modal.getOrCreateInstance(el).show()
})
})
}
@ -1098,11 +1105,18 @@ async function submitApplicationToServer() {
}
const d = (await r.json()) as Parameters<typeof applyServerPayload>[0] & {
channel_callback?: { redirect_url?: unknown } | null
success_notice?: { enabled?: unknown; message?: unknown } | null
}
applyServerPayload(d)
const redirectUrl = d.channel_callback?.redirect_url
const successNotice = d.success_notice
const successNoticeMessage =
successNotice?.enabled === true && typeof successNotice.message === 'string'
? successNotice.message.trim()
: ''
return {
redirectUrl: typeof redirectUrl === 'string' ? redirectUrl.trim() : '',
successNoticeMessage,
}
}
@ -1125,11 +1139,13 @@ async function onSubmitClick() {
if (!validateForm()) return
const result = await submitApplicationToServer()
if (!result) return
const successMessage = result.successNoticeMessage || '已提交报名'
if (result.redirectUrl) {
await showNotice(successMessage, '提交成功', 'success')
window.location.href = result.redirectUrl
return
}
showNotice('已提交报名', '提交成功', 'success')
showNotice(successMessage, '提交成功', 'success')
}
onBeforeUnmount(() => {

@ -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;
}

Loading…
Cancel
Save