增加渠道跳转报名功能

main
weizong song 2 weeks ago
parent 7737c5d439
commit 3d37a1b276

@ -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<SignupChannelRow[]> {
requireRealApi()
const { data } = await adminHttp.get<unknown>(`/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<SignupChannelRow> {
requireRealApi()
const { data } = await adminHttp.get<unknown>(
`/competitions/${competitionId}/signup-channels/${channelId}`,
)
return unwrapRow(data)
}
export async function createSignupChannel(
competitionId: number,
payload: SignupChannelPayload,
): Promise<SignupChannelRow> {
requireRealApi()
const { data } = await adminHttp.post<unknown>(
`/competitions/${competitionId}/signup-channels`,
payload,
)
return unwrapRow(data)
}
export async function updateSignupChannel(
competitionId: number,
channelId: number,
payload: Partial<SignupChannelPayload>,
): Promise<SignupChannelRow> {
requireRealApi()
const { data } = await adminHttp.put<unknown>(
`/competitions/${competitionId}/signup-channels/${channelId}`,
payload,
)
return unwrapRow(data)
}

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

@ -1096,9 +1096,14 @@ async function submitApplicationToServer() {
showNotice(submitFailureUserMessage(r.status, body), '提交失败', 'warning')
return false
}
const d = (await r.json()) as Parameters<typeof applyServerPayload>[0]
const d = (await r.json()) as Parameters<typeof applyServerPayload>[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')
}

@ -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<BrandingForm>(emptyBrandingForm())
const tracks = ref<CompetitionTrackRow[]>([])
const tracksLoading = ref(false)
const channels = ref<SignupChannelRow[]>([])
const channelsLoading = ref(false)
const trackDialogVisible = ref(false)
const trackEditingId = ref<number | null>(null)
@ -85,6 +96,17 @@ const trackForm = ref({
is_enabled: true,
})
const channelDialogVisible = ref(false)
const channelEditingId = ref<number | null>(null)
const channelSaving = ref(false)
const channelForm = ref<SignupChannelPayload>({
channel_name: '',
status: 'enabled',
shared_secret: '',
success_callback_url: '',
remark: null,
})
const saving = ref(false)
const tabKey = ref<TabKey>('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(() => {
</div>
</el-tab-pane>
<el-tab-pane v-if="!isCreate()" label="渠道管理" name="channels" lazy>
<div v-loading="channelsLoading" class="channels-pane">
<el-alert
v-if="adminUseMock()"
title="渠道管理需连接真实 API"
description="当前已开启管理端 Mock。请设置 VITE_ADMIN_USE_MOCK=false 并重启开发服务后再管理渠道。"
type="warning"
:closable="false"
show-icon
class="workspace-alert"
/>
<div class="pane-toolbar pane-toolbar--split">
<p class="form-hint pane-toolbar-tip">
渠道编码由系统自动生成共享密钥用于渠道跳转验签请与渠道方安全同步
</p>
<div>
<el-button :loading="channelsLoading" :disabled="adminUseMock()" @click="refreshChannels">
刷新列表
</el-button>
<el-button type="primary" :disabled="adminUseMock()" @click="openChannelModal()">
新增渠道
</el-button>
</div>
</div>
<el-table :data="channels" stripe border empty-text="" class="workspace-table">
<el-table-column prop="competition_name" label="所属赛事" min-width="160" show-overflow-tooltip />
<el-table-column prop="channel_code" label="渠道编码" min-width="170" />
<el-table-column prop="channel_name" label="渠道名称" min-width="140" />
<el-table-column label="状态" width="88">
<template #default="{ row }">
<el-tag :type="row.status === 'enabled' ? 'success' : 'info'" effect="plain">
{{ row.status === 'enabled' ? '已启用' : '已停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="success_callback_url" label="成功回跳地址" min-width="240" show-overflow-tooltip />
<el-table-column prop="remark" label="备注" min-width="150" show-overflow-tooltip>
<template #default="{ row }">
<span class="cell-muted">{{ row.remark || '—' }}</span>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" min-width="180" />
<el-table-column label="操作" width="150" fixed="right" align="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openChannelModal(row)"></el-button>
<el-button
link
:type="row.status === 'enabled' ? 'danger' : 'success'"
size="small"
@click="toggleChannelStatus(row)"
>
{{ row.status === 'enabled' ? '停用' : '启用' }}
</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>{{
@ -1166,6 +1347,45 @@ onMounted(() => {
</template>
</el-dialog>
<el-dialog
v-model="channelDialogVisible"
:title="channelEditingId ? '编辑渠道' : '新增渠道'"
width="600px"
destroy-on-close
align-center
>
<el-form label-position="top">
<el-form-item v-if="channelEditingId" label="渠道编码">
<el-input
:model-value="channels.find((row) => row.id === channelEditingId)?.channel_code ?? ''"
disabled
/>
</el-form-item>
<el-form-item label="渠道名称">
<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-form-item>
<el-form-item label="成功回跳地址">
<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" />
</el-form-item>
<el-form-item label="状态">
<el-radio-group v-model="channelForm.status">
<el-radio-button value="enabled">启用</el-radio-button>
<el-radio-button value="disabled">停用</el-radio-button>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="channelDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="channelSaving" @click="saveChannel"></el-button>
</template>
</el-dialog>
<el-dialog
v-model="trackDialogVisible"
:title="trackEditingId ? '编辑赛道' : '新增赛道'"
@ -1338,6 +1558,10 @@ onMounted(() => {
min-height: 120px;
}
.channels-pane {
min-height: 120px;
}
.login-link-preview {
margin-top: 8px;
}

Loading…
Cancel
Save