/** * 将设计文档中的 branding_json 拆为表单字段,供运营可视化编辑。 */ import { DEFAULT_DOCUMENT_TITLE, DEFAULT_DOCUMENT_TITLE_EN, DEFAULT_EVENT_SHORT_NAME } from '../config/site' import { cloneDefaultLoginConsultLines, type LoginConsultLine, } from '../config/participantContact' export interface BrandingForm { documentTitle: string login: { markLine: string headline: string slogan: string cardWelcome: string footerCopyright: string logoUrl: string faviconUrl: string themePrimary: string /** 是否展示登录/报名页咨询信息 */ showConsult: boolean consultLines: LoginConsultLine[] /** 接口是否显式下发了 consultLines(空数组表示故意不展示默认两行) */ consultLinesConfigured: boolean } apply: { pageTitle: string headerSubtitle: string } review: { pageTitle: string footerCopyright: string } admin: { loginTitle: string footerCopyright: string sidebarProductName: string } } export function emptyBrandingForm(): BrandingForm { return { documentTitle: '', login: { markLine: '', headline: '', slogan: '', cardWelcome: '', footerCopyright: '', logoUrl: '', faviconUrl: '', themePrimary: '', showConsult: true, consultLines: cloneDefaultLoginConsultLines(), consultLinesConfigured: false, }, apply: { pageTitle: '', headerSubtitle: '' }, review: { pageTitle: '', footerCopyright: '' }, admin: { loginTitle: '', footerCopyright: '', sidebarProductName: '' }, } } function asRecord(v: unknown): Record { return v !== null && typeof v === 'object' && !Array.isArray(v) ? (v as Record) : {} } /** 去掉零宽字符与首尾空白 */ export function normalizeDisplayWhitespace(s: string): string { return s.replace(/[\u200B-\u200D\uFEFF]/gu, '').trim() } /** 是否有可见字符(用于未配置、仅空格或零宽符时不覆盖端上默认文案) */ export function hasVisibleText(s: string): boolean { return /\S/u.test(normalizeDisplayWhitespace(s)) } function str(r: Record, key: string): string { const x = r[key] if (x == null) return '' if (typeof x === 'string') return x if (typeof x === 'number' && Number.isFinite(x)) return String(x) return '' } function parseConsultLines(raw: unknown): LoginConsultLine[] { if (!Array.isArray(raw)) return [] const out: LoginConsultLine[] = [] for (const item of raw) { if (item == null || typeof item !== 'object' || Array.isArray(item)) continue const row = item as Record const label = normalizeDisplayWhitespace(str(row, 'label')) const text = normalizeDisplayWhitespace(str(row, 'text')) if (!label && !text) continue out.push({ label, text }) } return out } export function brandingFormFromApi( json: unknown | null | undefined, locale: 'zh-CN' | 'en' = 'zh-CN', ): BrandingForm { const b = emptyBrandingForm() if (json == null) return b let parsed: unknown = json if (typeof parsed === 'string') { const t = parsed.trim() if (!t) return b try { parsed = JSON.parse(t) as unknown } catch { return b } } const root = asRecord(parsed) applyBrandingRoot(b, root) if (locale === 'en') { const enRoot = asRecord(root.en) if (Object.keys(enRoot).length) { applyBrandingRoot(b, enRoot, true) } } return b } function applyBrandingRoot(b: BrandingForm, root: Record, overlay = false): void { const documentTitle = normalizeDisplayWhitespace(str(root, 'documentTitle')) if (documentTitle || !overlay) b.documentTitle = documentTitle const login = asRecord(root.login) if (Object.keys(login).length || !overlay) { const markLine = normalizeDisplayWhitespace(str(login, 'markLine')) const headline = normalizeDisplayWhitespace(str(login, 'headline')) const slogan = normalizeDisplayWhitespace(str(login, 'slogan')) const cardWelcome = normalizeDisplayWhitespace(str(login, 'cardWelcome')) const footerCopyright = normalizeDisplayWhitespace(str(login, 'footerCopyright')) if (markLine || !overlay) b.login.markLine = markLine if (headline || !overlay) b.login.headline = headline if (slogan || !overlay) b.login.slogan = slogan if (cardWelcome || !overlay) b.login.cardWelcome = cardWelcome if (footerCopyright || !overlay) b.login.footerCopyright = footerCopyright if (overlay) { if (Object.prototype.hasOwnProperty.call(login, 'consultLines')) { const enLines = parseConsultLines(login.consultLines) if (enLines.length) { b.login.consultLinesConfigured = true b.login.consultLines = enLines } } } else { b.login.logoUrl = normalizeDisplayWhitespace(str(login, 'logoUrl')) b.login.faviconUrl = normalizeDisplayWhitespace(str(login, 'faviconUrl')) b.login.themePrimary = normalizeDisplayWhitespace(str(login, 'themePrimary')) if (Object.prototype.hasOwnProperty.call(login, 'showConsult')) { b.login.showConsult = login.showConsult !== false && login.showConsult !== 0 && login.showConsult !== 'false' } if (Object.prototype.hasOwnProperty.call(login, 'consultLines')) { b.login.consultLinesConfigured = true b.login.consultLines = parseConsultLines(login.consultLines) } } } const apply = asRecord(root.apply) if (Object.keys(apply).length || !overlay) { const pageTitle = normalizeDisplayWhitespace(str(apply, 'pageTitle')) const headerSubtitle = normalizeDisplayWhitespace(str(apply, 'headerSubtitle')) if (pageTitle || !overlay) b.apply.pageTitle = pageTitle if (headerSubtitle || !overlay) b.apply.headerSubtitle = headerSubtitle } if (overlay) return const review = asRecord(root.review) b.review.pageTitle = normalizeDisplayWhitespace(str(review, 'pageTitle')) b.review.footerCopyright = normalizeDisplayWhitespace(str(review, 'footerCopyright')) const admin = asRecord(root.admin) b.admin.loginTitle = normalizeDisplayWhitespace(str(admin, 'loginTitle')) b.admin.footerCopyright = normalizeDisplayWhitespace(str(admin, 'footerCopyright')) b.admin.sidebarProductName = normalizeDisplayWhitespace(str(admin, 'sidebarProductName')) } /** 选手端顶栏系统名(浏览器标题 / 导航栏) */ export function participantNavbarTitle(brand: BrandingForm): string { if (hasVisibleText(brand.documentTitle)) return normalizeDisplayWhitespace(brand.documentTitle) return DEFAULT_DOCUMENT_TITLE } /** 登录页与各端口顶栏共用的默认图形标志(未上传 Logo 时回退) */ export const DEFAULT_COMPETITION_MARK = '/frontend/assets/competition-radial-mark.png' export function resolveCompetitionLogoUrl(logoUrl?: string | null): string { const raw = normalizeDisplayWhitespace(String(logoUrl ?? '')) if (!raw) return DEFAULT_COMPETITION_MARK if (/^https?:\/\//i.test(raw) || raw.startsWith('data:') || raw.startsWith('/')) return raw return `/${raw.replace(/^\.?\//, '')}` } const CJK_RE = /[\u4e00-\u9fff]/u /** 观众票证等处的赛事名称:英文页优先用英文品牌标题,避免仍显示中文赛事名 */ export function localizedEventDisplayName( brand: BrandingForm, competitionName: string, locale: 'zh-CN' | 'en' = 'zh-CN', ): string { const title = normalizeDisplayWhitespace(brand.documentTitle) const name = normalizeDisplayWhitespace(competitionName) if (locale === 'en') { if (title && !CJK_RE.test(title)) return title if (name && !CJK_RE.test(name)) return name return DEFAULT_DOCUMENT_TITLE_EN } return name || title || DEFAULT_EVENT_SHORT_NAME } /** 登录页左侧赛事名:优先品牌主标题,否则用赛事名称 */ export function loginBrandTitle( brand: BrandingForm, competitionName: string, locale: 'zh-CN' | 'en' = 'zh-CN', ): string { if (hasVisibleText(brand.login.headline)) return normalizeDisplayWhitespace(brand.login.headline) return localizedEventDisplayName(brand, competitionName, locale) } /** 登录后顶栏赛事名(不用浏览器标题,避免带上「报名系统」) */ export function portalHeaderEventName( brand: BrandingForm, competitionName: string, locale: 'zh-CN' | 'en' = 'zh-CN', ): string { return localizedEventDisplayName(brand, competitionName, locale) } /** 中文赛事名按「青年科创大赛」拆成两行,与原型 lockup 一致 */ export function competitionNameLines(name: string): string[] { const n = normalizeDisplayWhitespace(name) if (!n) return [] if (/[A-Za-z]/.test(n) && !CJK_RE.test(n)) return [n] const idx = n.indexOf('青年科创大赛') if (idx > 0) return [n.slice(0, idx), n.slice(idx)] return [n] } /** 报名填报表单页内标题(固定「赛事报名」,与原型一致;顶栏赛事名由 participantNavbarTitle 负责) */ export function participantApplyFormTitle(_brand?: BrandingForm, fallback = '赛事报名'): string { return fallback } /** 选手端报名页主标题(导航栏 / 表单大标题共用,兼容旧调用) */ export function participantApplyPageTitle(brand: BrandingForm, competitionName: string): string { const a = brand.apply?.pageTitle if (hasVisibleText(a)) return normalizeDisplayWhitespace(a) const n = normalizeDisplayWhitespace(competitionName) return n ? `${n} · 赛事报名` : '赛事报名' } /** 评审工作台顶栏标题(对齐 branding.review.pageTitle) */ export function reviewWorkspacePageTitle(brand: BrandingForm, competitionName: string): string { const r = brand.review?.pageTitle if (hasVisibleText(r)) return normalizeDisplayWhitespace(r) const n = normalizeDisplayWhitespace(competitionName) return n ? `${n} · 项目评审` : '项目评审' } export function resolvedLoginConsultLines(brand: BrandingForm): LoginConsultLine[] { if (!brand.login.showConsult) return [] if (brand.login.consultLinesConfigured) { return brand.login.consultLines.filter((line) => hasVisibleText(line.label) || hasVisibleText(line.text)) } return cloneDefaultLoginConsultLines() } /** 去掉空字符串键,减少存储体积 */ function brandingJsonFromFormBase(form: BrandingForm): Record | null { const out: Record = {} if (form.documentTitle.trim()) out.documentTitle = form.documentTitle.trim() const login: Record = {} if (form.login.markLine.trim()) login.markLine = form.login.markLine.trim() if (form.login.headline.trim()) login.headline = form.login.headline.trim() if (form.login.slogan.trim()) login.slogan = form.login.slogan.trim() if (form.login.cardWelcome.trim()) login.cardWelcome = form.login.cardWelcome.trim() if (form.login.footerCopyright.trim()) login.footerCopyright = form.login.footerCopyright.trim() if (form.login.logoUrl.trim()) login.logoUrl = form.login.logoUrl.trim() if (form.login.faviconUrl.trim()) login.faviconUrl = form.login.faviconUrl.trim() if (form.login.themePrimary.trim()) login.themePrimary = form.login.themePrimary.trim() if (!form.login.showConsult) { login.showConsult = false } const consultLines = form.login.consultLines .map((line) => ({ label: line.label.trim(), text: line.text.trim(), })) .filter((line) => line.label !== '' || line.text !== '') login.consultLines = consultLines if (Object.keys(login).length) out.login = login const apply: Record = {} if (form.apply.pageTitle.trim()) apply.pageTitle = form.apply.pageTitle.trim() if (form.apply.headerSubtitle.trim()) apply.headerSubtitle = form.apply.headerSubtitle.trim() if (Object.keys(apply).length) out.apply = apply const review: Record = {} if (form.review.pageTitle.trim()) review.pageTitle = form.review.pageTitle.trim() if (form.review.footerCopyright.trim()) review.footerCopyright = form.review.footerCopyright.trim() if (Object.keys(review).length) out.review = review const admin: Record = {} if (form.admin.loginTitle.trim()) admin.loginTitle = form.admin.loginTitle.trim() if (form.admin.footerCopyright.trim()) admin.footerCopyright = form.admin.footerCopyright.trim() if (form.admin.sidebarProductName.trim()) admin.sidebarProductName = form.admin.sidebarProductName.trim() if (Object.keys(admin).length) out.admin = admin return Object.keys(out).length ? out : null } export interface BrandingEnCopy { documentTitle: string login: { markLine: string headline: string slogan: string cardWelcome: string footerCopyright: string consultLines: LoginConsultLine[] } apply: { pageTitle: string headerSubtitle: string } } export function emptyBrandingEn(): BrandingEnCopy { return { documentTitle: '', login: { markLine: '', headline: '', slogan: '', cardWelcome: '', footerCopyright: '', consultLines: [], }, apply: { pageTitle: '', headerSubtitle: '' }, } } export function brandingEnFromApi(json: unknown | null | undefined): BrandingEnCopy { const out = emptyBrandingEn() if (json == null) return out let parsed: unknown = json if (typeof parsed === 'string') { const t = parsed.trim() if (!t) return out try { parsed = JSON.parse(t) as unknown } catch { return out } } const en = asRecord(asRecord(parsed).en) const login = asRecord(en.login) const apply = asRecord(en.apply) out.documentTitle = normalizeDisplayWhitespace(str(en, 'documentTitle')) out.login.markLine = normalizeDisplayWhitespace(str(login, 'markLine')) out.login.headline = normalizeDisplayWhitespace(str(login, 'headline')) out.login.slogan = normalizeDisplayWhitespace(str(login, 'slogan')) out.login.cardWelcome = normalizeDisplayWhitespace(str(login, 'cardWelcome')) out.login.footerCopyright = normalizeDisplayWhitespace(str(login, 'footerCopyright')) if (Object.prototype.hasOwnProperty.call(login, 'consultLines')) { out.login.consultLines = parseConsultLines(login.consultLines) } out.apply.pageTitle = normalizeDisplayWhitespace(str(apply, 'pageTitle')) out.apply.headerSubtitle = normalizeDisplayWhitespace(str(apply, 'headerSubtitle')) return out } export function brandingJsonFromForm(form: BrandingForm, en?: BrandingEnCopy): Record | null { const out = brandingJsonFromFormBase(form) if (!en) return out const enOut: Record = {} if (en.documentTitle.trim()) enOut.documentTitle = en.documentTitle.trim() const login: Record = {} if (en.login.markLine.trim()) login.markLine = en.login.markLine.trim() if (en.login.headline.trim()) login.headline = en.login.headline.trim() if (en.login.slogan.trim()) login.slogan = en.login.slogan.trim() if (en.login.cardWelcome.trim()) login.cardWelcome = en.login.cardWelcome.trim() if (en.login.footerCopyright.trim()) login.footerCopyright = en.login.footerCopyright.trim() const enConsult = (en.login.consultLines ?? []) .map((line) => ({ label: line.label.trim(), text: line.text.trim(), })) .filter((line) => line.label || line.text) if (enConsult.length) login.consultLines = enConsult if (Object.keys(login).length) enOut.login = login const apply: Record = {} if (en.apply.pageTitle.trim()) apply.pageTitle = en.apply.pageTitle.trim() if (en.apply.headerSubtitle.trim()) apply.headerSubtitle = en.apply.headerSubtitle.trim() if (Object.keys(apply).length) enOut.apply = apply if (!Object.keys(enOut).length) return out const merged = out ? { ...out, en: enOut } : { en: enOut } return merged }