From 40411e614cb9646e1bce71c3a2998188e0faad57 Mon Sep 17 00:00:00 2001 From: weizong song Date: Fri, 5 Jun 2026 08:48:17 +0800 Subject: [PATCH] up --- src/api/admin/types.ts | 12 ++ src/views/ApplyFormView.vue | 26 +++- .../admin/competition/CompetitionFormView.vue | 146 +++++++++++++++++- 3 files changed, 175 insertions(+), 9 deletions(-) diff --git a/src/api/admin/types.ts b/src/api/admin/types.ts index d31cad6..28e7a82 100644 --- a/src/api/admin/types.ts +++ b/src/api/admin/types.ts @@ -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 } diff --git a/src/views/ApplyFormView.vue b/src/views/ApplyFormView.vue index 3b3d6a9..9e137ae 100644 --- a/src/views/ApplyFormView.vue +++ b/src/views/ApplyFormView.vue @@ -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 { 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[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(() => { diff --git a/src/views/admin/competition/CompetitionFormView.vue b/src/views/admin/competition/CompetitionFormView.vue index 3b7568e..3a087c2 100644 --- a/src/views/admin/competition/CompetitionFormView.vue +++ b/src/views/admin/competition/CompetitionFormView.vue @@ -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(emptyBrandingForm()) +const rawSettings = ref>({}) const tracks = ref([]) const tracksLoading = ref(false) const channels = ref([]) @@ -99,10 +103,12 @@ const trackForm = ref({ const channelDialogVisible = ref(false) const channelEditingId = ref(null) const channelSaving = ref(false) +const channelDetail = ref(null) const channelForm = ref({ 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).success_notice + : null + const notice = + raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record) : {} + + return { + enabled: notice.enabled === true, + message: typeof notice.message === 'string' ? notice.message : '', + } +} + +function settingsWithSuccessNotice(): Record | 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) } + : {} 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(() => { + + + +

+ 开启后,选手提交报名成功会看到自定义提示;关闭时保持系统默认提示。 +

+ +
+
@@ -1365,10 +1461,43 @@ onMounted(() => { - + + + + - + @@ -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; }