diff --git a/src/api/admin/signupChannels.ts b/src/api/admin/signupChannels.ts new file mode 100644 index 0000000..54fb3c7 --- /dev/null +++ b/src/api/admin/signupChannels.ts @@ -0,0 +1,59 @@ +import { adminUseMock } from '../../config/api' +import { adminHttp } from './http' +import type { SignupChannelPayload, SignupChannelRow } from './types' + +function requireRealApi(): void { + if (adminUseMock()) { + throw new Error('渠道管理不支持管理端 Mock,请设置 VITE_ADMIN_USE_MOCK=false 并连接真实 API') + } +} + +function unwrapRow(data: unknown): SignupChannelRow { + const row = (data as { data?: SignupChannelRow })?.data ?? (data as SignupChannelRow) + if (!row || typeof row !== 'object' || !('id' in row)) throw new Error('渠道响应格式无效') + return row +} + +export async function listSignupChannels(competitionId: number): Promise { + requireRealApi() + const { data } = await adminHttp.get(`/competitions/${competitionId}/signup-channels`) + const rows = (data as { data?: SignupChannelRow[] })?.data ?? (data as SignupChannelRow[]) + if (!Array.isArray(rows)) throw new Error('渠道列表响应格式无效') + return rows +} + +export async function getSignupChannel( + competitionId: number, + channelId: number, +): Promise { + requireRealApi() + const { data } = await adminHttp.get( + `/competitions/${competitionId}/signup-channels/${channelId}`, + ) + return unwrapRow(data) +} + +export async function createSignupChannel( + competitionId: number, + payload: SignupChannelPayload, +): Promise { + requireRealApi() + const { data } = await adminHttp.post( + `/competitions/${competitionId}/signup-channels`, + payload, + ) + return unwrapRow(data) +} + +export async function updateSignupChannel( + competitionId: number, + channelId: number, + payload: Partial, +): Promise { + requireRealApi() + const { data } = await adminHttp.put( + `/competitions/${competitionId}/signup-channels/${channelId}`, + payload, + ) + return unwrapRow(data) +} diff --git a/src/api/admin/types.ts b/src/api/admin/types.ts index 208625d..d31cad6 100644 --- a/src/api/admin/types.ts +++ b/src/api/admin/types.ts @@ -89,6 +89,32 @@ export interface CompetitionTrackPayload { is_enabled: boolean } +export type SignupChannelStatus = 'enabled' | 'disabled' + +/** 赛事报名渠道配置,channel_code 由后端创建时自动生成 */ +export interface SignupChannelRow { + id: number + competition_id: number + competition_name?: string + channel_code: string + channel_name: string + status: SignupChannelStatus + /** 列表接口隐藏密钥,详情接口才返回 */ + shared_secret?: string + success_callback_url: string + remark: string | null + created_at?: string + updated_at?: string +} + +export interface SignupChannelPayload { + channel_name: string + status: SignupChannelStatus + shared_secret: string + success_callback_url: string + remark: string | null +} + /** 评审员(评审端账号,后台维护) */ export interface ReviewerRow { id: number diff --git a/src/views/ApplyFormView.vue b/src/views/ApplyFormView.vue index ef6d2da..3b3d6a9 100644 --- a/src/views/ApplyFormView.vue +++ b/src/views/ApplyFormView.vue @@ -1096,9 +1096,14 @@ async function submitApplicationToServer() { showNotice(submitFailureUserMessage(r.status, body), '提交失败', 'warning') return false } - const d = (await r.json()) as Parameters[0] + const d = (await r.json()) as Parameters[0] & { + channel_callback?: { redirect_url?: unknown } | null + } applyServerPayload(d) - return true + const redirectUrl = d.channel_callback?.redirect_url + return { + redirectUrl: typeof redirectUrl === 'string' ? redirectUrl.trim() : '', + } } async function onSaveClick() { @@ -1118,8 +1123,12 @@ async function onSubmitClick() { return } if (!validateForm()) return - const ok = await submitApplicationToServer() - if (!ok) return + const result = await submitApplicationToServer() + if (!result) return + if (result.redirectUrl) { + window.location.href = result.redirectUrl + return + } showNotice('已提交报名', '提交成功', 'success') } diff --git a/src/views/admin/competition/CompetitionFormView.vue b/src/views/admin/competition/CompetitionFormView.vue index c6398e0..3b7568e 100644 --- a/src/views/admin/competition/CompetitionFormView.vue +++ b/src/views/admin/competition/CompetitionFormView.vue @@ -14,12 +14,20 @@ import { updateTrack, } from '../../../api/admin/competitions' import * as formSchemaApi from '../../../api/admin/formSchemas' +import { + createSignupChannel, + getSignupChannel, + listSignupChannels, + updateSignupChannel, +} from '../../../api/admin/signupChannels' import type { CompetitionPayload, CompetitionRow, CompetitionTrackPayload, CompetitionTrackRow, FormSchemaRow, + SignupChannelPayload, + SignupChannelRow, } from '../../../api/admin/types' import { brandingFormFromApi, @@ -39,11 +47,12 @@ import { type FormSchemaPurpose, } from '../../../utils/formSchemaEditor' -type TabKey = 'basic' | 'tracks' | 'signupForm' | 'review' | 'brand' +type TabKey = 'basic' | 'tracks' | 'channels' | 'signupForm' | 'review' | 'brand' const TAB_ITEMS: { tab: TabKey; label: string }[] = [ { tab: 'basic', label: '基础信息' }, { tab: 'tracks', label: '赛道管理' }, + { tab: 'channels', label: '渠道管理' }, { tab: 'signupForm', label: '报名表配置' }, { tab: 'review', label: '评审与计分' }, { tab: 'brand', label: '品牌与文案' }, @@ -74,6 +83,8 @@ const form = ref({ const brand = ref(emptyBrandingForm()) const tracks = ref([]) const tracksLoading = ref(false) +const channels = ref([]) +const channelsLoading = ref(false) const trackDialogVisible = ref(false) const trackEditingId = ref(null) @@ -85,6 +96,17 @@ const trackForm = ref({ is_enabled: true, }) +const channelDialogVisible = ref(false) +const channelEditingId = ref(null) +const channelSaving = ref(false) +const channelForm = ref({ + channel_name: '', + status: 'enabled', + shared_secret: '', + success_callback_url: '', + remark: null, +}) + const saving = ref(false) const tabKey = ref('basic') @@ -199,6 +221,7 @@ watch( watch(tabKey, (t) => { const cid = competitionId.value if (!cid) return + if (t === 'channels' && !adminUseMock()) void refreshChannels() if (t === 'signupForm') void refreshSignupSchemas() if (t === 'review') { void refreshReviewSchemas() @@ -382,6 +405,7 @@ async function loadDetail() { brand.value = brandingFormFromApi(row.branding_json) loadScoringFromRow(row) await refreshTracks() + if (tabKey.value === 'channels') await refreshChannels() if (tabKey.value === 'signupForm') await refreshSignupSchemas() if (tabKey.value === 'review') await refreshReviewSchemas() } catch (e) { @@ -518,6 +542,104 @@ async function saveTrack() { } } +async function refreshChannels() { + const cid = competitionId.value + if (!cid) { + channels.value = [] + return + } + channelsLoading.value = true + try { + channels.value = await listSignupChannels(cid) + } catch (e) { + channels.value = [] + ElMessage.error(e instanceof Error ? e.message : '加载渠道列表失败') + } finally { + channelsLoading.value = false + } +} + +async function openChannelModal(row?: SignupChannelRow) { + if (!competitionId.value) { + ElMessage.warning('请先保存赛事基础信息') + return + } + if (adminUseMock()) { + ElMessage.warning('渠道管理需关闭管理端 Mock 并连接真实 API') + return + } + let detail = row + if (row) { + try { + detail = await getSignupChannel(competitionId.value, row.id) + } catch (e) { + ElMessage.error(e instanceof Error ? e.message : '加载渠道详情失败') + return + } + } + channelEditingId.value = detail?.id ?? null + channelForm.value = detail + ? { + channel_name: detail.channel_name, + status: detail.status, + shared_secret: detail.shared_secret ?? '', + success_callback_url: detail.success_callback_url, + remark: detail.remark, + } + : { + channel_name: '', + status: 'enabled', + shared_secret: '', + success_callback_url: '', + remark: null, + } + channelDialogVisible.value = true +} + +async function saveChannel() { + const cid = competitionId.value + if (!cid) return + const payload: SignupChannelPayload = { + channel_name: channelForm.value.channel_name.trim(), + status: channelForm.value.status, + shared_secret: channelForm.value.shared_secret.trim(), + 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('请填写渠道名称、共享密钥与成功回跳地址') + return + } + channelSaving.value = true + try { + if (channelEditingId.value) { + await updateSignupChannel(cid, channelEditingId.value, payload) + } else { + await createSignupChannel(cid, payload) + } + channelDialogVisible.value = false + await refreshChannels() + ElMessage.success('渠道已保存') + } catch (e) { + ElMessage.error(e instanceof Error ? e.message : '保存渠道失败') + } finally { + channelSaving.value = false + } +} + +async function toggleChannelStatus(row: SignupChannelRow) { + const cid = competitionId.value + if (!cid) return + const nextStatus = row.status === 'enabled' ? 'disabled' : 'enabled' + try { + await updateSignupChannel(cid, row.id, { status: nextStatus }) + await refreshChannels() + ElMessage.success(nextStatus === 'enabled' ? '渠道已启用' : '渠道已停用') + } catch (e) { + ElMessage.error(e instanceof Error ? e.message : '更新渠道状态失败') + } +} + async function removeTrack(row: CompetitionTrackRow) { const cid = competitionId.value if (!cid) return @@ -893,6 +1015,65 @@ onMounted(() => { + +
+ +
+

+ 渠道编码由系统自动生成。共享密钥用于渠道跳转验签,请与渠道方安全同步。 +

+
+ + 刷新列表 + + + 新增渠道 + +
+
+ + + + + + + + + + + + + + + + +
+
+

管理选手报名表字段定义(JSON 数组)。可创建多个版本,设为当前报名表后选手端生效。当前绑定编号:{{ @@ -1166,6 +1347,45 @@ onMounted(() => { + + + + + + + + + + + + + + + + + + + + 启用 + 停用 + + + + + + { min-height: 120px; } +.channels-pane { + min-height: 120px; +} + .login-link-preview { margin-top: 8px; }