PageSourceSearch

https://www.ruanfujia.com/wp-content/themes/bigboom/js/rfj-search-dropdown.js?ver=0ebd0ce12cf8032a

js ruanfujia.com collected 2026-10-08 05:50:30 UTC 17,272 bytes, 468 lines download raw bytes

1/**
2 * RFJ Search Dropdown Component
3 *
4 * 搜索下拉组件 - 零 jQuery 依赖(可选 jQuery 降级)
5 * 支持多实例、定制化配置
6 *
7 * 使用方式:
8 * ```html
9 * <link rel="stylesheet" href="/css/rfj-search-dropdown.css">
10 * <script src="/js/rfj-search-dropdown.js"><\/script>
11 * <input type="text" id="my-search" placeholder="搜索...">
12 * <div id="my-search-dropdown"></div>
13 * <script>
14 *   RFJSearchDropdown.init('#my-search', '#my-search-dropdown', {
15 *     ajaxUrl: '<?php echo admin_url("admin-ajax.php"); ?>',
16 *     nonce: '<?php echo wp_create_nonce("rfj_search_nonce"); ?>'
17 *   });
18 *<\/script>
19 * ```
20 */
21
22(function (root, factory) {
23    if (typeof module === 'object' && module.exports) {
24        module.exports = factory();
25    } else if (typeof define === 'function' && define.amd) {
26        define(factory);
27    } else {
28        root.RFJSearchDropdown = factory();
29    }
30}(typeof self !== 'undefined' ? self : this, function () {
31
32    'use strict';
33
34    // ==================== 图标 SVG ====================
35    var ICONS = {
36        loading: '<svg class="rfj-sd-icon" viewBox="0 0 24 24" width="16" height="16"><circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2" stroke-dasharray="30 70" stroke-linecap="round"><animateTransform attributeName="transform" type="rotate" from="0 12 12" to="360 12 12" dur="0.8s" repeatCount="indefinite"/></circle></svg>',
37        category: '<svg class="rfj-sd-icon" viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M3 5h7v2H3zm0 5h7v2H3zm0 5h7v2H3zm11-5h7v2h-7zm0 5h7v2h-7zm0 5h7v2h-7z"/></svg>',
38        software: '<svg class="rfj-sd-icon" viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 14H4V6h16v12zM6 10h2v2H6zm0 4h8v2H6zm10 0h2v2h-2zm-6-4h8v2h-8z"/></svg>',
39        company: '<svg class="rfj-sd-icon" viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M12 7V3H2v18h20V7H12zM6 19H4v-2h2v2zm0-4H4v-2h2v2zm0-4H4V9h2v2zm0-4H4V5h2v2zm4 12H8v-2h2v2zm0-4H8v-2h2v2zm0-4H8V9h2v2zm0-4H8V5h2v2zm10 12h-8v-2h2v-2h-2v-2h2v-2h-2V9h8v10zm-2-8h-2v2h2v-2zm0 4h-2v2h2v-2z"/></svg>',
40        arrow: '<svg class="rfj-sd-icon" viewBox="0 0 24 24" width="12" height="12" fill="currentColor"><path d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.41z"/></svg>',
41        close: '<svg class="rfj-sd-icon" viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"/></svg>'
42    };
43
44    // ==================== 工具函数 ====================
45    function debounce(fn, delay) {
46        var timer = null;
47        return function () {
48            var args = arguments;
49            var ctx = this;
50            clearTimeout(timer);
51            timer = setTimeout(function () {
52                fn.apply(ctx, args);
53            }, delay);
54        };
55    }
56
57    function isMobile() {
58        return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
59    }
60
61    // ==================== 组件类 ====================
62    function RFJSearchDropdown() {
63        this.instances = new Map();
64        this.globalConfig = {
65            ajaxUrl: '/wp-admin/admin-ajax.php',
66            nonce: '',
67            debounceDelay: 300,
68            maxResults: {
69                categories: 5,
70                software: 5,
71                companies: 3
72            },
73            portalToBody: false,
74            onSelect: null,
75            onMore: null
76        };
77    }
78
79    /**
80     * 初始化搜索下拉
81     * @param {string} inputSelector - 输入框选择器
82     * @param {string|Element} dropdownContainer - 下拉容器选择器或元素
83     * @param {Object} options - 配置项
84     */
85    RFJSearchDropdown.prototype.init = function (inputSelector, dropdownContainer, options) {
86        var self = this;
87        // 支持字符串选择器或直接传入 DOM 元素
88        var input = typeof inputSelector === 'string'
89            ? document.querySelector(inputSelector)
90            : inputSelector;
91        if (!input) {
92            console.warn('[RFJSearchDropdown] Input not found:', inputSelector);
93            return null;
94        }
95
96        var container = typeof dropdownContainer === 'string'
97            ? document.querySelector(dropdownContainer)
98            : dropdownContainer;
99
100        if (!container) {
101            console.warn('[RFJSearchDropdown] Dropdown container not found:', dropdownContainer);
102            return null;
103        }
104
105        // 合并配置
106        var config = Object.assign({}, this.globalConfig, options);
107
108        // 生成唯一实例 ID
109        var instanceId = Date.now() + '_' + Math.random().toString(36).substr(2, 6);
110
111        // 实例状态
112        var state = {
113            id: instanceId,
114            input: input,
115            container: container,
116            config: config,
117            xhr: null,
118            isLoading: false,
119            currentKeyword: '',
120            resizeHandler: null,
121            scrollHandler: null
122        };
123
124        // 创建 DOM 结构
125        this._createDOM(state);
126
127        // 绑定事件
128        this._bindEvents(state);
129
130        // 保存实例
131        this.instances.set(instanceId, state);
132
133        return state;
134    };
135
136    /**
137     * 创建 DOM 结构
138     */
139    RFJSearchDropdown.prototype._createDOM = function (state) {
140        var container = state.container;
141        container.className = 'rfj-search-dropdown';
142        container.innerHTML = '';
143        container.style.display = 'none';
144
145        if (state.config.portalToBody) {
146            container.className += ' rfj-search-dropdown--body-portal';
147            if (container.parentNode !== document.body) {
148                document.body.appendChild(container);
149            }
150        }
151
152        // 列表容器
153        var listEl = document.createElement('div');
154        listEl.className = 'rfj-sd-list';
155        container.appendChild(listEl);
156        state.listEl = listEl;
157
158        // 底部"查看更多"
159        var footerEl = document.createElement('div');
160        footerEl.className = 'rfj-sd-footer';
161        footerEl.style.display = 'none';
162        container.appendChild(footerEl);
163        state.footerEl = footerEl;
164    };
165
166    /**
167     * 绑定事件
168     */
169    RFJSearchDropdown.prototype._bindEvents = function (state) {
170        var self = this;
171        var input = state.input;
172        var container = state.container;
173
174        if (state.config.portalToBody) {
175            state.resizeHandler = function () {
176                self._positionPortal(state);
177            };
178            state.scrollHandler = function () {
179                if (container.style.display !== 'none') {
180                    self._positionPortal(state);
181                }
182            };
183            window.addEventListener('resize', state.resizeHandler);
184            window.addEventListener('scroll', state.scrollHandler, true);
185        }
186
187        // 输入事件(防抖)
188        var debouncedSearch = debounce(function () {
189            var keyword = input.value.trim();
190            // 至少输入2个字符才执行搜索
191            if (keyword.length < 2) {
192                if (container.getAttribute('data-rfj-history-active') === '1') {
193                    return;
194                }
195                self.hide(state);
196                return;
197            }
198            self._fetchData(state, keyword);
199        }, state.config.debounceDelay);
200
201        input.addEventListener('input', debouncedSearch);
202
203        // Focus 事件
204        input.addEventListener('focus', function () {
205            var keyword = input.value.trim();
206            if (keyword.length >= 2 && container.innerHTML.trim() !== '') {
207                self._positionPortal(state);
208                container.style.display = 'block';
209            }
210        });
211
212        // 点击外部关闭
213        document.addEventListener('click', function (e) {
214            if (!container.contains(e.target) && e.target !== input) {
215                container.style.display = 'none';
216            }
217        });
218
219        // 键盘事件
220        input.addEventListener('keydown', function (e) {
221            if (e.key === 'Escape') {
222                container.style.display = 'none';
223            } else if (e.key === 'Enter') {
224                var keyword = input.value.trim();
225                if (keyword.length > 0) {
226                    var searchUrl = 'https://www.ruanfujia.com/search?kw=' + encodeURIComponent(keyword);
227                    window.open(searchUrl, '_blank');
228                }
229            }
230        });
231    };
232
233    /**
234     * 获取数据
235     */
236    RFJSearchDropdown.prototype._fetchData = function (state, keyword) {
237        var self = this;
238
239        // 取消之前的请求
240        if (state.xhr && state.xhr.abort) {
241            state.xhr.abort();
242        }
243
244        state.isLoading = true;
245        state.currentKeyword = keyword;
246        this._showLoading(state);
247
248        var formData = new FormData();
249        formData.append('action', 'rfj_search_dropdown');
250        formData.append('nonce', state.config.nonce);
251        formData.append('keyword', keyword);
252
253        var xhr = new XMLHttpRequest();
254        xhr.open('POST', state.config.ajaxUrl, true);
255        xhr.onload = function () {
256            state.isLoading = false;
257            if (xhr.status === 200) {
258                try {
259                    var res = JSON.parse(xhr.responseText);
260                    if (res.success) {
261                        self._render(state, res.data);
262                    } else {
263                        self._renderError(state, res.data ? res.data.message : '请求失败');
264                    }
265                } catch (e) {
266                    self._renderError(state, '数据解析失败');
267                }
268            } else {
269                self._renderError(state, '网络错误');
270            }
271        };
272        xhr.onerror = function () {
273            state.isLoading = false;
274            self._renderError(state, '网络错误');
275        };
276        xhr.send(formData);
277
278        state.xhr = xhr;
279    };
280
281    RFJSearchDropdown.prototype._positionPortal = function (state) {
282        if (!state.config.portalToBody) {
283            return;
284        }
285
286        var rect = state.input.getBoundingClientRect();
287        var minWidth = parseInt(state.input.getAttribute('data-rfj-search-dropdown-min-width'), 10) || 280;
288        var gutter = 12;
289        var width = Math.max(rect.width, minWidth);
290        width = Math.min(width, window.innerWidth - gutter * 2);
291        var left = Math.min(Math.max(gutter, rect.right - width), window.innerWidth - gutter - width);
292
293        state.container.style.position = 'fixed';
294        state.container.style.top = Math.round(rect.bottom + 8) + 'px';
295        state.container.style.left = Math.round(left) + 'px';
296        state.container.style.right = 'auto';
297        state.container.style.width = Math.round(width) + 'px';
298        state.container.style.zIndex = getComputedStyle(document.documentElement)
299            .getPropertyValue('--rfj-z-dropdown-portal').trim() || '10020';
300    };
301
302    /**
303     * 渲染结果
304     */
305    RFJSearchDropdown.prototype._render = function (state, data) {
306        var listEl = state.listEl;
307        var footerEl = state.footerEl;
308        var keyword = state.currentKeyword;
309
310        listEl.innerHTML = '';
311        var hasContent = false;
312
313        // 分类
314        if (data.categories && data.categories.length > 0) {
315            hasContent = true;
316            listEl.appendChild(this._renderSection(state, '分类', data.categories, 'category', data.search_url + '&type=cat'));
317        }
318
319        // 软件
320        if (data.software && data.software.length > 0) {
321            hasContent = true;
322            listEl.appendChild(this._renderSection(state, '软件', data.software, 'software', data.search_url + '&type=software'));
323        }
324
325        // 供应商
326        if (data.companies && data.companies.length > 0) {
327            hasContent = true;
328            listEl.appendChild(this._renderSection(state, '供应商', data.companies, 'company', data.search_url + '&type=company'));
329        }
330
331        // 底部更多
332        if (data.has_more || (data.categories && data.categories.length > 0) || (data.software && data.software.length > 0) || (data.companies && data.companies.length > 0)) {
333            footerEl.style.display = 'block';
334            footerEl.innerHTML = '<a href="' + data.search_url + '" class="rfj-sd-more-btn" target="_blank">' + ICONS.arrow + ' 查看更多结果</a>';
335        } else {
336            footerEl.style.display = 'none';
337        }
338
339        if (hasContent) {
340            this._positionPortal(state);
341            state.container.style.display = 'block';
342        } else {
343            state.container.style.display = 'none';
344        }
345    };
346
347    /**
348     * 渲染分类区块
349     */
350    RFJSearchDropdown.prototype._renderSection = function (state, title, items, type, moreUrl) {
351        var self = this;
352        var section = document.createElement('div');
353        section.className = 'rfj-sd-section';
354
355        // 标题行
356        var header = document.createElement('div');
357        header.className = 'rfj-sd-section-header';
358        header.innerHTML = '<span class="rfj-sd-section-title">' + ICONS[type] + title + '</span>';
359        section.appendChild(header);
360
361        // 列表
362        var list = document.createElement('ul');
363        list.className = 'rfj-sd-items';
364
365        items.forEach(function (item) {
366            var li = document.createElement('li');
367            li.className = 'rfj-sd-item';
368
369            var a = document.createElement('a');
370            a.href = item.link || 'javascript:void(0);';
371            a.target = '_blank';
372            a.className = 'rfj-sd-item-link';
373            a.textContent = item.title || item.name || '';
374
375            a.addEventListener('click', function (e) {
376                if (typeof state.config.onSelect === 'function') {
377                    state.config.onSelect(type, item);
378                }
379                self.hide(state);
380            });
381
382            li.appendChild(a);
383            list.appendChild(li);
384        });
385
386        section.appendChild(list);
387        return section;
388    };
389
390    /**
391     * 显示加载状态
392     */
393    RFJSearchDropdown.prototype._showLoading = function (state) {
394        state.listEl.innerHTML = '<div class="rfj-sd-loading">' + ICONS.loading + ' 搜索中...</div>';
395        this._positionPortal(state);
396        state.container.style.display = 'block';
397    };
398
399    /**
400     * 显示错误
401     */
402    RFJSearchDropdown.prototype._renderError = function (state, message) {
403        state.listEl.innerHTML = '<div class="rfj-sd-error">' + message + '</div>';
404        this._positionPortal(state);
405        state.container.style.display = 'block';
406    };
407
408    /**
409     * 隐藏下拉
410     */
411    RFJSearchDropdown.prototype.hide = function (state) {
412        if (state) {
413            state.container.style.display = 'none';
414        }
415    };
416
417    /**
418     * 销毁实例
419     */
420    RFJSearchDropdown.prototype.destroy = function (state) {
421        if (state && this.instances.has(state.id)) {
422            if (state.resizeHandler) {
423                window.removeEventListener('resize', state.resizeHandler);
424            }
425            if (state.scrollHandler) {
426                window.removeEventListener('scroll', state.scrollHandler, true);
427            }
428            this.instances.delete(state.id);
429            state.container.innerHTML = '';
430            state.container.style.display = '';
431        }
432    };
433
434    // ==================== 静态方法 ====================
435    RFJSearchDropdown.init = function (inputSelector, dropdownContainer, options) {
436        var instance = new RFJSearchDropdown();
437        return instance.init(inputSelector, dropdownContainer, options);
438    };
439
440    RFJSearchDropdown.hideAll = function () {
441        document.querySelectorAll('.rfj-search-dropdown').forEach(function (el) {
442            el.style.display = 'none';
443        });
444    };
445
446    // ==================== 初始化入口 ====================
447    // 如果页面有预定义的 data 属性,自动初始化
448    document.addEventListener('DOMContentLoaded', function () {
449        document.querySelectorAll('[data-rfj-search]').forEach(function (input) {
450            var containerId = input.getAttribute('data-rfj-search-dropdown');
451            var container = containerId ? document.querySelector(containerId) : null;
452            if (!container) {
453                container = document.createElement('div');
454                container.id = 'rfj-sd-' + Date.now();
455                input.parentNode.insertBefore(container, input.nextSibling);
456            }
457            var ajaxUrl = input.getAttribute('data-ajax-url') || '/wp-admin/admin-ajax.php';
458            var nonce = input.getAttribute('data-nonce') || '';
459            RFJSearchDropdown.init(input, container, {
460                ajaxUrl: ajaxUrl,
461                nonce: nonce,
462                portalToBody: input.getAttribute('data-rfj-search-portal') === 'body'
463            });
464        });
465    });
466
467    return RFJSearchDropdown;
468}));

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.