feat: 新增 HTML 导出功能 (#164)

- 后端:在 writeRowsToFile 中新增 html 分支
- 后端:实现 writeRowsToHTML 函数,生成包含内嵌 CSS 的独立 HTML 文件
- 后端:实现 formatExportHTMLCell 函数,进行 HTML 转义和换行处理
- 后端:新增测试用例验证 XSS 转义、样式存在、换行处理、空值显示
- 前端:在 DataGrid 所有导出菜单中新增 HTML 选项(右键菜单、工具栏、单元格菜单)
- 前端:在 Sidebar 表节点右键菜单中新增 HTML 选项
- 样式:响应式表格设计,支持斑马纹、悬停效果、表头吸顶
- 安全:所有用户数据经过 HTML 转义,防止 XSS 攻击
This commit is contained in:
Toskysun
2026-03-04 17:46:18 +08:00
committed by GitHub
parent 4570516678
commit e6da986927
4 changed files with 278 additions and 0 deletions

View File

@@ -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", "<br>")
}
func writeRowsToHTML(f *os.File, data []map[string]interface{}, columns []string) error {
w := bufio.NewWriterSize(f, 1024*256)
if _, err := w.WriteString(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GoNavi Export</title>
<style>
:root {
color-scheme: light;
--bg: #f8f9fa;
--card: #ffffff;
--line: #dee2e6;
--text: #212529;
--muted: #6c757d;
--hover: #f1f3f5;
--zebra: #f8f9fa;
--head: #ffffff;
}
* { box-sizing: border-box; }
body {
margin: 0;
padding: 24px;
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "PingFang SC", "Microsoft YaHei", sans-serif;
line-height: 1.6;
}
.export-wrap {
max-width: 100%;
margin: 0 auto;
background: var(--card);
border: 1px solid var(--line);
border-radius: 8px;
overflow: hidden;
}
.export-head {
padding: 16px 20px;
background: var(--head);
border-bottom: 2px solid var(--line);
}
.export-head h1 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--text);
}
.export-meta {
margin-top: 6px;
color: var(--muted);
font-size: 13px;
}
.table-wrap {
width: 100%;
overflow: auto;
padding: 16px;
}
table {
border-collapse: collapse;
width: auto;
font-size: 13px;
}
thead th {
position: sticky;
top: 0;
z-index: 2;
background: var(--head);
text-align: left;
font-weight: 600;
white-space: nowrap;
border-bottom: 2px solid var(--line);
color: var(--text);
padding: 12px 16px;
}
td {
padding: 10px 16px;
border-bottom: 1px solid var(--line);
vertical-align: top;
white-space: pre-wrap;
word-wrap: break-word;
overflow-wrap: anywhere;
max-width: 500px;
color: var(--text);
}
tbody tr:nth-child(even) {
background: var(--zebra);
}
tbody tr:hover {
background: var(--hover);
}
td.empty {
text-align: center;
color: var(--muted);
font-style: italic;
}
@media (max-width: 768px) {
body { padding: 16px; }
.export-head { padding: 12px 16px; }
.table-wrap { padding: 12px; }
th, td { padding: 8px 12px; font-size: 12px; }
}
@media print {
body { background: white; padding: 0; }
.export-wrap { border: none; }
}
</style>
</head>
<body>
<div class="export-wrap">
<div class="export-head">
<h1>GoNavi Data Export</h1>
<div class="export-meta">`); 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(`</div>
</div>
<div class="table-wrap">
<table>
<thead><tr>`); err != nil {
return err
}
for _, col := range columns {
if _, err := fmt.Fprintf(w, "<th>%s</th>", html.EscapeString(col)); err != nil {
return err
}
}
if _, err := w.WriteString(`</tr></thead><tbody>`); err != nil {
return err
}
if len(data) == 0 {
colspan := len(columns)
if colspan <= 0 {
colspan = 1
}
if _, err := fmt.Fprintf(w, `<tr><td class="empty" colspan="%d">(0 rows)</td></tr>`, colspan); err != nil {
return err
}
} else {
for _, rowMap := range data {
if _, err := w.WriteString("<tr>"); err != nil {
return err
}
for _, col := range columns {
if _, err := fmt.Fprintf(w, "<td>%s</td>", formatExportHTMLCell(rowMap[col])); err != nil {
return err
}
}
if _, err := w.WriteString("</tr>"); err != nil {
return err
}
}
}
if _, err := w.WriteString(`</tbody></table>
</div>
</div>
</body>
</html>`); err != nil {
return err
}
return w.Flush()
}
func formatExportCellText(val interface{}) string {
if val == nil {
return "NULL"

View File

@@ -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": "<script>alert(1)</script>",
"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, "<!DOCTYPE html>") {
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, "&lt;script&gt;alert(1)&lt;/script&gt;") {
t.Fatalf("html 导出未进行 XSS 转义: %s", content)
}
if strings.Contains(content, "<script>alert(1)</script>") {
t.Fatalf("html 导出包含未转义脚本: %s", content)
}
if !strings.Contains(content, "line1<br>line2") {
t.Fatalf("html 导出换行未转为 <br>: %s", content)
}
if !strings.Contains(content, "<td>NULL</td>") {
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 := "<b>name</b>"
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, "<th>&lt;b&gt;name&lt;/b&gt;</th>") || strings.Contains(content, "<th><b>name</b></th>") {
t.Fatalf("html 表头未正确转义: %s", content)
}
}