diff --git a/src/api/admin/applications.ts b/src/api/admin/applications.ts new file mode 100644 index 0000000..97cc0eb --- /dev/null +++ b/src/api/admin/applications.ts @@ -0,0 +1,64 @@ +import { adminHttp } from './http' +import type { + AdminApplicationDetail, + AdminApplicationListParams, + AdminApplicationRow, + Paginated, +} from './types' + +function unwrapPaginated(raw: unknown): Paginated { + if (!raw || typeof raw !== 'object') throw new Error('列表响应格式无效') + const o = raw as Record + 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> { + const { data } = await adminHttp.get(`/competitions/${competitionId}/applications`, { params }) + return unwrapPaginated(data) +} + +export async function getAdminApplication( + competitionId: number, + applicationId: number, +): Promise { + const { data } = await adminHttp.get( + `/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 { + const res = await adminHttp.get( + `/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) +} diff --git a/src/api/admin/auth.ts b/src/api/admin/auth.ts index 6972b28..ceb4147 100644 --- a/src/api/admin/auth.ts +++ b/src/api/admin/auth.ts @@ -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 { - 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('/auth/login', body) return unwrapLogin(data) diff --git a/src/api/admin/competitions.ts b/src/api/admin/competitions.ts index c589a3e..63b2bac 100644 --- a/src/api/admin/competitions.ts +++ b/src/api/admin/competitions.ts @@ -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(raw: unknown): Paginated { @@ -59,32 +50,11 @@ export async function listCompetitions(params: { page?: number per_page?: number }): Promise> { - 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('/competitions', { params }) return unwrapPaginated(data) } export async function getCompetition(id: number): Promise { - if (adminUseMock()) { - const row = mockFindCompetition(id) - if (!row) throw new Error('赛事不存在') - return row - } const { data } = await adminHttp.get(`/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 { } export async function createCompetition(payload: CompetitionPayload): Promise { - if (adminUseMock()) { - return mockUpsertCompetition(payload) - } const { data } = await adminHttp.post('/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 { - if (adminUseMock()) { - const existing = mockFindCompetition(id) - if (!existing) throw new Error('赛事不存在') - return mockUpsertCompetition({ ...existing, ...payload, id }) - } const { data } = await adminHttp.put(`/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): Promise { - if (adminUseMock()) { - const existing = mockFindCompetition(id) - if (!existing) throw new Error('赛事不存在') - return mockUpsertCompetition({ ...existing, ...partial, id }) - } const { data } = await adminHttp.patch(`/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 { - if (adminUseMock()) { - return mockListTracks(competitionId) - } const { data } = await adminHttp.get(`/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 { - if (adminUseMock()) { - return mockUpsertTrack(competitionId, { ...payload }) - } const { data } = await adminHttp.post(`/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 { - if (adminUseMock()) { - return mockUpsertTrack(competitionId, { ...payload, id: trackId }) - } const { data } = await adminHttp.put( `/competitions/${competitionId}/tracks/${trackId}`, payload, @@ -167,9 +115,5 @@ export async function updateTrack( } export async function deleteTrack(competitionId: number, trackId: number): Promise { - if (adminUseMock()) { - mockDeleteTrack(competitionId, trackId) - return - } await adminHttp.delete(`/competitions/${competitionId}/tracks/${trackId}`) } diff --git a/src/api/admin/formSchemas.ts b/src/api/admin/formSchemas.ts index f635f89..292bd8b 100644 --- a/src/api/admin/formSchemas.ts +++ b/src/api/admin/formSchemas.ts @@ -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 { - if (adminUseMock()) { - return mockListFormSchemas(competitionId, purpose) - } const { data } = await adminHttp.get(`/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 { - if (adminUseMock()) { - return mockGetFormSchema(competitionId, schemaId) - } const { data } = await adminHttp.get(`/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 { - if (adminUseMock()) { - return mockCreateFormSchema(competitionId, payload) - } const { data } = await adminHttp.post(`/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 { - if (adminUseMock()) { - return mockUpdateFormSchema(competitionId, schemaId, payload) - } const { data } = await adminHttp.patch( `/competitions/${competitionId}/form-schemas/${schemaId}`, payload, @@ -54,9 +34,5 @@ export async function updateFormSchema( } export async function deleteFormSchema(competitionId: number, schemaId: number): Promise { - if (adminUseMock()) { - mockDeleteFormSchema(competitionId, schemaId) - return - } await adminHttp.delete(`/competitions/${competitionId}/form-schemas/${schemaId}`) } diff --git a/src/api/admin/menus.ts b/src/api/admin/menus.ts index e03c6b0..d4b86ce 100644 --- a/src/api/admin/menus.ts +++ b/src/api/admin/menus.ts @@ -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 { - if (adminUseMock()) { - return MOCK_ADMIN_MENUS - } const { data } = await adminHttp.get('/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)) { diff --git a/src/api/admin/mockStore.ts b/src/api/admin/mockStore.ts deleted file mode 100644 index f993869..0000000 --- a/src/api/admin/mockStore.ts +++ /dev/null @@ -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() -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 & { 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() - -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() - -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() - -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() - 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 { - mockRefreshReviewerCounts() - const list = mockReviewerScopesRows.get(competitionId) ?? [] - const out: Record = {} - 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 { - 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): 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 { - 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() -} diff --git a/src/api/admin/reviewerScopes.ts b/src/api/admin/reviewerScopes.ts index 0799a3c..d8cb73b 100644 --- a/src/api/admin/reviewerScopes.ts +++ b/src/api/admin/reviewerScopes.ts @@ -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(raw: unknown): Paginated { @@ -33,17 +26,11 @@ export async function listReviewerScopes(params: { page?: number per_page?: number }): Promise> { - if (adminUseMock()) { - return mockListReviewerScopes(params) - } const { data } = await adminHttp.get('/reviewer-scopes', { params }) return unwrapPaginated(data) } export async function fetchReviewerScopeTrackCounts(competitionId: number): Promise> { - if (adminUseMock()) { - return mockReviewerScopeTrackCounts(competitionId) - } const { data } = await adminHttp.get(`/competitions/${competitionId}/reviewer-scope-track-counts`) const body = (data as { data?: Record })?.data ?? (data as Record) if (!body || typeof body !== 'object') return {} @@ -51,9 +38,6 @@ export async function fetchReviewerScopeTrackCounts(competitionId: number): Prom } export async function createReviewerScope(payload: ReviewerScopePayload): Promise { - if (adminUseMock()) { - return mockCreateReviewerScope(payload) - } const { data } = await adminHttp.post('/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 { - if (adminUseMock()) { - mockDeleteReviewerScope(id) - return - } await adminHttp.delete(`/reviewer-scopes/${id}`) } diff --git a/src/api/admin/reviewers.ts b/src/api/admin/reviewers.ts index 78c99b7..61fd4c4 100644 --- a/src/api/admin/reviewers.ts +++ b/src/api/admin/reviewers.ts @@ -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(raw: unknown): Paginated { @@ -32,17 +25,11 @@ export async function listReviewers(params: { q?: string status?: 'active' | 'disabled' | '' }): Promise> { - if (adminUseMock()) { - return mockListReviewers(params) - } const { data } = await adminHttp.get('/reviewers', { params }) return unwrapPaginated(data) } export async function createReviewer(payload: ReviewerPayload): Promise { - if (adminUseMock()) { - return mockCreateReviewer(payload) - } const { data } = await adminHttp.post('/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): Promise { - if (adminUseMock()) { - return mockUpdateReviewer(id, payload) - } const { data } = await adminHttp.put(`/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 { - if (adminUseMock()) { - mockDeleteReviewer(id) - return - } await adminHttp.delete(`/reviewers/${id}`) } diff --git a/src/api/admin/signupChannels.ts b/src/api/admin/signupChannels.ts index 54fb3c7..573a2ad 100644 --- a/src/api/admin/signupChannels.ts +++ b/src/api/admin/signupChannels.ts @@ -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 { - 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('渠道列表响应格式无效') @@ -26,7 +18,6 @@ export async function getSignupChannel( competitionId: number, channelId: number, ): Promise { - requireRealApi() const { data } = await adminHttp.get( `/competitions/${competitionId}/signup-channels/${channelId}`, ) @@ -37,7 +28,6 @@ export async function createSignupChannel( competitionId: number, payload: SignupChannelPayload, ): Promise { - requireRealApi() const { data } = await adminHttp.post( `/competitions/${competitionId}/signup-channels`, payload, @@ -50,7 +40,6 @@ export async function updateSignupChannel( channelId: number, payload: Partial, ): Promise { - requireRealApi() const { data } = await adminHttp.put( `/competitions/${competitionId}/signup-channels/${channelId}`, payload, diff --git a/src/api/admin/types.ts b/src/api/admin/types.ts index 119870c..a1edd4e 100644 --- a/src/api/admin/types.ts +++ b/src/api/admin/types.ts @@ -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 { diff --git a/src/components/admin/FormSchemaVisualEditor.vue b/src/components/admin/FormSchemaVisualEditor.vue index 83e917c..fa8d4aa 100644 --- a/src/components/admin/FormSchemaVisualEditor.vue +++ b/src/components/admin/FormSchemaVisualEditor.vue @@ -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) {
填报说明
+ +
空值预填
+ + + +
文件格式限制(可选)
{ @@ -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') diff --git a/src/views/ApplyFormView.vue b/src/views/ApplyFormView.vue index 9e137ae..95ed55c 100644 --- a/src/views/ApplyFormView.vue +++ b/src/views/ApplyFormView.vue @@ -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 +} + +declare global { + interface Window { + wx?: WechatJssdk + } +} + function templateRefEl(r: Ref): 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 | null = null + +function loadWechatJssdk(): Promise { + if (window.wx?.miniProgram) return Promise.resolve(window.wx) + if (wxJssdkLoading) return wxJssdkLoading + + const loading = new Promise((resolve, reject) => { + const existing = document.querySelector('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 { + 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[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') diff --git a/src/views/LoginView.vue b/src/views/LoginView.vue index b71b7e8..03953f9 100644 --- a/src/views/LoginView.vue +++ b/src/views/LoginView.vue @@ -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') diff --git a/src/views/admin/AdminLoginView.vue b/src/views/admin/AdminLoginView.vue index 453c129..60d5dd8 100644 --- a/src/views/admin/AdminLoginView.vue +++ b/src/views/admin/AdminLoginView.vue @@ -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 ? '登录中…' : '登录' }} -

- 连接真实后端时请将 VITE_ADMIN_USE_MOCK 设为 false 并重启前端;默认管理员账号由 Laravel - php artisan db:seed --class=AdminUserSeeder 写入。 -

@@ -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; -} diff --git a/src/views/admin/competition/CompetitionFormView.vue b/src/views/admin/competition/CompetitionFormView.vue index 781e961..2e37351 100644 --- a/src/views/admin/competition/CompetitionFormView.vue +++ b/src/views/admin/competition/CompetitionFormView.vue @@ -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([]) const tracksLoading = ref(false) const channels = ref([]) const channelsLoading = ref(false) +const applications = ref([]) +const applicationsLoading = ref(false) +const applicationDetailLoading = ref(false) +const applicationDetailVisible = ref(false) +const applicationDetail = ref(null) +const applicationPager = ref({ + page: 1, + perPage: 15, + total: 0, +}) +const applicationFilters = ref({ + keyword: '', + status: '', + track: '', +}) const trackDialogVisible = ref(false) const trackEditingId = ref(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(() => {
-

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

- + 刷新列表 - + 新增渠道
@@ -1211,6 +1311,89 @@ onMounted(() => {
+ +
+
+
+ + + + + + + + +
+
+ 重置 + 查询 +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+
+

管理选手报名表字段定义(JSON 数组)。可创建多个版本,设为当前报名表后选手端生效。当前绑定编号:{{ @@ -1427,6 +1610,91 @@ onMounted(() => { + +

+ +
+ + { autocomplete="off" placeholder="/pages/signup/result" /> -

可带查询参数;系统会追加 state、application_id、status、hash 等回调参数。

+

+ redirectTo、navigateTo、reLaunch 可带查询参数,系统会追加 state、application_id、status、hash + 等回调参数;switchTab 只能跳 tabBar 页面,不能携带查询参数。 +

+ @@ -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; diff --git a/src/views/admin/review/ReviewerManageView.vue b/src/views/admin/review/ReviewerManageView.vue index 20b4b46..ea89600 100644 --- a/src/views/admin/review/ReviewerManageView.vue +++ b/src/views/admin/review/ReviewerManageView.vue @@ -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) {

- {{ useMockBackend ? '当前为 Mock:密码可能与原型一致以明文展示。' : '正式环境仅存密码摘要;列表中为占位符而非明文密码。' }} + 正式环境仅存密码摘要;列表中为占位符而非明文密码。

diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index f5f70e9..d63b1e2 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -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 { diff --git a/vite.config.ts b/vite.config.ts index 0e6f0e9..20f7f5e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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' })