|
|
|
|
|
/** 与后端 `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)
|
|
|
|
|
|
}
|