From e6da9869272cc8a05499bb0fc138d3da3862113d Mon Sep 17 00:00:00 2001 From: Toskysun <3kddyys@gmail.com> Date: Wed, 4 Mar 2026 17:46:18 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20HTML=20=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=E5=8A=9F=E8=83=BD=20(#164)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端:在 writeRowsToFile 中新增 html 分支 - 后端:实现 writeRowsToHTML 函数,生成包含内嵌 CSS 的独立 HTML 文件 - 后端:实现 formatExportHTMLCell 函数,进行 HTML 转义和换行处理 - 后端:新增测试用例验证 XSS 转义、样式存在、换行处理、空值显示 - 前端:在 DataGrid 所有导出菜单中新增 HTML 选项(右键菜单、工具栏、单元格菜单) - 前端:在 Sidebar 表节点右键菜单中新增 HTML 选项 - 样式:响应式表格设计,支持斑马纹、悬停效果、表头吸顶 - 安全:所有用户数据经过 HTML 转义,防止 XSS 攻击 --- frontend/src/components/DataGrid.tsx | 19 +++ frontend/src/components/Sidebar.tsx | 1 + internal/app/methods_file.go | 188 +++++++++++++++++++++++ internal/app/methods_file_export_test.go | 70 +++++++++ 4 files changed, 278 insertions(+) diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 24f265c..89dd364 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -537,6 +537,7 @@ const ContextMenuRow = React.memo(({ children, record, ...props }: any) => { { key: 'exp-xlsx', label: 'Excel', onClick: () => handleExportSelected('xlsx', record) }, { key: 'exp-json', label: 'JSON', onClick: () => handleExportSelected('json', record) }, { key: 'exp-md', label: 'Markdown', onClick: () => handleExportSelected('md', record) }, + { key: 'exp-html', label: 'HTML', onClick: () => handleExportSelected('html', record) }, ] } ]; @@ -2588,6 +2589,7 @@ const DataGrid: React.FC = ({ { key: 'filtered-xlsx', label: 'Excel (XLSX)', onClick: () => handleExportFilteredAll('xlsx') }, { key: 'filtered-json', label: 'JSON', onClick: () => handleExportFilteredAll('json') }, { key: 'filtered-md', label: 'Markdown', onClick: () => handleExportFilteredAll('md') }, + { key: 'filtered-html', label: 'HTML', onClick: () => handleExportFilteredAll('html') }, ]}, { type: 'divider' }, { type: 'group', label: '全表', children: [ @@ -2595,12 +2597,14 @@ const DataGrid: React.FC = ({ { key: 'table-xlsx', label: 'Excel (XLSX)', onClick: () => handleExport('xlsx') }, { key: 'table-json', label: 'JSON', onClick: () => handleExport('json') }, { key: 'table-md', label: 'Markdown', onClick: () => handleExport('md') }, + { key: 'table-html', label: 'HTML', onClick: () => handleExport('html') }, ]}, ] : [ { key: 'csv', label: 'CSV', onClick: () => handleExport('csv') }, { key: 'xlsx', label: 'Excel (XLSX)', onClick: () => handleExport('xlsx') }, { key: 'json', label: 'JSON', onClick: () => handleExport('json') }, { key: 'md', label: 'Markdown', onClick: () => handleExport('md') }, + { key: 'html', label: 'HTML', onClick: () => handleExport('html') }, ]; const columnInfoSettingContent = ( @@ -3572,6 +3576,21 @@ const DataGrid: React.FC = ({ > 导出为 JSON +
e.currentTarget.style.background = darkMode ? '#303030' : '#f5f5f5'} + onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'} + onClick={() => { + if (cellContextMenu.record) handleExportSelected('html', cellContextMenu.record); + setCellContextMenu(prev => ({ ...prev, visible: false })); + }} + > + 导出为 HTML +
, document.body )} diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 981dc28..b76fa13 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -2806,6 +2806,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> { key: 'export-xlsx', label: '导出 Excel (XLSX)', onClick: () => handleExport(node, 'xlsx') }, { key: 'export-json', label: '导出 JSON', onClick: () => handleExport(node, 'json') }, { key: 'export-md', label: '导出 Markdown', onClick: () => handleExport(node, 'md') }, + { key: 'export-html', label: '导出 HTML', onClick: () => handleExport(node, 'html') }, ] } ]; diff --git a/internal/app/methods_file.go b/internal/app/methods_file.go index 2bdbce5..a68620e 100644 --- a/internal/app/methods_file.go +++ b/internal/app/methods_file.go @@ -6,6 +6,7 @@ import ( "encoding/csv" "encoding/json" "fmt" + "html" "math" "os" "path/filepath" @@ -1595,6 +1596,11 @@ func writeRowsToFile(f *os.File, data []map[string]interface{}, columns []string return writeRowsToXlsx(f.Name(), data, columns) } + // html 使用内嵌 CSS 输出可直接浏览器预览的独立页面 + if format == "html" { + return writeRowsToHTML(f, data, columns) + } + var csvWriter *csv.Writer var jsonEncoder *json.Encoder isJsonFirstRow := true @@ -1688,6 +1694,188 @@ func writeRowsToFile(f *os.File, data []map[string]interface{}, columns []string return nil } +func formatExportHTMLCell(val interface{}) string { + text := formatExportCellText(val) + escaped := html.EscapeString(text) + escaped = strings.ReplaceAll(escaped, "\r\n", "\n") + escaped = strings.ReplaceAll(escaped, "\r", "\n") + return strings.ReplaceAll(escaped, "\n", "
") +} + +func writeRowsToHTML(f *os.File, data []map[string]interface{}, columns []string) error { + w := bufio.NewWriterSize(f, 1024*256) + + if _, err := w.WriteString(` + + + + + GoNavi Export + + + +
+
+

GoNavi Data Export

+
`); err != nil { + return err + } + + if _, err := fmt.Fprintf(w, "Rows: %d · Columns: %d · Generated: %s", len(data), len(columns), time.Now().Format("2006-01-02 15:04:05")); err != nil { + return err + } + + if _, err := w.WriteString(`
+
+
+ + `); err != nil { + return err + } + + for _, col := range columns { + if _, err := fmt.Fprintf(w, "", html.EscapeString(col)); err != nil { + return err + } + } + + if _, err := w.WriteString(``); err != nil { + return err + } + + if len(data) == 0 { + colspan := len(columns) + if colspan <= 0 { + colspan = 1 + } + if _, err := fmt.Fprintf(w, ``, colspan); err != nil { + return err + } + } else { + for _, rowMap := range data { + if _, err := w.WriteString(""); err != nil { + return err + } + for _, col := range columns { + if _, err := fmt.Fprintf(w, "", formatExportHTMLCell(rowMap[col])); err != nil { + return err + } + } + if _, err := w.WriteString(""); err != nil { + return err + } + } + } + + if _, err := w.WriteString(`
%s
(0 rows)
%s
+
+
+ +`); err != nil { + return err + } + + return w.Flush() +} + func formatExportCellText(val interface{}) string { if val == nil { return "NULL" diff --git a/internal/app/methods_file_export_test.go b/internal/app/methods_file_export_test.go index 6a0b1b4..5ddaf9c 100644 --- a/internal/app/methods_file_export_test.go +++ b/internal/app/methods_file_export_test.go @@ -203,3 +203,73 @@ func TestGetExportQueryTimeout_CustomClickHouseUsesLongerMinimum(t *testing.T) { t.Fatalf("custom clickhouse 导出超时下限异常,want=%s got=%s", minClickHouseExportQueryTimeout, timeout) } } + +func TestWriteRowsToFile_HTML_EscapeAndStyle(t *testing.T) { + f, err := os.CreateTemp("", "gonavi-export-*.html") + if err != nil { + t.Fatalf("创建临时文件失败: %v", err) + } + defer os.Remove(f.Name()) + defer f.Close() + + data := []map[string]interface{}{ + { + "name": "", + "note": "line1\nline2", + "nullable": nil, + }, + } + columns := []string{"name", "note", "nullable"} + + if err := writeRowsToFile(f, data, columns, "html"); err != nil { + t.Fatalf("写入 html 失败: %v", err) + } + + contentBytes, err := os.ReadFile(f.Name()) + if err != nil { + t.Fatalf("读取 html 失败: %v", err) + } + content := string(contentBytes) + + if !strings.Contains(content, "") { + t.Fatalf("html 导出缺少 doctype: %s", content) + } + if !strings.Contains(content, "position: sticky") { + t.Fatalf("html 导出缺少表头吸顶样式: %s", content) + } + if !strings.Contains(content, "tbody tr:nth-child(even)") { + t.Fatalf("html 导出缺少斑马纹样式: %s", content) + } + if !strings.Contains(content, "<script>alert(1)</script>") { + t.Fatalf("html 导出未进行 XSS 转义: %s", content) + } + if strings.Contains(content, "") { + t.Fatalf("html 导出包含未转义脚本: %s", content) + } + if !strings.Contains(content, "line1
line2") { + t.Fatalf("html 导出换行未转为
: %s", content) + } + if !strings.Contains(content, "NULL") { + t.Fatalf("html 导出空值显示异常: %s", content) + } +} + +func TestWriteRowsToFile_HTML_EscapeHeader(t *testing.T) { + f, err := os.CreateTemp("", "gonavi-export-*.html") + if err != nil { + t.Fatalf("创建临时文件失败: %v", err) + } + defer os.Remove(f.Name()) + defer f.Close() + + columnName := "name" + data := []map[string]interface{}{{columnName: "ok"}} + if err := writeRowsToFile(f, data, []string{columnName}, "html"); err != nil { + t.Fatalf("写入 html 失败: %v", err) + } + contentBytes, _ := os.ReadFile(f.Name()) + content := string(contentBytes) + if !strings.Contains(content, "<b>name</b>") || strings.Contains(content, "name") { + t.Fatalf("html 表头未正确转义: %s", content) + } +}