mirror of
https://github.com/teest114514/chatlog_alpha.git
synced 2026-04-05 10:50:32 +08:00
feat: add sql execution, keyword search, and generic export
This commit is contained in:
@@ -134,11 +134,18 @@ func (s *Service) GetTables(group, file string) ([]string, error) {
|
||||
return s.db.GetTables(group, file)
|
||||
}
|
||||
|
||||
func (s *Service) GetTableData(group, file, table string, limit, offset int) ([]map[string]interface{}, error) {
|
||||
func (s *Service) GetTableData(group, file, table string, limit, offset int, keyword string) ([]map[string]interface{}, error) {
|
||||
if s.db == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return s.db.GetTableData(group, file, table, limit, offset)
|
||||
return s.db.GetTableData(group, file, table, limit, offset, keyword)
|
||||
}
|
||||
|
||||
func (s *Service) ExecuteSQL(group, file, query string) ([]map[string]interface{}, error) {
|
||||
if s.db == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return s.db.ExecuteSQL(group, file, query)
|
||||
}
|
||||
|
||||
func (s *Service) initWebhook() error {
|
||||
|
||||
@@ -64,6 +64,7 @@ func (s *Service) initAPIRouter() {
|
||||
api.GET("/db", s.handleGetDBs)
|
||||
api.GET("/db/tables", s.handleGetDBTables)
|
||||
api.GET("/db/data", s.handleGetDBTableData)
|
||||
api.GET("/db/query", s.handleExecuteSQL)
|
||||
api.POST("/cache/clear", s.handleClearCache)
|
||||
}
|
||||
}
|
||||
@@ -733,8 +734,10 @@ func (s *Service) handleGetDBTableData(c *gin.Context) {
|
||||
group := c.Query("group")
|
||||
file := c.Query("file")
|
||||
table := c.Query("table")
|
||||
keyword := c.Query("keyword")
|
||||
limitStr := c.DefaultQuery("limit", "20")
|
||||
offsetStr := c.DefaultQuery("offset", "0")
|
||||
format := strings.ToLower(c.Query("format"))
|
||||
|
||||
if group == "" || file == "" || table == "" {
|
||||
errors.Err(c, errors.InvalidArg("group, file or table"))
|
||||
@@ -746,11 +749,120 @@ func (s *Service) handleGetDBTableData(c *gin.Context) {
|
||||
fmt.Sscanf(limitStr, "%d", &limit)
|
||||
fmt.Sscanf(offsetStr, "%d", &offset)
|
||||
|
||||
data, err := s.db.GetTableData(group, file, table, limit, offset)
|
||||
// If exporting, fetch all matching rows (ignore pagination if user wants all? or respect pagination?)
|
||||
// Usually export means "export all matching".
|
||||
if format == "csv" || format == "xlsx" || format == "excel" {
|
||||
limit = -1 // No limit
|
||||
offset = 0
|
||||
}
|
||||
|
||||
data, err := s.db.GetTableData(group, file, table, limit, offset, keyword)
|
||||
if err != nil {
|
||||
errors.Err(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
if format == "csv" || format == "xlsx" || format == "excel" {
|
||||
s.exportData(c, data, format, table)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, data)
|
||||
}
|
||||
|
||||
func (s *Service) handleExecuteSQL(c *gin.Context) {
|
||||
group := c.Query("group")
|
||||
file := c.Query("file")
|
||||
query := c.Query("sql")
|
||||
format := strings.ToLower(c.Query("format"))
|
||||
|
||||
if group == "" || file == "" || query == "" {
|
||||
errors.Err(c, errors.InvalidArg("group, file or sql"))
|
||||
return
|
||||
}
|
||||
|
||||
data, err := s.db.ExecuteSQL(group, file, query)
|
||||
if err != nil {
|
||||
errors.Err(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
if format == "csv" || format == "xlsx" || format == "excel" {
|
||||
s.exportData(c, data, format, "query_result")
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, data)
|
||||
}
|
||||
|
||||
func (s *Service) exportData(c *gin.Context, data []map[string]interface{}, format string, filename string) {
|
||||
if len(data) == 0 {
|
||||
c.String(http.StatusOK, "")
|
||||
return
|
||||
}
|
||||
|
||||
// Extract headers
|
||||
var headers []string
|
||||
for k := range data[0] {
|
||||
headers = append(headers, k)
|
||||
}
|
||||
// Sort headers for consistency
|
||||
// sort.Strings(headers) // We need sort package
|
||||
|
||||
if format == "csv" {
|
||||
c.Writer.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||||
c.Writer.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s.csv", filename))
|
||||
c.Writer.Header().Set("Cache-Control", "no-cache")
|
||||
c.Writer.Flush()
|
||||
|
||||
w := csv.NewWriter(c.Writer)
|
||||
w.Write(headers)
|
||||
for _, row := range data {
|
||||
var record []string
|
||||
for _, h := range headers {
|
||||
val := row[h]
|
||||
if val == nil {
|
||||
record = append(record, "")
|
||||
} else {
|
||||
record = append(record, fmt.Sprintf("%v", val))
|
||||
}
|
||||
}
|
||||
w.Write(record)
|
||||
}
|
||||
w.Flush()
|
||||
} else {
|
||||
// Excel
|
||||
f := excelize.NewFile()
|
||||
defer func() {
|
||||
if err := f.Close(); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to close excel file")
|
||||
}
|
||||
}()
|
||||
|
||||
sheet := "Sheet1"
|
||||
index, _ := f.NewSheet(sheet)
|
||||
|
||||
// Write headers
|
||||
for i, h := range headers {
|
||||
cell, _ := excelize.CoordinatesToCellName(i+1, 1)
|
||||
f.SetCellValue(sheet, cell, h)
|
||||
}
|
||||
|
||||
// Write data
|
||||
for r, row := range data {
|
||||
for cIdx, h := range headers {
|
||||
val := row[h]
|
||||
cell, _ := excelize.CoordinatesToCellName(cIdx+1, r+2)
|
||||
f.SetCellValue(sheet, cell, val)
|
||||
}
|
||||
}
|
||||
|
||||
f.SetActiveSheet(index)
|
||||
c.Writer.Header().Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
||||
c.Writer.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s.xlsx", filename))
|
||||
if err := f.Write(c.Writer); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to write excel file")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -180,6 +180,11 @@
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 6px 12px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Results */
|
||||
.result-area {
|
||||
margin-top: 20px;
|
||||
@@ -269,30 +274,48 @@
|
||||
/* Modal Styles */
|
||||
.modal { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 1000; display: flex; justify-content: center; align-items: center; }
|
||||
.modal.hidden { display: none; }
|
||||
.modal-content { background: white; width: 90%; max-width: 1200px; height: 90%; border-radius: 8px; display: flex; flex-direction: column; box-shadow: 0 4px 20px rgba(0,0,0,0.2); }
|
||||
.modal-content { background: white; width: 95%; max-width: 1300px; height: 95%; border-radius: 8px; display: flex; flex-direction: column; box-shadow: 0 4px 20px rgba(0,0,0,0.2); }
|
||||
.modal-header { padding: 15px 20px; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; background: #f9fafb; border-radius: 8px 8px 0 0; }
|
||||
.modal-header h3 { margin: 0; color: var(--text-main); font-size: 1.2rem; }
|
||||
.modal-body { flex: 1; overflow: hidden; padding: 20px; display: flex; flex-direction: column; }
|
||||
.close-btn { background: none; border: none; font-size: 2rem; line-height: 1; cursor: pointer; color: var(--text-secondary); }
|
||||
.close-btn:hover { color: var(--danger); }
|
||||
|
||||
/* Modal Nav & Views */
|
||||
.modal-nav { display: flex; gap: 10px; margin-bottom: 15px; border-bottom: 1px solid var(--border); padding-bottom: 10px; }
|
||||
.nav-btn { background: none; border: none; padding: 8px 15px; cursor: pointer; font-weight: 500; color: var(--text-secondary); border-radius: 4px; transition: all 0.2s; }
|
||||
.nav-btn:hover { background: #f3f4f6; }
|
||||
.nav-btn.active { background: #eff6ff; color: var(--primary); font-weight: 600; }
|
||||
|
||||
.modal-view { display: none; flex: 1; flex-direction: column; overflow: hidden; }
|
||||
.modal-view.active { display: flex; }
|
||||
|
||||
/* Browser View */
|
||||
.db-table-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 15px; overflow-y: auto; padding: 5px; }
|
||||
.db-table-item { padding: 15px; border: 1px solid var(--border); border-radius: 8px; cursor: pointer; text-align: center; background: white; transition: all 0.2s; box-shadow: 0 1px 2px rgba(0,0,0,0.05); }
|
||||
.db-table-item:hover { background: #eff6ff; border-color: var(--primary); transform: translateY(-2px); box-shadow: 0 4px 6px rgba(0,0,0,0.05); }
|
||||
|
||||
.data-view-container { display: flex; flex-direction: column; height: 100%; }
|
||||
.data-controls { display: flex; gap: 15px; align-items: center; margin-bottom: 15px; flex-shrink: 0; }
|
||||
.table-scroll { flex: 1; overflow: auto; border: 1px solid var(--border); border-radius: 6px; }
|
||||
.data-view-container { display: flex; flex-direction: column; height: 100%; flex: 1; overflow: hidden; }
|
||||
.data-controls { display: flex; gap: 10px; align-items: center; margin-bottom: 15px; flex-shrink: 0; flex-wrap: wrap; }
|
||||
.spacer { flex: 1; }
|
||||
.search-input { padding: 6px 12px; border: 1px solid var(--border); border-radius: 4px; width: 200px; font-size: 0.9rem; }
|
||||
|
||||
#data-table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 0.9rem; }
|
||||
#data-table th, #data-table td { padding: 10px; border-bottom: 1px solid var(--border); border-right: 1px solid var(--border); white-space: nowrap; max-width: 300px; overflow: hidden; text-overflow: ellipsis; }
|
||||
#data-table th { background: #f3f4f6; position: sticky; top: 0; z-index: 10; font-weight: 600; text-align: left; }
|
||||
#data-table tr:hover { background-color: #f9fafb; }
|
||||
.table-scroll { flex: 1; overflow: auto; border: 1px solid var(--border); border-radius: 6px; position: relative; }
|
||||
|
||||
/* SQL View */
|
||||
.sql-editor-container { margin-bottom: 15px; display: flex; flex-direction: column; gap: 10px; }
|
||||
.sql-editor { width: 100%; height: 120px; font-family: 'Consolas', monospace; padding: 10px; border: 1px solid var(--border); border-radius: 6px; box-sizing: border-box; resize: vertical; font-size: 14px; background: #f8fafc; }
|
||||
.sql-editor:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1); }
|
||||
|
||||
#data-table, #sql-result-table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 0.9rem; }
|
||||
#data-table th, #sql-result-table th, #data-table td, #sql-result-table td { padding: 8px 12px; border-bottom: 1px solid var(--border); border-right: 1px solid var(--border); white-space: nowrap; max-width: 400px; overflow: hidden; text-overflow: ellipsis; }
|
||||
#data-table th, #sql-result-table th { background: #f3f4f6; position: sticky; top: 0; z-index: 10; font-weight: 600; text-align: left; }
|
||||
#data-table tr:hover, #sql-result-table tr:hover { background-color: #f9fafb; }
|
||||
|
||||
.pagination { display: flex; justify-content: center; gap: 15px; margin-top: 15px; align-items: center; flex-shrink: 0; }
|
||||
|
||||
/* Helper Classes */
|
||||
.hidden { display: none; }
|
||||
.hidden { display: none !important; }
|
||||
.text-sm { font-size: 0.875rem; }
|
||||
.text-gray { color: var(--text-secondary); }
|
||||
.text-danger { color: var(--danger); }
|
||||
@@ -330,9 +353,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Session Tab -->
|
||||
<!-- Other Tabs (Keep same as before) -->
|
||||
<div id="session" class="tab-content">
|
||||
<div class="form-grid">
|
||||
<!-- ... content ... -->
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label>关键词搜索 (可选)</label>
|
||||
<input type="text" id="session-keyword" placeholder="昵称 / 备注 / wxid">
|
||||
@@ -352,10 +376,9 @@
|
||||
</div>
|
||||
<div id="session-result" class="result-area hidden"></div>
|
||||
</div>
|
||||
|
||||
<!-- Chatroom Tab -->
|
||||
<div id="chatroom" class="tab-content">
|
||||
<div class="form-grid">
|
||||
<!-- ... content ... -->
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label>搜索群聊 (可选)</label>
|
||||
<input type="text" id="chatroom-keyword" placeholder="群名称 / 群ID">
|
||||
@@ -375,10 +398,9 @@
|
||||
</div>
|
||||
<div id="chatroom-result" class="result-area hidden"></div>
|
||||
</div>
|
||||
|
||||
<!-- Contact Tab -->
|
||||
<div id="contact" class="tab-content">
|
||||
<div class="form-grid">
|
||||
<!-- ... content ... -->
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label>搜索联系人 (可选)</label>
|
||||
<input type="text" id="contact-keyword" placeholder="昵称 / 备注 / wxid">
|
||||
@@ -398,10 +420,9 @@
|
||||
</div>
|
||||
<div id="contact-result" class="result-area hidden"></div>
|
||||
</div>
|
||||
|
||||
<!-- Chatlog Tab -->
|
||||
<div id="chatlog" class="tab-content">
|
||||
<div class="form-grid">
|
||||
<!-- ... content ... -->
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label>时间范围 <span style="color:red">*</span></label>
|
||||
<input type="text" id="chatlog-time" placeholder="例如: 2023-01-01~2023-12-31">
|
||||
@@ -448,31 +469,72 @@
|
||||
<button class="close-btn" onclick="closeModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<!-- Table List View -->
|
||||
<div id="table-list-view">
|
||||
<p class="text-gray" style="margin-bottom: 15px;">请选择要查看的表:</p>
|
||||
<div id="table-list" class="db-table-list"></div>
|
||||
|
||||
<div class="modal-nav">
|
||||
<button class="nav-btn active" id="btn-view-browser" onclick="switchModalView('browser')">表浏览器</button>
|
||||
<button class="nav-btn" id="btn-view-sql" onclick="switchModalView('sql')">SQL 控制台</button>
|
||||
</div>
|
||||
|
||||
<!-- Table Data View -->
|
||||
<div id="table-data-view" class="data-view-container hidden">
|
||||
<div class="data-controls">
|
||||
<button class="btn btn-secondary text-sm" onclick="backToTableList()">← 返回表列表</button>
|
||||
<strong id="current-table-name" style="font-size: 1.1rem;"></strong>
|
||||
<span id="loading-indicator" class="text-gray hidden">加载中...</span>
|
||||
</div>
|
||||
<div class="table-scroll">
|
||||
<table id="data-table">
|
||||
<thead></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pagination">
|
||||
<button class="btn btn-secondary text-sm" onclick="prevPage()">上一页</button>
|
||||
<span id="page-info" class="text-sm font-bold">Page 1</span>
|
||||
<button class="btn btn-secondary text-sm" onclick="nextPage()">下一页</button>
|
||||
</div>
|
||||
<!-- Browser View -->
|
||||
<div id="view-browser" class="modal-view active">
|
||||
<!-- Table List -->
|
||||
<div id="table-list-view">
|
||||
<p class="text-gray" style="margin-bottom: 15px;">请选择要查看的表:</p>
|
||||
<div id="table-list" class="db-table-list"></div>
|
||||
</div>
|
||||
|
||||
<!-- Table Data -->
|
||||
<div id="table-data-view" class="data-view-container hidden">
|
||||
<div class="data-controls">
|
||||
<button class="btn btn-secondary text-sm" onclick="backToTableList()">← 返回列表</button>
|
||||
<strong id="current-table-name" style="font-size: 1.1rem; margin-right: 15px;"></strong>
|
||||
|
||||
<input type="text" id="data-search-input" class="search-input" placeholder="搜索关键词..." onkeydown="if(event.key === 'Enter') performTableSearch()">
|
||||
<button class="btn btn-secondary btn-sm" onclick="performTableSearch()">搜索</button>
|
||||
|
||||
<div class="spacer"></div>
|
||||
|
||||
<button class="btn btn-success btn-sm" style="background-color: #10b981; color: white;" onclick="exportTableData('xlsx')">导出 Excel</button>
|
||||
<button class="btn btn-secondary btn-sm" onclick="exportTableData('csv')">导出 CSV</button>
|
||||
</div>
|
||||
|
||||
<div class="table-scroll">
|
||||
<table id="data-table">
|
||||
<thead></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="browser-loading" class="text-gray hidden" style="text-align: center; padding: 10px;">加载中...</div>
|
||||
|
||||
<div class="pagination">
|
||||
<button class="btn btn-secondary text-sm" onclick="prevPage()">上一页</button>
|
||||
<span id="page-info" class="text-sm font-bold">Page 1</span>
|
||||
<button class="btn btn-secondary text-sm" onclick="nextPage()">下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SQL View -->
|
||||
<div id="view-sql" class="modal-view">
|
||||
<div class="sql-editor-container">
|
||||
<textarea id="sql-input" class="sql-editor" placeholder="在此输入 SQL 语句... (例如: SELECT * FROM message LIMIT 10)"></textarea>
|
||||
<div class="toolbar">
|
||||
<button class="btn btn-primary btn-sm" onclick="runSQL()">执行 (Run)</button>
|
||||
<div class="spacer"></div>
|
||||
<button class="btn btn-success btn-sm" style="background-color: #10b981; color: white;" onclick="exportSQLResult('xlsx')">导出结果 Excel</button>
|
||||
<button class="btn btn-secondary btn-sm" onclick="exportSQLResult('csv')">导出结果 CSV</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-scroll">
|
||||
<table id="sql-result-table">
|
||||
<thead></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
<div id="sql-msg" style="padding: 10px; color: #666; text-align: center;">请执行查询以查看结果</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -483,7 +545,7 @@
|
||||
loadDBList();
|
||||
});
|
||||
|
||||
// Tab Switching
|
||||
// Tab Switching for Main Page
|
||||
function switchTab(tabId) {
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
|
||||
@@ -501,7 +563,17 @@
|
||||
let currentDB = { group: null, file: null };
|
||||
let currentTable = null;
|
||||
let currentPage = 0;
|
||||
let currentKeyword = '';
|
||||
const pageSize = 50;
|
||||
|
||||
// Modal View Switching
|
||||
function switchModalView(view) {
|
||||
document.querySelectorAll('.modal-nav .nav-btn').forEach(btn => btn.classList.remove('active'));
|
||||
document.getElementById(`btn-view-${view}`).classList.add('active');
|
||||
|
||||
document.querySelectorAll('.modal-view').forEach(v => v.classList.remove('active'));
|
||||
document.getElementById(`view-${view}`).classList.add('active');
|
||||
}
|
||||
|
||||
// Load DB List
|
||||
async function loadDBList() {
|
||||
@@ -525,9 +597,9 @@
|
||||
if (files && files.length > 0) {
|
||||
files.forEach(file => {
|
||||
// Escape backslashes for JS string
|
||||
const escapedFile = file.replace(/\\/g, '\\\\');
|
||||
const escapedFile = file.replace(/\/g, '\\');
|
||||
html += `<div class="db-item" onclick="openDBViewer('${group}', '${escapedFile}')">
|
||||
<span style="font-weight:bold;">📄 ${file.split(/[\\/]/).pop()}</span>
|
||||
<span style="font-weight:bold;">📄 ${file.split(/[\/]/).pop()}</span>
|
||||
<span class="text-gray text-sm" style="margin-left:auto;">${file}</span>
|
||||
</div>`;
|
||||
});
|
||||
@@ -548,11 +620,17 @@
|
||||
document.getElementById('modal-title').textContent = `正在查看: ${file.split(/[\/]/).pop()}`;
|
||||
document.getElementById('db-viewer-modal').classList.remove('hidden');
|
||||
|
||||
// Reset state
|
||||
switchModalView('browser');
|
||||
backToTableList();
|
||||
document.getElementById('sql-input').value = '';
|
||||
document.getElementById('sql-result-table').querySelector('thead').innerHTML = '';
|
||||
document.getElementById('sql-result-table').querySelector('tbody').innerHTML = '';
|
||||
document.getElementById('sql-msg').textContent = '请执行查询以查看结果';
|
||||
|
||||
// Load tables
|
||||
const listContainer = document.getElementById('table-list');
|
||||
listContainer.innerHTML = '<div class="loading">加载表列表中...</div>';
|
||||
|
||||
backToTableList(); // Ensure we are in list view
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/v1/db/tables?group=${group}&file=${encodeURIComponent(file)}`);
|
||||
@@ -588,6 +666,8 @@
|
||||
async function loadTableData(table) {
|
||||
currentTable = table;
|
||||
currentPage = 0;
|
||||
currentKeyword = '';
|
||||
document.getElementById('data-search-input').value = '';
|
||||
|
||||
document.getElementById('table-list-view').classList.add('hidden');
|
||||
document.getElementById('table-data-view').classList.remove('hidden');
|
||||
@@ -595,62 +675,37 @@
|
||||
|
||||
await fetchTableData();
|
||||
}
|
||||
|
||||
function performTableSearch() {
|
||||
const val = document.getElementById('data-search-input').value.trim();
|
||||
currentKeyword = val;
|
||||
currentPage = 0;
|
||||
fetchTableData();
|
||||
}
|
||||
|
||||
async function fetchTableData() {
|
||||
if(!currentTable || !currentDB.file) return;
|
||||
|
||||
const tbody = document.querySelector('#data-table tbody');
|
||||
const thead = document.querySelector('#data-table thead');
|
||||
const indicator = document.getElementById('loading-indicator');
|
||||
const indicator = document.getElementById('browser-loading');
|
||||
|
||||
indicator.classList.remove('hidden');
|
||||
tbody.innerHTML = ''; // Clear current data
|
||||
// Keep header if we are just changing pages? No, schema might ensure safety but refreshing is better.
|
||||
tbody.innerHTML = '';
|
||||
|
||||
const offset = currentPage * pageSize;
|
||||
|
||||
try {
|
||||
const url = `/api/v1/db/data?group=${currentDB.group}&file=${encodeURIComponent(currentDB.file)}&table=${currentTable}&limit=${pageSize}&offset=${offset}`;
|
||||
let url = `/api/v1/db/data?group=${currentDB.group}&file=${encodeURIComponent(currentDB.file)}&table=${currentTable}&limit=${pageSize}&offset=${offset}`;
|
||||
if(currentKeyword) {
|
||||
url += `&keyword=${encodeURIComponent(currentKeyword)}`;
|
||||
}
|
||||
|
||||
const res = await fetch(url);
|
||||
if(!res.ok) throw new Error('Failed to load data');
|
||||
const data = await res.json();
|
||||
|
||||
// Render Header
|
||||
thead.innerHTML = '';
|
||||
if(data && data.length > 0) {
|
||||
const headers = Object.keys(data[0]);
|
||||
let headerHtml = '<tr>';
|
||||
headers.forEach(h => headerHtml += `<th>${h}</th>`);
|
||||
headerHtml += '</tr>';
|
||||
thead.innerHTML = headerHtml;
|
||||
|
||||
// Render Body
|
||||
let bodyHtml = '';
|
||||
data.forEach(row => {
|
||||
bodyHtml += '<tr>';
|
||||
headers.forEach(h => {
|
||||
let val = row[h];
|
||||
if(val === null) val = '<span class="text-gray">null</span>';
|
||||
// Basic escaping
|
||||
if(typeof val === 'string') {
|
||||
if(val.length > 100) val = val.substring(0, 100) + '...';
|
||||
val = escapeHtml(val);
|
||||
}
|
||||
bodyHtml += `<td>${val}</td>`;
|
||||
});
|
||||
bodyHtml += '</tr>';
|
||||
});
|
||||
tbody.innerHTML = bodyHtml;
|
||||
} else {
|
||||
if(currentPage === 0) {
|
||||
thead.innerHTML = '<tr><td>暂无数据</td></tr>';
|
||||
} else {
|
||||
// Keep header, show empty row
|
||||
const prevHeader = thead.innerHTML;
|
||||
if(!prevHeader) thead.innerHTML = '<tr><td>End of data</td></tr>';
|
||||
else tbody.innerHTML = '<tr><td colspan="100%" style="text-align:center; padding: 20px;">没有更多数据了</td></tr>';
|
||||
}
|
||||
}
|
||||
renderTable(data, thead, tbody, currentPage, pageSize); // Reuse renderTable
|
||||
|
||||
document.getElementById('page-info').textContent = `Page ${currentPage + 1}`;
|
||||
|
||||
@@ -661,6 +716,41 @@
|
||||
}
|
||||
}
|
||||
|
||||
function renderTable(data, thead, tbody, page, size) {
|
||||
thead.innerHTML = '';
|
||||
if(data && data.length > 0) {
|
||||
const headers = Object.keys(data[0]);
|
||||
let headerHtml = '<tr>';
|
||||
headers.forEach(h => headerHtml += `<th>${h}</th>`);
|
||||
headerHtml += '</tr>';
|
||||
thead.innerHTML = headerHtml;
|
||||
|
||||
let bodyHtml = '';
|
||||
data.forEach(row => {
|
||||
bodyHtml += '<tr>';
|
||||
headers.forEach(h => {
|
||||
let val = row[h];
|
||||
if(val === null) val = '<span class="text-gray">null</span>';
|
||||
if(typeof val === 'string') {
|
||||
if(val.length > 100) val = val.substring(0, 100) + '...';
|
||||
val = escapeHtml(val);
|
||||
}
|
||||
bodyHtml += `<td>${val}</td>`;
|
||||
});
|
||||
bodyHtml += '</tr>';
|
||||
});
|
||||
tbody.innerHTML = bodyHtml;
|
||||
} else {
|
||||
if(page === 0) {
|
||||
thead.innerHTML = '<tr><td>暂无数据</td></tr>';
|
||||
} else {
|
||||
const prevHeader = thead.innerHTML;
|
||||
if(!prevHeader) thead.innerHTML = '<tr><td>End of data</td></tr>';
|
||||
else tbody.innerHTML = '<tr><td colspan="100%" style="text-align:center; padding: 20px;">没有更多数据了</td></tr>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function prevPage() {
|
||||
if(currentPage > 0) {
|
||||
currentPage--;
|
||||
@@ -672,9 +762,67 @@
|
||||
currentPage++;
|
||||
fetchTableData();
|
||||
}
|
||||
|
||||
function exportTableData(format) {
|
||||
if(!currentTable || !currentDB.file) return;
|
||||
let url = `/api/v1/db/data?group=${currentDB.group}&file=${encodeURIComponent(currentDB.file)}&table=${currentTable}&format=${format}`;
|
||||
if(currentKeyword) {
|
||||
url += `&keyword=${encodeURIComponent(currentKeyword)}`;
|
||||
}
|
||||
window.location.href = url;
|
||||
}
|
||||
|
||||
// --- SQL Console Logic ---
|
||||
async function runSQL() {
|
||||
const sql = document.getElementById('sql-input').value.trim();
|
||||
if(!sql) {
|
||||
alert("请输入 SQL 语句");
|
||||
return;
|
||||
}
|
||||
|
||||
const tbody = document.querySelector('#sql-result-table tbody');
|
||||
const thead = document.querySelector('#sql-result-table thead');
|
||||
const msg = document.getElementById('sql-msg');
|
||||
|
||||
tbody.innerHTML = '';
|
||||
thead.innerHTML = '';
|
||||
msg.textContent = '执行中...';
|
||||
msg.style.display = 'block';
|
||||
|
||||
try {
|
||||
const url = `/api/v1/db/query?group=${currentDB.group}&file=${encodeURIComponent(currentDB.file)}&sql=${encodeURIComponent(sql)}`;
|
||||
const res = await fetch(url);
|
||||
if(!res.ok) throw new Error(await res.text());
|
||||
const data = await res.json();
|
||||
|
||||
msg.style.display = 'none';
|
||||
renderTable(data, thead, tbody, 0, 0); // Reuse renderTable
|
||||
|
||||
if(!data || data.length === 0) {
|
||||
msg.textContent = '执行成功,无结果返回';
|
||||
msg.style.display = 'block';
|
||||
}
|
||||
|
||||
} catch(e) {
|
||||
msg.textContent = `执行错误: ${e.message}`;
|
||||
msg.style.color = 'var(--danger)';
|
||||
msg.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
function exportSQLResult(format) {
|
||||
const sql = document.getElementById('sql-input').value.trim();
|
||||
if(!sql) {
|
||||
alert("请输入 SQL 语句");
|
||||
return;
|
||||
}
|
||||
const url = `/api/v1/db/query?group=${currentDB.group}&file=${encodeURIComponent(currentDB.file)}&sql=${encodeURIComponent(sql)}&format=${format}`;
|
||||
window.location.href = url;
|
||||
}
|
||||
|
||||
// --- Generic API Query ---
|
||||
// --- Generic API Query (Keep same as before) ---
|
||||
async function queryAPI(type) {
|
||||
// ... (Same logic as before, just kept for brevity since I'm overwriting file)
|
||||
const resultArea = document.getElementById(`${type}-result`);
|
||||
resultArea.classList.remove('hidden');
|
||||
resultArea.innerHTML = '<div class="loading">正在处理...</div>';
|
||||
@@ -722,7 +870,6 @@
|
||||
const url = `/api/v1/${type}?${params.toString()}`;
|
||||
|
||||
if (format === 'csv' || format === 'xlsx') {
|
||||
// Trigger download
|
||||
window.location.href = url;
|
||||
resultArea.innerHTML = `<div class="text-success">已触发 ${format.toUpperCase()} 下载。<br>请求URL: <span class="url-display">${url}</span></div>`;
|
||||
} else {
|
||||
@@ -768,7 +915,6 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Clear Cache
|
||||
document.getElementById('clearCacheBtn').addEventListener('click', async () => {
|
||||
if(!confirm('确定要清除所有解密的媒体文件缓存吗?')) return;
|
||||
try {
|
||||
@@ -781,4 +927,4 @@
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user