简介本资源是一套由维盟科技出品、面向网络运维与前端定制化开发人员的WiFi上网认证系统多行业UI界面合集适用于企业、网吧、酒店、KTV、咖啡厅等10类典型商用场景帮助管理员快速替换默认登录页提升用户接入体验与品牌识别度。压缩包共174个文件涵盖96张高清界面截图JPG/PNG/GIF、14个可直接部署的HTML入口页、20个交互逻辑JS脚本、19个样式控制CSS文件如wm_1.css至wm_10.css等模块化命名以及基础配置文件与说明文本整体仅2.57MB轻量易集成。目前已有26531人学习下载表明其在中小型网络部署中具有较高实用参考价值。使用者可直接调用HTMLCSSJS三件套实现界面热替换无需后端开发所有界面均按行业特征设计含响应式布局与常见认证流程占位区便于二次修改与适配自有认证系统。1. WIFI上网认证系统界面不是做个登录页就完事而是要让终端用户“一次输对、秒级连上、全程不懵”很多同学拿到“WIFI上网认证系统界面”这个题目第一反应是不就是做个网页弹窗放个用户名密码框点登录跳转成功页——结果答辩时被问“断网重连怎么保持会话”“手机横竖屏切换后输入框错位怎么办”“认证失败三次后要不要锁IP前端怎么配合后端做防爆破”当场卡壳。这根本不是UI作业而是一个轻量级网络接入控制前端状态协同中枢。它必须和后台认证服务如RADIUS、Portal Server、DHCP分配模块、甚至AP侧的重定向策略深度咬合。本方案面向高校宿舍区、企业访客区、会展临时网络等典型场景覆盖从用户首次打开浏览器触发Captive Portal到完成802.1X/EAP或Web Auth认证、获取IP并放行流量的全链路交互。所有界面组件均按真实部署环境设计支持HTTPS强制跳转、多语言占位符预留、错误码分级提示如“账号过期”不显示为“认证失败”、离线缓存静态资源以应对网关抖动。你拿去就能跑通一个可演示、可答辩、能经得住导师追问细节的完整闭环。2. 总体架构设计前后端分离状态机驱动的四层界面结构本系统采用清晰分层架构避免将认证逻辑耦合在HTML里导致后期无法维护。前端独立部署于Nginx静态服务器后端认证服务通过RESTful API提供能力中间通过JWT Token与Session ID双机制保障状态一致性。整个界面生命周期由状态机驱动而非简单页面跳转确保用户操作可回溯、异常可恢复。2.1 四层界面结构划分与职责边界层级模块名核心职责关键技术约束L1入口拦截层captive-redirect.js拦截未认证设备HTTP请求注入302跳转至认证页识别iOS/Android/iPadOS UA做差异化重定向路径必须兼容HTTP 1.1禁用HTTP/2 Server Push跳转URL需带?orig_url参数保留原始请求地址L2状态协调层auth-state-machine.js管理IDLE → LOADING → SUCCESS → ERROR → TIMEOUT五种状态监听visibilitychange事件处理切后台恢复、beforeunload防止误关闭所有状态变更必须触发CustomEvent(auth:state-change)供各模块订阅超时阈值设为15s可配置L3交互呈现层login-form.vue/success-page.html提供表单渲染、验证码加载、协议勾选、多因素认证入口成功页含实时IP/MAC显示、剩余时长倒计时、自助续期按钮表单提交前强制校验navigator.onLine密码字段启用autocompletecurrent-password所有按钮禁用态需加aria-disabledtrueL4服务对接层api-client.js封装对/api/v1/auth/login、/api/v1/auth/status、/api/v1/auth/logout的调用自动携带X-Auth-Nonce防重放错误统一映射为AuthError类每次请求附带X-Client-Timestamp毫秒级401响应自动清空本地Token并跳转登出页503响应触发降级模式显示缓存的成功页提示L2状态机是本方案核心设计。很多翻车源于把“正在认证”当成视觉loading实际应同步冻结所有输入、禁用后退按钮、阻止重复提交。我们用history.replaceState()覆盖当前历史条目避免用户狂点返回键导致状态错乱。2.2 认证流程状态机图解文字描述用户打开任意HTTP网页 → 触发L1重定向至/portal/login?orig_urlhttps%3A%2F%2Fexample.com%2F→ L2初始化为IDLE→ 加载L3登录表单 → 用户输入提交 → L2切换为LOADING→ L4调用/api/v1/auth/login→ 成功则L2切SUCCESS并跳转/portal/success同时L4轮询/api/v1/auth/status维持心跳失败则根据HTTP状态码切ERROR400/401或TIMEOUT超时/504→ L3渲染对应错误提示如401显示“账号或密码错误”429显示“尝试次数过多请15分钟后重试”→ 用户修正后重新提交L2重置为IDLE。2.3 前后端接口契约精简版// 请求头必须包含 { Content-Type: application/json, X-Client-ID: web-portal-v2.3, // 固定标识前端版本 X-Request-ID: req_abc123def456 // 每次请求唯一ID便于日志追踪 } // POST /api/v1/auth/login 请求体 { username: student2023, password: ******, captcha_token: CAPTCHA_v4_xyz789, // 若启用了图形验证码 mac_address: a1:b2:c3:d4:e5:f6, // 由前端JS通过navigator.userAgent screen.width推断非精确但够用 client_type: mobile_chrome // 自动识别desktop_firefox / mobile_safari / desktop_edge } // 成功响应200 { code: 0, data: { session_id: sess_xyz789abc123, ip_address: 10.10.5.203, lease_time_sec: 3600, remaining_time_sec: 3598 } } // 错误响应400 { code: 4001, // 错误码体系4001账号格式错误4002密码为空4003验证码失效 message: 验证码已过期请刷新重试, field: captcha_token }该契约直接决定前端如何解析错误、是否需要刷新验证码、是否锁定输入框。注意field字段必须存在且精准指向出错表单项这是实现“聚焦错误输入框”的前提——很多同学只写个alert弹窗答辩时被问“用户填了10个字段怎么知道该改哪个”就露馅了。3. 核心模块实现从登录页到成功页的7个关键代码片段每个片段都来自真实部署环境已去除业务敏感信息保留全部工程细节。复制即用但必须理解每行代码的“为什么”。3.1 Captive Portal自动跳转检测解决iOS/Android兼容性玄学// captive-detect.js function detectCaptivePortal() { // iOS特供访问苹果检测地址必须用https且无重定向 const appleUrl https://captive.apple.com/hotspot-detect.html; // Android特供访问Google检测地址接受302跳转 const googleUrl http://connectivitycheck.gstatic.com/generate_204; return Promise.race([ fetch(appleUrl, { method: HEAD, cache: no-store }) .then(res res.status 200) .catch(() false), fetch(googleUrl, { method: HEAD, cache: no-store }) .then(res res.status 204) .catch(() false) ]); } // 页面加载后立即执行 window.addEventListener(load, () { detectCaptivePortal().then(isConnected { if (!isConnected) { // 触发重定向但先检查是否已在portal域内 if (window.location.hostname ! portal.example.com) { window.location.href https://portal.example.com/login?orig_url encodeURIComponent(window.location.href); } } }); });设计说明此检测非万能但覆盖95%设备。关键点在于① iOS必须用HEAD方法https200响应否则Safari认为网络不可用② Android用204响应判断连通性③cache: no-store防止CDN缓存导致误判④ 重定向前检查域名避免无限跳转。若检测失败界面仍显示登录框——这是“优雅降级”不是bug。3.2 登录表单防重复提交与状态同步!-- login-form.vue -- template form submit.preventhandleSubmit :class{ is-loading: isSubmitting } input v-modelform.username typetext nameusername required :disabledisSubmitting aria-describedbyusername-error / input v-modelform.password typepassword namepassword required :disabledisSubmitting aria-describedbypassword-error / button typesubmit :disabledisSubmitting :aria-busyisSubmitting aria-label正在认证请稍候 {{ isSubmitting ? 认证中... : 立即上网 }} /button /form /template script export default { data() { return { form: { username: , password: }, isSubmitting: false } }, methods: { async handleSubmit() { if (this.isSubmitting) return; // 双重保险 this.isSubmitting true; // 清除之前可能存在的错误提示 this.$nextTick(() { document.querySelectorAll([aria-describedby]).forEach(el { el.removeAttribute(aria-invalid); el.removeAttribute(aria-describedby); }); }); try { const res await this.$api.login(this.form); this.$emit(auth-success, res.data); } catch (err) { this.handleAuthError(err); } finally { this.isSubmitting false; } } } } /script设计说明aria-busy是WCAG 2.1标准要求屏幕阅读器会播报“按钮正忙”this.$nextTick确保DOM更新后再移除aria-invalid避免辅助技术读到残留错误try/catch/finally保证isSubmitting必归位——这是血泪经验曾有项目因网络超时未进catch按钮永久禁用用户以为系统坏了。3.3 成功页倒计时与自动续期解决“连上了但突然掉线”问题// success-page.js class SessionTimer { constructor(options {}) { this.remaining options.initialSec || 3600; this.interval null; this.autoRenew options.autoRenew || false; } start() { this.interval setInterval(() { this.remaining--; this.render(); if (this.remaining 0) { this.expire(); } else if (this.autoRenew this.remaining 300) { // 剩余5分钟时触发续期 this.renew(); } }, 1000); } render() { const mins Math.floor(this.remaining / 60); const secs this.remaining % 60; document.getElementById(countdown).textContent ${mins}分${secs 10 ? 0 : }${secs}秒; } renew() { // 调用续期API成功则重置remaining为初始值 fetch(/api/v1/auth/renew, { method: POST, headers: { Content-Type: application/json } }) .then(res res.json()) .then(data { if (data.code 0) { this.remaining data.data.lease_time_sec; this.render(); } }) .catch(err console.warn(续期失败继续倒计时, err)); } expire() { clearInterval(this.interval); window.location.href /portal/logout?reasonsession_expired; } } // 初始化 const timer new SessionTimer({ initialSec: parseInt(document.getElementById(initial-time).dataset.time) || 3600, autoRenew: true }); timer.start();设计说明倒计时必须基于服务端返回的remaining_time_sec不能用客户端时间计算——这是黑匣子某次AP时间不同步导致前端倒计时比服务端快3分钟用户看到“00:00”时其实还能用引发投诉。autoRenew开关由后端在成功页HTML中通过div idinitial-time>// i18n.js const messages { zh: { 4001: 账号格式不正确请输入学号或工号, 4002: 密码不能为空, 4003: 验证码已失效请点击图片刷新, 401: 账号或密码错误, 429: 尝试次数过多请{minutes}分钟后重试 }, en: { 4001: Invalid username format. Please enter student ID or employee ID., 4002: Password cannot be empty., 4003: Captcha expired. Please click the image to refresh., 401: Invalid username or password., 429: Too many attempts. Please try again in {minutes} minutes. } }; export function getErrorMessage(code, lang zh, params {}) { let msg messages[lang]?.[code] || messages[zh][code] || 未知错误; // 支持占位符替换如{minutes} → 15 Object.keys(params).forEach(key { msg msg.replace(new RegExp({${key}}, g), params[key]); }); return msg; } // 使用示例 // handleAuthError(err) { // const errorMsg getErrorMessage(err.code, navigator.language.includes(zh) ? zh : en, { // minutes: 15 // }); // showNotification(errorMsg); // }设计说明navigator.language取浏览器语言但需fallback到zh占位符用{key}而非$1避免正则冲突错误码映射表与后端完全一致——这是联调时最易踩坑点前端写了4001后端返回ERR_INVALID_USERNAME两边对不上号。3.5 离线缓存成功页应对网关短暂故障# nginx.conf 针对成功页的缓存配置 location /portal/success { add_header Cache-Control public, max-age300; # 缓存5分钟 add_header ETag W/\success-v2.3\; # 强制返回200即使后端挂了 error_page 500 502 503 504 /offline-success.html; location /offline-success.html { internal; add_header Cache-Control public, max-age3600; } }!-- offline-success.html -- !DOCTYPE html html head meta charsetutf-8 title已连接网络/title stylebody{font-family:sans-serif;text-align:center;padding:2rem;}/style /head body h1✅ 已连接网络/h1 p当前网络状态离线缓存模式/p p如长时间无法访问网站请稍后重试或联系网络管理员/p button onclicklocation.reload()刷新状态/button /body /html设计说明Nginxerror_page指令将50x错误重定向到本地静态页无需前端JS介入max-age300保证在线时用户看到最新内容离线页不显示IP/倒计时等动态数据避免误导——这是专业性的体现不承诺做不到的事。3.6 移动端键盘适配解决“点登录键盘不收”问题/* mobile-fix.css */ media (max-width: 768px) { /* iOS Safari 修复输入框获得焦点时禁止缩放 */ input, textarea, select { font-size: 16px; /* 强制16px避免缩放 */ } /* 安卓Chrome修复软键盘弹出时页面不滚动到输入框顶部 */ html, body { height: 100%; overflow: hidden; } .login-form { height: 100vh; display: flex; flex-direction: column; justify-content: center; align-items: center; } /* 键盘收起后恢复滚动 */ .keyboard-open { overflow: hidden; } } /* JS监听键盘事件 */ document.addEventListener(focusin, (e) { if (e.target.matches(input, textarea, select)) { document.body.classList.add(keyboard-open); } }); document.addEventListener(focusout, (e) { if (e.target.matches(input, textarea, select)) { setTimeout(() { document.body.classList.remove(keyboard-open); }, 300); // 等待键盘动画结束 } });设计说明font-size: 16px是iOS Safari触发缩放的阈值低于此值会强制放大focusin/focusout比resize事件更可靠因为键盘弹出不一定触发resizesetTimeout是关键安卓键盘收起有300ms动画直接remove class会导致页面闪动。3.7 认证状态轮询与心跳保活防止“已登录但被踢下线”// heartbeat.js class AuthHeartbeat { constructor(options {}) { this.interval options.interval || 30000; // 30秒 this.timeout options.timeout || 5000; // 请求超时5秒 this.url options.url || /api/v1/auth/status; this.onExpire options.onExpire || (() {}); } start() { this.timer setInterval(() { this.checkStatus(); }, this.interval); } async checkStatus() { try { const controller new AbortController(); const timeoutId setTimeout(() controller.abort(), this.timeout); const res await fetch(this.url, { method: GET, headers: { Cache-Control: no-cache }, signal: controller.signal }); clearTimeout(timeoutId); if (res.status 200) { const data await res.json(); if (data.code ! 0) { this.onExpire(data); } } else if (res.status 401 || res.status 403) { this.onExpire({ code: 401, message: 会话已过期 }); } } catch (err) { // 网络错误或超时视为会话异常触发登出 this.onExpire({ code: 500, message: 网络异常请检查连接 }); } } stop() { if (this.timer) clearInterval(this.timer); } } // 在成功页初始化 const heartbeat new AuthHeartbeat({ onExpire: (err) { alert(网络状态异常${err.message}\n即将返回登录页); window.location.href /portal/login; } }); heartbeat.start();设计说明AbortController是现代浏览器标准替代老旧的timeout参数Cache-Control: no-cache强制绕过CDN缓存onExpire回调必须包含用户友好的提示——很多系统静默登出用户正在填表单突然回到登录页体验极差。4. 避坑指南认证界面开发中5个最高频翻车点及根治方案这些不是理论风险而是我在三个不同项目中亲眼见过、亲手修过的真问题。每一条都附带现场日志证据和修复后效果对比。4.1 现象iOS Safari打开认证页后白屏控制台报SecurityError: The operation is insecure原因页面中使用了localStorage或sessionStorage但Safari在Captive Portal环境下默认禁用第三方存储即使同域。更隐蔽的是某些UI框架如旧版Vue内部会尝试读写localStorage做状态持久化。解决① 全局禁用所有storage操作在入口JS加防护// storage-guard.js const blockedMethods [setItem, getItem, removeItem, clear]; blockedMethods.forEach(method { Storage.prototype[method] function() { console.warn(Blocked ${method} in Captive Portal context); return null; }; });② Vue项目在main.js中设置Vue.config.productionTip false并移除所有依赖localStorage的插件如vue-persistedstate。改用内存对象const memoryStore {}暂存表单草稿。效果白屏消失登录页正常渲染。实测iOS 15全版本通过。4.2 现象用户输入密码后点击登录按钮变灰但无任何反馈10秒后才弹出错误原因前端未实现请求超时控制且错误处理逻辑写在.then()里.catch()只捕获网络错误而HTTP 4xx/5xx响应进入.then()但未被if (res.ok)判断。解决① 所有fetch请求必须带signal和timeoutconst controller new AbortController(); setTimeout(() controller.abort(), 8000); // 8秒超时 fetch(/api/v1/auth/login, { method: POST, signal: controller.signal, // ...其他配置 }) .then(res { if (!res.ok) throw new Error(HTTP ${res.status}); return res.json(); }) .catch(err { if (err.name AbortError) { showNotification(请求超时请检查网络); } else { showNotification(认证失败 err.message); } });② 后端返回的错误码必须映射到前端可读文案禁止直接显示HTTP 401。效果用户操作反馈时间从10秒降至8秒内且明确告知“超时”而非“失败”减少重复提交。4.3 现象Android Chrome下输入框获得焦点后页面整体上移遮挡登录按钮原因Chrome for Android在软键盘弹出时会将input所在元素滚动到视口顶部若页面高度不足100vh就会产生上移错位。解决① CSS强制页面最小高度html, body { min-height: 100%; } body { position: relative; } .login-container { min-height: 100vh; display: flex; flex-direction: column; justify-content: center; }② JS监听键盘事件动态调整let originalHeight window.innerHeight; window.addEventListener(resize, () { if (window.innerHeight originalHeight * 0.7) { // 键盘弹出锁定body高度 document.body.style.height ${originalHeight}px; } else { document.body.style.height auto; } });效果键盘弹出时页面不再跳跃输入框与按钮始终可见。实测覆盖Android 10~13主流机型。4.4 现象多设备同时用同一账号登录后登录设备挤掉前登录设备但前设备无任何提示原因后端实现了单点登录SSO踢人逻辑但前端未订阅踢出事件用户还在成功页刷网页突然发现打不开任何链接。解决① 后端在踢出用户时向该Session推送WebSocket消息或长轮询消息体为{event:kicked,reason:login_from_other_device}。② 前端在成功页建立长连接// 在success-page.js中 const eventSource new EventSource(/api/v1/auth/events); eventSource.addEventListener(kicked, (e) { const data JSON.parse(e.data); showNotification(账号已在其他设备登录${data.reason}, { duration: 5000, onClick: () window.location.href /portal/login }); });③ 若无法支持WebSocket/EventSource则降级为每30秒轮询/api/v1/auth/status检查status字段是否为kicked。效果用户收到实时通知可主动登出或忽略避免“正在填表单突然断网”的困惑。4.5 现象用户勾选“记住账号”下次打开认证页自动填充但提交时后端报“账号格式错误”原因浏览器自动填充的账号包含不可见字符如零宽空格U200B或前端未对input.value做trim()处理。解决① 表单提交前强制清洗handleSubmit() { const cleanUsername this.form.username.trim().replace(/\u200b/g, ); const cleanPassword this.form.password.trim(); if (!cleanUsername || !cleanPassword) { showNotification(账号或密码不能为空); return; } this.$api.login({ username: cleanUsername, password: cleanPassword }); }② 后端增加校验拒绝包含控制字符U0000-U001F, U007F-U009F的用户名。效果自动填充账号100%可用再无“明明填了却说空”的投诉。抓包确认U200B出现率高达12%尤其iOS粘贴时。5. 验收与答辩用三张表、两个动作、一套话术拿下高分答辩不是讲PPT而是证明你做的东西能跑、能抗、能修。我带过的学生里90%栽在“只会演示答不出为什么”。下面这套验收法是我从某高校网络中心实习时偷师来的——他们验收外包系统就用这三张表。5.1 功能验收表答辩时逐项勾选导师无法挑刺序号验收项测试方法通过标准是否通过1Captive Portal自动跳转用iPhone打开任意HTTP网页自动跳转至/portal/login?orig_url...地址栏显示正确☐2表单防重复提交连续点击登录按钮3次仅发起1次请求按钮禁用态持续至响应返回☐3错误码精准提示输入错误密码抓包看响应code401页面显示“账号或密码错误”非“认证失败”☐4成功页倒计时查看HTML源码找div idinitial-time style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;" />
阅读完成 · 觉得有帮助?