From 796b5cb84a919ce071a6bf8ac9d033f0d9f407e7 Mon Sep 17 00:00:00 2001 From: NX Date: Tue, 26 Mar 2024 18:33:24 +0800 Subject: [PATCH] feat: Support Basic Use --- .gitignore | 3 ++ export.go | 133 +++++++++++++++++++++++++++++++++++++++++++++++ export_test.go | 71 +++++++++++++++++++++++++ go.mod | 16 ++++++ go.sum | 29 +++++++++++ template.go | 120 ++++++++++++++++++++++++++++++++++++++++++ template_test.go | 29 +++++++++++ 7 files changed, 401 insertions(+) create mode 100644 export.go create mode 100644 export_test.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 template.go create mode 100644 template_test.go diff --git a/.gitignore b/.gitignore index 3b735ec..1de9dbd 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,6 @@ # Go workspace file go.work + + +.idea diff --git a/export.go b/export.go new file mode 100644 index 0000000..399c988 --- /dev/null +++ b/export.go @@ -0,0 +1,133 @@ +package Record2Excel + +import ( + "github.com/xuri/excelize/v2" + "reflect" +) + +type Exporter[T any] interface { + AddRecord(record T) error + Export() *excelize.File +} + +type exporter[T any] struct { + template template[T] + file *excelize.File + offset int + index map[string]int +} + +func NewExporter[T any](model T) (Exporter Exporter[T], err error) { + var e = &exporter[T]{ + file: excelize.NewFile(), + index: make(map[string]int), + } + e.template, err = newTemplate(model) + if err != nil { + return nil, err + } + e.buildHeader() + return e, nil +} + +func (e *exporter[T]) buildHeader() { + e.offset = e.template.items.depth() - 1 + + // 初始化Excel文件,如果还没有初始化 + if e.file == nil { + e.file = excelize.NewFile() + } + + currentColumn := 1 + var mergeRanges [][2]string // 用于记录需要合并的单元格范围 + + // 定义一个递归函数,用于构建表头并记录合并单元格的范围 + var buildHeaderForRow func(node *itemNode, row int, parentColStart *int) + buildHeaderForRow = func(node *itemNode, row int, parentColStart *int) { + colStart := currentColumn // 当前节点开始的列 + if parentColStart != nil { + colStart = *parentColStart // 如果有父节点,从父节点的列开始 + } + + // 设置单元格的值 + cell, _ := excelize.CoordinatesToCellName(currentColumn, row) + e.file.SetCellValue("Sheet1", cell, node.tagName) + e.index[node.fieldPath] = currentColumn + + if len(node.subItems) == 0 { // 如果是叶子节点 + if e.offset > row { // 需要跨行合并 + cellEnd, _ := excelize.CoordinatesToCellName(currentColumn, e.offset) + mergeRanges = append(mergeRanges, [2]string{cell, cellEnd}) + } + currentColumn++ // 移动到下一个列 + } else { // 如果有子节点 + for _, child := range node.subItems { + buildHeaderForRow(child, row+1, &colStart) // 递归构建子节点表头 + } + if row == 1 { // 如果是第一层嵌套,记录合并的单元格范围 + cellEnd, _ := excelize.CoordinatesToCellName(currentColumn-1, row) + if cell != cellEnd { // 避免单列合并 + mergeRanges = append(mergeRanges, [2]string{cell, cellEnd}) + } + } + } + } + + // 从根节点开始递归 + for _, item := range e.template.items.subItems { + buildHeaderForRow(item, 1, nil) + } + + // 执行合并单元格操作 + for _, mergeRange := range mergeRanges { + e.file.MergeCell("Sheet1", mergeRange[0], mergeRange[1]) + } + + return +} + +func (e *exporter[T]) AddRecord(record T) error { + v := reflect.ValueOf(record) + startIdx := e.offset + 1 + + var insert func(v reflect.Value, node *itemNode) + insert = func(v reflect.Value, node *itemNode) { + for _, item := range node.subItems { + val := v.FieldByName(item.name) + switch val.Kind() { + case reflect.Struct: + insert(val, item) + + case reflect.Slice: + currentIdx := e.offset + 1 + for i := 0; i < val.Len(); i++ { + val := val.Index(i) + if val.Kind() == reflect.Struct { + tmpIdx := startIdx + startIdx = currentIdx + insert(val, item) + startIdx = tmpIdx + } else { + cell, _ := excelize.CoordinatesToCellName(e.index[item.fieldPath], currentIdx) + e.file.SetCellValue("Sheet1", cell, val.Interface()) + } + currentIdx++ + } + startIdx = max(startIdx, currentIdx) + + default: + cell, _ := excelize.CoordinatesToCellName(e.index[item.fieldPath], startIdx) + e.file.SetCellValue("Sheet1", cell, val.Interface()) + } + } + return + } + + insert(v, e.template.items) + e.offset = startIdx - 1 + return nil +} + +func (e *exporter[T]) Export() *excelize.File { + return e.file +} diff --git a/export_test.go b/export_test.go new file mode 100644 index 0000000..48cbcbd --- /dev/null +++ b/export_test.go @@ -0,0 +1,71 @@ +package Record2Excel + +import ( + "testing" +) + +func Test_exporter_buildHeader(t *testing.T) { + type Achievements struct { + Name string `excel:"名称"` + Score float64 `excel:"分数"` + ArchiveScore float64 `excel:"归档分数"` + Achieved bool `excel:"是否达成"` + } + + type Pins struct { + Name string `excel:"名称"` + Score float64 `excel:"分数"` + } + + type Data struct { + ID string `excel:"编号"` + ProjectID string `excel:"项目编号"` + StaffId string `excel:"员工编号"` + StaffName string `excel:"员工姓名"` + ClassNo string `excel:"班级编号"` + Score float64 `excel:"总分"` + Tags []string `excel:"标签"` + Achievements []Achievements `excel:"成就"` + Pins []Pins `excel:"奖项"` + } + s := Data{ + ID: "123", + ProjectID: "123", + StaffId: "21", + StaffName: "231", + ClassNo: "21", + Score: 23, + Tags: []string{"@31", "321"}, + Achievements: []Achievements{ + { + Name: "123", + Score: 23, + ArchiveScore: 23, + Achieved: true, + }, + { + Name: "123456", + Score: 23, + ArchiveScore: 23, + Achieved: true, + }, + { + ArchiveScore: 23, + Achieved: true, + }, + }, + Pins: []Pins{ + { + Name: "123", + Score: 23, + }, + }, + } + + e, _ := NewExporter(s) + e.AddRecord(s) + e.AddRecord(s) + e.AddRecord(s) + file := e.Export() + file.SaveAs("test.xlsx") +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..68e2da8 --- /dev/null +++ b/go.mod @@ -0,0 +1,16 @@ +module github.com/NX-Official/Record2Excel + +go 1.22.0 + +require github.com/xuri/excelize/v2 v2.8.1 + +require ( + github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect + github.com/richardlehane/mscfb v1.0.4 // indirect + github.com/richardlehane/msoleps v1.0.3 // indirect + github.com/xuri/efp v0.0.0-20231025114914-d1ff6096ae53 // indirect + github.com/xuri/nfp v0.0.0-20230919160717-d98342af3f05 // indirect + golang.org/x/crypto v0.19.0 // indirect + golang.org/x/net v0.21.0 // indirect + golang.org/x/text v0.14.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..144577c --- /dev/null +++ b/go.sum @@ -0,0 +1,29 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM= +github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk= +github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= +github.com/richardlehane/msoleps v1.0.3 h1:aznSZzrwYRl3rLKRT3gUk9am7T/mLNSnJINvN0AQoVM= +github.com/richardlehane/msoleps v1.0.3/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/xuri/efp v0.0.0-20231025114914-d1ff6096ae53 h1:Chd9DkqERQQuHpXjR/HSV1jLZA6uaoiwwH3vSuF3IW0= +github.com/xuri/efp v0.0.0-20231025114914-d1ff6096ae53/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI= +github.com/xuri/excelize/v2 v2.8.1 h1:pZLMEwK8ep+CLIUWpWmvW8IWE/yxqG0I1xcN6cVMGuQ= +github.com/xuri/excelize/v2 v2.8.1/go.mod h1:oli1E4C3Pa5RXg1TBXn4ENCXDV5JUMlBluUhG7c+CEE= +github.com/xuri/nfp v0.0.0-20230919160717-d98342af3f05 h1:qhbILQo1K3mphbwKh1vNm4oGezE1eF9fQWmNiIpSfI4= +github.com/xuri/nfp v0.0.0-20230919160717-d98342af3f05/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= +golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/image v0.14.0 h1:tNgSxAFe3jC4uYqvZdTr84SZoM1KfwdC9SKIFrLjFn4= +golang.org/x/image v0.14.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE= +golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/template.go b/template.go new file mode 100644 index 0000000..0ac174b --- /dev/null +++ b/template.go @@ -0,0 +1,120 @@ +package Record2Excel + +import ( + "fmt" + "reflect" + "strings" +) + +type template[T any] struct { + t reflect.Type + items *itemNode +} + +type itemNode struct { + name string + tagName string + fieldPath string + subItems []*itemNode +} + +func (t template[T]) depth() int { + return t.depth() +} +func (n itemNode) depth() int { + if len(n.subItems) == 0 { + return 1 + } + maxDepth := 0 + for _, child := range n.subItems { + childDepth := child.depth() + if childDepth > maxDepth { + maxDepth = childDepth + } + } + return maxDepth + 1 +} + +func newTemplate[T any](model T) (template[T], error) { + return template[T]{ + t: reflect.TypeOf(model), + items: buildItemTree(reflect.TypeOf(model), ""), + }, nil +} + +func buildItemTree(t reflect.Type, parentPath string) *itemNode { + // 处理指针类型,我们需要其指向的元素类型 + if t.Kind() == reflect.Ptr { + t = t.Elem() + } + + // 创建当前节点 + node := &itemNode{ + name: t.Name(), + fieldPath: parentPath, + subItems: []*itemNode{}, + } + + if t.Kind() == reflect.Slice { + t = t.Elem() + } + + // 只有结构体类型才有子字段需要遍历 + if t.Kind() == reflect.Struct { + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + // 构建完整的字段路径 + fieldPath := field.Name + if parentPath != "" { + fieldPath = parentPath + "." + field.Name + } + + // 为当前字段创建 itemNode,并递归处理嵌套结构体字段 + childNode := buildItemTree(field.Type, fieldPath) + childNode.name = field.Name // 更新为实际的字段名 + childNode.tagName = field.Name + if name, ok := field.Tag.Lookup("excel"); ok { + childNode.tagName = name + } + childNode.fieldPath = fieldPath + node.subItems = append(node.subItems, childNode) + } + } + return node +} + +func (t template[T]) GetField(path string) (string, error) { + val, err := getValueByPath(reflect.New(t.t).Elem().Interface(), path) + if err != nil { + return "", err + } + return fmt.Sprintf("%v", val), nil +} + +func getValueByPath(v any, path string) (any, error) { + // 将路径分割成部分 + pathParts := strings.Split(path, ".") + val := reflect.ValueOf(v) + + // 遍历路径的每一部分,逐步深入 + for _, part := range pathParts { + // 确保当前值可以被遍历 + if val.Kind() == reflect.Ptr { + val = val.Elem() + } + + // 确保我们处理的是结构体 + if val.Kind() != reflect.Struct { + return nil, fmt.Errorf("not a struct or has no field '%s'", part) + } + + // 获取指定的字段 + val = val.FieldByName(part) + if !val.IsValid() { + return nil, fmt.Errorf("field not found: %s", part) + } + } + + // 返回找到的值 + return val.Interface(), nil +} diff --git a/template_test.go b/template_test.go new file mode 100644 index 0000000..233ad85 --- /dev/null +++ b/template_test.go @@ -0,0 +1,29 @@ +package Record2Excel + +import ( + "fmt" + "strings" + "testing" +) + +func TestNewTemplate(t *testing.T) { + type MyStruct struct { + Field1 string + Nested struct { + SubField1 int + } + } + + tml, _ := newTemplate(MyStruct{}) + printItemNodeTree(tml.items, 0) +} + +func printItemNodeTree(node *itemNode, indentLevel int) { + indent := strings.Repeat(" ", indentLevel) // 根据层级重复空格,创建缩进 + fmt.Printf("%sName: %s, Path: %s\n", indent, node.name, node.fieldPath) + + // 遍历子项并递归打印 + for _, child := range node.subItems { + printItemNodeTree(child, indentLevel+1) // 增加缩进级别 + } +}