报名表单
+仅在报名阶段开放
+当前不在报名时间段 请关注后续通知
+ +未检测到 Formidable Forms 插件 请先启用插件
+ +请在 设置 > 招新设置 中填写报名表单 Shortcode
+ + + +diff --git a/README.md b/README.md index 77e74b7..61349de 100644 --- a/README.md +++ b/README.md @@ -213,3 +213,69 @@ MIT License - 详见 LICENSE 文件 - ✅ 响应式设计完成 - ✅ 主题切换功能 - ✅ 自定义文章类型(公告) + +--- + +## 加入我们页面(/join)使用说明 + +主题已内置「加入我们」页面模板(`page-join.php`),并支持在没有创建 WordPress 页面时通过 `/join` 自动 fallback 渲染。 + +### 1. 需要安装的插件 + +1. **Formidable Forms** +用于报名表单、进度查询表单、公示视图(可选使用 Formidable Views)。 +2. **WP Mail SMTP** +用于 Formidable 提交邮件发送(站点邮件走 SMTP)。 + +### 2. 后台时间节点设置 + +进入:**设置 > 招新设置** + +可配置字段: +- 报名开始时间(datetime) +- 报名结束时间(datetime) +- 第一次面试日期(date) +- 第二次面试日期(date) +- 录取公示开始日期(date,系统自动延续 7 天) +- 报名表单 Shortcode +- 结果查询 Shortcode +- 公示视图 Shortcode + +说明: +- 「国庆能力摸底阶段」固定为每年 **10/01 - 10/07**。 +- 年份优先取报名开始时间年份;若未设置,则取公示开始年份;仍未设置则取当前年份。 + +### 3. 前台显示逻辑 + +- 报名表单:仅在报名阶段显示。 +- 结果查询表单:从报名开始到公示结束前可用。 +- 公示视图:仅在公示 7 天窗口内显示。 +- Canvas 进度条:根据当前时间自动高亮阶段(已完成/进行中/未开始)。 + +### 4. Formidable 推荐配置方式 + +1. 新建报名表单,复制 shortcode,填入「报名表单 Shortcode」。 +2. 新建结果查询表单(例如:学号/邮箱/手机号 + 状态查询逻辑),填入「结果查询 Shortcode」。 +3. 如需公示名单展示,创建 Formidable View,复制 shortcode,填入「公示视图 Shortcode」。 + +### 5. WP Mail SMTP 配置建议 + +1. 安装并启用 **WP Mail SMTP**。 +2. 在插件中填写 SMTP 服务信息(Host、Port、加密方式、账号密码)。 +3. 设置发件人邮箱与发件人名称。 +4. 使用插件自带发送测试邮件,确认可达后再开放报名。 + +## Join Page Quick Guide (ASCII) + +- Admin path: Settings -> Recruitment Settings. +- Configure timeline nodes: registration start/end, interview I date, interview II date, notice start date. +- Stage "National Day Assessment" is fixed to Oct 1 - Oct 7 (auto year). +- Set Formidable shortcodes: + - registration form shortcode + - progress lookup shortcode + - public notice view shortcode +- Frontend visibility rules: + - registration form: only in registration window + - lookup form: from registration start until notice window ends + - public notice view: only during 7-day notice window +- Mail delivery: configure WP Mail SMTP plugin for Formidable emails. diff --git a/assets/css/join-page.css b/assets/css/join-page.css new file mode 100644 index 0000000..7f12f36 --- /dev/null +++ b/assets/css/join-page.css @@ -0,0 +1,475 @@ +.join-page { + padding: 36px 0 72px; +} + +.join-head { + margin-bottom: 26px; +} + +.join-title { + margin: 0; + font-size: clamp(2.1rem, 1.9vw + 1.3rem, 3.2rem); + line-height: 1.08; + color: var(--color-primary); +} + +.join-hero { + display: flex; + flex-direction: column; + gap: 16px; + margin-bottom: 34px; +} + +.join-canvas-shell { + position: relative; + border: 1px solid var(--border-default); + border-radius: 18px; + overflow: hidden; + background: color-mix(in srgb, var(--bg-card) 94%, transparent); + box-shadow: var(--shadow-sm); +} + +.join-stage-photo-frame { + position: relative; + overflow: hidden; +} + +.join-stage-photo { + width: 100%; + height: clamp(220px, 28vw, 360px); + object-fit: cover; + display: block; +} + +.join-wave-layer { + position: relative; + margin-top: clamp(-20px, -2.4vw, -12px); + z-index: 1; +} + +.join-progress-canvas { + display: block; + width: 100%; + height: clamp(56px, 5.8vw, 82px); +} + +.join-wave-progress { + position: absolute; + inset: 0; + pointer-events: none; + z-index: 2; +} + +.join-wave-track { + position: relative; + position: absolute; + left: clamp(18px, 2.2vw, 30px); + right: clamp(18px, 2.2vw, 30px); + top: 68%; + height: 4px; + border-radius: 999px; + transform: translateY(-50%); + background: color-mix(in srgb, var(--text-secondary) 26%, transparent); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--border-default) 60%, transparent); +} + +.join-wave-fill { + position: absolute; + top: 0; + left: 0; + bottom: 0; + width: 0; + border-radius: inherit; + background: linear-gradient( + 90deg, + color-mix(in srgb, var(--color-primary) 56%, transparent) 0%, + color-mix(in srgb, var(--color-primary) 82%, #7ed7ff 18%) 100% + ); + box-shadow: 0 0 10px color-mix(in srgb, var(--color-primary) 35%, transparent); +} + +.join-wave-marks { + position: absolute; + inset: 0; + overflow: visible; +} + +.join-wave-mark { + --mark-wave-offset: 0px; + --mark-tilt: 0deg; + position: absolute; + top: 50%; + width: clamp(20px, 1.9vw, 27px); + transform: translate(-50%, calc(-58% + var(--mark-wave-offset))) rotate(var(--mark-tilt)); + transform-origin: 50% 78%; + filter: drop-shadow(0 2px 6px color-mix(in srgb, #00142b 50%, transparent)); + will-change: transform; +} + +.join-wave-mark.is-lighthouse { + width: clamp(22px, 2vw, 30px); + transform: translate(-50%, -80%); + transform-origin: 50% 96%; +} + +.join-wave-mark.is-active { + filter: drop-shadow(0 0 10px color-mix(in srgb, var(--color-primary) 36%, transparent)); +} + +.join-wave-mark-icon { + display: block; + width: 100%; + height: auto; + transform-origin: 50% 80%; + will-change: transform, opacity; +} + +.join-wave-mark svg { + display: block; + width: 100%; + height: auto; +} + +.join-wave-mark .marker-buoy-ring { + fill: color-mix(in srgb, #5fa0df 72%, #8ec8ff 28%); +} + +.join-wave-mark .marker-buoy-base { + fill: color-mix(in srgb, #fbfdff 92%, #d5e9ff 8%); +} + +.join-wave-mark .marker-buoy-stripe { + fill: color-mix(in srgb, #f6814c 70%, #ec4d44 30%); +} + +.join-wave-mark .marker-buoy-cap { + fill: color-mix(in srgb, #29497d 74%, #1c2e57 26%); +} + +.join-wave-mark .marker-buoy-light { + fill: color-mix(in srgb, #ffe083 72%, #ffc74a 28%); +} + +.join-wave-mark .marker-buoy-gloss { + fill: color-mix(in srgb, #ffffff 88%, transparent); +} + +.join-wave-mark .marker-lh-base { + fill: color-mix(in srgb, var(--color-primary) 70%, #0d2e62 30%); +} + +.join-wave-mark .marker-lh-tower { + fill: color-mix(in srgb, #f6fbff 88%, var(--color-primary) 12%); +} + +.join-wave-mark .marker-lh-top { + fill: color-mix(in srgb, #d44545 72%, #a52f2f 28%); +} + +.join-wave-mark .marker-lh-beam { + fill: color-mix(in srgb, #ffef9f 64%, transparent); + opacity: 0.28; +} + +.join-wave-mark .marker-reef-rock { + fill: color-mix(in srgb, #365a7f 72%, #2a4868 28%); +} + +.join-wave-mark .marker-reef-highlight { + fill: color-mix(in srgb, #5f82a6 64%, #486a8e 36%); +} + +.join-wave-boat { + --boat-wave-offset: 0px; + --boat-tilt: 0deg; + --boat-waterline-adjust: -4px; + position: absolute; + top: 50%; + left: 0; + width: clamp(42px, 4.4vw, 58px); + transform: translate(-50%, calc(-62% + var(--boat-waterline-adjust) + var(--boat-wave-offset))) rotate(var(--boat-tilt)); + color: color-mix(in srgb, var(--color-primary) 78%, #8ed5ff 22%); + filter: drop-shadow(0 3px 8px color-mix(in srgb, #00132f 60%, transparent)); + will-change: transform, left; +} + +.join-wave-boat-icon { + display: block; + transform-origin: 50% 60%; +} + +.join-wave-boat svg { + display: block; + width: 100%; + height: auto; +} + +.join-wave-boat .boat-hull { + fill: color-mix(in srgb, var(--color-primary) 64%, #102e5f 36%); +} + +.join-wave-boat .boat-sail-main { + fill: color-mix(in srgb, #f2f9ff 90%, var(--color-primary) 10%); +} + +.join-wave-boat .boat-sail-side { + fill: color-mix(in srgb, #cbe6ff 80%, var(--color-primary) 20%); +} + +.join-wave-boat .boat-mast { + fill: color-mix(in srgb, var(--text-primary) 75%, #294b7a 25%); +} + +.join-wave-boat .boat-porthole { + fill: color-mix(in srgb, #eef6ff 85%, var(--color-primary) 15%); +} + +.join-canvas-overlay { + position: absolute; + top: 16px; + left: 16px; + right: 16px; + z-index: 2; + max-width: 540px; + padding: 14px 16px; + border-radius: 12px; + border: 1px solid color-mix(in srgb, var(--border-default) 72%, transparent); + background: color-mix(in srgb, var(--bg-body) 68%, transparent); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); +} + +.join-current-label { + margin: 0; + font-size: 0.84rem; + letter-spacing: 0.04em; + color: var(--text-secondary); +} + +.join-current-stage { + margin: 4px 0 0; + font-size: clamp(1.2rem, 0.9vw + 1rem, 1.7rem); + line-height: 1.25; + color: var(--text-primary); +} + +.join-current-range { + margin: 6px 0 0; + font-size: 0.92rem; + color: var(--text-secondary); +} + +.join-stage-list { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 10px; + margin: 0; + padding: 0; + list-style: none; +} + +.join-stage-item { + border: 1px solid var(--border-default); + border-radius: 10px; + background: color-mix(in srgb, var(--bg-card) 95%, transparent); + padding: 10px 11px; + min-height: 98px; +} + +.join-stage-item.is-current { + border-color: color-mix(in srgb, var(--color-primary) 52%, var(--border-default) 48%); + box-shadow: var(--shadow-sm); +} + +.join-stage-item.is-completed .join-stage-status { + color: #0f8a4f; +} + +.join-stage-item.is-active .join-stage-status { + color: var(--color-primary); +} + +.join-stage-item.is-upcoming .join-stage-status, +.join-stage-item.is-pending .join-stage-status { + color: var(--text-secondary); +} + +.join-stage-title-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; + column-gap: 8px; + row-gap: 4px; +} + +.join-stage-name { + margin: 0; + min-width: 0; + color: var(--text-primary); + font-size: 0.94rem; + line-height: 1.3; + overflow-wrap: anywhere; + word-break: break-word; +} + +.join-stage-status { + flex-shrink: 0; + white-space: nowrap; + align-self: start; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.02em; +} + +.join-stage-time { + margin: 8px 0 0; + color: var(--text-secondary); + font-size: 0.8rem; + line-height: 1.45; +} + +.join-forms-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} + +.join-form-card { + border: 1px solid var(--border-default); + border-radius: 14px; + background: color-mix(in srgb, var(--bg-card) 95%, transparent); + padding: 18px; +} + +.join-form-card-full { + grid-column: 1 / -1; +} + +.join-form-head h2 { + margin: 0; + color: var(--text-primary); + font-size: 1.22rem; +} + +.join-form-head p { + margin: 6px 0 0; + color: var(--text-secondary); + font-size: 0.9rem; +} + +.join-form-content { + margin-top: 14px; +} + +.join-form-content form { + margin: 0; +} + +.join-form-content .frm_forms { + margin: 0; +} + +.join-form-content .frm_style_formidable-style.with_frm_style .frm_form_fields, +.join-form-content .frm_style_formidable-style.with_frm_style .frm_submit { + max-width: none; +} + +.join-form-content .frm_style_formidable-style.with_frm_style input[type="text"], +.join-form-content .frm_style_formidable-style.with_frm_style input[type="email"], +.join-form-content .frm_style_formidable-style.with_frm_style input[type="number"], +.join-form-content .frm_style_formidable-style.with_frm_style input[type="tel"], +.join-form-content .frm_style_formidable-style.with_frm_style input[type="date"], +.join-form-content .frm_style_formidable-style.with_frm_style input[type="datetime-local"], +.join-form-content .frm_style_formidable-style.with_frm_style textarea, +.join-form-content .frm_style_formidable-style.with_frm_style select { + background: color-mix(in srgb, var(--bg-body) 90%, transparent); + border-color: var(--border-default); + color: var(--text-primary); +} + +.join-form-content .frm_style_formidable-style.with_frm_style input[type="submit"], +.join-form-content .frm_style_formidable-style.with_frm_style button.frm_button_submit { + border-radius: 9px; + border: 1px solid color-mix(in srgb, var(--color-primary) 62%, var(--border-default) 38%); + background: color-mix(in srgb, var(--color-primary) 10%, transparent); + color: var(--color-primary); +} + +.join-form-tip { + margin: 0; + padding: 14px; + border-radius: 10px; + border: 1px dashed var(--border-default); + color: var(--text-secondary); + font-size: 0.92rem; + line-height: 1.65; +} + +.join-form-footnote { + margin-top: 12px; + color: var(--text-secondary); + font-size: 0.82rem; +} + +@media (max-width: 1080px) { + .join-stage-list { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +@media (max-width: 900px) { + .join-page { + padding: 30px 0 60px; + } + + .join-stage-list { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .join-forms-grid { + grid-template-columns: 1fr; + } + + .join-form-card-full { + grid-column: auto; + } +} + +@media (max-width: 640px) { + .join-head { + margin-bottom: 22px; + } + + .join-hero { + margin-bottom: 24px; + } + + .join-canvas-overlay { + top: 12px; + left: 12px; + right: 12px; + padding: 10px 11px; + } + + .join-stage-photo { + height: 230px; + } + + .join-current-stage { + margin-top: 2px; + font-size: 1.1rem; + } + + .join-current-range { + margin-top: 5px; + font-size: 0.82rem; + } + + .join-stage-list { + grid-template-columns: 1fr; + } + + .join-stage-item { + min-height: 0; + } +} diff --git a/assets/js/join-canvas.js b/assets/js/join-canvas.js new file mode 100644 index 0000000..bad553e --- /dev/null +++ b/assets/js/join-canvas.js @@ -0,0 +1,496 @@ +(() => { + const canvas = document.getElementById('joinProgressCanvas'); + if (!canvas) { + return; + } + + const context = canvas.getContext('2d'); + if (!context) { + return; + } + + const animeLib = window.anime; + const hasAnime = typeof animeLib === 'function'; + + const waveTrack = document.getElementById('joinWaveTrack'); + const waveFill = document.getElementById('joinWaveFill'); + const waveBoat = document.getElementById('joinWaveBoat'); + const waveMarks = document.getElementById('joinWaveMarks'); + const joinData = window.itstudioJoinData && typeof window.itstudioJoinData === 'object' + ? window.itstudioJoinData + : {}; + + const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); + + let width = 0; + let height = 0; + let dpr = 1; + let rafId = 0; + let targetProgress = 0; + let displayedProgress = 0; + let trackLeft = 0; + let trackWidth = 0; + let stageMarkers = []; + + const waveShape1 = { base: 0.16, amp: 7.5, len: 280, speed: 0.9 }; + const waveShape2 = { base: 0.44, amp: 6, len: 340, speed: 0.75 }; + const waveShape3 = { base: 0.68, amp: 6.5, len: 400, speed: 0.62 }; + + const palettes = { + light: { + wave1: 'rgba(198, 227, 255, 0.92)', + wave2: 'rgba(142, 193, 236, 0.4)', + wave3: 'rgba(84, 151, 214, 0.48)', + }, + dark: { + wave1: 'rgba(132, 190, 242, 0.88)', + wave2: 'rgba(88, 151, 214, 0.4)', + wave3: 'rgba(56, 118, 183, 0.48)', + }, + }; + + function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)); + } + + function getTheme() { + return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light'; + } + + function getNumber(value) { + const numeric = Number(value); + return Number.isFinite(numeric) ? numeric : null; + } + + function getNavigationType() { + if (window.performance && typeof window.performance.getEntriesByType === 'function') { + const entries = window.performance.getEntriesByType('navigation'); + if (entries && entries.length > 0 && entries[0].type) { + return entries[0].type; + } + } + + if (window.performance && window.performance.navigation) { + if (window.performance.navigation.type === 1) { + return 'reload'; + } + if (window.performance.navigation.type === 2) { + return 'back_forward'; + } + } + + return 'navigate'; + } + + function shouldAnimateEntry() { + if (prefersReducedMotion.matches) { + return false; + } + + return getNavigationType() !== 'reload'; + } + + function resize() { + dpr = window.devicePixelRatio || 1; + width = canvas.clientWidth; + height = canvas.clientHeight; + canvas.width = Math.max(1, Math.floor(width * dpr)); + canvas.height = Math.max(1, Math.floor(height * dpr)); + context.setTransform(dpr, 0, 0, dpr, 0, 0); + updateTrackMetrics(); + } + + function updateTrackMetrics() { + if (!waveTrack) { + trackLeft = 0; + trackWidth = width; + return; + } + + const canvasRect = canvas.getBoundingClientRect(); + const trackRect = waveTrack.getBoundingClientRect(); + trackLeft = clamp(trackRect.left - canvasRect.left, 0, width); + trackWidth = Number.isFinite(trackRect.width) && trackRect.width > 0 ? trackRect.width : width; + } + + function drawWave(color, yBase, amplitude, wavelength, speed, time) { + const overscan = 24; + const step = 6; + context.beginPath(); + context.moveTo(-overscan, height + 2); + for (let x = -overscan; x <= width + overscan; x += step) { + const theta = (x / wavelength) * Math.PI * 2 + time * speed; + const y = yBase + Math.sin(theta) * amplitude; + context.lineTo(x, y); + } + context.lineTo(width + overscan, height + 2); + context.lineTo(-overscan, height + 2); + context.closePath(); + context.fillStyle = color; + context.fill(); + } + + function sampleWaveAtX(shape, x, time) { + const theta = (x / shape.len) * Math.PI * 2 + time * shape.speed; + const y = Math.sin(theta) * shape.amp; + const slope = Math.cos(theta) * (Math.PI * 2 / shape.len) * shape.amp; + return { y, slope }; + } + + function getWaveXFromProgress(progress) { + const safeProgress = clamp(progress, 0, 1); + const currentTrackWidth = trackWidth > 0 ? trackWidth : width; + return trackLeft + (safeProgress * currentTrackWidth); + } + + function applyFloatingMotion(time) { + if (width <= 0) { + return; + } + + const boatWave = sampleWaveAtX(waveShape3, getWaveXFromProgress(displayedProgress), time); + if (waveBoat) { + const boatTilt = clamp(boatWave.slope * 34, -7.2, 7.2); + waveBoat.style.setProperty('--boat-wave-offset', `${boatWave.y.toFixed(2)}px`); + waveBoat.style.setProperty('--boat-tilt', `${boatTilt.toFixed(2)}deg`); + } + + stageMarkers.forEach((marker) => { + if (marker.type === 'lighthouse') { + marker.element.style.setProperty('--mark-wave-offset', '0px'); + marker.element.style.setProperty('--mark-tilt', '0deg'); + return; + } + + const markerWave = sampleWaveAtX(waveShape3, getWaveXFromProgress(marker.progress), time); + const markerTilt = clamp(markerWave.slope * 34, -7.2, 7.2); + marker.element.style.setProperty('--mark-wave-offset', `${markerWave.y.toFixed(2)}px`); + marker.element.style.setProperty('--mark-tilt', `${markerTilt.toFixed(2)}deg`); + }); + } + + function renderWaves(timestamp) { + const theme = getTheme(); + const palette = palettes[theme]; + const time = timestamp * 0.001; + + context.clearRect(0, 0, width, height); + drawWave(palette.wave1, height * waveShape1.base, waveShape1.amp, waveShape1.len, waveShape1.speed, time); + drawWave(palette.wave2, height * waveShape2.base, waveShape2.amp, waveShape2.len, waveShape2.speed, time); + drawWave(palette.wave3, height * waveShape3.base, waveShape3.amp, waveShape3.len, waveShape3.speed, time); + applyFloatingMotion(time); + + rafId = requestAnimationFrame(renderWaves); + } + + function stopWaves() { + if (rafId) { + cancelAnimationFrame(rafId); + rafId = 0; + } + } + + function drawStaticWaves() { + const theme = getTheme(); + const palette = palettes[theme]; + + context.clearRect(0, 0, width, height); + drawWave(palette.wave1, height * waveShape1.base, waveShape1.amp, waveShape1.len, waveShape1.speed, 0); + drawWave(palette.wave2, height * waveShape2.base, waveShape2.amp, waveShape2.len, waveShape2.speed, 0); + drawWave(palette.wave3, height * waveShape3.base, waveShape3.amp, waveShape3.len, waveShape3.speed, 0); + applyFloatingMotion(0); + } + + function startWaves() { + stopWaves(); + resize(); + if (prefersReducedMotion.matches) { + drawStaticWaves(); + return; + } + rafId = requestAnimationFrame(renderWaves); + } + + function computeTargetProgress() { + const stages = Array.isArray(joinData.stages) ? joinData.stages : []; + if (!stages.length) { + return 0; + } + + const denominator = stages.length > 1 ? (stages.length - 1) : 1; + const currentIndex = getNumber(joinData.currentStageIndex); + + // 有进行中阶段时,船严格对齐对应浮标节点。 + if (currentIndex !== null && currentIndex >= 0) { + return clamp(currentIndex / denominator, 0, 1); + } + + // 无进行中阶段时,停在最近已完成阶段节点。 + let lastCompletedIndex = -1; + for (let i = 0; i < stages.length; i += 1) { + if (stages[i] && stages[i].status === 'completed') { + lastCompletedIndex = i; + } + } + + if (lastCompletedIndex >= 0) { + return clamp(lastCompletedIndex / denominator, 0, 1); + } + + return 0; + } + + function getBuoyMarkerSvg() { + return ` + + `; + } + + function getLighthouseMarkerSvg() { + return ` + + `; + } + + function renderStageMarks() { + if (!waveMarks) { + return; + } + + stageMarkers = []; + waveMarks.innerHTML = ''; + + const stages = Array.isArray(joinData.stages) ? joinData.stages : []; + if (!stages.length) { + return; + } + + const currentIndex = getNumber(joinData.currentStageIndex); + const safeCurrentIndex = currentIndex === null ? -1 : Math.round(currentIndex); + const denominator = stages.length > 1 ? (stages.length - 1) : 1; + + stages.forEach((stage, index) => { + const marker = document.createElement('span'); + marker.className = 'join-wave-mark'; + const progress = denominator > 0 ? (index / denominator) : 0; + marker.style.left = `${(progress * 100).toFixed(3)}%`; + + const isLighthouse = index === stages.length - 1 || (stage && stage.key === 'public_notice'); + marker.classList.add(isLighthouse ? 'is-lighthouse' : 'is-buoy'); + if (index === safeCurrentIndex) { + marker.classList.add('is-active'); + } + + const icon = document.createElement('span'); + icon.className = 'join-wave-mark-icon'; + icon.innerHTML = isLighthouse ? getLighthouseMarkerSvg() : getBuoyMarkerSvg(); + marker.appendChild(icon); + + const beams = isLighthouse ? Array.from(icon.querySelectorAll('.marker-lh-beam')) : []; + + waveMarks.appendChild(marker); + stageMarkers.push({ + element: marker, + icon, + beams, + progress, + type: isLighthouse ? 'lighthouse' : 'buoy', + }); + }); + } + + function stopBoatAnimations() { + if (!hasAnime) { + return; + } + + const targets = [waveFill, waveBoat]; + stageMarkers.forEach((marker) => { + if (marker.icon) { + targets.push(marker.icon); + } + if (Array.isArray(marker.beams) && marker.beams.length) { + marker.beams.forEach((beam) => targets.push(beam)); + } + }); + animeLib.remove(targets); + } + + function startLighthouseBeacon() { + const allBeams = []; + const leftBeams = []; + const rightBeams = []; + + stageMarkers.forEach((marker) => { + if (marker.type !== 'lighthouse' || !Array.isArray(marker.beams)) { + return; + } + + marker.beams.forEach((beam) => { + allBeams.push(beam); + beam.style.opacity = '0.28'; + if (beam.classList.contains('marker-lh-beam-left')) { + leftBeams.push(beam); + } else if (beam.classList.contains('marker-lh-beam-right')) { + rightBeams.push(beam); + } + }); + }); + + if (!allBeams.length || !hasAnime || prefersReducedMotion.matches) { + return; + } + + const duration = 3600; + const easing = 'easeInOutSine'; + + if (rightBeams.length) { + animeLib({ + targets: rightBeams, + opacity: [0.2, 0.86, 0.2], + duration, + easing, + loop: true, + }); + } + + if (leftBeams.length) { + animeLib({ + targets: leftBeams, + opacity: [0.2, 0.82, 0.2], + duration, + delay: duration / 2, + easing, + loop: true, + }); + } else if (!rightBeams.length) { + animeLib({ + targets: allBeams, + opacity: [0.2, 0.84, 0.2], + duration, + easing, + loop: true, + }); + } + } + + function setProgress(value) { + const safe = clamp(value, 0, 1); + displayedProgress = safe; + const percent = `${(safe * 100).toFixed(3)}%`; + + if (waveFill) { + waveFill.style.width = percent; + } + if (waveBoat) { + waveBoat.style.left = percent; + } + } + + function animateBoatToTarget() { + if (!waveBoat || !waveFill) { + return; + } + + stopBoatAnimations(); + + if (!hasAnime || !shouldAnimateEntry()) { + setProgress(targetProgress); + startLighthouseBeacon(); + return; + } + + const state = { value: 0 }; + setProgress(0); + + animeLib({ + targets: state, + value: targetProgress, + duration: 1750, + easing: 'easeOutCubic', + update: () => { + setProgress(state.value); + }, + complete: () => { + setProgress(targetProgress); + startLighthouseBeacon(); + }, + }); + + animeLib({ + targets: waveFill, + opacity: [0.55, 1], + duration: 1100, + easing: 'easeOutSine', + }); + + const activeIcon = waveMarks ? waveMarks.querySelector('.join-wave-mark.is-active .join-wave-mark-icon') : null; + if (activeIcon) { + animeLib({ + targets: activeIcon, + scale: [1, 1.24, 1], + opacity: [0.82, 1, 0.92], + duration: 850, + delay: 980, + easing: 'easeOutSine', + }); + } + } + + function handleMotionPreferenceChange() { + targetProgress = computeTargetProgress(); + setProgress(targetProgress); + stopBoatAnimations(); + startWaves(); + startLighthouseBeacon(); + } + + const observer = new MutationObserver(() => { + if (prefersReducedMotion.matches) { + drawStaticWaves(); + } + }); + observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }); + + if (typeof prefersReducedMotion.addEventListener === 'function') { + prefersReducedMotion.addEventListener('change', handleMotionPreferenceChange); + } else if (typeof prefersReducedMotion.addListener === 'function') { + prefersReducedMotion.addListener(handleMotionPreferenceChange); + } + + window.addEventListener('resize', () => { + startWaves(); + setProgress(targetProgress); + }); + + window.addEventListener('orientationchange', () => { + startWaves(); + setProgress(targetProgress); + }); + + window.addEventListener('beforeunload', () => { + stopWaves(); + stopBoatAnimations(); + }); + + targetProgress = computeTargetProgress(); + renderStageMarks(); + startWaves(); + animateBoatToTarget(); +})(); diff --git a/functions.php b/functions.php index b0659fd..d3b0c6b 100644 --- a/functions.php +++ b/functions.php @@ -1104,3 +1104,829 @@ function itstudio_track_post_views() { update_post_meta($post_id, 'itstudio_views', $views); } add_action('template_redirect', 'itstudio_track_post_views', 20); + +function itstudio_is_join_page_context() { + $is_join = is_page('join') || is_page_template('page-join.php'); + if (!$is_join && is_404()) { + global $wp; + $request = isset($wp->request) ? trim((string) $wp->request, '/') : ''; + $is_join = ($request === 'join'); + } + + return $is_join; +} + +function itstudio_join_get_default_settings() { + return array( + 'registration_start' => '', + 'registration_end' => '', + 'first_interview_date' => '', + 'second_interview_date' => '', + 'notice_start_date' => '', + 'photo_registration' => 0, + 'photo_first_interview' => 0, + 'photo_assessment' => 0, + 'photo_second_interview' => 0, + 'photo_public_notice' => 0, + 'photo_inactive' => 0, + 'signup_form_shortcode' => '', + 'query_form_shortcode' => '', + 'notice_view_shortcode' => '', + ); +} + +function itstudio_join_get_photo_field_map() { + return array( + 'registration' => 'photo_registration', + 'first_interview' => 'photo_first_interview', + 'assessment' => 'photo_assessment', + 'second_interview' => 'photo_second_interview', + 'public_notice' => 'photo_public_notice', + 'inactive' => 'photo_inactive', + ); +} + +function itstudio_join_sanitize_datetime_local_value($value) { + $value = trim((string) $value); + if ($value === '') { + return ''; + } + + $value = preg_replace('/\s+/', 'T', $value); + + // 兼容旧数据:仅日期 + if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) { + return $value; + } + + // 兼容输入:YYYY-MM-DDTHH:MM 或 YYYY-MM-DDTHH:MM:SS + if (preg_match('/^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})(:\d{2})?$/', $value, $matches)) { + return $matches[1] . 'T' . $matches[2]; + } + + return ''; +} + +function itstudio_join_sanitize_date_value($value) { + $value = trim((string) $value); + if ($value === '') { + return ''; + } + + if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) { + return ''; + } + + return $value; +} + +function itstudio_join_sanitize_shortcode_value($value) { + if (!is_string($value)) { + return ''; + } + + return trim(sanitize_text_field(wp_unslash($value))); +} + +function itstudio_join_sanitize_settings($input) { + $defaults = itstudio_join_get_default_settings(); + $input = is_array($input) ? $input : array(); + + $sanitized = array( + 'registration_start' => itstudio_join_sanitize_datetime_local_value($input['registration_start'] ?? ''), + 'registration_end' => itstudio_join_sanitize_datetime_local_value($input['registration_end'] ?? ''), + 'first_interview_date' => itstudio_join_sanitize_date_value($input['first_interview_date'] ?? ''), + 'second_interview_date' => itstudio_join_sanitize_date_value($input['second_interview_date'] ?? ''), + 'notice_start_date' => itstudio_join_sanitize_date_value($input['notice_start_date'] ?? ''), + 'signup_form_shortcode' => itstudio_join_sanitize_shortcode_value($input['signup_form_shortcode'] ?? ''), + 'query_form_shortcode' => itstudio_join_sanitize_shortcode_value($input['query_form_shortcode'] ?? ''), + 'notice_view_shortcode' => itstudio_join_sanitize_shortcode_value($input['notice_view_shortcode'] ?? ''), + ); + + foreach (itstudio_join_get_photo_field_map() as $field_key) { + $sanitized[$field_key] = isset($input[$field_key]) ? absint($input[$field_key]) : 0; + } + + return array_merge($defaults, $sanitized); +} + +function itstudio_join_get_settings() { + $defaults = itstudio_join_get_default_settings(); + $stored = get_option('itstudio_join_settings', array()); + if (!is_array($stored)) { + return $defaults; + } + + return array_merge($defaults, itstudio_join_sanitize_settings($stored)); +} + +function itstudio_join_parse_datetime_local($value, $date_only_as_end_of_day = false) { + $value = itstudio_join_sanitize_datetime_local_value($value); + if ($value === '') { + return null; + } + + $timezone = wp_timezone(); + $format = 'Y-m-d\TH:i'; + if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) { + $format = 'Y-m-d'; + } + + $parsed = DateTimeImmutable::createFromFormat('!' . $format, $value, $timezone); + $errors = DateTimeImmutable::getLastErrors(); + if (!($parsed instanceof DateTimeImmutable)) { + return null; + } + + if (is_array($errors) && (($errors['warning_count'] ?? 0) > 0 || ($errors['error_count'] ?? 0) > 0)) { + return null; + } + + if ($format === 'Y-m-d') { + return $date_only_as_end_of_day + ? $parsed->setTime(23, 59, 59) + : $parsed->setTime(0, 0, 0); + } + + return $parsed; +} + +function itstudio_join_parse_date($value) { + $value = itstudio_join_sanitize_date_value($value); + if ($value === '') { + return null; + } + + $timezone = wp_timezone(); + $parsed = DateTimeImmutable::createFromFormat('!Y-m-d', $value, $timezone); + $errors = DateTimeImmutable::getLastErrors(); + if (!($parsed instanceof DateTimeImmutable)) { + return null; + } + + if (is_array($errors) && (($errors['warning_count'] ?? 0) > 0 || ($errors['error_count'] ?? 0) > 0)) { + return null; + } + + return $parsed; +} + +function itstudio_join_datetime_to_ms($date) { + if (!($date instanceof DateTimeImmutable)) { + return null; + } + + return (int) $date->format('U') * 1000; +} + +function itstudio_join_resolve_stage_status($now, $start, $end) { + if (!($now instanceof DateTimeImmutable)) { + return 'pending'; + } + + if (!($start instanceof DateTimeImmutable) && !($end instanceof DateTimeImmutable)) { + return 'pending'; + } + + if ($start instanceof DateTimeImmutable && $now < $start) { + return 'upcoming'; + } + + if ($start instanceof DateTimeImmutable && $end instanceof DateTimeImmutable) { + if ($now >= $start && $now <= $end) { + return 'active'; + } + if ($now > $end) { + return 'completed'; + } + } + + if ($start instanceof DateTimeImmutable && !($end instanceof DateTimeImmutable)) { + if ($now >= $start) { + return 'active'; + } + } + + if (!($start instanceof DateTimeImmutable) && $end instanceof DateTimeImmutable) { + return $now <= $end ? 'active' : 'completed'; + } + + return 'upcoming'; +} + +function itstudio_join_is_in_window($now, $start, $end) { + if (!($now instanceof DateTimeImmutable) || !($start instanceof DateTimeImmutable)) { + return false; + } + + if ($now < $start) { + return false; + } + + if ($end instanceof DateTimeImmutable && $now > $end) { + return false; + } + + return true; +} + +function itstudio_join_format_stage_range($start, $end, $all_day = false) { + if (!($start instanceof DateTimeImmutable) && !($end instanceof DateTimeImmutable)) { + return array( + 'cn' => '时间待定', + 'en' => 'Schedule TBA', + ); + } + + $format_cn_day = 'Y.m.d'; + $format_cn_full = 'Y.m.d H:i'; + $format_en_day = 'M j, Y'; + $format_en_full = 'M j, Y H:i'; + + if ($start instanceof DateTimeImmutable && !($end instanceof DateTimeImmutable)) { + return array( + 'cn' => $all_day ? $start->format($format_cn_day) : $start->format($format_cn_full), + 'en' => $all_day ? $start->format($format_en_day) : $start->format($format_en_full), + ); + } + + if (!($start instanceof DateTimeImmutable) && $end instanceof DateTimeImmutable) { + return array( + 'cn' => $all_day ? $end->format($format_cn_day) : $end->format($format_cn_full), + 'en' => $all_day ? $end->format($format_en_day) : $end->format($format_en_full), + ); + } + + if (!($start instanceof DateTimeImmutable) || !($end instanceof DateTimeImmutable)) { + return array( + 'cn' => '时间待定', + 'en' => 'Schedule TBA', + ); + } + + $start_cn = $all_day ? $start->format($format_cn_day) : $start->format($format_cn_full); + $end_cn = $all_day ? $end->format($format_cn_day) : $end->format($format_cn_full); + $start_en = $all_day ? $start->format($format_en_day) : $start->format($format_en_full); + $end_en = $all_day ? $end->format($format_en_day) : $end->format($format_en_full); + + if ($start_cn === $end_cn) { + return array( + 'cn' => $start_cn, + 'en' => $start_en, + ); + } + + return array( + 'cn' => $start_cn . ' - ' . $end_cn, + 'en' => $start_en . ' - ' . $end_en, + ); +} + +function itstudio_join_get_stage_photo_url($settings, $stage_key) { + $settings = is_array($settings) ? $settings : array(); + $field_map = itstudio_join_get_photo_field_map(); + $field_key = isset($field_map[$stage_key]) ? $field_map[$stage_key] : ''; + + $attachment_id = 0; + if ($field_key !== '' && isset($settings[$field_key])) { + $attachment_id = absint($settings[$field_key]); + } + + if ($attachment_id > 0) { + $photo_url = wp_get_attachment_image_url($attachment_id, 'full'); + if (is_string($photo_url) && $photo_url !== '') { + return $photo_url; + } + } + + return ''; +} + +function itstudio_join_get_runtime_data() { + static $cached = null; + if (is_array($cached)) { + return $cached; + } + + $settings = itstudio_join_get_settings(); + $timezone = wp_timezone(); + $now = new DateTimeImmutable('now', $timezone); + + $registration_start = itstudio_join_parse_datetime_local($settings['registration_start'], false); + $registration_end = itstudio_join_parse_datetime_local($settings['registration_end'], true); + if ($registration_start instanceof DateTimeImmutable) { + $registration_start = $registration_start->setTime(0, 0, 0); + } + if ($registration_end instanceof DateTimeImmutable) { + $registration_end = $registration_end->setTime(23, 59, 59); + } + if ($registration_start instanceof DateTimeImmutable && $registration_end instanceof DateTimeImmutable && $registration_end < $registration_start) { + $registration_end = $registration_start; + } + + $first_interview_day = itstudio_join_parse_date($settings['first_interview_date']); + $first_interview_start = $first_interview_day instanceof DateTimeImmutable ? $first_interview_day->setTime(0, 0, 0) : null; + $first_interview_end = $first_interview_day instanceof DateTimeImmutable ? $first_interview_day->setTime(23, 59, 59) : null; + + $second_interview_day = itstudio_join_parse_date($settings['second_interview_date']); + $second_interview_start = $second_interview_day instanceof DateTimeImmutable ? $second_interview_day->setTime(0, 0, 0) : null; + $second_interview_end = $second_interview_day instanceof DateTimeImmutable ? $second_interview_day->setTime(23, 59, 59) : null; + + $notice_start_day = itstudio_join_parse_date($settings['notice_start_date']); + $notice_start = $notice_start_day instanceof DateTimeImmutable ? $notice_start_day->setTime(0, 0, 0) : null; + $notice_end = $notice_start instanceof DateTimeImmutable ? $notice_start->modify('+6 days')->setTime(23, 59, 59) : null; + + $recruitment_year = null; + if ($registration_start instanceof DateTimeImmutable) { + $recruitment_year = (int) $registration_start->format('Y'); + } elseif ($notice_start instanceof DateTimeImmutable) { + $recruitment_year = (int) $notice_start->format('Y'); + } else { + $recruitment_year = (int) $now->format('Y'); + } + + $assessment_start = DateTimeImmutable::createFromFormat('!Y-m-d', sprintf('%04d-10-01', $recruitment_year), $timezone); + $assessment_end_base = DateTimeImmutable::createFromFormat('!Y-m-d', sprintf('%04d-10-07', $recruitment_year), $timezone); + $assessment_end = $assessment_end_base instanceof DateTimeImmutable ? $assessment_end_base->setTime(23, 59, 59) : null; + + $stage_seed = array( + array( + 'key' => 'registration', + 'label_cn' => '报名阶段', + 'label_en' => 'Registration', + 'short_cn' => '报名', + 'short_en' => 'Reg', + 'start' => $registration_start, + 'end' => $registration_end, + 'all_day' => true, + ), + array( + 'key' => 'first_interview', + 'label_cn' => '第一次面试', + 'label_en' => 'Interview I', + 'short_cn' => '一面', + 'short_en' => 'I-1', + 'start' => $first_interview_start, + 'end' => $first_interview_end, + 'all_day' => true, + ), + array( + 'key' => 'assessment', + 'label_cn' => '国庆能力摸底', + 'label_en' => 'Assessment', + 'short_cn' => '摸底', + 'short_en' => 'Assess', + 'start' => $assessment_start, + 'end' => $assessment_end, + 'all_day' => true, + ), + array( + 'key' => 'second_interview', + 'label_cn' => '第二次面试', + 'label_en' => 'Interview II', + 'short_cn' => '二面', + 'short_en' => 'I-2', + 'start' => $second_interview_start, + 'end' => $second_interview_end, + 'all_day' => true, + ), + array( + 'key' => 'public_notice', + 'label_cn' => '录取结果公示', + 'label_en' => 'Public Notice', + 'short_cn' => '公示', + 'short_en' => 'Notice', + 'start' => $notice_start, + 'end' => $notice_end, + 'all_day' => true, + ), + ); + + $stages = array(); + foreach ($stage_seed as $stage) { + $range = itstudio_join_format_stage_range($stage['start'], $stage['end'], !empty($stage['all_day'])); + $status = itstudio_join_resolve_stage_status($now, $stage['start'], $stage['end']); + $stages[] = array( + 'key' => $stage['key'], + 'label_cn' => $stage['label_cn'], + 'label_en' => $stage['label_en'], + 'short_cn' => $stage['short_cn'], + 'short_en' => $stage['short_en'], + 'status' => $status, + 'range_cn' => $range['cn'], + 'range_en' => $range['en'], + 'start_ts' => itstudio_join_datetime_to_ms($stage['start']), + 'end_ts' => itstudio_join_datetime_to_ms($stage['end']), + ); + } + + $current_stage_index = -1; + foreach ($stages as $index => $stage) { + if ($stage['status'] === 'active') { + $current_stage_index = (int) $index; + break; + } + } + + $current_stage = ($current_stage_index >= 0 && isset($stages[$current_stage_index])) + ? $stages[$current_stage_index] + : array( + 'key' => 'inactive', + 'label_cn' => '当前未在招新时段', + 'label_en' => 'Recruitment is currently closed', + 'short_cn' => '', + 'short_en' => '', + 'status' => 'inactive', + 'range_cn' => '请关注后续通知', + 'range_en' => 'Please check later updates', + 'start_ts' => null, + 'end_ts' => null, + ); + + $is_registration_open = itstudio_join_is_in_window($now, $registration_start, $registration_end); + + $is_query_open = false; + if ($registration_start instanceof DateTimeImmutable) { + if ($notice_end instanceof DateTimeImmutable) { + $is_query_open = itstudio_join_is_in_window($now, $registration_start, $notice_end); + } else { + $is_query_open = ($now >= $registration_start); + } + } + + $is_notice_open = itstudio_join_is_in_window($now, $notice_start, $notice_end); + $current_stage_photo_url = itstudio_join_get_stage_photo_url($settings, (string) ($current_stage['key'] ?? '')); + if ($current_stage_photo_url === '') { + $current_stage_photo_url = get_template_directory_uri() . '/resources/it_logo_2024.svg'; + } + + $cached = array( + 'settings' => $settings, + 'timezone' => $timezone->getName(), + 'recruitment_year' => $recruitment_year, + 'now_ts' => (int) $now->format('U') * 1000, + 'stages' => $stages, + 'current_stage_index' => $current_stage_index, + 'current_stage' => $current_stage, + 'is_registration_open' => $is_registration_open, + 'is_query_open' => $is_query_open, + 'is_notice_open' => $is_notice_open, + 'current_stage_photo_url' => $current_stage_photo_url, + 'query_deadline_cn' => $notice_end instanceof DateTimeImmutable ? $notice_end->format('Y-m-d H:i') : '', + 'query_deadline_en' => $notice_end instanceof DateTimeImmutable ? $notice_end->format('M j, Y H:i') : '', + ); + + return $cached; +} + +function itstudio_join_get_frontend_payload() { + $runtime = itstudio_join_get_runtime_data(); + return array( + 'nowTs' => (int) ($runtime['now_ts'] ?? 0), + 'currentStageIndex' => (int) ($runtime['current_stage_index'] ?? -1), + 'stages' => array_values(array_map(static function ($stage) { + return array( + 'key' => (string) ($stage['key'] ?? ''), + 'labelCn' => (string) ($stage['label_cn'] ?? ''), + 'labelEn' => (string) ($stage['label_en'] ?? ''), + 'shortCn' => (string) ($stage['short_cn'] ?? ''), + 'shortEn' => (string) ($stage['short_en'] ?? ''), + 'status' => (string) ($stage['status'] ?? 'pending'), + 'rangeCn' => (string) ($stage['range_cn'] ?? ''), + 'rangeEn' => (string) ($stage['range_en'] ?? ''), + 'startTs' => isset($stage['start_ts']) ? $stage['start_ts'] : null, + 'endTs' => isset($stage['end_ts']) ? $stage['end_ts'] : null, + ); + }, (array) ($runtime['stages'] ?? array()))), + ); +} + +function itstudio_join_enqueue_assets() { + if (!itstudio_is_join_page_context()) { + return; + } + + wp_enqueue_style( + 'itstudio-join-page', + get_template_directory_uri() . '/assets/css/join-page.css', + array('itstudio-content'), + '1.1.0' + ); + + wp_enqueue_script( + 'itstudio-animejs', + get_template_directory_uri() . '/assets/js/vendor/anime.min.js', + array(), + '3.2.2', + true + ); + + wp_enqueue_script( + 'itstudio-join-canvas', + get_template_directory_uri() . '/assets/js/join-canvas.js', + array('itstudio-animejs'), + '1.1.0', + true + ); + + wp_localize_script('itstudio-join-canvas', 'itstudioJoinData', itstudio_join_get_frontend_payload()); +} +add_action('wp_enqueue_scripts', 'itstudio_join_enqueue_assets', 30); + +function itstudio_join_register_settings() { + register_setting( + 'itstudio_join_settings_group', + 'itstudio_join_settings', + array( + 'type' => 'array', + 'sanitize_callback' => 'itstudio_join_sanitize_settings', + 'default' => itstudio_join_get_default_settings(), + ) + ); +} +add_action('admin_init', 'itstudio_join_register_settings'); + +function itstudio_join_register_settings_page() { + add_options_page( + '招新设置', + '招新设置', + 'manage_options', + 'itstudio-join-settings', + 'itstudio_join_render_settings_page' + ); +} +add_action('admin_menu', 'itstudio_join_register_settings_page'); + +function itstudio_join_render_photo_field_row($field_key, $label, $settings) { + $attachment_id = isset($settings[$field_key]) ? absint($settings[$field_key]) : 0; + $preview_url = $attachment_id > 0 ? wp_get_attachment_image_url($attachment_id, 'medium_large') : ''; + ?> +
用于配置「加入我们」页面的时间节点、表单和公示视图。
+ + +提示:未检测到 Formidable Forms 插件。报名表单、查询表单、公示视图将无法在前台渲染。
+提示:未检测到 WP Mail SMTP 插件。建议启用后再开放报名邮件通知。
+国庆能力摸底阶段固定为每年 10 月 1 日至 10 月 7 日,年份自动取报名开始年份(未配置则取公示开始年份,再否则取当前年份)。
+| 阶段 | +时间 | +状态 | +
|---|---|---|
| + | + | + |
+ +
+仅在报名阶段开放
+当前不在报名时间段 请关注后续通知
+ +未检测到 Formidable Forms 插件 请先启用插件
+ +请在 设置 > 招新设置 中填写报名表单 Shortcode
+ + + +报名开始后至公示结束前可查询进度
+当前查询通道未开放或已关闭
+ +未检测到 Formidable Forms 插件 请先启用插件
+ +请在 设置 > 招新设置 中填写查询表单 Shortcode
+ + + +公示阶段自动展示 公示期为 7 天
+当前未进入公示阶段
+ +未检测到 Formidable Forms 插件 请先启用插件
+ +请在 设置 > 招新设置 中填写公示视图 Shortcode
+ + + +