feat: update admin frontend and signup prefill

main
weizong song 2 weeks ago
parent 923bd94b7d
commit def9d833dc

@ -0,0 +1,64 @@
import { adminHttp } from './http'
import type {
AdminApplicationDetail,
AdminApplicationListParams,
AdminApplicationRow,
Paginated,
} from './types'
function unwrapPaginated<T>(raw: unknown): Paginated<T> {
if (!raw || typeof raw !== 'object') throw new Error('列表响应格式无效')
const o = raw as Record<string, unknown>
if (Array.isArray(o.data) && typeof o.current_page === 'number') {
const rows = o.data as T[]
return {
data: rows,
meta: {
current_page: o.current_page as number,
last_page: typeof o.last_page === 'number' ? o.last_page : 1,
per_page: typeof o.per_page === 'number' ? o.per_page : rows.length || 15,
total: typeof o.total === 'number' ? o.total : rows.length,
},
}
}
throw new Error('列表响应格式无效')
}
export async function listAdminApplications(
competitionId: number,
params: AdminApplicationListParams,
): Promise<Paginated<AdminApplicationRow>> {
const { data } = await adminHttp.get<unknown>(`/competitions/${competitionId}/applications`, { params })
return unwrapPaginated<AdminApplicationRow>(data)
}
export async function getAdminApplication(
competitionId: number,
applicationId: number,
): Promise<AdminApplicationDetail> {
const { data } = await adminHttp.get<unknown>(
`/competitions/${competitionId}/applications/${applicationId}`,
)
if (!data || typeof data !== 'object') throw new Error('报名详情无效')
return data as AdminApplicationDetail
}
export async function downloadAdminApplicationFile(
competitionId: number,
applicationId: number,
fileId: number,
fallbackName: string,
): Promise<void> {
const res = await adminHttp.get<Blob>(
`/competitions/${competitionId}/applications/${applicationId}/files/${fileId}/download`,
{ responseType: 'blob' },
)
const blobUrl = URL.createObjectURL(res.data)
const a = document.createElement('a')
a.href = blobUrl
a.download = fallbackName || '附件'
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(blobUrl)
}

@ -1,19 +1,9 @@
import axios from 'axios'
import { adminUseMock } from '../../config/api'
import type { AdminLoginBody, AdminLoginResult } from './types'
import { adminHttp } from './http'
/** POST /auth/login — 与 `routes/api.php` 中 `v1/admin` 分组一致 */
export async function adminLogin(body: AdminLoginBody): Promise<AdminLoginResult> {
if (adminUseMock()) {
if (!body.username.trim() || !body.password.trim()) {
throw new Error('请输入账号和密码')
}
return {
token: `mock_admin_${Date.now()}`,
user: { id: 1, name: 'Mock 管理员', username: body.username.trim() },
}
}
try {
const { data } = await adminHttp.post<unknown>('/auth/login', body)
return unwrapLogin(data)

@ -1,4 +1,3 @@
import { adminUseMock } from '../../config/api'
import type {
CompetitionPayload,
CompetitionRow,
@ -6,14 +5,6 @@ import type {
CompetitionTrackRow,
Paginated,
} from './types'
import {
mockDeleteTrack,
mockFindCompetition,
mockListCompetitions,
mockListTracks,
mockUpsertCompetition,
mockUpsertTrack,
} from './mockStore'
import { adminHttp } from './http'
function unwrapPaginated<T>(raw: unknown): Paginated<T> {
@ -59,32 +50,11 @@ export async function listCompetitions(params: {
page?: number
per_page?: number
}): Promise<Paginated<CompetitionRow>> {
if (adminUseMock()) {
const all = mockListCompetitions()
const page = params.page ?? 1
const perPage = params.per_page ?? 15
const start = (page - 1) * perPage
const slice = all.slice(start, start + perPage)
return {
data: slice,
meta: {
current_page: page,
last_page: Math.max(1, Math.ceil(all.length / perPage)),
per_page: perPage,
total: all.length,
},
}
}
const { data } = await adminHttp.get<unknown>('/competitions', { params })
return unwrapPaginated<CompetitionRow>(data)
}
export async function getCompetition(id: number): Promise<CompetitionRow> {
if (adminUseMock()) {
const row = mockFindCompetition(id)
if (!row) throw new Error('赛事不存在')
return row
}
const { data } = await adminHttp.get<unknown>(`/competitions/${id}`)
const body = (data as { data?: CompetitionRow })?.data ?? (data as CompetitionRow)
if (!body || typeof body !== 'object') throw new Error('赛事详情无效')
@ -92,9 +62,6 @@ export async function getCompetition(id: number): Promise<CompetitionRow> {
}
export async function createCompetition(payload: CompetitionPayload): Promise<CompetitionRow> {
if (adminUseMock()) {
return mockUpsertCompetition(payload)
}
const { data } = await adminHttp.post<unknown>('/competitions', payload)
const body = (data as { data?: CompetitionRow })?.data ?? (data as CompetitionRow)
if (!body || typeof body !== 'object') throw new Error('创建响应无效')
@ -102,11 +69,6 @@ export async function createCompetition(payload: CompetitionPayload): Promise<Co
}
export async function updateCompetition(id: number, payload: CompetitionPayload): Promise<CompetitionRow> {
if (adminUseMock()) {
const existing = mockFindCompetition(id)
if (!existing) throw new Error('赛事不存在')
return mockUpsertCompetition({ ...existing, ...payload, id })
}
const { data } = await adminHttp.put<unknown>(`/competitions/${id}`, payload)
const body = (data as { data?: CompetitionRow })?.data ?? (data as CompetitionRow)
if (!body || typeof body !== 'object') throw new Error('更新响应无效')
@ -115,11 +77,6 @@ export async function updateCompetition(id: number, payload: CompetitionPayload)
/** 局部更新(绑定表单、计分规则等) */
export async function patchCompetition(id: number, partial: Partial<CompetitionPayload>): Promise<CompetitionRow> {
if (adminUseMock()) {
const existing = mockFindCompetition(id)
if (!existing) throw new Error('赛事不存在')
return mockUpsertCompetition({ ...existing, ...partial, id })
}
const { data } = await adminHttp.patch<unknown>(`/competitions/${id}`, partial)
const body = (data as { data?: CompetitionRow })?.data ?? (data as CompetitionRow)
if (!body || typeof body !== 'object') throw new Error('更新响应无效')
@ -127,9 +84,6 @@ export async function patchCompetition(id: number, partial: Partial<CompetitionP
}
export async function listTracks(competitionId: number): Promise<CompetitionTrackRow[]> {
if (adminUseMock()) {
return mockListTracks(competitionId)
}
const { data } = await adminHttp.get<unknown>(`/competitions/${competitionId}/tracks`)
const list = (data as { data?: CompetitionTrackRow[] })?.data ?? (data as CompetitionTrackRow[])
if (!Array.isArray(list)) throw new Error('赛道列表无效')
@ -140,9 +94,6 @@ export async function createTrack(
competitionId: number,
payload: CompetitionTrackPayload,
): Promise<CompetitionTrackRow> {
if (adminUseMock()) {
return mockUpsertTrack(competitionId, { ...payload })
}
const { data } = await adminHttp.post<unknown>(`/competitions/${competitionId}/tracks`, payload)
const row = (data as { data?: CompetitionTrackRow })?.data ?? (data as CompetitionTrackRow)
if (!row || typeof row !== 'object') throw new Error('创建赛道无效')
@ -154,9 +105,6 @@ export async function updateTrack(
trackId: number,
payload: CompetitionTrackPayload,
): Promise<CompetitionTrackRow> {
if (adminUseMock()) {
return mockUpsertTrack(competitionId, { ...payload, id: trackId })
}
const { data } = await adminHttp.put<unknown>(
`/competitions/${competitionId}/tracks/${trackId}`,
payload,
@ -167,9 +115,5 @@ export async function updateTrack(
}
export async function deleteTrack(competitionId: number, trackId: number): Promise<void> {
if (adminUseMock()) {
mockDeleteTrack(competitionId, trackId)
return
}
await adminHttp.delete(`/competitions/${competitionId}/tracks/${trackId}`)
}

@ -1,18 +1,7 @@
import { adminUseMock } from '../../config/api'
import { adminHttp } from './http'
import {
mockCreateFormSchema,
mockDeleteFormSchema,
mockGetFormSchema,
mockListFormSchemas,
mockUpdateFormSchema,
} from './mockStore'
import type { FormSchemaPayload, FormSchemaRow } from './types'
export async function listFormSchemas(competitionId: number, purpose?: 'signup' | 'review'): Promise<FormSchemaRow[]> {
if (adminUseMock()) {
return mockListFormSchemas(competitionId, purpose)
}
const { data } = await adminHttp.get<unknown>(`/competitions/${competitionId}/form-schemas`, {
params: purpose ? { purpose } : {},
})
@ -21,9 +10,6 @@ export async function listFormSchemas(competitionId: number, purpose?: 'signup'
}
export async function getFormSchema(competitionId: number, schemaId: number): Promise<FormSchemaRow> {
if (adminUseMock()) {
return mockGetFormSchema(competitionId, schemaId)
}
const { data } = await adminHttp.get<unknown>(`/competitions/${competitionId}/form-schemas/${schemaId}`)
const body = (data as { data?: FormSchemaRow })?.data ?? (data as FormSchemaRow)
if (!body || typeof body !== 'object' || !('id' in body)) throw new Error('表单详情无效')
@ -31,9 +17,6 @@ export async function getFormSchema(competitionId: number, schemaId: number): Pr
}
export async function createFormSchema(competitionId: number, payload: FormSchemaPayload): Promise<FormSchemaRow> {
if (adminUseMock()) {
return mockCreateFormSchema(competitionId, payload)
}
const { data } = await adminHttp.post<FormSchemaRow>(`/competitions/${competitionId}/form-schemas`, payload)
return data
}
@ -43,9 +26,6 @@ export async function updateFormSchema(
schemaId: number,
payload: Partial<{ name: string; schema_json: unknown[]; is_published: boolean }>,
): Promise<FormSchemaRow> {
if (adminUseMock()) {
return mockUpdateFormSchema(competitionId, schemaId, payload)
}
const { data } = await adminHttp.patch<FormSchemaRow>(
`/competitions/${competitionId}/form-schemas/${schemaId}`,
payload,
@ -54,9 +34,5 @@ export async function updateFormSchema(
}
export async function deleteFormSchema(competitionId: number, schemaId: number): Promise<void> {
if (adminUseMock()) {
mockDeleteFormSchema(competitionId, schemaId)
return
}
await adminHttp.delete(`/competitions/${competitionId}/form-schemas/${schemaId}`)
}

@ -1,6 +1,4 @@
import { adminUseMock } from '../../config/api'
import type { AdminMenuNode } from './types'
import { MOCK_ADMIN_MENUS } from './mockStore'
import { adminHttp } from './http'
/**
@ -8,18 +6,10 @@ import { adminHttp } from './http'
* API unwrap
*/
export async function fetchAdminMenus(): Promise<AdminMenuNode[]> {
if (adminUseMock()) {
return MOCK_ADMIN_MENUS
}
const { data } = await adminHttp.get<unknown>('/me/menus')
return unwrapMenus(data)
}
/** 后端未实现菜单接口时的静态降级(仍走动态注册路由流程) */
export function getFallbackStaticMenus(): AdminMenuNode[] {
return MOCK_ADMIN_MENUS
}
function unwrapMenus(raw: unknown): AdminMenuNode[] {
const list = (raw as { data?: unknown })?.data ?? raw
if (!Array.isArray(list)) {

@ -1,597 +0,0 @@
import type {
AdminMenuNode,
CompetitionPayload,
CompetitionRow,
CompetitionTrackRow,
FormSchemaPayload,
FormSchemaRow,
Paginated,
ReviewerPayload,
ReviewerRow,
ReviewerScopePayload,
ReviewerScopeRow,
} from './types'
let nextCompetitionId = 100
let nextTrackId = 1000
/** 与后端 GET /me/menus 结构一致 */
export const MOCK_ADMIN_MENUS: AdminMenuNode[] = [
{ section: '赛事中心' },
{
name: 'admin-competitions-list',
path: 'competitions',
title: '赛事列表',
permissionCode: 'competition.read',
},
{ section: '评审管理' },
{
name: 'admin-reviewers-list',
path: 'review/reviewers',
title: '评审员管理',
permissionCode: 'reviewer.manage',
},
{
name: 'admin-review-portal',
path: 'review/portal',
title: '评审端入口',
permissionCode: 'reviewer.manage',
},
]
let mockCompetitions: CompetitionRow[] = [
{
id: 1,
slug: '新消费大赛-演示',
name: '演示赛事 2026',
description: '模拟数据',
status: 'draft',
published: false,
signup_open_at: null,
signup_close_at: null,
branding_json: null,
},
]
const mockTracksByCompetition = new Map<number, CompetitionTrackRow[]>()
mockTracksByCompetition.set(1, [
{
id: 10,
competition_id: 1,
track_code: 'xfc',
title: '特色消费',
description: '',
sort: 10,
is_enabled: true,
},
{
id: 11,
competition_id: 1,
track_code: 'jkxf',
title: '健康消费',
description: '',
sort: 20,
is_enabled: true,
},
{
id: 12,
competition_id: 1,
track_code: 'wl',
title: '商文旅融合',
description: '',
sort: 30,
is_enabled: true,
},
{
id: 13,
competition_id: 1,
track_code: 'ai',
title: '消费+人工智能',
description: '',
sort: 40,
is_enabled: true,
},
])
export function mockListCompetitions(): CompetitionRow[] {
return [...mockCompetitions].sort((a, b) => b.id - a.id)
}
export function mockFindCompetition(id: number): CompetitionRow | undefined {
return mockCompetitions.find((c) => c.id === id)
}
export function mockUpsertCompetition(payload: CompetitionPayload & { id?: number }): CompetitionRow {
if (payload.id) {
const existing = mockFindCompetition(payload.id)
if (!existing) {
throw new Error('赛事不存在')
}
const merged: CompetitionRow = {
...existing,
...payload,
id: payload.id,
branding_json: payload.branding_json ?? existing.branding_json ?? null,
}
mockCompetitions = mockCompetitions.map((c) => (c.id === payload.id ? merged : c))
return merged
}
const id = nextCompetitionId++
const created: CompetitionRow = {
id,
slug: payload.slug,
name: payload.name,
description: payload.description,
status: payload.status,
published: payload.published,
signup_open_at: payload.signup_open_at,
signup_close_at: payload.signup_close_at,
branding_json: payload.branding_json ?? null,
}
mockCompetitions = [created, ...mockCompetitions]
mockTracksByCompetition.set(id, [])
return created
}
export function mockListTracks(competitionId: number): CompetitionTrackRow[] {
return [...(mockTracksByCompetition.get(competitionId) ?? [])].sort((a, b) => a.sort - b.sort)
}
export function mockUpsertTrack(
competitionId: number,
payload: Omit<CompetitionTrackRow, 'id' | 'competition_id'> & { id?: number },
): CompetitionTrackRow {
const list = mockTracksByCompetition.get(competitionId) ?? []
if (payload.id) {
const next = list.map((t) =>
t.id === payload.id ? { ...t, ...payload, id: payload.id, competition_id: competitionId } : t,
)
mockTracksByCompetition.set(competitionId, next)
return next.find((t) => t.id === payload.id)!
}
const id = nextTrackId++
const row: CompetitionTrackRow = {
id,
competition_id: competitionId,
track_code: payload.track_code,
title: payload.title,
description: payload.description ?? null,
sort: payload.sort,
is_enabled: payload.is_enabled,
}
mockTracksByCompetition.set(competitionId, [...list, row])
return row
}
export function mockDeleteTrack(competitionId: number, trackId: number): void {
const list = mockTracksByCompetition.get(competitionId) ?? []
mockTracksByCompetition.set(
competitionId,
list.filter((t) => t.id !== trackId),
)
}
let nextFormSchemaId = 20000
/** 内存中的表单定义Mock 用) */
type MockFormSchemaStored = FormSchemaRow & { schema_json: unknown[] }
const mockFormSchemasByCompetition = new Map<number, MockFormSchemaStored[]>()
function mockExtractFieldLabels(schemaJson: unknown[]): string[] {
const labels: string[] = []
for (const item of schemaJson) {
if (item !== null && typeof item === 'object' && 'label' in item) {
const lb = String((item as { label?: string }).label || '')
if (lb) labels.push(lb)
}
if (labels.length >= 12) break
}
return labels
}
function mockToBrief(s: MockFormSchemaStored, c: CompetitionRow): FormSchemaRow {
return {
id: s.id,
competition_id: s.competition_id,
purpose: s.purpose,
name: s.name,
version: s.version,
is_published: s.is_published,
is_current_signup: (c.form_schema_id ?? null) === s.id,
is_current_review: (c.review_form_schema_id ?? null) === s.id,
field_labels: mockExtractFieldLabels(Array.isArray(s.schema_json) ? s.schema_json : []),
created_at: s.created_at,
updated_at: s.updated_at,
}
}
export function mockListFormSchemas(competitionId: number, purpose?: 'signup' | 'review'): FormSchemaRow[] {
const c = mockFindCompetition(competitionId)
if (!c) return []
let list = [...(mockFormSchemasByCompetition.get(competitionId) ?? [])]
if (purpose) {
list = list.filter((s) => s.purpose === purpose)
}
return list
.sort((a, b) => b.version - a.version || b.id - a.id)
.map((s) => mockToBrief(s, c))
}
export function mockGetFormSchema(competitionId: number, schemaId: number): FormSchemaRow {
const c = mockFindCompetition(competitionId)
if (!c) throw new Error('赛事不存在')
const list = mockFormSchemasByCompetition.get(competitionId) ?? []
const s = list.find((x) => x.id === schemaId)
if (!s) throw new Error('表单版本不存在')
return { ...mockToBrief(s, c), schema_json: s.schema_json }
}
export function mockCreateFormSchema(competitionId: number, payload: FormSchemaPayload): FormSchemaRow {
const c = mockFindCompetition(competitionId)
if (!c) throw new Error('赛事不存在')
const list = mockFormSchemasByCompetition.get(competitionId) ?? []
const nextVersion =
Math.max(0, ...list.filter((x) => x.purpose === payload.purpose).map((x) => x.version)) + 1
const now = new Date().toISOString()
const id = nextFormSchemaId++
const row: MockFormSchemaStored = {
id,
competition_id: competitionId,
purpose: payload.purpose,
name: payload.name,
version: nextVersion,
is_published: payload.is_published ?? false,
is_current_signup: false,
is_current_review: false,
schema_json: Array.isArray(payload.schema_json) ? payload.schema_json : [],
created_at: now,
updated_at: now,
}
mockFormSchemasByCompetition.set(competitionId, [...list, row])
return { ...mockToBrief(row, c), schema_json: row.schema_json }
}
export function mockUpdateFormSchema(
competitionId: number,
schemaId: number,
payload: Partial<{ name: string; schema_json: unknown[]; is_published: boolean }>,
): FormSchemaRow {
const c = mockFindCompetition(competitionId)
if (!c) throw new Error('赛事不存在')
const list = mockFormSchemasByCompetition.get(competitionId) ?? []
const idx = list.findIndex((x) => x.id === schemaId)
if (idx < 0) throw new Error('表单版本不存在')
const prev = list[idx]
const now = new Date().toISOString()
const next: MockFormSchemaStored = {
...prev,
name: payload.name !== undefined ? payload.name : prev.name,
schema_json:
payload.schema_json !== undefined
? payload.schema_json
: prev.schema_json,
is_published: payload.is_published !== undefined ? payload.is_published : prev.is_published,
updated_at: now,
}
const copy = [...list]
copy[idx] = next
mockFormSchemasByCompetition.set(competitionId, copy)
return { ...mockToBrief(next, c), schema_json: next.schema_json }
}
export function mockDeleteFormSchema(competitionId: number, schemaId: number): void {
const c = mockFindCompetition(competitionId)
if (!c) throw new Error('赛事不存在')
if ((c.form_schema_id ?? null) === schemaId || (c.review_form_schema_id ?? null) === schemaId) {
throw new Error('该版本正被本场赛事使用,请先更换绑定后再删除。')
}
const list = mockFormSchemasByCompetition.get(competitionId) ?? []
mockFormSchemasByCompetition.set(
competitionId,
list.filter((x) => x.id !== schemaId),
)
}
let nextReviewerId = 500
let nextReviewerScopeId = 8000
/** Mock 下列表「密码」列展示的明文(与原型演示一致;仅内存,刷新即失) */
const mockReviewerPasswordPlain = new Map<number, string>()
let mockReviewers: ReviewerRow[] = [
{
id: 401,
mobile: '13800138001',
username: 'demo_review',
name: '演示评委',
status: 'active',
password_set: true,
reviewer_scopes_count: 0,
last_login_at: null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
]
mockReviewerPasswordPlain.set(401, 'Demo2026!')
const mockReviewerScopesRows = new Map<number, ReviewerScopeRow[]>()
function mockPwdDisplay(id: number, row: ReviewerRow): string {
const plain = mockReviewerPasswordPlain.get(id)
if (plain) return plain
if (row.password_set) return '········'
return '—'
}
function mockRefreshReviewerCounts() {
const counts = new Map<number, number>()
for (const list of mockReviewerScopesRows.values()) {
for (const row of list) {
counts.set(row.reviewer_id, (counts.get(row.reviewer_id) ?? 0) + 1)
}
}
mockReviewers = mockReviewers.map((r) => ({
...r,
reviewer_scopes_count: counts.get(r.id) ?? 0,
}))
}
export function mockReviewerScopeTrackCounts(competitionId: number): Record<string, number> {
mockRefreshReviewerCounts()
const list = mockReviewerScopesRows.get(competitionId) ?? []
const out: Record<string, number> = {}
for (const row of list) {
const k = row.track_code
out[k] = (out[k] ?? 0) + 1
}
return out
}
export function mockListReviewers(params: {
page?: number
per_page?: number
q?: string
status?: 'active' | 'disabled' | ''
}): Paginated<ReviewerRow> {
mockRefreshReviewerCounts()
let filtered = [...mockReviewers].sort((a, b) => b.id - a.id)
const q = params.q?.trim()
if (q) {
filtered = filtered.filter(
(r) =>
(r.mobile?.includes(q) ?? false) ||
r.name.includes(q) ||
(r.username?.includes(q) ?? false),
)
}
if (params.status === 'active' || params.status === 'disabled') {
filtered = filtered.filter((r) => r.status === params.status)
}
const page = params.page ?? 1
const perPage = params.per_page ?? 15
const start = (page - 1) * perPage
const slice = filtered.slice(start, start + perPage)
return {
data: slice,
meta: {
current_page: page,
last_page: Math.max(1, Math.ceil(filtered.length / perPage)),
per_page: perPage,
total: filtered.length,
},
}
}
export function mockCreateReviewer(payload: ReviewerPayload): ReviewerRow {
if (payload.username == null || String(payload.username).trim() === '') {
throw new Error('请填写账户')
}
const username = String(payload.username).trim()
if (mockReviewers.some((r) => r.username === username)) {
throw new Error('账户已存在')
}
const mobTrim = payload.mobile?.trim() ?? ''
if (mobTrim !== '' && mockReviewers.some((r) => r.mobile === mobTrim)) {
throw new Error('手机号已存在')
}
const pw = payload.password?.trim()
if (!pw) throw new Error('请填写密码')
const now = new Date().toISOString()
const row: ReviewerRow = {
id: nextReviewerId++,
mobile: mobTrim !== '' ? mobTrim : null,
username,
name: payload.name,
status: payload.status ?? 'active',
password_set: true,
reviewer_scopes_count: 0,
last_login_at: null,
created_at: now,
updated_at: now,
}
mockReviewerPasswordPlain.set(row.id, pw)
mockReviewers = [row, ...mockReviewers]
return row
}
export function mockUpdateReviewer(id: number, payload: Partial<ReviewerPayload>): ReviewerRow {
mockRefreshReviewerCounts()
const idx = mockReviewers.findIndex((r) => r.id === id)
if (idx < 0) throw new Error('评审员不存在')
const prev = mockReviewers[idx]
const uname = payload.username?.trim()
if (
uname !== undefined &&
uname !== '' &&
uname !== prev.username &&
mockReviewers.some((r) => r.username === uname && r.id !== id)
) {
throw new Error('账户已存在')
}
const mob = payload.mobile?.trim()
if (
mob !== undefined &&
mob !== '' &&
mockReviewers.some((r) => r.mobile === mob && r.id !== id)
) {
throw new Error('手机号已存在')
}
const pw = payload.password?.trim()
if (pw) {
mockReviewerPasswordPlain.set(id, pw)
}
const next: ReviewerRow = {
...prev,
...(uname !== undefined && uname !== '' ? { username: uname } : {}),
...(mob !== undefined ? { mobile: mob || null } : {}),
...(payload.name !== undefined ? { name: payload.name } : {}),
...(payload.status !== undefined ? { status: payload.status } : {}),
id,
password_set: prev.password_set || Boolean(pw),
updated_at: new Date().toISOString(),
}
mockReviewers = mockReviewers.map((r) => (r.id === id ? next : r))
mockRefreshReviewerCounts()
return mockReviewers.find((x) => x.id === id)!
}
export function mockDeleteReviewer(id: number): void {
const exists = mockReviewers.some((r) => r.id === id)
if (!exists) throw new Error('评审员不存在')
mockReviewerPasswordPlain.delete(id)
mockReviewers = mockReviewers.filter((r) => r.id !== id)
for (const [cid, list] of mockReviewerScopesRows) {
mockReviewerScopesRows.set(
cid,
list.filter((s) => s.reviewer_id !== id),
)
}
mockRefreshReviewerCounts()
}
export function mockListReviewerScopes(params: {
competition_id: number
reviewer_id?: number
track_code?: string
page?: number
per_page?: number
}): Paginated<ReviewerScopeRow> {
const cid = params.competition_id
const list = [...(mockReviewerScopesRows.get(cid) ?? [])].sort((a, b) => b.id - a.id)
let filtered = list
if (params.reviewer_id) {
filtered = filtered.filter((s) => s.reviewer_id === params.reviewer_id)
}
if (params.track_code) {
filtered = filtered.filter((s) => s.track_code === params.track_code)
}
const page = params.page ?? 1
const perPage = params.per_page ?? 100
const start = (page - 1) * perPage
const slice = filtered.slice(start, start + perPage)
const c = mockFindCompetition(cid)
function enrich(row: ReviewerScopeRow): ReviewerScopeRow {
const reviewer = mockReviewers.find((x) => x.id === row.reviewer_id)
const tracks = mockTracksByCompetition.get(cid) ?? []
const track = tracks.find((t) => t.track_code === row.track_code)
return {
...row,
track_title: track?.title ?? null,
reviewer: reviewer
? {
id: reviewer.id,
mobile: reviewer.mobile ?? null,
username: reviewer.username ?? null,
name: reviewer.name,
status: reviewer.status,
password_display: mockPwdDisplay(reviewer.id, reviewer),
}
: null,
competition: c
? {
id: c.id,
slug: c.slug,
name: c.name,
}
: null,
}
}
return {
data: slice.map(enrich),
meta: {
current_page: page,
last_page: Math.max(1, Math.ceil(filtered.length / perPage)),
per_page: perPage,
total: filtered.length,
},
}
}
export function mockCreateReviewerScope(payload: ReviewerScopePayload): ReviewerScopeRow {
const reviewer = mockReviewers.find((r) => r.id === payload.reviewer_id)
if (!reviewer) throw new Error('评审员不存在')
const c = mockFindCompetition(payload.competition_id)
if (!c) throw new Error('赛事不存在')
const tracks = mockTracksByCompetition.get(payload.competition_id) ?? []
const track = tracks.find((t) => t.track_code === payload.track_code)
if (!track || !track.is_enabled) {
throw new Error('赛道编码无效或未启用')
}
const list = mockReviewerScopesRows.get(payload.competition_id) ?? []
if (
list.some(
(s) =>
s.reviewer_id === payload.reviewer_id &&
s.track_code === payload.track_code &&
s.competition_id === payload.competition_id,
)
) {
throw new Error('该评审员在本场对该赛道已有范围配置')
}
const row: ReviewerScopeRow = {
id: nextReviewerScopeId++,
reviewer_id: payload.reviewer_id,
competition_id: payload.competition_id,
track_code: payload.track_code,
created_at: new Date().toISOString(),
track_title: track.title,
reviewer: {
id: reviewer.id,
mobile: reviewer.mobile ?? null,
username: reviewer.username ?? null,
name: reviewer.name,
status: reviewer.status,
password_display: mockPwdDisplay(reviewer.id, reviewer),
},
competition: {
id: c.id,
slug: c.slug,
name: c.name,
},
}
mockReviewerScopesRows.set(payload.competition_id, [row, ...list])
mockRefreshReviewerCounts()
return row
}
export function mockDeleteReviewerScope(id: number): void {
let found = false
for (const [cid, list] of mockReviewerScopesRows) {
const next = list.filter((s) => s.id !== id)
if (next.length !== list.length) {
mockReviewerScopesRows.set(cid, next)
found = true
break
}
}
if (!found) throw new Error('记录不存在')
mockRefreshReviewerCounts()
}

@ -1,11 +1,4 @@
import { adminUseMock } from '../../config/api'
import type { Paginated, ReviewerScopePayload, ReviewerScopeRow } from './types'
import {
mockCreateReviewerScope,
mockDeleteReviewerScope,
mockListReviewerScopes,
mockReviewerScopeTrackCounts,
} from './mockStore'
import { adminHttp } from './http'
function unwrapPaginated<T>(raw: unknown): Paginated<T> {
@ -33,17 +26,11 @@ export async function listReviewerScopes(params: {
page?: number
per_page?: number
}): Promise<Paginated<ReviewerScopeRow>> {
if (adminUseMock()) {
return mockListReviewerScopes(params)
}
const { data } = await adminHttp.get<unknown>('/reviewer-scopes', { params })
return unwrapPaginated<ReviewerScopeRow>(data)
}
export async function fetchReviewerScopeTrackCounts(competitionId: number): Promise<Record<string, number>> {
if (adminUseMock()) {
return mockReviewerScopeTrackCounts(competitionId)
}
const { data } = await adminHttp.get<unknown>(`/competitions/${competitionId}/reviewer-scope-track-counts`)
const body = (data as { data?: Record<string, number> })?.data ?? (data as Record<string, number>)
if (!body || typeof body !== 'object') return {}
@ -51,9 +38,6 @@ export async function fetchReviewerScopeTrackCounts(competitionId: number): Prom
}
export async function createReviewerScope(payload: ReviewerScopePayload): Promise<ReviewerScopeRow> {
if (adminUseMock()) {
return mockCreateReviewerScope(payload)
}
const { data } = await adminHttp.post<unknown>('/reviewer-scopes', payload)
const body = (data as { data?: ReviewerScopeRow })?.data ?? (data as ReviewerScopeRow)
if (!body || typeof body !== 'object') throw new Error('创建响应无效')
@ -61,9 +45,5 @@ export async function createReviewerScope(payload: ReviewerScopePayload): Promis
}
export async function deleteReviewerScope(id: number): Promise<void> {
if (adminUseMock()) {
mockDeleteReviewerScope(id)
return
}
await adminHttp.delete(`/reviewer-scopes/${id}`)
}

@ -1,11 +1,4 @@
import { adminUseMock } from '../../config/api'
import type { Paginated, ReviewerPayload, ReviewerRow } from './types'
import {
mockCreateReviewer,
mockDeleteReviewer,
mockListReviewers,
mockUpdateReviewer,
} from './mockStore'
import { adminHttp } from './http'
function unwrapPaginated<T>(raw: unknown): Paginated<T> {
@ -32,17 +25,11 @@ export async function listReviewers(params: {
q?: string
status?: 'active' | 'disabled' | ''
}): Promise<Paginated<ReviewerRow>> {
if (adminUseMock()) {
return mockListReviewers(params)
}
const { data } = await adminHttp.get<unknown>('/reviewers', { params })
return unwrapPaginated<ReviewerRow>(data)
}
export async function createReviewer(payload: ReviewerPayload): Promise<ReviewerRow> {
if (adminUseMock()) {
return mockCreateReviewer(payload)
}
const { data } = await adminHttp.post<unknown>('/reviewers', payload)
const body = (data as { data?: ReviewerRow })?.data ?? (data as ReviewerRow)
if (!body || typeof body !== 'object') throw new Error('创建响应无效')
@ -50,9 +37,6 @@ export async function createReviewer(payload: ReviewerPayload): Promise<Reviewer
}
export async function updateReviewer(id: number, payload: Partial<ReviewerPayload>): Promise<ReviewerRow> {
if (adminUseMock()) {
return mockUpdateReviewer(id, payload)
}
const { data } = await adminHttp.put<unknown>(`/reviewers/${id}`, payload)
const body = (data as { data?: ReviewerRow })?.data ?? (data as ReviewerRow)
if (!body || typeof body !== 'object') throw new Error('更新响应无效')
@ -60,9 +44,5 @@ export async function updateReviewer(id: number, payload: Partial<ReviewerPayloa
}
export async function deleteReviewer(id: number): Promise<void> {
if (adminUseMock()) {
mockDeleteReviewer(id)
return
}
await adminHttp.delete(`/reviewers/${id}`)
}

@ -1,13 +1,6 @@
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('渠道响应格式无效')
@ -15,7 +8,6 @@ function unwrapRow(data: unknown): SignupChannelRow {
}
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('渠道列表响应格式无效')
@ -26,7 +18,6 @@ export async function getSignupChannel(
competitionId: number,
channelId: number,
): Promise<SignupChannelRow> {
requireRealApi()
const { data } = await adminHttp.get<unknown>(
`/competitions/${competitionId}/signup-channels/${channelId}`,
)
@ -37,7 +28,6 @@ export async function createSignupChannel(
competitionId: number,
payload: SignupChannelPayload,
): Promise<SignupChannelRow> {
requireRealApi()
const { data } = await adminHttp.post<unknown>(
`/competitions/${competitionId}/signup-channels`,
payload,
@ -50,7 +40,6 @@ export async function updateSignupChannel(
channelId: number,
payload: Partial<SignupChannelPayload>,
): Promise<SignupChannelRow> {
requireRealApi()
const { data } = await adminHttp.put<unknown>(
`/competitions/${competitionId}/signup-channels/${channelId}`,
payload,

@ -95,9 +95,70 @@ export interface CompetitionTrackPayload {
is_enabled: boolean
}
export interface AdminApplicationListParams {
page?: number
per_page?: number
status?: string
track?: string
keyword?: string
}
export interface AdminApplicationRow {
id: number
status: string
project_name: string
player_name: string
school: string
contact_mobile: string
entry_group: string
track_code: string
track_title: string
submitted_at: string | null
updated_at: string | null
files_count: number
submitted_review_count: number
team_sum: number | null
team_avg: number | null
}
export interface AdminApplicationFileRow {
id: number
kind: string
original_name: string | null
size: number | null
mime: string | null
}
export interface AdminApplicationReviewScoreRow {
id: number
reviewer_id: number
reviewer_name: string | null
line_total: string | null
updated_at: string | null
}
export interface AdminApplicationDetail extends AdminApplicationRow {
competition: { id: number; slug: string; name: string }
participant: { id: number | null; mobile: string | null; name: string | null; email: string | null }
contact_email: string
degree: string
company_name: string
location_country: string
location_province: string
location_city: string
oversea_country: string
intro: string
promise_signed_at: string | null
signup_channel: { id: number; channel_code: string; channel_name: string } | null
signup_channel_code: string | null
signup_channel_state: string | null
files: AdminApplicationFileRow[]
review_scores: AdminApplicationReviewScoreRow[]
}
export type SignupChannelStatus = 'enabled' | 'disabled'
export type SignupChannelCallbackType = 'none' | 'web' | 'mini_program'
export type SignupChannelMiniProgramMethod = 'navigateTo' | 'redirectTo' | 'reLaunch'
export type SignupChannelMiniProgramMethod = 'navigateTo' | 'redirectTo' | 'reLaunch' | 'switchTab'
/** 赛事报名渠道配置channel_code 由后端创建时自动生成 */
export interface SignupChannelRow {

@ -8,6 +8,7 @@ import {
REVIEW_FIELD_TYPES,
SIGNUP_COMMITMENT_TYPE,
SIGNUP_FIELD_TYPES,
SIGNUP_PREFILL_SOURCES,
} from '../../utils/formSchemaEditor'
const props = defineProps<{
@ -45,6 +46,7 @@ function onTypeChange(element: FormSchemaEditorItem) {
if (element.type === SIGNUP_COMMITMENT_TYPE) {
element.key = 'commitment_accepted'
element.options = []
element.prefillFrom = ''
element.requiredWhenField = ''
element.requiredWhenValuesLines = ''
if (!element.label.trim()) {
@ -54,6 +56,9 @@ function onTypeChange(element: FormSchemaEditorItem) {
} else if (element.type !== 'select') {
element.options = []
}
if (element.type === 'file') {
element.prefillFrom = ''
}
}
function optionsToText(opts?: { label: string; value: string }[]) {
@ -133,6 +138,21 @@ function applyOptionsText(element: FormSchemaEditorItem, raw: string) {
<div class="field-label">填报说明</div>
<el-input v-model="element.help" placeholder="可选,展示在表单项下方" />
</el-col>
<el-col
v-if="purpose === 'signup' && element.type !== 'file' && element.type !== SIGNUP_COMMITMENT_TYPE"
:xs="24"
:sm="12"
>
<div class="field-label">空值预填</div>
<el-select v-model="element.prefillFrom" class="w-100">
<el-option
v-for="src in SIGNUP_PREFILL_SOURCES"
:key="src.value"
:label="src.label"
:value="src.value"
/>
</el-select>
</el-col>
<el-col v-if="purpose === 'signup' && element.type === 'file'" :span="24">
<div class="field-label">文件格式限制可选</div>
<el-input

@ -55,14 +55,6 @@ export function pathnameForAdminLogin(): string {
return `${prefix}/admin/login`
}
/** 是否启用管理端 Mock仅读 `VITE_` 变量;改后需重启 `npm run dev` */
export function adminUseMock(): boolean {
const raw = import.meta.env.VITE_ADMIN_USE_MOCK
if (raw == null) return false
const s = String(raw).trim().toLowerCase().replace(/^['"]|['"]$/g, '')
return s === 'true' || s === '1' || s === 'yes' || s === 'on'
}
/** 后端 API 根地址(无尾部斜杠)。本地开发/预览默认空字符串,走 Vite 代理的 `/api`,避免跨域。 */
export function getApiBase(): string {
const fromEnv = import.meta.env.VITE_API_BASE as string | undefined

@ -1,7 +1,7 @@
import type { Router } from 'vue-router'
import { ElMessage } from 'element-plus'
import type { AdminMenuNode } from '../../api/admin/types'
import { fetchAdminMenus, getFallbackStaticMenus } from '../../api/admin/menus'
import { fetchAdminMenus } from '../../api/admin/menus'
import { ADMIN_COMPONENT_LOADERS } from './adminComponentMap'
import { useAdminMenuStore } from '../../stores/adminMenu'
@ -74,8 +74,8 @@ export async function registerAdminDynamicRoutes(router: Router) {
menus = await fetchAdminMenus()
} catch (e) {
const msg = e instanceof Error ? e.message : '菜单加载失败'
ElMessage.error(`${msg}已使用本地降级菜单,请检查接口与登录态。`)
menus = getFallbackStaticMenus()
ElMessage.error(`${msg}请检查接口与登录态。`)
throw e
}
useAdminMenuStore().setMenus(menus)

@ -55,7 +55,7 @@ body.login-page-wls .login-page-wls__hint--error {
color: #052d62;
}
/** 「本次验证码」等成功提示:仅保留主题字色,无底色与描边 */
/** 成功提示:仅保留主题字色,无底色与描边 */
body.login-page-wls .login-page-wls__hint--success {
color: #052d62 !important;
background: none !important;

@ -12,6 +12,8 @@ export interface SignupFormSchemaField {
placeholder?: string
help?: string
options?: { label: string; value: string }[]
/** 字段为空时从静态资料源预填;当前后端支持 user.name / user.mobile / user.email */
prefill_from?: string
/** 当依赖字段取值命中列表时,本字段视为必填(与企业名称依赖参赛组别一致) */
required_when?: { field: string; values: string[] }
/** 报名表文件字段:允许的扩展名(不含点,小写);未配置则使用系统默认白名单 */
@ -200,6 +202,9 @@ export function normalizeSignupSchema(raw: unknown): SignupFormSchemaField[] {
const helpRaw = o.help
const help =
helpRaw != null && String(helpRaw).trim() !== '' ? String(helpRaw).trim() : undefined
const pfRaw = o.prefill_from
const prefill_from =
pfRaw != null && String(pfRaw).trim() !== '' ? String(pfRaw).trim() : undefined
const tsRaw = o.title_supplement
const title_supplement =
tsRaw != null && String(tsRaw).trim() !== '' ? String(tsRaw).trim() : undefined
@ -249,6 +254,7 @@ export function normalizeSignupSchema(raw: unknown): SignupFormSchemaField[] {
required,
placeholder,
help,
prefill_from,
options,
required_when,
file_extensions,

@ -15,6 +15,8 @@ export interface FormSchemaEditorItem {
help?: string
/** select 等:{ label, value } */
options?: { label: string; value: string }[]
/** 报名表:字段为空时从指定静态资料源预填(入库 prefill_from */
prefillFrom?: string
/** 报名表:条件必填依赖字段 key入库为 required_when.field */
requiredWhenField?: string
/** 报名表:依赖取值,每行一个(入库为 required_when.values */
@ -47,6 +49,13 @@ export const REVIEW_FIELD_TYPES: { value: string; label: string }[] = [
{ value: 'text', label: '单行文本' },
]
export const SIGNUP_PREFILL_SOURCES: { value: string; label: string }[] = [
{ value: '', label: '不绑定' },
{ value: 'user.name', label: '用户姓名' },
{ value: 'user.mobile', label: '用户手机号' },
{ value: 'user.email', label: '用户邮箱' },
]
function newUid(): string {
return globalThis.crypto?.randomUUID?.() ?? `f_${Date.now()}_${Math.random().toString(36).slice(2)}`
}
@ -66,6 +75,7 @@ export function createEmptySchemaItem(
placeholder: '',
help: '',
options: [],
prefillFrom: '',
requiredWhenField: '',
requiredWhenValuesLines: '',
fileExtensionsLines: '',
@ -123,6 +133,7 @@ export function schemaJsonToEditorItems(json: unknown, purpose: FormSchemaPurpos
placeholder: o.placeholder != null ? String(o.placeholder) : '',
help: o.help != null ? String(o.help) : '',
options: type === 'select' ? normalizeOptions(o.options) : [],
prefillFrom: purpose === 'signup' && o.prefill_from != null ? String(o.prefill_from).trim() : '',
requiredWhenField,
requiredWhenValuesLines,
fileExtensionsLines: (() => {
@ -163,6 +174,9 @@ export function editorItemsToSchemaJson(items: FormSchemaEditorItem[], purpose:
if (item.placeholder?.trim()) row.placeholder = item.placeholder.trim()
if (item.help?.trim()) row.help = item.help.trim()
if (item.titleSupplement?.trim()) row.title_supplement = item.titleSupplement.trim()
if (purpose === 'signup' && !isCommitment && item.type !== 'file' && item.prefillFrom?.trim()) {
row.prefill_from = item.prefillFrom.trim()
}
if (item.type === 'file') {
const extLines = String(item.fileExtensionsLines ?? '')
.split('\n')

@ -53,6 +53,38 @@ interface PublicTrackRow {
sort: number
}
type ChannelMiniProgramCallbackMethod = 'navigateTo' | 'redirectTo' | 'reLaunch' | 'switchTab'
interface ChannelWebCallback {
type?: 'web'
redirect_url?: unknown
}
interface ChannelMiniProgramCallback {
type: 'mini_program'
path?: unknown
method?: unknown
}
type ChannelCallback = ChannelWebCallback | ChannelMiniProgramCallback
interface WechatMiniProgramBridge {
navigateTo: (options: { url: string }) => void
redirectTo: (options: { url: string }) => void
reLaunch: (options: { url: string }) => void
switchTab: (options: { url: string }) => void
}
interface WechatJssdk {
miniProgram?: Partial<WechatMiniProgramBridge>
}
declare global {
interface Window {
wx?: WechatJssdk
}
}
function templateRefEl<T extends HTMLElement>(r: Ref<T | T[] | null>): T | null {
const v = r.value
if (v == null) return null
@ -403,6 +435,81 @@ function showNotice(message: string, title = '提示', type: 'success' | 'warnin
})
}
function normalizeMiniProgramMethod(method: unknown): ChannelMiniProgramCallbackMethod {
return method === 'navigateTo' || method === 'reLaunch' || method === 'switchTab'
? method
: 'redirectTo'
}
function normalizeChannelCallback(callback: ChannelCallback | null | undefined):
| { type: 'web'; redirectUrl: string }
| { type: 'mini_program'; path: string; method: ChannelMiniProgramCallbackMethod }
| null {
if (!callback) return null
if (callback.type === 'mini_program') {
const path = typeof callback.path === 'string' ? callback.path.trim() : ''
if (!path) return null
return {
type: 'mini_program',
path,
method: normalizeMiniProgramMethod(callback.method),
}
}
const redirectUrl = typeof callback.redirect_url === 'string' ? callback.redirect_url.trim() : ''
return redirectUrl ? { type: 'web', redirectUrl } : null
}
let wxJssdkLoading: Promise<WechatJssdk> | null = null
function loadWechatJssdk(): Promise<WechatJssdk> {
if (window.wx?.miniProgram) return Promise.resolve(window.wx)
if (wxJssdkLoading) return wxJssdkLoading
const loading = new Promise<WechatJssdk>((resolve, reject) => {
const existing = document.querySelector<HTMLScriptElement>('script[data-cxcyds-wx-jssdk="true"]')
if (existing) {
existing.addEventListener('load', () => (window.wx ? resolve(window.wx) : reject(new Error('wx missing'))), {
once: true,
})
existing.addEventListener('error', () => reject(new Error('wx jssdk load failed')), { once: true })
return
}
const script = document.createElement('script')
script.src = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js'
script.async = true
script.dataset.cxcydsWxJssdk = 'true'
script.onload = () => {
if (window.wx?.miniProgram) {
resolve(window.wx)
} else {
reject(new Error('wx miniProgram bridge missing'))
}
}
script.onerror = () => reject(new Error('wx jssdk load failed'))
document.head.appendChild(script)
}).finally(() => {
wxJssdkLoading = null
})
wxJssdkLoading = loading
return loading
}
async function navigateMiniProgramCallback(path: string, method: ChannelMiniProgramCallbackMethod): Promise<boolean> {
try {
const wx = await loadWechatJssdk()
const bridgeMethod = wx.miniProgram?.[method]
if (typeof bridgeMethod !== 'function') return false
bridgeMethod({ url: path })
return true
} catch {
return false
}
}
/** 与后端 `ApplicationController` 校验上限一致base64 图片) */
const PROMISE_SIG_MAX_CHARS = 2_097_152
@ -1104,18 +1211,17 @@ async function submitApplicationToServer() {
return false
}
const d = (await r.json()) as Parameters<typeof applyServerPayload>[0] & {
channel_callback?: { redirect_url?: unknown } | null
channel_callback?: ChannelCallback | 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() : '',
channelCallback: normalizeChannelCallback(d.channel_callback),
successNoticeMessage,
}
}
@ -1140,9 +1246,19 @@ async function onSubmitClick() {
const result = await submitApplicationToServer()
if (!result) return
const successMessage = result.successNoticeMessage || '已提交报名'
if (result.redirectUrl) {
if (result.channelCallback) {
await showNotice(successMessage, '提交成功', 'success')
window.location.href = result.redirectUrl
if (result.channelCallback.type === 'mini_program') {
const navigated = await navigateMiniProgramCallback(
result.channelCallback.path,
result.channelCallback.method,
)
if (!navigated) {
showNotice('未检测到微信小程序回跳能力,请从小程序内重新进入或联系管理员。', '回跳失败', 'warning')
}
return
}
window.location.href = result.channelCallback.redirectUrl
return
}
showNotice(successMessage, '提交成功', 'success')

@ -238,12 +238,7 @@ async function sendCode() {
clearCodeCooldown()
return
}
const d = data as { debug_code?: string }
if (d.debug_code) {
setHint(`本次验证码:${d.debug_code}`, 'success')
} else {
clearHint()
}
clearHint()
startSmsResendCooldown()
} catch {
setHint('网络错误请确认后端已启动Vite 将 /api 代理至 php artisan serve 端口,见 vite.config', 'error')

@ -13,6 +13,20 @@ const username = ref('')
const password = ref('')
const submitting = ref(false)
function loginErrorMessage(e: unknown): string {
if (e instanceof Error && e.message.trim()) return e.message
if (typeof e === 'string' && e.trim()) return e
return '登录失败'
}
function resolvePostLoginPath(): string {
const redir = typeof route.query.redirect === 'string' ? route.query.redirect : ''
if (redir && redir.startsWith('/admin') && !redir.includes('/login')) {
return redir
}
return '/admin'
}
async function onSubmit() {
if (!username.value.trim() || !password.value.trim()) {
ElMessage.warning('请输入账号和密码')
@ -25,15 +39,10 @@ async function onSubmit() {
password: password.value.trim(),
})
adminAuth.setToken(res.token)
const redir = typeof route.query.redirect === 'string' ? route.query.redirect : ''
if (redir && redir.startsWith('/admin')) {
await router.replace(redir)
} else {
await router.replace({ name: 'admin-competitions-list' })
}
await router.replace(resolvePostLoginPath())
ElMessage.success('登录成功')
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : '登录失败')
ElMessage.error(loginErrorMessage(e))
} finally {
submitting.value = false
}
@ -57,10 +66,6 @@ async function onSubmit() {
{{ submitting ? '登录中…' : '登录' }}
</el-button>
</el-form>
<p class="hint">
连接真实后端时请将 <code>VITE_ADMIN_USE_MOCK</code> 设为 <code>false</code> 并重启前端默认管理员账号由 Laravel
<code>php artisan db:seed --class=AdminUserSeeder</code> 写入
</p>
</el-card>
</div>
</template>
@ -90,11 +95,4 @@ async function onSubmit() {
width: 100%;
}
.hint {
margin-top: 16px;
margin-bottom: 0;
font-size: 12px;
color: var(--el-text-color-secondary);
text-align: center;
}
</style>

@ -20,7 +20,14 @@ import {
listSignupChannels,
updateSignupChannel,
} from '../../../api/admin/signupChannels'
import {
downloadAdminApplicationFile,
getAdminApplication,
listAdminApplications,
} from '../../../api/admin/applications'
import type {
AdminApplicationDetail,
AdminApplicationRow,
CompetitionPayload,
CompetitionSuccessNoticeSettings,
CompetitionRow,
@ -36,7 +43,6 @@ import {
emptyBrandingForm,
type BrandingForm,
} from '../../../utils/competitionBranding'
import { adminUseMock } from '../../../config/api'
import { useAdminCompetitionStore } from '../../../stores/adminCompetition'
import FormSchemaVisualEditor from '../../../components/admin/FormSchemaVisualEditor.vue'
import PledgeRichTextEditor from '../../../components/admin/PledgeRichTextEditor.vue'
@ -48,12 +54,13 @@ import {
type FormSchemaPurpose,
} from '../../../utils/formSchemaEditor'
type TabKey = 'basic' | 'tracks' | 'channels' | 'signupForm' | 'review' | 'brand'
type TabKey = 'basic' | 'tracks' | 'channels' | 'applications' | 'signupForm' | 'review' | 'brand'
const TAB_ITEMS: { tab: TabKey; label: string }[] = [
{ tab: 'basic', label: '基础信息' },
{ tab: 'tracks', label: '赛道管理' },
{ tab: 'channels', label: '渠道管理' },
{ tab: 'applications', label: '报名信息' },
{ tab: 'signupForm', label: '报名表配置' },
{ tab: 'review', label: '评审与计分' },
{ tab: 'brand', label: '品牌与文案' },
@ -89,6 +96,21 @@ const tracks = ref<CompetitionTrackRow[]>([])
const tracksLoading = ref(false)
const channels = ref<SignupChannelRow[]>([])
const channelsLoading = ref(false)
const applications = ref<AdminApplicationRow[]>([])
const applicationsLoading = ref(false)
const applicationDetailLoading = ref(false)
const applicationDetailVisible = ref(false)
const applicationDetail = ref<AdminApplicationDetail | null>(null)
const applicationPager = ref({
page: 1,
perPage: 15,
total: 0,
})
const applicationFilters = ref({
keyword: '',
status: '',
track: '',
})
const trackDialogVisible = ref(false)
const trackEditingId = ref<number | null>(null)
@ -257,7 +279,8 @@ watch(
watch(tabKey, (t) => {
const cid = competitionId.value
if (!cid) return
if (t === 'channels' && !adminUseMock()) void refreshChannels()
if (t === 'channels') void refreshChannels()
if (t === 'applications') void refreshApplications()
if (t === 'signupForm') void refreshSignupSchemas()
if (t === 'review') {
void refreshReviewSchemas()
@ -451,6 +474,7 @@ async function loadDetail() {
loadScoringFromRow(row)
await refreshTracks()
if (tabKey.value === 'channels') await refreshChannels()
if (tabKey.value === 'applications') await refreshApplications()
if (tabKey.value === 'signupForm') await refreshSignupSchemas()
if (tabKey.value === 'review') await refreshReviewSchemas()
} catch (e) {
@ -502,13 +526,7 @@ async function saveBasic() {
competitionStore.selectCompetition(row.id)
await router.replace({ name: 'admin-competition-workspace' })
await loadDetail()
if (adminUseMock()) {
ElMessage.success(
'已创建(当前为 Mock 模式,浏览器不会发 HTTP联调请设 VITE_ADMIN_USE_MOCK=false 并重启 dev',
)
} else {
ElMessage.success('已创建,请在本页顶部标签切换「赛道 / 报名表」等模块继续配置')
}
ElMessage.success('已创建,请在本页顶部标签切换「赛道 / 报名表」等模块继续配置')
return
}
@ -609,15 +627,106 @@ async function refreshChannels() {
}
}
function applicationStatusLabel(status: string): string {
if (status === 'submitted') return '已提交'
if (status === 'draft') return '草稿'
return status || '—'
}
function applicationStatusTag(status: string): 'success' | 'info' | 'warning' | 'danger' | 'primary' {
if (status === 'submitted') return 'success'
if (status === 'draft') return 'info'
return 'warning'
}
function formatDateTime(s: string | null | undefined): string {
if (!s) return '—'
const d = new Date(s)
if (Number.isNaN(d.getTime())) return s
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
function formatScore(n: number | string | null | undefined): string {
if (n == null || n === '') return '—'
const v = Number(n)
if (!Number.isFinite(v)) return String(n)
return v.toFixed(2).replace(/\.?0+$/, '')
}
function formatFileSize(size: number | null | undefined): string {
if (!size || size <= 0) return '—'
if (size < 1024) return `${size}B`
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)}KB`
return `${(size / 1024 / 1024).toFixed(1)}MB`
}
async function refreshApplications(page = applicationPager.value.page) {
const cid = competitionId.value
if (!cid) {
applications.value = []
applicationPager.value.total = 0
return
}
applicationsLoading.value = true
try {
const res = await listAdminApplications(cid, {
page,
per_page: applicationPager.value.perPage,
keyword: applicationFilters.value.keyword.trim() || undefined,
status: applicationFilters.value.status || undefined,
track: applicationFilters.value.track || undefined,
})
applications.value = res.data
applicationPager.value = {
page: res.meta.current_page,
perPage: res.meta.per_page,
total: res.meta.total,
}
} catch (e) {
applications.value = []
ElMessage.error(e instanceof Error ? e.message : '加载报名列表失败')
} finally {
applicationsLoading.value = false
}
}
function resetApplicationFilters() {
applicationFilters.value = { keyword: '', status: '', track: '' }
void refreshApplications(1)
}
async function openApplicationDetail(row: AdminApplicationRow) {
const cid = competitionId.value
if (!cid) return
applicationDetailVisible.value = true
applicationDetailLoading.value = true
applicationDetail.value = null
try {
applicationDetail.value = await getAdminApplication(cid, row.id)
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : '加载报名详情失败')
} finally {
applicationDetailLoading.value = false
}
}
async function downloadApplicationFile(fileId: number, fileName: string | null | undefined) {
const cid = competitionId.value
const appId = applicationDetail.value?.id
if (!cid || !appId) return
try {
await downloadAdminApplicationFile(cid, appId, fileId, fileName || '附件')
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : '下载附件失败')
}
}
async function openChannelModal(row?: SignupChannelRow) {
if (!competitionId.value) {
ElMessage.warning('请先保存赛事基础信息')
return
}
if (adminUseMock()) {
ElMessage.warning('渠道管理需关闭管理端 Mock 并连接真实 API')
return
}
let detail = row
if (row) {
try {
@ -1145,24 +1254,15 @@ onMounted(() => {
<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 :loading="channelsLoading" @click="refreshChannels">
刷新列表
</el-button>
<el-button type="primary" :disabled="adminUseMock()" @click="openChannelModal()">
<el-button type="primary" @click="openChannelModal()">
新增渠道
</el-button>
</div>
@ -1211,6 +1311,89 @@ onMounted(() => {
</div>
</el-tab-pane>
<el-tab-pane v-if="!isCreate()" label="报名信息" name="applications" lazy>
<div v-loading="applicationsLoading" class="applications-pane">
<div class="pane-toolbar pane-toolbar--split pane-toolbar--wrap">
<div class="application-filters">
<el-input
v-model="applicationFilters.keyword"
placeholder="搜索项目、姓名、学校、手机号"
clearable
class="application-filter-keyword"
@keyup.enter="refreshApplications(1)"
/>
<el-select v-model="applicationFilters.status" placeholder="状态" clearable class="application-filter-select">
<el-option label="草稿" value="draft" />
<el-option label="已提交" value="submitted" />
</el-select>
<el-select v-model="applicationFilters.track" placeholder="赛道" clearable class="application-filter-select">
<el-option
v-for="track in tracks"
:key="track.track_code"
:label="track.title"
:value="track.track_code"
/>
</el-select>
</div>
<div class="application-actions">
<el-button @click="resetApplicationFilters"></el-button>
<el-button type="primary" @click="refreshApplications(1)"></el-button>
</div>
</div>
<el-table :data="applications" stripe border empty-text="" class="workspace-table">
<el-table-column label="状态" width="92">
<template #default="{ row }">
<el-tag :type="applicationStatusTag(row.status)" effect="plain">
{{ applicationStatusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="project_name" label="项目名称" min-width="180" show-overflow-tooltip />
<el-table-column prop="player_name" label="负责人" width="110" show-overflow-tooltip />
<el-table-column prop="school" label="学校" min-width="150" show-overflow-tooltip />
<el-table-column prop="entry_group" label="组别" width="100">
<template #default="{ row }">
<span class="cell-muted">{{ row.entry_group || '—' }}</span>
</template>
</el-table-column>
<el-table-column prop="track_title" label="赛道" min-width="130" show-overflow-tooltip />
<el-table-column prop="contact_mobile" label="手机号" width="128" />
<el-table-column label="附件" width="76" align="right">
<template #default="{ row }">{{ row.files_count }}</template>
</el-table-column>
<el-table-column label="评分" width="150">
<template #default="{ row }">
<span class="cell-muted">
{{ row.submitted_review_count }} / 均分 {{ formatScore(row.team_avg) }}
</span>
</template>
</el-table-column>
<el-table-column label="提交时间" min-width="150">
<template #default="{ row }">{{ formatDateTime(row.submitted_at) }}</template>
</el-table-column>
<el-table-column label="操作" width="92" fixed="right" align="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openApplicationDetail(row)"></el-button>
</template>
</el-table-column>
</el-table>
<div class="table-pagination">
<el-pagination
v-model:current-page="applicationPager.page"
v-model:page-size="applicationPager.perPage"
background
layout="total, sizes, prev, pager, next"
:page-sizes="[10, 15, 30, 50, 100]"
:total="applicationPager.total"
@current-change="refreshApplications"
@size-change="refreshApplications(1)"
/>
</div>
</div>
</el-tab-pane>
<el-tab-pane v-if="!isCreate()" label="报名表配置" name="signupForm" lazy>
<p class="form-hint">
管理选手报名表字段定义JSON 数组可创建多个版本设为当前报名表后选手端生效当前绑定编号<strong>{{
@ -1427,6 +1610,91 @@ onMounted(() => {
</el-tab-pane>
</el-tabs>
<el-drawer
v-model="applicationDetailVisible"
title="报名详情"
size="min(760px, 94vw)"
destroy-on-close
>
<div v-loading="applicationDetailLoading" class="application-detail">
<template v-if="applicationDetail">
<el-descriptions :column="2" border class="application-detail-section">
<el-descriptions-item label="项目名称" :span="2">
{{ applicationDetail.project_name || '—' }}
</el-descriptions-item>
<el-descriptions-item label="负责人">{{ applicationDetail.player_name || '—' }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="applicationStatusTag(applicationDetail.status)" effect="plain">
{{ applicationStatusLabel(applicationDetail.status) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="学校">{{ applicationDetail.school || '—' }}</el-descriptions-item>
<el-descriptions-item label="学历">{{ applicationDetail.degree || '—' }}</el-descriptions-item>
<el-descriptions-item label="手机号">{{ applicationDetail.contact_mobile || '—' }}</el-descriptions-item>
<el-descriptions-item label="邮箱">{{ applicationDetail.contact_email || '—' }}</el-descriptions-item>
<el-descriptions-item label="组别">{{ applicationDetail.entry_group || '—' }}</el-descriptions-item>
<el-descriptions-item label="企业">{{ applicationDetail.company_name || '—' }}</el-descriptions-item>
<el-descriptions-item label="赛道">{{ applicationDetail.track_title || '—' }}</el-descriptions-item>
<el-descriptions-item label="所在地">
{{
[applicationDetail.location_country, applicationDetail.location_province, applicationDetail.location_city, applicationDetail.oversea_country]
.filter(Boolean)
.join(' / ') || '—'
}}
</el-descriptions-item>
<el-descriptions-item label="提交时间">{{ formatDateTime(applicationDetail.submitted_at) }}</el-descriptions-item>
<el-descriptions-item label="承诺书签署">{{ formatDateTime(applicationDetail.promise_signed_at) }}</el-descriptions-item>
<el-descriptions-item label="报名渠道" :span="2">
{{
applicationDetail.signup_channel
? `${applicationDetail.signup_channel.channel_name}${applicationDetail.signup_channel.channel_code}`
: applicationDetail.signup_channel_code || '—'
}}
</el-descriptions-item>
</el-descriptions>
<h3 class="section-title">项目简介</h3>
<p class="application-intro">{{ applicationDetail.intro || '—' }}</p>
<h3 class="section-title">附件</h3>
<el-table :data="applicationDetail.files" border empty-text="" class="workspace-table workspace-table--mb">
<el-table-column prop="kind" label="类型" width="110" />
<el-table-column label="文件名" min-width="220" show-overflow-tooltip>
<template #default="{ row }">{{ row.original_name || '附件' }}</template>
</el-table-column>
<el-table-column label="大小" width="100">
<template #default="{ row }">{{ formatFileSize(row.size) }}</template>
</el-table-column>
<el-table-column label="操作" width="90" align="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="downloadApplicationFile(row.id, row.original_name)">
下载
</el-button>
</template>
</el-table-column>
</el-table>
<h3 class="section-title">评审汇总</h3>
<div class="review-summary">
<span>已评 {{ applicationDetail.submitted_review_count }} </span>
<span>总分 {{ formatScore(applicationDetail.team_sum) }}</span>
<span>均分 {{ formatScore(applicationDetail.team_avg) }}</span>
</div>
<el-table :data="applicationDetail.review_scores" border empty-text="" class="workspace-table">
<el-table-column prop="reviewer_name" label="评审员" min-width="140">
<template #default="{ row }">{{ row.reviewer_name || `评审员 #${row.reviewer_id}` }}</template>
</el-table-column>
<el-table-column label="分数" width="100">
<template #default="{ row }">{{ formatScore(row.line_total) }}</template>
</el-table-column>
<el-table-column label="更新时间" min-width="160">
<template #default="{ row }">{{ formatDateTime(row.updated_at) }}</template>
</el-table-column>
</el-table>
</template>
</div>
</el-drawer>
<el-dialog
v-model="schemaDialogVisible"
:title="schemaModalPurpose === 'signup' ? '新建报名表版本' : '新建评审表版本'"
@ -1558,13 +1826,17 @@ onMounted(() => {
autocomplete="off"
placeholder="/pages/signup/result"
/>
<p class="form-hint">可带查询参数系统会追加 stateapplication_idstatushash 等回调参数</p>
<p class="form-hint">
redirectTonavigateToreLaunch 可带查询参数系统会追加 stateapplication_idstatushash
等回调参数switchTab 只能跳 tabBar 页面不能携带查询参数
</p>
</el-form-item>
<el-form-item label="小程序路由方式">
<el-select v-model="channelForm.mini_program_callback_method" class="w-100">
<el-option label="redirectTo替换当前页面" value="redirectTo" />
<el-option label="navigateTo打开新页面" value="navigateTo" />
<el-option label="reLaunch重启到指定页" value="reLaunch" />
<el-option label="switchTab返回 tabBar 页面)" value="switchTab" />
</el-select>
</el-form-item>
</template>
@ -1763,6 +2035,64 @@ onMounted(() => {
min-height: 120px;
}
.applications-pane {
min-height: 180px;
}
.application-filters {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.application-filter-keyword {
width: min(320px, 100%);
}
.application-filter-select {
width: 150px;
}
.application-actions {
display: flex;
align-items: center;
gap: 8px;
}
.table-pagination {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
.application-detail {
min-height: 240px;
}
.application-detail-section {
margin-bottom: 18px;
}
.application-intro {
margin: 0 0 18px;
padding: 12px;
min-height: 72px;
border: 1px solid var(--el-border-color-lighter);
border-radius: var(--el-border-radius-base);
color: var(--el-text-color-regular);
line-height: 1.7;
white-space: pre-wrap;
}
.review-summary {
display: flex;
gap: 16px;
flex-wrap: wrap;
margin: 0 0 10px;
color: var(--el-text-color-regular);
}
.channel-public-key :deep(textarea) {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;

@ -18,9 +18,6 @@ import {
} from '../../../api/admin/reviewerScopes'
import type { CompetitionTrackRow, ReviewerScopeRow } from '../../../api/admin/types'
import { useAdminCompetitionStore } from '../../../stores/adminCompetition'
import { adminUseMock } from '../../../config/api'
const useMockBackend = computed(() => adminUseMock())
const competitionStore = useAdminCompetitionStore()
const { selectedCompetitionId } = storeToRefs(competitionStore)
@ -347,7 +344,7 @@ async function onDelete(row: ReviewerScopeRow) {
</div>
</div>
<p class="footnote">
{{ useMockBackend ? '当前为 Mock密码可能与原型一致以明文展示。' : '正式环境仅存密码摘要;列表中为占位符而非明文密码。' }}
正式环境仅存密码摘要列表中为占位符而非明文密码
</p>
</section>
</div>

2
src/vite-env.d.ts vendored

@ -4,8 +4,6 @@ interface ImportMetaEnv {
readonly VITE_API_BASE?: string
/** 管理端 API 基址;默认与 `VITE_API_BASE` / 站内 `/api` 同源,仅前缀不同 */
readonly VITE_ADMIN_API_BASE?: string
/** 设为 `true` 时管理端接口走本地 Mock competitions / menus / login */
readonly VITE_ADMIN_USE_MOCK?: string
}
interface ImportMeta {

@ -8,7 +8,7 @@ import { viteStaticCopy } from 'vite-plugin-static-copy'
import type { Plugin } from 'vite'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const laravelPublicAdmin = path.resolve(__dirname, '../cxxfds-service/public/admin')
const laravelPublicAdmin = path.resolve(__dirname, '../backend/public/admin')
function spaHistoryFallback(): Plugin {
const middleware = history({ index: '/index.html' })

Loading…
Cancel
Save