script.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506
  1. const siteHeader = document.getElementById('siteHeader')
  2. const mainNavbar = document.getElementById('mainNavbar')
  3. const navbarToggler = document.querySelector('.navbar-toggler')
  4. const siteLogoImg = document.getElementById('siteLogoImg')
  5. const navMode = siteHeader?.dataset?.navMode || 'inner'
  6. const isHomeNav = navMode === 'home'
  7. const bootstrapCollapse = mainNavbar && window.bootstrap ? new bootstrap.Collapse(mainNavbar, { toggle: false }) : null
  8. const setHeroHeaderTheme = (isDark) => {
  9. if (!siteHeader) return
  10. siteHeader.classList.remove('hero-theme-light', 'hero-theme-dark')
  11. siteHeader.classList.add(isDark ? 'hero-theme-dark' : 'hero-theme-light')
  12. if (siteLogoImg) {
  13. const lightLogo = siteLogoImg.getAttribute('data-logo-light') || '/assets/xtu/vendor/img/XTU-logo.png'
  14. const darkLogo = siteLogoImg.getAttribute('data-logo-dark') || '/assets/xtu/vendor/img/XTU-logo2.png'
  15. siteLogoImg.src = isDark ? darkLogo : lightLogo
  16. }
  17. }
  18. const syncHeroHeaderThemeFromSlide = () => {
  19. if (!siteHeader || siteHeader.classList.contains('scrolled') || siteHeader.classList.contains('menu-open') || siteHeader.classList.contains('header-search-open')) return
  20. const activeSlide = document.querySelector('#heroSwiper .swiper-slide-active[data-is-b], #heroSwiper .swiper-slide[data-swiper-slide-index][data-is-b].swiper-slide-active') || document.querySelector('#heroSwiper .swiper-slide[data-is-b]')
  21. const isB = activeSlide ? String(activeSlide.getAttribute('data-is-b') || '0') : '0'
  22. setHeroHeaderTheme(isB === '1')
  23. }
  24. const syncHeaderState = () => {
  25. const menuOpen = !!mainNavbar?.classList.contains('show')
  26. if (siteHeader) siteHeader.classList.toggle('menu-open', menuOpen)
  27. if (navbarToggler) navbarToggler.setAttribute('aria-expanded', menuOpen ? 'true' : 'false')
  28. }
  29. const onScroll = () => {
  30. if (!siteHeader || !isHomeNav || siteHeader.classList.contains('page-navbar-solid') || siteHeader.classList.contains('header-search-open')) return
  31. if (window.scrollY > 30) {
  32. siteHeader.classList.add('scrolled')
  33. setHeroHeaderTheme(true)
  34. } else if (!siteHeader.classList.contains('menu-open')) {
  35. siteHeader.classList.remove('scrolled')
  36. syncHeroHeaderThemeFromSlide()
  37. }
  38. }
  39. if (isHomeNav) {
  40. window.addEventListener('scroll', onScroll)
  41. onScroll()
  42. } else {
  43. siteHeader?.classList.add('scrolled')
  44. setHeroHeaderTheme(true)
  45. }
  46. mainNavbar?.addEventListener('show.bs.collapse', () => {
  47. siteHeader?.classList.add('menu-open', 'scrolled')
  48. setHeroHeaderTheme(true)
  49. document.body.classList.add('modal-open')
  50. syncHeaderState()
  51. })
  52. mainNavbar?.addEventListener('hide.bs.collapse', () => {
  53. siteHeader?.classList.remove('menu-open')
  54. document.body.classList.remove('modal-open')
  55. syncHeaderState()
  56. onScroll()
  57. })
  58. mainNavbar?.addEventListener('shown.bs.collapse', syncHeaderState)
  59. mainNavbar?.addEventListener('hidden.bs.collapse', syncHeaderState)
  60. mainNavbar?.querySelectorAll('.nav-link:not(.dropdown-toggle), .dropdown-item').forEach((link) => {
  61. link.addEventListener('click', () => {
  62. if (window.innerWidth < 992 && mainNavbar.classList.contains('show')) bootstrapCollapse?.hide()
  63. })
  64. })
  65. if (mainNavbar && window.bootstrap) {
  66. mainNavbar.querySelectorAll('.nav-item.dropdown').forEach((item) => {
  67. const toggle = item.querySelector(':scope > .dropdown-toggle')
  68. const menu = item.querySelector(':scope > .dropdown-menu')
  69. if (!toggle || !menu) return
  70. const dropdownInstance = bootstrap.Dropdown.getOrCreateInstance(toggle, { autoClose: true })
  71. let hideTimer = null
  72. const clearHideTimer = () => {
  73. if (hideTimer) {
  74. clearTimeout(hideTimer)
  75. hideTimer = null
  76. }
  77. }
  78. const showDesktopDropdown = () => {
  79. if (window.innerWidth < 992 || siteHeader?.classList.contains('header-search-open')) return
  80. clearHideTimer()
  81. item.classList.add('show')
  82. toggle.classList.add('show')
  83. toggle.setAttribute('aria-expanded', 'true')
  84. menu.classList.add('show')
  85. dropdownInstance.show()
  86. }
  87. const hideDesktopDropdown = () => {
  88. if (window.innerWidth < 992) return
  89. clearHideTimer()
  90. item.classList.remove('show')
  91. toggle.classList.remove('show')
  92. toggle.setAttribute('aria-expanded', 'false')
  93. menu.classList.remove('show')
  94. dropdownInstance.hide()
  95. }
  96. const scheduleHideDesktopDropdown = () => {
  97. if (window.innerWidth < 992) return
  98. clearHideTimer()
  99. hideTimer = setTimeout(() => hideDesktopDropdown(), 120)
  100. }
  101. toggle.removeAttribute('data-bs-toggle')
  102. item.addEventListener('mouseenter', showDesktopDropdown)
  103. item.addEventListener('mouseleave', scheduleHideDesktopDropdown)
  104. menu.addEventListener('mouseenter', showDesktopDropdown)
  105. menu.addEventListener('mouseleave', scheduleHideDesktopDropdown)
  106. toggle.addEventListener('focus', showDesktopDropdown)
  107. item.addEventListener('focusout', () => {
  108. setTimeout(() => {
  109. if (!item.contains(document.activeElement)) scheduleHideDesktopDropdown()
  110. }, 0)
  111. })
  112. toggle.addEventListener('click', (e) => {
  113. if (window.innerWidth >= 992) {
  114. const href = toggle.getAttribute('href') || ''
  115. if (!href || href === '#') e.preventDefault()
  116. }
  117. })
  118. window.addEventListener('resize', () => {
  119. if (window.innerWidth < 992) {
  120. clearHideTimer()
  121. hideDesktopDropdown()
  122. toggle.setAttribute('data-bs-toggle', 'dropdown')
  123. } else {
  124. toggle.removeAttribute('data-bs-toggle')
  125. }
  126. })
  127. })
  128. }
  129. const openModal = (id) => {
  130. const modal = document.getElementById(id)
  131. if (!modal) return
  132. modal.classList.add('is-open')
  133. document.body.classList.add('modal-open')
  134. }
  135. const closeModal = (id) => {
  136. const modal = document.getElementById(id)
  137. if (!modal) return
  138. modal.classList.remove('is-open')
  139. if (!document.querySelector('.custom-modal.is-open') && !mainNavbar?.classList.contains('show')) document.body.classList.remove('modal-open')
  140. }
  141. document.getElementById('openLanguageModal')?.addEventListener('click', () => openModal('languageModal'))
  142. document.getElementById('openLanguageModalMobile')?.addEventListener('click', () => openModal('languageModal'))
  143. document.getElementById('openLanguageModalFooter')?.addEventListener('click', () => openModal('languageModal'))
  144. document.querySelectorAll('[data-close-modal]').forEach((btn) => btn.addEventListener('click', () => closeModal(btn.dataset.closeModal)))
  145. document.querySelectorAll('.custom-modal').forEach((modal) => {
  146. modal.addEventListener('click', (e) => {
  147. if (e.target === modal) closeModal(modal.id)
  148. })
  149. })
  150. const headerInlineSearch = document.getElementById('headerInlineSearch')
  151. const openHeaderSearchBtn = document.getElementById('openHeaderSearch')
  152. const closeHeaderSearchBtn = document.getElementById('closeHeaderSearch')
  153. const headerInlineSearchInput = document.getElementById('headerInlineSearchInput')
  154. const headerInlineSearchForm = document.getElementById('headerInlineSearchForm')
  155. const openHeaderSearch = () => {
  156. if (!headerInlineSearch || window.innerWidth < 992) return
  157. siteHeader?.classList.add('header-search-open', 'scrolled')
  158. setHeroHeaderTheme(true)
  159. headerInlineSearch.classList.add('is-open')
  160. headerInlineSearch.setAttribute('aria-hidden', 'false')
  161. document.body.classList.add('modal-open')
  162. setTimeout(() => {
  163. headerInlineSearchInput?.focus()
  164. if (headerInlineSearchInput?.value) {
  165. headerInlineSearchInput.setSelectionRange(0, headerInlineSearchInput.value.length)
  166. }
  167. }, 60)
  168. }
  169. const closeHeaderSearch = () => {
  170. if (!headerInlineSearch) return
  171. siteHeader?.classList.remove('header-search-open')
  172. headerInlineSearch.classList.remove('is-open')
  173. headerInlineSearch.setAttribute('aria-hidden', 'true')
  174. headerInlineSearchInput?.blur()
  175. if (!mainNavbar?.classList.contains('show')) document.body.classList.remove('modal-open')
  176. if (isHomeNav) {
  177. onScroll()
  178. if (window.scrollY <= 30 && !mainNavbar?.classList.contains('show')) syncHeroHeaderThemeFromSlide()
  179. }
  180. }
  181. openHeaderSearchBtn?.addEventListener('click', (e) => {
  182. e.preventDefault()
  183. e.stopPropagation()
  184. openHeaderSearch()
  185. })
  186. closeHeaderSearchBtn?.addEventListener('click', (e) => {
  187. e.preventDefault()
  188. e.stopPropagation()
  189. closeHeaderSearch()
  190. })
  191. document.getElementById('openSearchModalMobile')?.addEventListener('click', () => {
  192. window.location.href = '/search'
  193. })
  194. headerInlineSearchForm?.addEventListener('submit', (e) => {
  195. const keyword = (headerInlineSearchInput?.value || '').trim()
  196. if (!keyword) {
  197. e.preventDefault()
  198. headerInlineSearchInput?.focus()
  199. headerInlineSearchInput?.setCustomValidity(document.documentElement.lang === 'en' ? 'Please enter a keyword' : '请输入搜索关键词')
  200. headerInlineSearchInput?.reportValidity()
  201. return
  202. }
  203. headerInlineSearchInput?.setCustomValidity('')
  204. })
  205. headerInlineSearchInput?.addEventListener('input', (e) => {
  206. e.target.setCustomValidity('')
  207. })
  208. document.addEventListener('keydown', (e) => {
  209. if (e.key === 'Escape' && headerInlineSearch?.classList.contains('is-open')) closeHeaderSearch()
  210. })
  211. document.querySelectorAll('.footer-social-item').forEach((item) => {
  212. const closeAll = () => document.querySelectorAll('.footer-social-item.is-open').forEach((el) => el.classList.remove('is-open'))
  213. item.addEventListener('mouseenter', () => item.classList.add('is-open'))
  214. item.addEventListener('mouseleave', () => item.classList.remove('is-open'))
  215. item.addEventListener('click', (e) => {
  216. if (window.matchMedia('(hover: none)').matches) {
  217. e.preventDefault()
  218. const willOpen = !item.classList.contains('is-open')
  219. closeAll()
  220. if (willOpen) item.classList.add('is-open')
  221. }
  222. })
  223. item.addEventListener('focusin', () => item.classList.add('is-open'))
  224. item.addEventListener('focusout', () => item.classList.remove('is-open'))
  225. document.addEventListener('click', (e) => {
  226. if (!item.contains(e.target)) item.classList.remove('is-open')
  227. })
  228. })
  229. const resetSlideVideos = (selector) => {
  230. document.querySelectorAll(selector).forEach((video) => {
  231. try { video.pause(); video.currentTime = 0 } catch {}
  232. video.classList.remove('is-ready')
  233. })
  234. }
  235. const playSlideVideo = (slide) => {
  236. if (!slide) return
  237. const video = slide.querySelector('.slide-video')
  238. if (!video) return
  239. const showVideo = () => video.classList.add('is-ready')
  240. if (video.readyState >= 2) {
  241. showVideo()
  242. } else {
  243. video.addEventListener('loadeddata', showVideo, { once: true })
  244. video.addEventListener('canplay', showVideo, { once: true })
  245. }
  246. video.play().then(showVideo).catch(() => {})
  247. }
  248. document.querySelectorAll('.slide-media-layer, .card-media-layer').forEach((wrap) => {
  249. wrap.addEventListener('click', () => {
  250. playSlideVideo(wrap.closest('.swiper-slide') || wrap)
  251. })
  252. })
  253. if (document.querySelector('#heroSwiper') && window.Swiper) {
  254. const heroSlides = Array.from(document.querySelectorAll('#heroSwiper .swiper-slide[data-bullet-title]'))
  255. const heroBulletTitles = heroSlides.map((slide) => slide.getAttribute('data-bullet-title') || '')
  256. new Swiper('#heroSwiper', {
  257. loop: true,
  258. speed: 900,
  259. effect: 'fade',
  260. fadeEffect: { crossFade: true },
  261. autoplay: { delay: 5000, disableOnInteraction: false },
  262. navigation: { nextEl: '.hero-next', prevEl: '.hero-prev' },
  263. pagination: {
  264. el: '.hero-pagination',
  265. clickable: true,
  266. renderBullet: function (index, className) {
  267. return `<span class="${className} hero-bar-bullet"><em>${heroBulletTitles[index] || ''}</em></span>`
  268. },
  269. },
  270. on: {
  271. init(swiper) {
  272. syncHeroHeaderThemeFromSlide()
  273. playSlideVideo(swiper.slides[swiper.activeIndex])
  274. },
  275. slideChangeTransitionStart: () => {
  276. resetSlideVideos('#heroSwiper .slide-video')
  277. syncHeroHeaderThemeFromSlide()
  278. },
  279. slideChangeTransitionEnd(swiper) {
  280. playSlideVideo(swiper.slides[swiper.activeIndex])
  281. }
  282. },
  283. })
  284. syncHeroHeaderThemeFromSlide()
  285. }
  286. if (document.querySelector('#showcaseSwiper') && window.Swiper) {
  287. const showcaseEl = document.querySelector('#showcaseSwiper')
  288. const showcaseWrapper = showcaseEl ? showcaseEl.querySelector('.swiper-wrapper') : null
  289. if (showcaseWrapper) {
  290. const originalSlides = Array.from(showcaseWrapper.querySelectorAll('.swiper-slide'))
  291. if (originalSlides.length > 0 && originalSlides.length < 4) {
  292. let i = 0
  293. while (showcaseWrapper.querySelectorAll('.swiper-slide').length < 4) {
  294. const clone = originalSlides[i % originalSlides.length].cloneNode(true)
  295. clone.classList.add('showcase-card-fill-js')
  296. showcaseWrapper.appendChild(clone)
  297. i++
  298. }
  299. }
  300. }
  301. new Swiper('#showcaseSwiper', {
  302. loop: false,
  303. rewind: true,
  304. speed: 800,
  305. centeredSlides: false,
  306. watchOverflow: false,
  307. slidesPerView: 1.08,
  308. spaceBetween: 12,
  309. autoplay: {
  310. delay: 4500,
  311. disableOnInteraction: false,
  312. pauseOnMouseEnter: true
  313. },
  314. navigation: {
  315. nextEl: '.showcase-next',
  316. prevEl: '.showcase-prev'
  317. },
  318. pagination: {
  319. el: '.showcase-pagination',
  320. clickable: true
  321. },
  322. breakpoints: {
  323. 768: {
  324. slidesPerView: 1.3,
  325. spaceBetween: 12
  326. },
  327. 992: {
  328. slidesPerView: 2,
  329. spaceBetween: 12
  330. }
  331. },
  332. on: {
  333. init(swiper) {
  334. const bullets = swiper.pagination && swiper.pagination.bullets ? swiper.pagination.bullets : []
  335. bullets.forEach((bullet, index) => {
  336. bullet.style.setProperty('--showcase-progress-delay', index === swiper.activeIndex ? '0ms' : '0ms')
  337. })
  338. },
  339. slideChangeTransitionStart(swiper) {
  340. resetSlideVideos('#showcaseSwiper .slide-video')
  341. const bullets = swiper.pagination && swiper.pagination.bullets ? swiper.pagination.bullets : []
  342. bullets.forEach((bullet) => {
  343. bullet.style.animation = 'none'
  344. void bullet.offsetWidth
  345. bullet.style.animation = ''
  346. })
  347. }
  348. }
  349. })
  350. }
  351. const backToTopBtn = document.getElementById('backToTopBtn')
  352. const backToTopShowOffset = 320
  353. const feedbackFloatForm = document.getElementById('feedbackFloatForm')
  354. const feedbackFloatRatingInput = document.getElementById('feedbackFloatRating')
  355. const feedbackFloatSubmit = document.getElementById('feedbackFloatSubmit')
  356. const feedbackFloatOptions = document.querySelectorAll('.feedback-float-option')
  357. const feedbackFloatPrompt = document.querySelector('.feedback-float-rating-block h3')
  358. const feedbackFloatMessage = document.getElementById('feedbackFloatMessage')
  359. const feedbackSourcePageInput = feedbackFloatForm?.querySelector('input[name="source_page"]')
  360. const feedbackStars = Array.from(document.querySelectorAll('.feedback-star'))
  361. const feedbackPromptMap = {
  362. website: '请对“官网体验”进行评价。',
  363. service: '请对“服务体验”进行评价。',
  364. product: '请对“产品体验”进行评价。'
  365. }
  366. const syncFeedbackPrompt = (value) => {
  367. const key = value || 'website'
  368. if (feedbackFloatPrompt && feedbackPromptMap[key]) feedbackFloatPrompt.textContent = feedbackPromptMap[key]
  369. }
  370. const setFeedbackMessage = (text, type = '') => {
  371. if (!feedbackFloatMessage) return
  372. feedbackFloatMessage.textContent = text || ''
  373. feedbackFloatMessage.className = 'feedback-float-message' + (type ? ` is-${type}` : '')
  374. }
  375. const syncBackToTopVisibility = () => {
  376. if (!backToTopBtn) return
  377. const isVisible = window.scrollY > backToTopShowOffset
  378. backToTopBtn.classList.toggle('is-visible', isVisible)
  379. }
  380. backToTopBtn?.addEventListener('click', () => {
  381. window.scrollTo({ top: 0, behavior: 'smooth' })
  382. })
  383. window.addEventListener('scroll', syncBackToTopVisibility, { passive: true })
  384. syncBackToTopVisibility()
  385. const setFeedbackRating = (value) => {
  386. const rating = Number(value) || 0
  387. if (feedbackFloatRatingInput) feedbackFloatRatingInput.value = String(rating)
  388. feedbackStars.forEach((star) => {
  389. const starValue = Number(star.dataset.value || 0)
  390. star.classList.toggle('is-active', starValue <= rating)
  391. })
  392. if (feedbackFloatSubmit) feedbackFloatSubmit.disabled = rating < 1
  393. }
  394. feedbackStars.forEach((star) => {
  395. star.addEventListener('click', () => setFeedbackRating(star.dataset.value || 0))
  396. })
  397. feedbackFloatOptions.forEach((item) => {
  398. const input = item.querySelector('input[type="radio"]')
  399. input?.addEventListener('change', () => {
  400. feedbackFloatOptions.forEach((node) => node.classList.remove('is-active'))
  401. item.classList.add('is-active')
  402. const value = input.value || 'website'
  403. syncFeedbackPrompt(value)
  404. setFeedbackRating(0)
  405. setFeedbackMessage('')
  406. })
  407. })
  408. if (feedbackSourcePageInput) feedbackSourcePageInput.value = window.location.href
  409. syncFeedbackPrompt(document.querySelector('.feedback-float-option input[type="radio"]:checked')?.value || 'website')
  410. feedbackFloatForm?.addEventListener('submit', async (e) => {
  411. e.preventDefault()
  412. const rating = Number(feedbackFloatRatingInput?.value || 0) || 0
  413. if (rating < 1) return
  414. const submitUrl = feedbackFloatForm.dataset.submitUrl || ''
  415. if (!submitUrl) {
  416. setFeedbackMessage('提交地址缺失', 'error')
  417. return
  418. }
  419. const originalText = feedbackFloatSubmit?.textContent || '提交'
  420. if (feedbackFloatSubmit) {
  421. feedbackFloatSubmit.disabled = true
  422. feedbackFloatSubmit.textContent = '提交中...'
  423. }
  424. setFeedbackMessage('')
  425. try {
  426. const body = new URLSearchParams(new FormData(feedbackFloatForm))
  427. const res = await fetch(submitUrl, {
  428. method: 'POST',
  429. headers: {
  430. 'X-Requested-With': 'XMLHttpRequest',
  431. 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
  432. },
  433. body: body.toString()
  434. })
  435. const data = await res.json()
  436. if (data.code === 1) {
  437. setFeedbackMessage(data.msg || '提交成功,感谢你的评价', 'success')
  438. feedbackFloatForm.reset()
  439. feedbackFloatOptions.forEach((node, index) => node.classList.toggle('is-active', index === 0))
  440. syncFeedbackPrompt('website')
  441. setFeedbackRating(0)
  442. } else {
  443. setFeedbackMessage(data.msg || '提交失败,请稍后再试', 'error')
  444. if (feedbackFloatSubmit) feedbackFloatSubmit.disabled = rating < 1
  445. }
  446. } catch (err) {
  447. setFeedbackMessage('提交失败,请稍后再试', 'error')
  448. if (feedbackFloatSubmit) feedbackFloatSubmit.disabled = rating < 1
  449. } finally {
  450. if (feedbackFloatSubmit) feedbackFloatSubmit.textContent = originalText
  451. }
  452. })