You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

405 lines
15 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

/**
* 将设计文档中的 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<string, unknown> {
return v !== null && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : {}
}
/** 去掉零宽字符与首尾空白 */
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<string, unknown>, 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<string, unknown>
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<string, unknown>, 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<string, unknown> | null {
const out: Record<string, unknown> = {}
if (form.documentTitle.trim()) out.documentTitle = form.documentTitle.trim()
const login: Record<string, unknown> = {}
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<string, string> = {}
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<string, string> = {}
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<string, string> = {}
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<string, unknown> | null {
const out = brandingJsonFromFormBase(form)
if (!en) return out
const enOut: Record<string, unknown> = {}
if (en.documentTitle.trim()) enOut.documentTitle = en.documentTitle.trim()
const login: Record<string, unknown> = {}
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<string, string> = {}
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
}