/** 与后端 `SMS_RESEND_INTERVAL_SECONDS` 一致,用于「获取验证码」按钮倒计时(秒);可在 .env 设 `VITE_SMS_RESEND_INTERVAL_SECONDS`) */ export function getSmsResendCooldownSeconds(): number { const raw = import.meta.env.VITE_SMS_RESEND_INTERVAL_SECONDS as string | undefined if (raw != null && String(raw).trim() !== '') { const n = parseInt(String(raw).trim(), 10) if (Number.isFinite(n) && n >= 1) return Math.min(n, 600) } return 60 } export const PARTICIPANT_SMS_SEND_PATH = '/api/auth/sms/send' as const export const PARTICIPANT_SMS_LOGIN_PATH = '/api/auth/sms/login' as const export const TOKEN_KEY = 'cxxfds_token' as const /** 管理员代登选手端标记(sessionStorage) */ export const ADMIN_IMPERSONATION_FLAG_KEY = 'cxxfds_admin_impersonation' as const /** 选手端最近一次访问的赛事 slug(用于 / 与旧链接跳转) */ export const PARTICIPANT_COMPETITION_SLUG_KEY = 'cxxfds_participant_competition_slug' as const /** 管理端 Bearer Token(与选手端分离) */ export const ADMIN_TOKEN_KEY = 'cxxfds_admin_token' as const /** 管理端登录用户摘要(username / name,用于顶栏展示) */ export const ADMIN_USER_KEY = 'cxxfds_admin_user' as const /** 当前选中赛事(顶栏切换器),仅存 id */ export const ADMIN_COMPETITION_ID_KEY = 'cxxfds_admin_competition_id' as const /** 评审端 Bearer Token(与选手端、管理端分离) */ export const REVIEWER_TOKEN_KEY = 'cxxfds_reviewer_token' as const /** 评审端会话绑定的赛事 slug */ export const REVIEWER_COMPETITION_SLUG_KEY = 'cxxfds_reviewer_competition_slug' as const export const REVIEW_AUTH_LOGIN_PATH = '/api/v1/review/auth/login' as const export const REVIEW_ME_PATH = '/api/v1/review/me' as const export const REVIEW_APPLICATIONS_INDEX_PATH = '/api/v1/review/applications' as const export function reviewApplicationDetailUrl(applicationId: number, competitionSlug: string): string { const qs = new URLSearchParams({ competition_slug: competitionSlug }) return `${getApiBase()}/api/v1/review/applications/${applicationId}?${qs.toString()}` } /** 提交/更新本人评审打分(`Authorization: Bearer` + JSON body:`competition_slug`、`payload`) */ export function reviewApplicationSubmitScoreUrl(applicationId: number): string { return `${getApiBase()}/api/v1/review/applications/${applicationId}/score` } /** 管理端 REST 前缀(相对 `/api`),与 Laravel `routes/api.php` 分组对齐 */ export const ADMIN_API_PREFIX = '/v1/admin' as const /** 管理端登录页在浏览器中的 pathname(生产环境 base 为 `/admin/` 时为 `/admin/admin/login`) */ export function pathnameForAdminLogin(): string { const baseUrl = import.meta.env.BASE_URL || '/' if (baseUrl === '/') { return '/admin/login' } const prefix = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl return `${prefix}/admin/login` } /** * 本地开发(Vite base=/)时,将生产构建的 pathname(/admin/admin/...)折叠为 /admin/..., * 避免登录后 redirect 落到无匹配路由的地址。 */ export function collapseDevAdminPath(path: string): string { const baseUrl = import.meta.env.BASE_URL || '/' if (baseUrl !== '/') return path if (!path.startsWith('/admin/admin')) return path const collapsed = path.replace(/^\/admin\/admin(?=\/|$)/, '/admin') return collapsed || '/admin' } /** 登录后 redirect 参数规整(去掉生产 base 前缀,并折叠 dev 下的 /admin/admin) */ export function normalizeAdminRedirectPath(path: string): string { const trimmed = path.trim() if (!trimmed) return '' const baseUrl = import.meta.env.BASE_URL || '/' const basePrefix = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl if (basePrefix && basePrefix !== '/' && trimmed.startsWith(`${basePrefix}/admin`)) { return collapseDevAdminPath(trimmed.slice(basePrefix.length) || '/admin') } return collapseDevAdminPath(trimmed) } /** 后端 API 根地址(无尾部斜杠)。本地开发/预览默认空字符串,走 Vite 代理的 `/api`,避免跨域。 */ export function getApiBase(): string { const fromEnv = import.meta.env.VITE_API_BASE as string | undefined if (fromEnv?.trim()) { return fromEnv.trim().replace(/\/$/, '') } const host = window.location.hostname if (host === 'localhost' || host === '127.0.0.1' || host === '') { return '' } return '' } /** 规整附件公开 URL,避免 Laravel `APP_URL` 带尾斜杠时出现 `//storage/...` 导致 404 */ export function normalizePublicAssetUrl(raw: string | null | undefined): string { const s = String(raw ?? '').trim() if (!s) return s if (/^https?:\/\//i.test(s)) { try { const u = new URL(s) const collapsed = u.pathname.replace(/\/{2,}/g, '/') u.pathname = collapsed.startsWith('/') ? collapsed : `/${collapsed}` return u.toString() } catch { return s.replace(/([^:])\/\/+/g, '$1/') } } return s.replace(/\/{2,}/g, '/') } /** 评审端附件下载 URL(需请求头 `Authorization: Bearer`,见详情页 `downloadAttachment`) */ export function reviewApplicationFileDownloadUrl( applicationId: number, fileId: number, competitionSlug: string, ): string { const qs = new URLSearchParams({ competition_slug: competitionSlug }) return `${getApiBase()}/api/v1/review/applications/${applicationId}/files/${fileId}/download?${qs.toString()}` } /** 管理端接口根路径(无尾部斜杠),形如 `{origin}/api/v1/admin` */ export function getAdminApiRoot(): string { const fromEnv = import.meta.env.VITE_ADMIN_API_BASE?.trim() if (fromEnv) { return fromEnv.replace(/\/$/, '') } return `${getApiBase()}/api${ADMIN_API_PREFIX}` } export function readReviewerSession(): { token: string; competitionSlug: string } { return { token: localStorage.getItem(REVIEWER_TOKEN_KEY) ?? '', competitionSlug: localStorage.getItem(REVIEWER_COMPETITION_SLUG_KEY) ?? '', } } export function writeReviewerSession(token: string, competitionSlug: string): void { localStorage.setItem(REVIEWER_TOKEN_KEY, token) localStorage.setItem(REVIEWER_COMPETITION_SLUG_KEY, competitionSlug) } export function clearReviewerSession(): void { localStorage.removeItem(REVIEWER_TOKEN_KEY) localStorage.removeItem(REVIEWER_COMPETITION_SLUG_KEY) } /** `/c/:slug/review…` — 不向选手 slug 写入 localStorage */ export function pathIsReviewerZone(fullPath: string): boolean { return /^\/c\/[^/]+\/review(?:\/|$)/.test(fullPath) } /** `/c/:slug/manage…` — 赛事管理端(管理员) */ export function pathIsManageZone(fullPath: string): boolean { return /^\/c\/[^/]+\/manage(?:\/|$)/.test(fullPath) }