UNPKG

yyf_aka-web-components

Version:

A collection of modern Web Components including counter, data table and custom button components

1,010 lines (890 loc) 34.1 kB
/** * 数据表格组件 - 复杂数据处理示例 * * 功能特性: * - 自定义元素:使用 <data-table> 标签 * - Shadow DOM:封装样式和结构 * - 数据绑定:支持动态数据加载和更新 * - 排序功能:支持多列排序,升序/降序切换 * - 搜索功能:支持全局搜索,实时过滤 * - 分页功能:支持自定义页大小,分页导航 * - 自定义列:支持状态徽章、操作按钮等自定义渲染 * - 事件系统:触发多种自定义事件 * - 公共 API:提供多种数据操作方法 * * 使用示例: * <data-table page-size="5" searchable="true" sortable="true" pagination="true"></data-table> * * 属性说明: * - page-size: 每页显示的行数(默认:10) * - searchable: 是否启用搜索功能(默认:true) * - sortable: 是否启用排序功能(默认:true) * - pagination: 是否启用分页功能(默认:true) * * 事件说明: * - row-added: 添加行时触发 * detail: { row: object } * - row-updated: 更新行时触发 * detail: { index: number, row: object } * - row-deleted: 删除行时触发 * detail: { index: number, row: object } * - row-view: 查看行时触发 * detail: { index: number, row: object } * - row-edit: 编辑行时触发 * detail: { index: number, row: object } * * 公共方法: * - setData(data): 设置表格数据 * - setColumns(columns): 设置列定义 * - addRow(row): 添加新行 * - updateRow(index, row): 更新指定行 * - deleteRow(index): 删除指定行 * - viewRow(rowIndex): 查看指定行 * - editRow(rowIndex): 编辑指定行 * - getData(): 获取所有数据 * - getFilteredData(): 获取过滤后的数据 */ class DataTable extends HTMLElement { /** * 构造函数 * 初始化组件的基本属性和状态 */ constructor() { super(); // 创建 Shadow DOM,mode: 'open' 允许外部访问 this.attachShadow({ mode: 'open' }); // 数据相关属性 this._data = []; // 原始数据数组 this._columns = []; // 列定义数组 this._filteredData = []; // 过滤后的数据数组 // 分页相关属性 this._currentPage = 1; // 当前页码 this._pageSize = 10; // 每页显示的行数 // 排序相关属性 this._sortColumn = null; // 当前排序列 this._sortDirection = 'asc'; // 排序方向(asc/desc) // 搜索相关属性 this._searchTerm = ''; // 搜索关键词 // 功能开关 this._searchable = true; // 是否启用搜索 this._sortable = true; // 是否启用排序 this._pagination = true; // 是否启用分页 // 事件监听器引用,用于清理 this._eventListeners = new Map(); } /** * 定义需要观察的属性 * 当这些属性发生变化时,会触发 attributeChangedCallback * @returns {string[]} 需要观察的属性名数组 */ static get observedAttributes() { return ['page-size', 'searchable', 'sortable', 'pagination']; } /** * 组件连接到 DOM 时调用 * 初始化组件状态、加载数据、渲染界面、绑定事件 */ connectedCallback() { // 从属性中读取配置参数 this._pageSize = parseInt(this.getAttribute('page-size')) || 10; this._searchable = this.getAttribute('searchable') !== 'false'; this._sortable = this.getAttribute('sortable') !== 'false'; this._pagination = this.getAttribute('pagination') !== 'false'; // 加载数据(从属性或插槽) this.loadData(); // 渲染组件界面 this.render(); // 绑定事件监听器 this.addEventListeners(); } /** * 组件从 DOM 中移除时调用 * 清理事件监听器,防止内存泄漏 */ disconnectedCallback() { this.removeEventListeners(); } /** * 观察的属性发生变化时调用 * @param {string} name - 发生变化的属性名 * @param {string} oldValue - 变化前的值 * @param {string} newValue - 变化后的值 */ attributeChangedCallback(name, oldValue, newValue) { // 只有当值真正发生变化时才处理 if (oldValue !== newValue) { switch (name) { case 'page-size': // 更新页大小,并重置到第一页 this._pageSize = parseInt(newValue) || 10; this._currentPage = 1; break; case 'searchable': // 更新搜索功能开关 this._searchable = newValue !== 'false'; break; case 'sortable': // 更新排序功能开关 this._sortable = newValue !== 'false'; break; case 'pagination': // 更新分页功能开关 this._pagination = newValue !== 'false'; break; } // 重新渲染界面 this.render(); } } /** * 绑定事件监听器 * 为搜索、排序、分页等功能添加事件处理 */ addEventListeners() { const shadow = this.shadowRoot; // 搜索功能事件监听 if (this._searchable) { const searchInput = shadow.querySelector('.search-input'); if (searchInput) { const searchHandler = (e) => { // 更新搜索关键词 this._searchTerm = e.target.value; // 重置到第一页 this._currentPage = 1; // 执行数据过滤 this.filterData(); // 重新渲染界面 this.render(); }; // 存储监听器引用 this._eventListeners.set('search', { element: searchInput, handler: searchHandler, event: 'input' }); searchInput.addEventListener('input', searchHandler); } } // 排序功能事件监听 if (this._sortable) { const sortButtons = shadow.querySelectorAll('.sort-btn'); sortButtons.forEach((btn, index) => { const sortHandler = () => { // 从按钮的 data-column 属性获取列名 const column = btn.dataset.column; // 执行排序 this.sortData(column); }; // 存储监听器引用 this._eventListeners.set(`sort-${index}`, { element: btn, handler: sortHandler, event: 'click' }); btn.addEventListener('click', sortHandler); }); } // 分页功能事件监听 if (this._pagination) { const paginationContainer = shadow.querySelector('.pagination'); if (paginationContainer) { const paginationHandler = (e) => { // 检查点击的是否为分页按钮 if (e.target.classList.contains('page-btn')) { const page = parseInt(e.target.dataset.page); // 确保页码有效且不是当前页 if (page && page !== this._currentPage && !e.target.disabled) { this._currentPage = page; // 重新渲染界面 this.render(); } } }; // 存储监听器引用 this._eventListeners.set('pagination', { element: paginationContainer, handler: paginationHandler, event: 'click' }); paginationContainer.addEventListener('click', paginationHandler); } } // 操作按钮事件监听(使用事件委托) const tableBody = shadow.querySelector('.data-table tbody'); if (tableBody) { const actionHandler = (e) => { // 检查点击的是否为操作按钮 if (e.target.classList.contains('action-btn')) { const action = e.target.dataset.action; const rowIndex = parseInt(e.target.dataset.rowIndex); // 确保操作类型和行索引有效 if (action && rowIndex !== undefined) { switch (action) { case 'view': this.viewRow(rowIndex); break; case 'edit': this.editRow(rowIndex); break; case 'delete': this.deleteRow(rowIndex); break; } } } }; // 存储监听器引用 this._eventListeners.set('actions', { element: tableBody, handler: actionHandler, event: 'click' }); tableBody.addEventListener('click', actionHandler); } } /** * 移除事件监听器 * 清理事件监听器,防止内存泄漏 */ removeEventListeners() { // 清理所有存储的事件监听器 this._eventListeners.forEach(({ element, handler, event }) => { if (element && element.removeEventListener) { element.removeEventListener(event, handler); } }); this._eventListeners.clear(); } /** * 加载数据 * 从属性或插槽中获取数据和列定义,并更新过滤后的数据 */ loadData() { // 从属性中获取数据 const dataAttr = this.getAttribute('data'); if (dataAttr) { try { this._data = JSON.parse(dataAttr); } catch (e) { console.error('Invalid data attribute:', e); this._data = []; } } // 从属性中获取列定义 const columnsAttr = this.getAttribute('columns'); if (columnsAttr) { try { this._columns = JSON.parse(columnsAttr); } catch (e) { console.error('Invalid columns attribute:', e); this._columns = []; } } // 如果没有列定义,提供默认列 if (this._columns.length === 0 && this._data.length > 0) { this._columns = Object.keys(this._data[0]).map(key => ({ key: key, label: key.charAt(0).toUpperCase() + key.slice(1), type: 'text' })); } // 更新过滤后的数据 this._filteredData = [...this._data]; } /** * 执行数据过滤 * 根据当前的搜索关键词过滤数据 */ filterData() { if (!this._searchTerm || this._searchTerm.trim() === '') { this._filteredData = [...this._data]; return; } const searchTerm = this._searchTerm.toLowerCase().trim(); this._filteredData = this._data.filter(item => { return Object.values(item).some(value => { if (value === null || value === undefined) return false; return String(value).toLowerCase().includes(searchTerm); }); }); } /** * 执行数据排序 * @param {string} column - 要排序的列名 */ sortData(column) { if (this._sortColumn === column) { this._sortDirection = this._sortDirection === 'asc' ? 'desc' : 'asc'; } else { this._sortColumn = column; this._sortDirection = 'asc'; } this._filteredData.sort((a, b) => { let aVal = a[column]; let bVal = b[column]; // 处理null和undefined值 if (aVal === null || aVal === undefined) aVal = ''; if (bVal === null || bVal === undefined) bVal = ''; // 处理数字类型 if (!isNaN(aVal) && !isNaN(bVal) && aVal !== '' && bVal !== '') { aVal = parseFloat(aVal); bVal = parseFloat(bVal); } else { // 字符串比较 aVal = String(aVal).toLowerCase(); bVal = String(bVal).toLowerCase(); } if (aVal < bVal) return this._sortDirection === 'asc' ? -1 : 1; if (aVal > bVal) return this._sortDirection === 'asc' ? 1 : -1; return 0; }); // 重置到第一页 this._currentPage = 1; this.render(); } /** * 获取当前页的数据 * @returns {Array} 当前页的数据数组 */ getPaginatedData() { if (!this._pagination) { return this._filteredData; } const startIndex = (this._currentPage - 1) * this._pageSize; const endIndex = startIndex + this._pageSize; return this._filteredData.slice(startIndex, endIndex); } /** * 获取总页数 * @returns {number} 总页数 */ getTotalPages() { if (this._filteredData.length === 0) return 0; return Math.ceil(this._filteredData.length / this._pageSize); } /** * 渲染组件界面 * 根据当前状态(数据、分页、排序、搜索)重新渲染表格内容 */ render() { // 先清理旧的事件监听器 this.removeEventListeners(); const paginatedData = this.getPaginatedData(); const totalPages = this.getTotalPages(); const totalItems = this._filteredData.length; // 确保当前页码有效 if (this._currentPage > totalPages && totalPages > 0) { this._currentPage = totalPages; } this.shadowRoot.innerHTML = ` <style> :host { display: block; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; } .data-table-container { background: white; border-radius: 12px; box-shadow: 0 4px 20px rgba(0,0,0,0.1); overflow: hidden; } .table-header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 20px; display: flex; justify-content: space-between; align-items: center; } .table-title { margin: 0; font-size: 20px; font-weight: 300; } .table-controls { display: flex; gap: 15px; align-items: center; } .search-container { position: relative; } .search-input { padding: 8px 12px 8px 35px; border: none; border-radius: 6px; background: rgba(255,255,255,0.9); color: #333; font-size: 14px; width: 200px; } .search-input:focus { outline: none; background: white; } .search-icon { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); color: #666; font-size: 14px; } .table-wrapper { overflow-x: auto; } .data-table { width: 100%; border-collapse: collapse; background: white; } .data-table th { background: #f8f9fa; padding: 15px 12px; text-align: left; font-weight: 600; color: #333; border-bottom: 2px solid #e9ecef; position: relative; } .data-table th.sortable { cursor: pointer; user-select: none; } .data-table th.sortable:hover { background: #e9ecef; } .sort-btn { background: none; border: none; cursor: pointer; padding: 0; width: 100%; text-align: left; font-weight: inherit; color: inherit; display: flex; align-items: center; justify-content: space-between; } .sort-icon { font-size: 12px; margin-left: 5px; opacity: 0.5; } .sort-icon.active { opacity: 1; } .data-table td { padding: 12px; border-bottom: 1px solid #f0f0f0; color: #333; } .data-table tbody tr:hover { background: #f8f9fa; } .data-table tbody tr:nth-child(even) { background: #fafbfc; } .data-table tbody tr:nth-child(even):hover { background: #f0f1f2; } .empty-state { text-align: center; padding: 40px 20px; color: #999; } .empty-state h3 { margin: 0 0 10px 0; font-weight: 300; } .table-footer { display: flex; justify-content: space-between; align-items: center; padding: 15px 20px; background: #f8f9fa; border-top: 1px solid #e9ecef; } .table-info { font-size: 14px; color: #666; } .pagination { display: flex; gap: 5px; align-items: center; } .page-btn { padding: 6px 12px; border: 1px solid #ddd; background: white; border-radius: 4px; cursor: pointer; font-size: 14px; transition: all 0.3s ease; } .page-btn:hover { background: #f0f1f2; } .page-btn.active { background: #667eea; color: white; border-color: #667eea; } .page-btn:disabled { opacity: 0.5; cursor: not-allowed; } .status-badge { padding: 4px 8px; border-radius: 12px; font-size: 12px; font-weight: 500; } .status-active { background: #d4edda; color: #155724; } .status-inactive { background: #f8d7da; color: #721c24; } .status-pending { background: #fff3cd; color: #856404; } .action-btn { padding: 4px 8px; border: none; border-radius: 4px; cursor: pointer; font-size: 12px; margin-right: 5px; transition: all 0.3s ease; } .edit-btn { background: #ffc107; color: #333; } .delete-btn { background: #dc3545; color: white; } .view-btn { background: #17a2b8; color: white; } @media (max-width: 768px) { .table-header { flex-direction: column; gap: 15px; align-items: stretch; } .table-controls { justify-content: center; } .search-input { width: 100%; } .table-footer { flex-direction: column; gap: 15px; } .pagination { justify-content: center; } } </style> <div class="data-table-container"> <div class="table-header"> <h2 class="table-title">数据表格</h2> ${this._searchable ? ` <div class="table-controls"> <div class="search-container"> <span class="search-icon">🔍</span> <input type="text" class="search-input" placeholder="搜索..." value="${this._searchTerm}"> </div> </div> ` : ''} </div> <div class="table-wrapper"> <table class="data-table"> <thead> <tr> ${this._columns.length > 0 ? this._columns.map(column => ` <th class="${this._sortable ? 'sortable' : ''}"> ${this._sortable ? ` <button class="sort-btn" data-column="${column.key}"> ${column.label} <span class="sort-icon ${this._sortColumn === column.key ? 'active' : ''}"> ${this._sortColumn === column.key ? (this._sortDirection === 'asc' ? '↑' : '↓') : '↕'} </span> </button> ` : column.label} </th> `).join('') : '<th>暂无列定义</th>'} </tr> </thead> <tbody> ${paginatedData.length > 0 ? paginatedData.map((row, index) => ` <tr> ${this._columns.length > 0 ? this._columns.map(column => ` <td> ${this.renderCell(row, column, index)} </td> `).join('') : '<td>暂无数据</td>'} </tr> `).join('') : ` <tr> <td colspan="${Math.max(this._columns.length, 1)}" class="empty-state"> <h3>暂无数据</h3> <p>${this._searchTerm ? '没有找到匹配的数据' : '请添加数据'}</p> </td> </tr> `} </tbody> </table> </div> ${this._pagination && totalPages > 1 ? ` <div class="table-footer"> <div class="table-info"> 显示 ${totalItems > 0 ? (this._currentPage - 1) * this._pageSize + 1 : 0} - ${Math.min(this._currentPage * this._pageSize, totalItems)} 条,共 ${totalItems} 条数据 </div> <div class="pagination"> <button class="page-btn" data-page="${this._currentPage - 1}" ${this._currentPage <= 1 ? 'disabled' : ''}> 上一页 </button> ${this.getPageNumbers().map(page => ` <button class="page-btn ${page === this._currentPage ? 'active' : ''}" data-page="${page}"> ${page} </button> `).join('')} <button class="page-btn" data-page="${this._currentPage + 1}" ${this._currentPage >= totalPages ? 'disabled' : ''}> 下一页 </button> </div> </div> ` : ''} </div> <slot name="footer"></slot> `; // 重新绑定事件监听器 this.addEventListeners(); } /** * 渲染单个单元格 * @param {object} row - 当前行数据 * @param {object} column - 列定义 * @param {number} rowIndex - 当前行在分页数据中的索引 * @returns {string} 渲染后的单元格内容 */ renderCell(row, column, rowIndex) { const value = row[column.key]; // 自定义渲染器 if (column.render) { return column.render(value, row, rowIndex); } // 状态徽章 if (column.type === 'status') { const statusMap = { 'active': 'status-active', 'inactive': 'status-inactive', 'pending': 'status-pending' }; const statusClass = statusMap[value] || 'status-inactive'; return `<span class="status-badge ${statusClass}">${value || 'unknown'}</span>`; } // 操作按钮 if (column.type === 'actions') { return ` <button class="action-btn view-btn" data-action="view" data-row-index="${rowIndex}">查看</button> <button class="action-btn edit-btn" data-action="edit" data-row-index="${rowIndex}">编辑</button> <button class="action-btn delete-btn" data-action="delete" data-row-index="${rowIndex}">删除</button> `; } // 默认渲染 return value !== null && value !== undefined ? String(value) : ''; } /** * 获取分页按钮的页码数组 * @returns {Array} 页码数组 */ getPageNumbers() { const totalPages = this.getTotalPages(); const current = this._currentPage; const pages = []; if (totalPages <= 7) { for (let i = 1; i <= totalPages; i++) { pages.push(i); } } else { if (current <= 4) { for (let i = 1; i <= 5; i++) { pages.push(i); } if (totalPages > 5) { pages.push('...'); pages.push(totalPages); } } else if (current >= totalPages - 3) { pages.push(1); pages.push('...'); for (let i = totalPages - 4; i <= totalPages; i++) { pages.push(i); } } else { pages.push(1); pages.push('...'); for (let i = current - 1; i <= current + 1; i++) { pages.push(i); } pages.push('...'); pages.push(totalPages); } } return pages; } // 公共方法 /** * 设置表格数据 * @param {Array} data - 新的数据数组 */ setData(data) { this._data = Array.isArray(data) ? data : []; this._filteredData = [...this._data]; this._currentPage = 1; this.render(); } /** * 设置列定义 * @param {Array} columns - 新的列定义数组 */ setColumns(columns) { this._columns = Array.isArray(columns) ? columns : []; this.render(); } /** * 添加新行 * @param {object} row - 要添加的行数据 */ addRow(row) { if (row && typeof row === 'object') { this._data.push(row); this._filteredData = [...this._data]; this.render(); this.dispatchEvent(new CustomEvent('row-added', { detail: { row }, bubbles: true, composed: true })); } } /** * 更新指定行 * @param {number} index - 要更新的行在原始数据中的索引 * @param {object} row - 新的行数据 */ updateRow(index, row) { if (index >= 0 && index < this._data.length && row && typeof row === 'object') { this._data[index] = { ...this._data[index], ...row }; this._filteredData = [...this._data]; this.render(); this.dispatchEvent(new CustomEvent('row-updated', { detail: { index, row: this._data[index] }, bubbles: true, composed: true })); } } /** * 删除指定行 * @param {number} index - 要删除的行在原始数据中的索引 */ deleteRow(index) { if (index >= 0 && index < this._data.length) { const deletedRow = this._data.splice(index, 1)[0]; this._filteredData = [...this._data]; // 如果当前页没有数据了,回到上一页 if (this.getPaginatedData().length === 0 && this._currentPage > 1) { this._currentPage--; } this.render(); this.dispatchEvent(new CustomEvent('row-deleted', { detail: { index, row: deletedRow }, bubbles: true, composed: true })); } } /** * 查看指定行 * @param {number} rowIndex - 要查看的行在分页数据中的索引 */ viewRow(rowIndex) { const paginatedData = this.getPaginatedData(); const row = paginatedData[rowIndex]; if (row) { // 找到在原始数据中的索引 const originalIndex = this._data.findIndex(item => { // 尝试使用id字段,如果没有则使用对象引用比较 if (item.id !== undefined && row.id !== undefined) { return item.id === row.id; } return item === row; }); this.dispatchEvent(new CustomEvent('row-view', { detail: { index: originalIndex, row }, bubbles: true, composed: true })); } } /** * 编辑指定行 * @param {number} rowIndex - 要编辑的行在分页数据中的索引 */ editRow(rowIndex) { const paginatedData = this.getPaginatedData(); const row = paginatedData[rowIndex]; if (row) { // 找到在原始数据中的索引 const originalIndex = this._data.findIndex(item => { // 尝试使用id字段,如果没有则使用对象引用比较 if (item.id !== undefined && row.id !== undefined) { return item.id === row.id; } return item === row; }); this.dispatchEvent(new CustomEvent('row-edit', { detail: { index: originalIndex, row }, bubbles: true, composed: true })); } } /** * 获取所有原始数据 * @returns {Array} 原始数据数组 */ getData() { return [...this._data]; } /** * 获取过滤后的数据 * @returns {Array} 过滤后的数据数组 */ getFilteredData() { return [...this._filteredData]; } /** * 获取当前页码 * @returns {number} 当前页码 */ getCurrentPage() { return this._currentPage; } /** * 设置当前页码 * @param {number} page - 要设置的页码 */ setCurrentPage(page) { const totalPages = this.getTotalPages(); if (page >= 1 && page <= totalPages) { this._currentPage = page; this.render(); } } } // 导出组件类 export default DataTable;