|
|
/**
|
|
|
* 将设计文档中的 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
|
|
|
}
|