feat(knowledge): add FAQ Excel import export
This commit is contained in:
@@ -28,6 +28,7 @@ require (
|
|||||||
github.com/silenceper/wechat/v2 v2.1.12
|
github.com/silenceper/wechat/v2 v2.1.12
|
||||||
github.com/spf13/cast v1.6.0
|
github.com/spf13/cast v1.6.0
|
||||||
github.com/wk8/go-ordered-map/v2 v2.1.8
|
github.com/wk8/go-ordered-map/v2 v2.1.8
|
||||||
|
github.com/xuri/excelize/v2 v2.10.1
|
||||||
github.com/yuin/goldmark v1.4.13
|
github.com/yuin/goldmark v1.4.13
|
||||||
golang.org/x/crypto v0.49.0
|
golang.org/x/crypto v0.49.0
|
||||||
golang.org/x/net v0.51.0
|
golang.org/x/net v0.51.0
|
||||||
@@ -86,6 +87,8 @@ require (
|
|||||||
github.com/quic-go/qpack v0.6.0 // indirect
|
github.com/quic-go/qpack v0.6.0 // indirect
|
||||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
github.com/richardlehane/mscfb v1.0.6 // indirect
|
||||||
|
github.com/richardlehane/msoleps v1.0.6 // indirect
|
||||||
github.com/segmentio/asm v1.2.1 // indirect
|
github.com/segmentio/asm v1.2.1 // indirect
|
||||||
github.com/segmentio/encoding v0.5.4 // indirect
|
github.com/segmentio/encoding v0.5.4 // indirect
|
||||||
github.com/sirupsen/logrus v1.9.4 // indirect
|
github.com/sirupsen/logrus v1.9.4 // indirect
|
||||||
@@ -94,8 +97,11 @@ require (
|
|||||||
github.com/tidwall/match v1.1.1 // indirect
|
github.com/tidwall/match v1.1.1 // indirect
|
||||||
github.com/tidwall/pretty v1.2.1 // indirect
|
github.com/tidwall/pretty v1.2.1 // indirect
|
||||||
github.com/tidwall/sjson v1.2.5 // indirect
|
github.com/tidwall/sjson v1.2.5 // indirect
|
||||||
|
github.com/tiendc/go-deepcopy v1.7.2 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||||
|
github.com/xuri/efp v0.0.1 // indirect
|
||||||
|
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
|
||||||
github.com/yargevad/filepathx v1.0.0 // indirect
|
github.com/yargevad/filepathx v1.0.0 // indirect
|
||||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||||
|
|||||||
@@ -239,6 +239,10 @@ github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRC
|
|||||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/richardlehane/mscfb v1.0.6 h1:eN3bvvZCp00bs7Zf52bxNwAx5lJDBK1tCuH19qq5aC8=
|
||||||
|
github.com/richardlehane/mscfb v1.0.6/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo=
|
||||||
|
github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg=
|
||||||
|
github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
||||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||||
@@ -289,6 +293,8 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
|||||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||||
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||||
|
github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44=
|
||||||
|
github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||||
@@ -297,6 +303,12 @@ github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/
|
|||||||
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
|
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
|
||||||
github.com/x-cray/logrus-prefixed-formatter v0.5.2 h1:00txxvfBM9muc0jiLIEAkAcIMJzfthRT6usrui8uGmg=
|
github.com/x-cray/logrus-prefixed-formatter v0.5.2 h1:00txxvfBM9muc0jiLIEAkAcIMJzfthRT6usrui8uGmg=
|
||||||
github.com/x-cray/logrus-prefixed-formatter v0.5.2/go.mod h1:2duySbKsL6M18s5GU7VPsoEPHyzalCE06qoARUCeBBE=
|
github.com/x-cray/logrus-prefixed-formatter v0.5.2/go.mod h1:2duySbKsL6M18s5GU7VPsoEPHyzalCE06qoARUCeBBE=
|
||||||
|
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
|
||||||
|
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
|
||||||
|
github.com/xuri/excelize/v2 v2.10.1 h1:V62UlqopMqha3kOpnlHy2CcRVw1V8E63jFoWUmMzxN0=
|
||||||
|
github.com/xuri/excelize/v2 v2.10.1/go.mod h1:iG5tARpgaEeIhTqt3/fgXCGoBRt4hNXgCp3tfXKoOIc=
|
||||||
|
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
|
||||||
|
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
|
||||||
github.com/yargevad/filepathx v1.0.0 h1:SYcT+N3tYGi+NvazubCNlvgIPbzAk7i7y2dwg3I5FYc=
|
github.com/yargevad/filepathx v1.0.0 h1:SYcT+N3tYGi+NvazubCNlvgIPbzAk7i7y2dwg3I5FYc=
|
||||||
github.com/yargevad/filepathx v1.0.0/go.mod h1:BprfX/gpYNJHJfc35GjRRpVcwWXS89gGulUIU5tK3tA=
|
github.com/yargevad/filepathx v1.0.0/go.mod h1:BprfX/gpYNJHJfc35GjRRpVcwWXS89gGulUIU5tK3tA=
|
||||||
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||||
@@ -335,6 +347,8 @@ golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
|||||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||||
golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 h1:985EYyeCOxTpcgOTJpflJUwOeEz0CQOdPt73OzpE9F8=
|
golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 h1:985EYyeCOxTpcgOTJpflJUwOeEz0CQOdPt73OzpE9F8=
|
||||||
golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0/go.mod h1:/lliqkxwWAhPjf5oSOIJup2XcqJaw8RGS6k3TGEc7GI=
|
golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0/go.mod h1:/lliqkxwWAhPjf5oSOIJup2XcqJaw8RGS6k3TGEc7GI=
|
||||||
|
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
|
||||||
|
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
|
||||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||||
|
|||||||
@@ -262,9 +262,12 @@ func registerDashboardKnowledgeDocumentRoutes(group *gin.RouterGroup) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func registerDashboardKnowledgeFAQRoutes(group *gin.RouterGroup) {
|
func registerDashboardKnowledgeFAQRoutes(group *gin.RouterGroup) {
|
||||||
|
group.GET("/import_template", dashboard.KnowledgeFAQGetImport_template)
|
||||||
|
group.GET("/export", dashboard.KnowledgeFAQGetExport)
|
||||||
group.GET("/:id", dashboard.KnowledgeFAQGetBy)
|
group.GET("/:id", dashboard.KnowledgeFAQGetBy)
|
||||||
group.POST("/create", dashboard.KnowledgeFAQPostCreate)
|
group.POST("/create", dashboard.KnowledgeFAQPostCreate)
|
||||||
group.POST("/delete", dashboard.KnowledgeFAQPostDelete)
|
group.POST("/delete", dashboard.KnowledgeFAQPostDelete)
|
||||||
|
group.POST("/import", dashboard.KnowledgeFAQPostImport)
|
||||||
group.Any("/list", dashboard.KnowledgeFAQAnyList)
|
group.Any("/list", dashboard.KnowledgeFAQAnyList)
|
||||||
group.POST("/update", dashboard.KnowledgeFAQPostUpdate)
|
group.POST("/update", dashboard.KnowledgeFAQPostUpdate)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,11 @@ import (
|
|||||||
"agent-desk/internal/pkg/constants"
|
"agent-desk/internal/pkg/constants"
|
||||||
"agent-desk/internal/pkg/dto/request"
|
"agent-desk/internal/pkg/dto/request"
|
||||||
"agent-desk/internal/pkg/dto/response"
|
"agent-desk/internal/pkg/dto/response"
|
||||||
|
"agent-desk/internal/pkg/errorsx"
|
||||||
"agent-desk/internal/pkg/httpx"
|
"agent-desk/internal/pkg/httpx"
|
||||||
"agent-desk/internal/services"
|
"agent-desk/internal/services"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
"agent-desk/internal/pkg/httpx/params"
|
"agent-desk/internal/pkg/httpx/params"
|
||||||
|
|
||||||
@@ -14,6 +17,79 @@ import (
|
|||||||
"github.com/mlogclub/simple/web"
|
"github.com/mlogclub/simple/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func KnowledgeFAQGetImport_template(ctx *gin.Context) {
|
||||||
|
if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionKnowledgeFAQView); err != nil {
|
||||||
|
httpx.WriteJSON(ctx, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file, err := services.KnowledgeFAQService.BuildKnowledgeFAQImportTemplate()
|
||||||
|
if err != nil {
|
||||||
|
httpx.WriteJSON(ctx, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeKnowledgeFAQExcelFile(ctx, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
func KnowledgeFAQGetExport(ctx *gin.Context) {
|
||||||
|
if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionKnowledgeFAQView); err != nil {
|
||||||
|
httpx.WriteJSON(ctx, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
knowledgeBaseID, ok := params.GetInt64(ctx, "knowledgeBaseId")
|
||||||
|
if !ok || knowledgeBaseID <= 0 {
|
||||||
|
httpx.WriteJSON(ctx, errorsx.InvalidParam("知识库不存在"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file, err := services.KnowledgeFAQService.ExportKnowledgeFAQs(knowledgeBaseID)
|
||||||
|
if err != nil {
|
||||||
|
httpx.WriteJSON(ctx, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeKnowledgeFAQExcelFile(ctx, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
func KnowledgeFAQPostImport(ctx *gin.Context) {
|
||||||
|
operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionKnowledgeFAQCreate)
|
||||||
|
if err != nil {
|
||||||
|
httpx.WriteJSON(ctx, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mode := request.KnowledgeFAQImportMode(ctx.PostForm("mode"))
|
||||||
|
if mode == request.KnowledgeFAQImportModeOverwrite {
|
||||||
|
if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionKnowledgeFAQUpdate); err != nil {
|
||||||
|
httpx.WriteJSON(ctx, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
knowledgeBaseID, ok := params.GetInt64(ctx, "knowledgeBaseId")
|
||||||
|
if !ok || knowledgeBaseID <= 0 {
|
||||||
|
httpx.WriteJSON(ctx, errorsx.InvalidParam("知识库不存在"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
header, err := ctx.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
httpx.WriteJSON(ctx, errorsx.InvalidParam("请选择导入文件"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file, err := header.Open()
|
||||||
|
if err != nil {
|
||||||
|
httpx.WriteJSON(ctx, errorsx.InvalidParam("导入文件读取失败"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
result, err := services.KnowledgeFAQService.ImportKnowledgeFAQs(request.ImportKnowledgeFAQRequest{
|
||||||
|
KnowledgeBaseID: knowledgeBaseID,
|
||||||
|
Mode: mode,
|
||||||
|
Filename: header.Filename,
|
||||||
|
Reader: file,
|
||||||
|
}, operator)
|
||||||
|
if err != nil {
|
||||||
|
httpx.WriteJSON(ctx, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httpx.WriteJSON(ctx, result)
|
||||||
|
}
|
||||||
|
|
||||||
func KnowledgeFAQAnyList(ctx *gin.Context) {
|
func KnowledgeFAQAnyList(ctx *gin.Context) {
|
||||||
if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionKnowledgeFAQView); err != nil {
|
if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionKnowledgeFAQView); err != nil {
|
||||||
httpx.WriteJSON(ctx, err)
|
httpx.WriteJSON(ctx, err)
|
||||||
@@ -106,3 +182,9 @@ func KnowledgeFAQPostDelete(ctx *gin.Context) {
|
|||||||
}
|
}
|
||||||
httpx.WriteJSON(ctx, nil)
|
httpx.WriteJSON(ctx, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func writeKnowledgeFAQExcelFile(ctx *gin.Context, file *response.KnowledgeFAQExportedFile) {
|
||||||
|
ctx.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, file.Filename))
|
||||||
|
ctx.Header("Cache-Control", "no-store")
|
||||||
|
ctx.Data(http.StatusOK, file.ContentType, file.Data)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package request
|
package request
|
||||||
|
|
||||||
import "agent-desk/internal/pkg/enums"
|
import (
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"agent-desk/internal/pkg/enums"
|
||||||
|
)
|
||||||
|
|
||||||
type CreateKnowledgeBaseRequest struct {
|
type CreateKnowledgeBaseRequest struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -47,6 +51,20 @@ type UpdateKnowledgeFAQRequest struct {
|
|||||||
CreateKnowledgeFAQRequest
|
CreateKnowledgeFAQRequest
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type KnowledgeFAQImportMode string
|
||||||
|
|
||||||
|
const (
|
||||||
|
KnowledgeFAQImportModeAppend KnowledgeFAQImportMode = "append"
|
||||||
|
KnowledgeFAQImportModeOverwrite KnowledgeFAQImportMode = "overwrite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ImportKnowledgeFAQRequest struct {
|
||||||
|
KnowledgeBaseID int64
|
||||||
|
Mode KnowledgeFAQImportMode
|
||||||
|
Filename string
|
||||||
|
Reader io.Reader
|
||||||
|
}
|
||||||
|
|
||||||
type KnowledgeSearchRequest struct {
|
type KnowledgeSearchRequest struct {
|
||||||
KnowledgeBaseIDs []int64 `json:"knowledgeBaseIds"`
|
KnowledgeBaseIDs []int64 `json:"knowledgeBaseIds"`
|
||||||
Question string `json:"question"`
|
Question string `json:"question"`
|
||||||
|
|||||||
@@ -90,6 +90,26 @@ type KnowledgeFAQResponse struct {
|
|||||||
UpdateUserName string `json:"updateUserName"`
|
UpdateUserName string `json:"updateUserName"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type KnowledgeFAQExportedFile struct {
|
||||||
|
Filename string
|
||||||
|
ContentType string
|
||||||
|
Data []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
type KnowledgeFAQImportError struct {
|
||||||
|
Row int `json:"row"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type KnowledgeFAQImportResult struct {
|
||||||
|
Total int `json:"total"`
|
||||||
|
Created int `json:"created"`
|
||||||
|
Updated int `json:"updated"`
|
||||||
|
Skipped int `json:"skipped"`
|
||||||
|
Failed int `json:"failed"`
|
||||||
|
Errors []KnowledgeFAQImportError `json:"errors"`
|
||||||
|
}
|
||||||
|
|
||||||
type KnowledgeSearchResult struct {
|
type KnowledgeSearchResult struct {
|
||||||
KnowledgeBaseID int64 `json:"knowledgeBaseId"`
|
KnowledgeBaseID int64 `json:"knowledgeBaseId"`
|
||||||
ChunkID int64 `json:"chunkId"`
|
ChunkID int64 `json:"chunkId"`
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package repositories
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"agent-desk/internal/models"
|
"agent-desk/internal/models"
|
||||||
|
"agent-desk/internal/pkg/enums"
|
||||||
|
|
||||||
"agent-desk/internal/pkg/httpx/params"
|
"agent-desk/internal/pkg/httpx/params"
|
||||||
|
|
||||||
@@ -65,6 +66,19 @@ func (r *knowledgeFAQRepository) FindByIDs(db *gorm.DB, ids []int64) (list []mod
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *knowledgeFAQRepository) FindAllByKnowledgeBaseID(db *gorm.DB, knowledgeBaseID int64) (list []models.KnowledgeFAQ) {
|
||||||
|
db.Where("knowledge_base_id = ? AND status <> ?", knowledgeBaseID, enums.StatusDeleted).Order("id DESC").Find(&list)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *knowledgeFAQRepository) FindByKnowledgeBaseIDAndQuestions(db *gorm.DB, knowledgeBaseID int64, questions []string) (list []models.KnowledgeFAQ) {
|
||||||
|
if len(questions) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
db.Where("knowledge_base_id = ? AND question IN ?", knowledgeBaseID, questions).Find(&list)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
func (r *knowledgeFAQRepository) CountByKnowledgeBaseID(db *gorm.DB, knowledgeBaseID int64) int64 {
|
func (r *knowledgeFAQRepository) CountByKnowledgeBaseID(db *gorm.DB, knowledgeBaseID int64) int64 {
|
||||||
var count int64
|
var count int64
|
||||||
db.Model(&models.KnowledgeFAQ{}).Where("knowledge_base_id = ?", knowledgeBaseID).Count(&count)
|
db.Model(&models.KnowledgeFAQ{}).Where("knowledge_base_id = ?", knowledgeBaseID).Count(&count)
|
||||||
|
|||||||
@@ -0,0 +1,370 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"agent-desk/internal/ai/rag"
|
||||||
|
"agent-desk/internal/models"
|
||||||
|
"agent-desk/internal/pkg/dto"
|
||||||
|
"agent-desk/internal/pkg/dto/request"
|
||||||
|
"agent-desk/internal/pkg/dto/response"
|
||||||
|
"agent-desk/internal/pkg/enums"
|
||||||
|
"agent-desk/internal/pkg/errorsx"
|
||||||
|
"agent-desk/internal/pkg/utils"
|
||||||
|
"agent-desk/internal/repositories"
|
||||||
|
|
||||||
|
"github.com/mlogclub/simple/sqls"
|
||||||
|
"github.com/xuri/excelize/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
knowledgeFAQExcelSheetName = "FAQ"
|
||||||
|
knowledgeFAQExcelContentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||||
|
)
|
||||||
|
|
||||||
|
var indexKnowledgeFAQAfterImport = func(faqID int64) error {
|
||||||
|
return rag.Index.IndexFAQByID(context.Background(), faqID)
|
||||||
|
}
|
||||||
|
|
||||||
|
type knowledgeFAQImportRow struct {
|
||||||
|
RowNo int
|
||||||
|
Question string
|
||||||
|
Answer string
|
||||||
|
SimilarQuestions []string
|
||||||
|
Remark string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *knowledgeFAQService) BuildKnowledgeFAQImportTemplate() (*response.KnowledgeFAQExportedFile, error) {
|
||||||
|
workbook := excelize.NewFile()
|
||||||
|
defer workbook.Close()
|
||||||
|
if err := writeKnowledgeFAQWorkbookHeader(workbook); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
_ = workbook.SetCellValue(knowledgeFAQExcelSheetName, "A2", "如何重置密码?")
|
||||||
|
_ = workbook.SetCellValue(knowledgeFAQExcelSheetName, "B2", "进入个人设置后点击重置密码。")
|
||||||
|
_ = workbook.SetCellValue(knowledgeFAQExcelSheetName, "C2", "忘记密码怎么办\n重置密码在哪里")
|
||||||
|
_ = workbook.SetCellValue(knowledgeFAQExcelSheetName, "D2", "账号")
|
||||||
|
return buildKnowledgeFAQExcelFile("knowledge-faq-import-template.xlsx", workbook)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *knowledgeFAQService) ExportKnowledgeFAQs(knowledgeBaseID int64) (*response.KnowledgeFAQExportedFile, error) {
|
||||||
|
if _, err := s.requireFAQKnowledgeBase(knowledgeBaseID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
list := repositories.KnowledgeFAQRepository.FindAllByKnowledgeBaseID(sqls.DB(), knowledgeBaseID)
|
||||||
|
workbook := excelize.NewFile()
|
||||||
|
defer workbook.Close()
|
||||||
|
if err := writeKnowledgeFAQWorkbookHeader(workbook); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for index, item := range list {
|
||||||
|
rowNo := index + 2
|
||||||
|
_ = workbook.SetCellValue(knowledgeFAQExcelSheetName, fmt.Sprintf("A%d", rowNo), item.Question)
|
||||||
|
_ = workbook.SetCellValue(knowledgeFAQExcelSheetName, fmt.Sprintf("B%d", rowNo), item.Answer)
|
||||||
|
_ = workbook.SetCellValue(knowledgeFAQExcelSheetName, fmt.Sprintf("C%d", rowNo), strings.Join(decodeKnowledgeFAQSimilarQuestions(item.SimilarQuestions), "\n"))
|
||||||
|
_ = workbook.SetCellValue(knowledgeFAQExcelSheetName, fmt.Sprintf("D%d", rowNo), item.Remark)
|
||||||
|
}
|
||||||
|
filename := fmt.Sprintf("knowledge-faq-%d-%s.xlsx", knowledgeBaseID, time.Now().Format("20060102150405"))
|
||||||
|
return buildKnowledgeFAQExcelFile(filename, workbook)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *knowledgeFAQService) ImportKnowledgeFAQs(req request.ImportKnowledgeFAQRequest, operator *dto.AuthPrincipal) (*response.KnowledgeFAQImportResult, error) {
|
||||||
|
if operator == nil {
|
||||||
|
return nil, errorsx.Unauthorized("未登录或登录已过期")
|
||||||
|
}
|
||||||
|
if req.Mode != request.KnowledgeFAQImportModeAppend && req.Mode != request.KnowledgeFAQImportModeOverwrite {
|
||||||
|
return nil, errorsx.InvalidParam("导入模式不合法")
|
||||||
|
}
|
||||||
|
if req.Reader == nil {
|
||||||
|
return nil, errorsx.InvalidParam("请选择导入文件")
|
||||||
|
}
|
||||||
|
if strings.ToLower(filepath.Ext(req.Filename)) != ".xlsx" {
|
||||||
|
return nil, errorsx.InvalidParam("仅支持.xlsx文件")
|
||||||
|
}
|
||||||
|
kb, err := s.requireFAQKnowledgeBase(req.KnowledgeBaseID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rows, result, err := parseKnowledgeFAQImportRows(req.Reader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
questions := make([]string, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
questions = append(questions, row.Question)
|
||||||
|
}
|
||||||
|
existingList := repositories.KnowledgeFAQRepository.FindByKnowledgeBaseIDAndQuestions(sqls.DB(), req.KnowledgeBaseID, questions)
|
||||||
|
existingMap := make(map[string]models.KnowledgeFAQ, len(existingList))
|
||||||
|
for _, item := range existingList {
|
||||||
|
if _, ok := existingMap[item.Question]; !ok {
|
||||||
|
existingMap[item.Question] = item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
indexFAQIDs := make([]int64, 0, len(rows))
|
||||||
|
err = sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||||
|
for _, row := range rows {
|
||||||
|
existing, exists := existingMap[row.Question]
|
||||||
|
if exists && req.Mode == request.KnowledgeFAQImportModeAppend {
|
||||||
|
result.Skipped++
|
||||||
|
result.Errors = append(result.Errors, response.KnowledgeFAQImportError{Row: row.RowNo, Message: "标准问题已存在,已跳过"})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
similarQuestions, marshalErr := json.Marshal(row.SimilarQuestions)
|
||||||
|
if marshalErr != nil {
|
||||||
|
result.Failed++
|
||||||
|
result.Errors = append(result.Errors, response.KnowledgeFAQImportError{Row: row.RowNo, Message: "相似问格式不合法"})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if exists {
|
||||||
|
if updateErr := repositories.KnowledgeFAQRepository.Updates(ctx.Tx, existing.ID, map[string]any{
|
||||||
|
"answer": row.Answer,
|
||||||
|
"similar_questions": string(similarQuestions),
|
||||||
|
"index_status": enums.KnowledgeDocumentIndexStatusPending,
|
||||||
|
"indexed_at": nil,
|
||||||
|
"index_error": "",
|
||||||
|
"remark": row.Remark,
|
||||||
|
"update_user_id": operator.UserID,
|
||||||
|
"update_user_name": operator.Username,
|
||||||
|
"updated_at": time.Now(),
|
||||||
|
}); updateErr != nil {
|
||||||
|
return updateErr
|
||||||
|
}
|
||||||
|
result.Updated++
|
||||||
|
indexFAQIDs = append(indexFAQIDs, existing.ID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
item := &models.KnowledgeFAQ{
|
||||||
|
KnowledgeBaseID: req.KnowledgeBaseID,
|
||||||
|
Question: row.Question,
|
||||||
|
Answer: row.Answer,
|
||||||
|
SimilarQuestions: string(similarQuestions),
|
||||||
|
Status: kb.Status,
|
||||||
|
IndexStatus: enums.KnowledgeDocumentIndexStatusPending,
|
||||||
|
IndexError: "",
|
||||||
|
IndexedAt: nil,
|
||||||
|
Remark: row.Remark,
|
||||||
|
AuditFields: utils.BuildAuditFields(operator),
|
||||||
|
}
|
||||||
|
if createErr := repositories.KnowledgeFAQRepository.Create(ctx.Tx, item); createErr != nil {
|
||||||
|
return createErr
|
||||||
|
}
|
||||||
|
result.Created++
|
||||||
|
indexFAQIDs = append(indexFAQIDs, item.ID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, faqID := range indexFAQIDs {
|
||||||
|
if indexErr := indexKnowledgeFAQAfterImport(faqID); indexErr != nil {
|
||||||
|
slog.Error("failed to index imported knowledge faq", "faq_id", faqID, "error", indexErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeKnowledgeFAQWorkbookHeader(workbook *excelize.File) error {
|
||||||
|
sheet := workbook.GetSheetName(0)
|
||||||
|
if sheet == "" {
|
||||||
|
sheet = knowledgeFAQExcelSheetName
|
||||||
|
workbook.NewSheet(sheet)
|
||||||
|
} else if sheet != knowledgeFAQExcelSheetName {
|
||||||
|
if err := workbook.SetSheetName(sheet, knowledgeFAQExcelSheetName); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
headers := []string{"标准问题", "答案", "相似问", "备注"}
|
||||||
|
for index, header := range headers {
|
||||||
|
cell, err := excelize.CoordinatesToCellName(index+1, 1)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := workbook.SetCellValue(knowledgeFAQExcelSheetName, cell, header); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
style, err := workbook.NewStyle(&excelize.Style{
|
||||||
|
Font: &excelize.Font{Bold: true},
|
||||||
|
Alignment: &excelize.Alignment{WrapText: true, Vertical: "top"},
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
_ = workbook.SetCellStyle(knowledgeFAQExcelSheetName, "A1", "D1", style)
|
||||||
|
}
|
||||||
|
_ = workbook.SetColWidth(knowledgeFAQExcelSheetName, "A", "A", 36)
|
||||||
|
_ = workbook.SetColWidth(knowledgeFAQExcelSheetName, "B", "B", 60)
|
||||||
|
_ = workbook.SetColWidth(knowledgeFAQExcelSheetName, "C", "C", 40)
|
||||||
|
_ = workbook.SetColWidth(knowledgeFAQExcelSheetName, "D", "D", 24)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildKnowledgeFAQExcelFile(filename string, workbook *excelize.File) (*response.KnowledgeFAQExportedFile, error) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := workbook.Write(&buf); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &response.KnowledgeFAQExportedFile{
|
||||||
|
Filename: filename,
|
||||||
|
ContentType: knowledgeFAQExcelContentType,
|
||||||
|
Data: buf.Bytes(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseKnowledgeFAQImportRows(reader io.Reader) ([]knowledgeFAQImportRow, *response.KnowledgeFAQImportResult, error) {
|
||||||
|
workbook, err := excelize.OpenReader(reader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, errorsx.InvalidParam("Excel文件解析失败")
|
||||||
|
}
|
||||||
|
defer workbook.Close()
|
||||||
|
sheet := knowledgeFAQExcelSheetName
|
||||||
|
if index, _ := workbook.GetSheetIndex(sheet); index < 0 {
|
||||||
|
sheets := workbook.GetSheetList()
|
||||||
|
if len(sheets) == 0 {
|
||||||
|
return nil, nil, errorsx.InvalidParam("Excel文件为空")
|
||||||
|
}
|
||||||
|
sheet = sheets[0]
|
||||||
|
}
|
||||||
|
table, err := workbook.GetRows(sheet)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, errorsx.InvalidParam("Excel文件读取失败")
|
||||||
|
}
|
||||||
|
if len(table) == 0 {
|
||||||
|
return nil, nil, errorsx.InvalidParam("Excel文件为空")
|
||||||
|
}
|
||||||
|
headerMap := buildKnowledgeFAQHeaderMap(table[0])
|
||||||
|
if _, ok := headerMap["question"]; !ok {
|
||||||
|
return nil, nil, errorsx.InvalidParam("缺少标准问题列")
|
||||||
|
}
|
||||||
|
if _, ok := headerMap["answer"]; !ok {
|
||||||
|
return nil, nil, errorsx.InvalidParam("缺少答案列")
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &response.KnowledgeFAQImportResult{Errors: make([]response.KnowledgeFAQImportError, 0)}
|
||||||
|
rows := make([]knowledgeFAQImportRow, 0, len(table)-1)
|
||||||
|
seen := make(map[string]int)
|
||||||
|
for index := 1; index < len(table); index++ {
|
||||||
|
current := table[index]
|
||||||
|
if isKnowledgeFAQExcelRowEmpty(current) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result.Total++
|
||||||
|
rowNo := index + 1
|
||||||
|
row := knowledgeFAQImportRow{
|
||||||
|
RowNo: rowNo,
|
||||||
|
Question: getKnowledgeFAQExcelCell(current, headerMap["question"]),
|
||||||
|
Answer: getKnowledgeFAQExcelCell(current, headerMap["answer"]),
|
||||||
|
SimilarQuestions: parseKnowledgeFAQSimilarQuestions(getKnowledgeFAQExcelCell(current, headerMap["similarQuestions"])),
|
||||||
|
Remark: getKnowledgeFAQExcelCell(current, headerMap["remark"]),
|
||||||
|
}
|
||||||
|
if row.Question == "" {
|
||||||
|
result.Failed++
|
||||||
|
result.Errors = append(result.Errors, response.KnowledgeFAQImportError{Row: rowNo, Message: "问题不能为空"})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len([]rune(row.Question)) > 500 {
|
||||||
|
result.Failed++
|
||||||
|
result.Errors = append(result.Errors, response.KnowledgeFAQImportError{Row: rowNo, Message: "问题不能超过500字"})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if row.Answer == "" {
|
||||||
|
result.Failed++
|
||||||
|
result.Errors = append(result.Errors, response.KnowledgeFAQImportError{Row: rowNo, Message: "答案不能为空"})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if firstRow, exists := seen[row.Question]; exists {
|
||||||
|
result.Failed++
|
||||||
|
result.Errors = append(result.Errors, response.KnowledgeFAQImportError{Row: rowNo, Message: fmt.Sprintf("同一文件中标准问题重复,首次出现于第%d行", firstRow)})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[row.Question] = rowNo
|
||||||
|
rows = append(rows, row)
|
||||||
|
}
|
||||||
|
return rows, result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildKnowledgeFAQHeaderMap(headerRow []string) map[string]int {
|
||||||
|
accepted := map[string]string{
|
||||||
|
"标准问题": "question",
|
||||||
|
"问题": "question",
|
||||||
|
"question": "question",
|
||||||
|
"答案": "answer",
|
||||||
|
"answer": "answer",
|
||||||
|
"相似问": "similarQuestions",
|
||||||
|
"相似问题": "similarQuestions",
|
||||||
|
"similarquestions": "similarQuestions",
|
||||||
|
"备注": "remark",
|
||||||
|
"remark": "remark",
|
||||||
|
}
|
||||||
|
result := make(map[string]int)
|
||||||
|
for index, header := range headerRow {
|
||||||
|
key := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(header, "\uFEFF")))
|
||||||
|
if field, ok := accepted[key]; ok {
|
||||||
|
if _, exists := result[field]; !exists {
|
||||||
|
result[field] = index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func getKnowledgeFAQExcelCell(row []string, index int) string {
|
||||||
|
if index < 0 || index >= len(row) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(row[index])
|
||||||
|
}
|
||||||
|
|
||||||
|
func isKnowledgeFAQExcelRowEmpty(row []string) bool {
|
||||||
|
for _, cell := range row {
|
||||||
|
if strings.TrimSpace(cell) != "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseKnowledgeFAQSimilarQuestions(value string) []string {
|
||||||
|
lines := strings.Split(strings.ReplaceAll(value, "\r\n", "\n"), "\n")
|
||||||
|
items := make([]string, 0, len(lines))
|
||||||
|
seen := make(map[string]struct{}, len(lines))
|
||||||
|
for _, line := range lines {
|
||||||
|
item := strings.TrimSpace(strings.ReplaceAll(line, "\r", ""))
|
||||||
|
if item == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[item]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[item] = struct{}{}
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeKnowledgeFAQSimilarQuestions(value string) []string {
|
||||||
|
var items []string
|
||||||
|
if strings.TrimSpace(value) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(value), &items); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"agent-desk/internal/models"
|
||||||
|
"agent-desk/internal/pkg/dto"
|
||||||
|
"agent-desk/internal/pkg/dto/request"
|
||||||
|
"agent-desk/internal/pkg/enums"
|
||||||
|
"agent-desk/internal/repositories"
|
||||||
|
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"github.com/mlogclub/simple/sqls"
|
||||||
|
"github.com/xuri/excelize/v2"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestKnowledgeFAQExcelTemplateContainsExpectedHeaders(t *testing.T) {
|
||||||
|
file, err := KnowledgeFAQService.BuildKnowledgeFAQImportTemplate()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildKnowledgeFAQImportTemplate() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
workbook, err := excelize.OpenReader(bytes.NewReader(file.Data))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open template workbook: %v", err)
|
||||||
|
}
|
||||||
|
defer workbook.Close()
|
||||||
|
|
||||||
|
rows, err := workbook.GetRows("FAQ")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get FAQ rows: %v", err)
|
||||||
|
}
|
||||||
|
if len(rows) == 0 {
|
||||||
|
t.Fatal("template has no rows")
|
||||||
|
}
|
||||||
|
want := []string{"标准问题", "答案", "相似问", "备注"}
|
||||||
|
for i, value := range want {
|
||||||
|
if rows[0][i] != value {
|
||||||
|
t.Fatalf("header %d = %q, want %q", i, rows[0][i], value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKnowledgeFAQExcelExportWritesSimilarQuestionsWithLineBreaks(t *testing.T) {
|
||||||
|
setupKnowledgeFAQExcelTestDB(t)
|
||||||
|
kb := createKnowledgeFAQExcelTestBase(t, enums.StatusOk)
|
||||||
|
similarQuestions, _ := json.Marshal([]string{"怎么重置密码", "忘记密码怎么办"})
|
||||||
|
if err := repositories.KnowledgeFAQRepository.Create(sqls.DB(), &models.KnowledgeFAQ{
|
||||||
|
KnowledgeBaseID: kb.ID,
|
||||||
|
Question: "如何重置密码?",
|
||||||
|
Answer: "进入个人设置后点击重置密码。",
|
||||||
|
SimilarQuestions: string(similarQuestions),
|
||||||
|
Status: enums.StatusOk,
|
||||||
|
IndexStatus: enums.KnowledgeDocumentIndexStatusPending,
|
||||||
|
Remark: "账号",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("create faq: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := KnowledgeFAQService.ExportKnowledgeFAQs(kb.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ExportKnowledgeFAQs() error = %v", err)
|
||||||
|
}
|
||||||
|
workbook, err := excelize.OpenReader(bytes.NewReader(file.Data))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open export workbook: %v", err)
|
||||||
|
}
|
||||||
|
defer workbook.Close()
|
||||||
|
|
||||||
|
cell, err := workbook.GetCellValue("FAQ", "C2")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get C2: %v", err)
|
||||||
|
}
|
||||||
|
if cell != "怎么重置密码\n忘记密码怎么办" {
|
||||||
|
t.Fatalf("similar questions cell = %q", cell)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImportKnowledgeFAQsAppendSkipsExistingQuestion(t *testing.T) {
|
||||||
|
setupKnowledgeFAQExcelTestDB(t)
|
||||||
|
restore := stubKnowledgeFAQImportIndex(t)
|
||||||
|
defer restore()
|
||||||
|
kb := createKnowledgeFAQExcelTestBase(t, enums.StatusOk)
|
||||||
|
if err := repositories.KnowledgeFAQRepository.Create(sqls.DB(), &models.KnowledgeFAQ{
|
||||||
|
KnowledgeBaseID: kb.ID,
|
||||||
|
Question: "如何重置密码?",
|
||||||
|
Answer: "旧答案",
|
||||||
|
Status: enums.StatusOk,
|
||||||
|
IndexStatus: enums.KnowledgeDocumentIndexStatusIndexed,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("create faq: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := KnowledgeFAQService.ImportKnowledgeFAQs(request.ImportKnowledgeFAQRequest{
|
||||||
|
KnowledgeBaseID: kb.ID,
|
||||||
|
Mode: request.KnowledgeFAQImportModeAppend,
|
||||||
|
Filename: "faq.xlsx",
|
||||||
|
Reader: bytes.NewReader(buildKnowledgeFAQExcelWorkbook(t, [][]string{
|
||||||
|
{"标准问题", "答案", "相似问", "备注"},
|
||||||
|
{"如何重置密码?", "新答案", "重置密码在哪里\n忘记密码", "账号"},
|
||||||
|
{"支持哪些渠道?", "支持网页和企业微信。", "", "渠道"},
|
||||||
|
})),
|
||||||
|
}, knowledgeFAQExcelTestOperator())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ImportKnowledgeFAQs() error = %v", err)
|
||||||
|
}
|
||||||
|
if result.Created != 1 || result.Updated != 0 || result.Skipped != 1 || result.Failed != 0 {
|
||||||
|
t.Fatalf("result = %+v", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
list := repositories.KnowledgeFAQRepository.Find(sqls.DB(), sqls.NewCnd().Eq("knowledge_base_id", kb.ID))
|
||||||
|
if len(list) != 2 {
|
||||||
|
t.Fatalf("faq count = %d, want 2", len(list))
|
||||||
|
}
|
||||||
|
existing := repositories.KnowledgeFAQRepository.Find(sqls.DB(), sqls.NewCnd().Eq("question", "如何重置密码?"))
|
||||||
|
if len(existing) != 1 || existing[0].Answer != "旧答案" {
|
||||||
|
t.Fatalf("existing faq changed: %+v", existing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImportKnowledgeFAQsOverwriteUpdatesExistingQuestion(t *testing.T) {
|
||||||
|
setupKnowledgeFAQExcelTestDB(t)
|
||||||
|
restore := stubKnowledgeFAQImportIndex(t)
|
||||||
|
defer restore()
|
||||||
|
kb := createKnowledgeFAQExcelTestBase(t, enums.StatusDisabled)
|
||||||
|
if err := repositories.KnowledgeFAQRepository.Create(sqls.DB(), &models.KnowledgeFAQ{
|
||||||
|
KnowledgeBaseID: kb.ID,
|
||||||
|
Question: "如何重置密码?",
|
||||||
|
Answer: "旧答案",
|
||||||
|
Status: enums.StatusOk,
|
||||||
|
IndexStatus: enums.KnowledgeDocumentIndexStatusIndexed,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("create faq: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := KnowledgeFAQService.ImportKnowledgeFAQs(request.ImportKnowledgeFAQRequest{
|
||||||
|
KnowledgeBaseID: kb.ID,
|
||||||
|
Mode: request.KnowledgeFAQImportModeOverwrite,
|
||||||
|
Filename: "faq.xlsx",
|
||||||
|
Reader: bytes.NewReader(buildKnowledgeFAQExcelWorkbook(t, [][]string{
|
||||||
|
{"标准问题", "答案", "相似问", "备注"},
|
||||||
|
{"如何重置密码?", "新答案", "重置密码在哪里\n重置密码在哪里\n忘记密码", "账号"},
|
||||||
|
{"支持哪些渠道?", "支持网页和企业微信。", "", "渠道"},
|
||||||
|
})),
|
||||||
|
}, knowledgeFAQExcelTestOperator())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ImportKnowledgeFAQs() error = %v", err)
|
||||||
|
}
|
||||||
|
if result.Created != 1 || result.Updated != 1 || result.Skipped != 0 || result.Failed != 0 {
|
||||||
|
t.Fatalf("result = %+v", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
updated := repositories.KnowledgeFAQRepository.Find(sqls.DB(), sqls.NewCnd().Eq("question", "如何重置密码?"))
|
||||||
|
if len(updated) != 1 {
|
||||||
|
t.Fatalf("updated faq count = %d", len(updated))
|
||||||
|
}
|
||||||
|
if updated[0].Answer != "新答案" {
|
||||||
|
t.Fatalf("updated answer = %q", updated[0].Answer)
|
||||||
|
}
|
||||||
|
if updated[0].IndexStatus != enums.KnowledgeDocumentIndexStatusPending {
|
||||||
|
t.Fatalf("index status = %q", updated[0].IndexStatus)
|
||||||
|
}
|
||||||
|
var similar []string
|
||||||
|
if err := json.Unmarshal([]byte(updated[0].SimilarQuestions), &similar); err != nil {
|
||||||
|
t.Fatalf("unmarshal similar questions: %v", err)
|
||||||
|
}
|
||||||
|
if len(similar) != 2 || similar[0] != "重置密码在哪里" || similar[1] != "忘记密码" {
|
||||||
|
t.Fatalf("similar questions = %#v", similar)
|
||||||
|
}
|
||||||
|
|
||||||
|
created := repositories.KnowledgeFAQRepository.Find(sqls.DB(), sqls.NewCnd().Eq("question", "支持哪些渠道?"))
|
||||||
|
if len(created) != 1 || created[0].Status != enums.StatusDisabled {
|
||||||
|
t.Fatalf("created faq = %+v", created)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupKnowledgeFAQExcelTestDB(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open sqlite db: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&models.KnowledgeBase{}, &models.KnowledgeFAQ{}, &models.KnowledgeChunk{}); err != nil {
|
||||||
|
t.Fatalf("auto migrate: %v", err)
|
||||||
|
}
|
||||||
|
sqls.SetDB(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createKnowledgeFAQExcelTestBase(t *testing.T, status enums.Status) *models.KnowledgeBase {
|
||||||
|
t.Helper()
|
||||||
|
item := &models.KnowledgeBase{
|
||||||
|
Name: "FAQ",
|
||||||
|
KnowledgeType: string(enums.KnowledgeBaseTypeFAQ),
|
||||||
|
Status: status,
|
||||||
|
}
|
||||||
|
if err := repositories.KnowledgeBaseRepository.Create(sqls.DB(), item); err != nil {
|
||||||
|
t.Fatalf("create knowledge base: %v", err)
|
||||||
|
}
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildKnowledgeFAQExcelWorkbook(t *testing.T, rows [][]string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
workbook := excelize.NewFile()
|
||||||
|
defer workbook.Close()
|
||||||
|
sheet := workbook.GetSheetName(0)
|
||||||
|
if err := workbook.SetSheetName(sheet, "FAQ"); err != nil {
|
||||||
|
t.Fatalf("set sheet name: %v", err)
|
||||||
|
}
|
||||||
|
for rowIndex, row := range rows {
|
||||||
|
for colIndex, value := range row {
|
||||||
|
cell, err := excelize.CoordinatesToCellName(colIndex+1, rowIndex+1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("cell name: %v", err)
|
||||||
|
}
|
||||||
|
if err := workbook.SetCellValue("FAQ", cell, value); err != nil {
|
||||||
|
t.Fatalf("set cell value: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := workbook.Write(&buf); err != nil {
|
||||||
|
t.Fatalf("write workbook: %v", err)
|
||||||
|
}
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func knowledgeFAQExcelTestOperator() *dto.AuthPrincipal {
|
||||||
|
return &dto.AuthPrincipal{UserID: 1, Username: "admin"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stubKnowledgeFAQImportIndex(t *testing.T) func() {
|
||||||
|
t.Helper()
|
||||||
|
original := indexKnowledgeFAQAfterImport
|
||||||
|
indexed := make([]int64, 0)
|
||||||
|
indexKnowledgeFAQAfterImport = func(faqID int64) error {
|
||||||
|
indexed = append(indexed, faqID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return func() {
|
||||||
|
if len(indexed) == 0 {
|
||||||
|
t.Fatalf("expected imported FAQs to be indexed")
|
||||||
|
}
|
||||||
|
indexKnowledgeFAQAfterImport = original
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,209 +1,36 @@
|
|||||||
"use client";
|
"use client"
|
||||||
|
|
||||||
import { DownloadIcon, FileUpIcon, InfoIcon } from "lucide-react";
|
import { DownloadIcon, FileUpIcon, InfoIcon } from "lucide-react"
|
||||||
import { useMemo, useRef, useState } from "react";
|
import { useMemo, useRef, useState } from "react"
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner"
|
||||||
|
|
||||||
import { ProjectDialog } from "@/components/project-dialog";
|
import { OptionCombobox } from "@/components/option-combobox"
|
||||||
import { Button } from "@/components/ui/button";
|
import { ProjectDialog } from "@/components/project-dialog"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
Field,
|
Field,
|
||||||
FieldContent,
|
FieldContent,
|
||||||
FieldDescription,
|
FieldDescription,
|
||||||
FieldGroup,
|
FieldGroup,
|
||||||
FieldLabel,
|
FieldLabel,
|
||||||
} from "@/components/ui/field";
|
} from "@/components/ui/field"
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input"
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
import { createKnowledgeFAQ, type CreateKnowledgeFAQPayload } from "@/lib/api/admin";
|
import {
|
||||||
import { useI18n } from "@/i18n/provider";
|
downloadKnowledgeFAQImportTemplate,
|
||||||
|
importKnowledgeFAQs,
|
||||||
|
type KnowledgeFAQImportMode,
|
||||||
|
type KnowledgeFAQImportResult,
|
||||||
|
} from "@/lib/api/admin"
|
||||||
|
import { useI18n } from "@/i18n/provider"
|
||||||
|
|
||||||
type FAQImportDialogProps = {
|
type FAQImportDialogProps = {
|
||||||
open: boolean;
|
open: boolean
|
||||||
knowledgeBaseId: number | null;
|
knowledgeBaseId: number | null
|
||||||
importing: boolean;
|
importing: boolean
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void
|
||||||
onImportingChange: (importing: boolean) => void;
|
onImportingChange: (importing: boolean) => void
|
||||||
onImported: () => Promise<void>;
|
onImported: () => Promise<void>
|
||||||
};
|
|
||||||
|
|
||||||
type ParsedFAQRow = {
|
|
||||||
rowNo: number;
|
|
||||||
question: string;
|
|
||||||
answer: string;
|
|
||||||
similarQuestions: string[];
|
|
||||||
remark: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ParseResult = {
|
|
||||||
rows: ParsedFAQRow[];
|
|
||||||
warnings: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type TFunction = (key: string, values?: Record<string, string | number>) => string;
|
|
||||||
|
|
||||||
const acceptedHeaderMap: Record<string, keyof Omit<ParsedFAQRow, "rowNo">> = {
|
|
||||||
question: "question",
|
|
||||||
answer: "answer",
|
|
||||||
similarquestions: "similarQuestions",
|
|
||||||
"similarQuestions": "similarQuestions",
|
|
||||||
remark: "remark",
|
|
||||||
"\u6807\u51c6\u95ee\u9898": "question",
|
|
||||||
"\u95ee\u9898": "question",
|
|
||||||
"\u7b54\u6848": "answer",
|
|
||||||
"\u76f8\u4f3c\u95ee": "similarQuestions",
|
|
||||||
"\u76f8\u4f3c\u95ee\u9898": "similarQuestions",
|
|
||||||
"\u5907\u6ce8": "remark",
|
|
||||||
};
|
|
||||||
|
|
||||||
function normalizeHeader(value: string) {
|
|
||||||
return value.trim().replace(/^\uFEFF/, "").toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseDelimitedText(input: string): string[][] {
|
|
||||||
const text = input.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
||||||
const rows: string[][] = [];
|
|
||||||
let row: string[] = [];
|
|
||||||
let cell = "";
|
|
||||||
let inQuotes = false;
|
|
||||||
let delimiter = ",";
|
|
||||||
|
|
||||||
function pushCell() {
|
|
||||||
row.push(cell.trim());
|
|
||||||
cell = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function pushRow() {
|
|
||||||
if (row.length === 1 && row[0] === "" && rows.length === 0) {
|
|
||||||
row = [];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (row.some((item) => item !== "")) {
|
|
||||||
rows.push(row);
|
|
||||||
}
|
|
||||||
row = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < text.length; i += 1) {
|
|
||||||
const char = text[i];
|
|
||||||
const next = text[i + 1];
|
|
||||||
|
|
||||||
if (!inQuotes && rows.length === 0 && row.length === 0 && cell.length > 0 && char === "\t") {
|
|
||||||
delimiter = "\t";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (char === '"') {
|
|
||||||
if (inQuotes && next === '"') {
|
|
||||||
cell += '"';
|
|
||||||
i += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
inQuotes = !inQuotes;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!inQuotes && char === delimiter) {
|
|
||||||
pushCell();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!inQuotes && char === "\n") {
|
|
||||||
pushCell();
|
|
||||||
pushRow();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
cell += char;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cell.length > 0 || row.length > 0) {
|
|
||||||
pushCell();
|
|
||||||
pushRow();
|
|
||||||
}
|
|
||||||
|
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseSimilarQuestions(value: string) {
|
|
||||||
return value
|
|
||||||
.split(/\r?\n|\|/g)
|
|
||||||
.map((item) => item.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseFAQFileContent(input: string, t: TFunction): ParseResult {
|
|
||||||
const table = parseDelimitedText(input);
|
|
||||||
if (table.length === 0) {
|
|
||||||
throw new Error(t("knowledge.fileEmpty"));
|
|
||||||
}
|
|
||||||
|
|
||||||
const headerRow = table[0];
|
|
||||||
const headerMap = new Map<keyof Omit<ParsedFAQRow, "rowNo">, number>();
|
|
||||||
for (let index = 0; index < headerRow.length; index += 1) {
|
|
||||||
const header = acceptedHeaderMap[normalizeHeader(headerRow[index])];
|
|
||||||
if (header && !headerMap.has(header)) {
|
|
||||||
headerMap.set(header, index);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!headerMap.has("question") || !headerMap.has("answer")) {
|
|
||||||
throw new Error(t("knowledge.missingFAQColumns"));
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows: ParsedFAQRow[] = [];
|
|
||||||
const warnings: string[] = [];
|
|
||||||
|
|
||||||
for (let index = 1; index < table.length; index += 1) {
|
|
||||||
const current = table[index];
|
|
||||||
const rowNo = index + 1;
|
|
||||||
const question = current[headerMap.get("question") ?? -1]?.trim() ?? "";
|
|
||||||
const answer = current[headerMap.get("answer") ?? -1]?.trim() ?? "";
|
|
||||||
const similarQuestionsRaw = current[headerMap.get("similarQuestions") ?? -1]?.trim() ?? "";
|
|
||||||
const remark = current[headerMap.get("remark") ?? -1]?.trim() ?? "";
|
|
||||||
|
|
||||||
if (!question && !answer && !similarQuestionsRaw && !remark) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!question || !answer) {
|
|
||||||
warnings.push(t("knowledge.skipRowMissingFAQ", { row: rowNo }));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
rows.push({
|
|
||||||
rowNo,
|
|
||||||
question,
|
|
||||||
answer,
|
|
||||||
similarQuestions: parseSimilarQuestions(similarQuestionsRaw),
|
|
||||||
remark,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return { rows, warnings };
|
|
||||||
}
|
|
||||||
|
|
||||||
function downloadTemplate() {
|
|
||||||
const templateContent = [
|
|
||||||
"question,answer,similarQuestions,remark",
|
|
||||||
'"How do I reset my password?","Open profile settings and choose Reset Password.","forgot password|where is reset password","Account FAQ"',
|
|
||||||
'"Which channels are supported?","Web chat and WeCom customer service channels are currently supported.","available channels|supported channels","Channel guide"',
|
|
||||||
].join("\n");
|
|
||||||
const blob = new Blob([templateContent], { type: "text/csv;charset=utf-8;" });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const link = document.createElement("a");
|
|
||||||
link.href = url;
|
|
||||||
link.download = "knowledge-faq-import-template.csv";
|
|
||||||
link.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildPayload(row: ParsedFAQRow, knowledgeBaseId: number): CreateKnowledgeFAQPayload {
|
|
||||||
return {
|
|
||||||
knowledgeBaseId,
|
|
||||||
question: row.question,
|
|
||||||
answer: row.answer,
|
|
||||||
similarQuestions: row.similarQuestions,
|
|
||||||
remark: row.remark,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FAQImportDialog({
|
export function FAQImportDialog({
|
||||||
@@ -214,85 +41,86 @@ export function FAQImportDialog({
|
|||||||
onImportingChange,
|
onImportingChange,
|
||||||
onImported,
|
onImported,
|
||||||
}: FAQImportDialogProps) {
|
}: FAQImportDialogProps) {
|
||||||
const t = useI18n();
|
const t = useI18n()
|
||||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
const fileInputRef = useRef<HTMLInputElement | null>(null)
|
||||||
const [fileName, setFileName] = useState("");
|
const [file, setFile] = useState<File | null>(null)
|
||||||
const [rows, setRows] = useState<ParsedFAQRow[]>([]);
|
const [mode, setMode] = useState<KnowledgeFAQImportMode>("append")
|
||||||
const [warnings, setWarnings] = useState<string[]>([]);
|
const [result, setResult] = useState<KnowledgeFAQImportResult | null>(null)
|
||||||
|
|
||||||
const previewRows = useMemo(() => rows.slice(0, 5), [rows]);
|
const modeOptions = useMemo(
|
||||||
|
() => [
|
||||||
|
{ value: "append", label: t("knowledge.importModeAppend") },
|
||||||
|
{ value: "overwrite", label: t("knowledge.importModeOverwrite") },
|
||||||
|
],
|
||||||
|
[t],
|
||||||
|
)
|
||||||
|
|
||||||
function resetState() {
|
function resetState() {
|
||||||
setFileName("");
|
setFile(null)
|
||||||
setRows([]);
|
setMode("append")
|
||||||
setWarnings([]);
|
setResult(null)
|
||||||
if (fileInputRef.current) {
|
if (fileInputRef.current) {
|
||||||
fileInputRef.current.value = "";
|
fileInputRef.current.value = ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) {
|
function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) {
|
||||||
const file = event.target.files?.[0];
|
const nextFile = event.target.files?.[0] ?? null
|
||||||
if (!file) {
|
setResult(null)
|
||||||
return;
|
if (!nextFile) {
|
||||||
|
setFile(null)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
if (!nextFile.name.toLowerCase().endsWith(".xlsx")) {
|
||||||
try {
|
setFile(null)
|
||||||
const content = await file.text();
|
if (fileInputRef.current) {
|
||||||
const parsed = parseFAQFileContent(content, t);
|
fileInputRef.current.value = ""
|
||||||
setFileName(file.name);
|
|
||||||
setRows(parsed.rows);
|
|
||||||
setWarnings(parsed.warnings);
|
|
||||||
if (parsed.rows.length === 0) {
|
|
||||||
toast.error(t("knowledge.importNoRows"));
|
|
||||||
} else {
|
|
||||||
toast.success(t("knowledge.parsedFAQRows", { count: parsed.rows.length }));
|
|
||||||
}
|
}
|
||||||
|
toast.error(t("knowledge.importXlsxOnly"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setFile(nextFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDownloadTemplate() {
|
||||||
|
try {
|
||||||
|
await downloadKnowledgeFAQImportTemplate()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
resetState();
|
toast.error(error instanceof Error ? error.message : t("knowledge.downloadTemplateFailed"))
|
||||||
toast.error(error instanceof Error ? error.message : t("knowledge.parseImportFailed"));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleImport() {
|
async function handleImport() {
|
||||||
if (!knowledgeBaseId || rows.length === 0 || importing) {
|
if (!knowledgeBaseId || !file || importing) {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
onImportingChange(true);
|
onImportingChange(true)
|
||||||
let successCount = 0;
|
setResult(null)
|
||||||
const failedRows: string[] = [];
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (const row of rows) {
|
const data = await importKnowledgeFAQs({
|
||||||
try {
|
knowledgeBaseId,
|
||||||
await createKnowledgeFAQ(buildPayload(row, knowledgeBaseId));
|
mode,
|
||||||
successCount += 1;
|
file,
|
||||||
} catch (error) {
|
})
|
||||||
failedRows.push(
|
setResult(data)
|
||||||
t("knowledge.importRowFailed", {
|
await onImported()
|
||||||
row: row.rowNo,
|
toast.success(
|
||||||
message: error instanceof Error ? error.message : t("knowledge.importFailed"),
|
t("knowledge.importFAQResultToast", {
|
||||||
})
|
created: data.created,
|
||||||
);
|
updated: data.updated,
|
||||||
}
|
skipped: data.skipped,
|
||||||
|
failed: data.failed,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if (data.failed === 0) {
|
||||||
|
resetState()
|
||||||
|
onOpenChange(false)
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
await onImported();
|
toast.error(error instanceof Error ? error.message : t("knowledge.importFailed"))
|
||||||
|
|
||||||
if (successCount > 0) {
|
|
||||||
toast.success(t("knowledge.importSuccess", { count: successCount }));
|
|
||||||
}
|
|
||||||
if (failedRows.length > 0) {
|
|
||||||
toast.error(t("knowledge.importSomeFailed", { count: failedRows.length }));
|
|
||||||
setWarnings((current) => [...current, ...failedRows]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
resetState();
|
|
||||||
onOpenChange(false);
|
|
||||||
} finally {
|
} finally {
|
||||||
onImportingChange(false);
|
onImportingChange(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,33 +129,49 @@ export function FAQImportDialog({
|
|||||||
open={open}
|
open={open}
|
||||||
onOpenChange={(nextOpen) => {
|
onOpenChange={(nextOpen) => {
|
||||||
if (!nextOpen && !importing) {
|
if (!nextOpen && !importing) {
|
||||||
resetState();
|
resetState()
|
||||||
}
|
}
|
||||||
onOpenChange(nextOpen);
|
onOpenChange(nextOpen)
|
||||||
}}
|
}}
|
||||||
title={t("knowledge.importFAQTitle")}
|
title={t("knowledge.importFAQTitle")}
|
||||||
description={t("knowledge.importFAQDescription")}
|
description={t("knowledge.importFAQDescription")}
|
||||||
size="lg"
|
size="lg"
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button type="button" variant="outline" onClick={() => downloadTemplate()}>
|
<Button type="button" variant="outline" onClick={() => void handleDownloadTemplate()}>
|
||||||
<DownloadIcon className="size-4" />
|
<DownloadIcon className="size-4" />
|
||||||
{t("knowledge.downloadTemplate")}
|
{t("knowledge.downloadTemplate")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={importing}>
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={importing}>
|
||||||
{t("knowledge.cancel")}
|
{t("knowledge.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" onClick={() => void handleImport()} disabled={importing || rows.length === 0}>
|
<Button type="button" onClick={() => void handleImport()} disabled={importing || !file}>
|
||||||
{importing
|
{importing ? t("knowledge.importing") : t("knowledge.startImport")}
|
||||||
? t("knowledge.importing")
|
|
||||||
: rows.length > 0
|
|
||||||
? t("knowledge.startImportWithCount", { count: rows.length })
|
|
||||||
: t("knowledge.startImport")}
|
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<FieldGroup>
|
<FieldGroup>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>{t("knowledge.importMode")}</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<OptionCombobox
|
||||||
|
value={mode}
|
||||||
|
options={modeOptions}
|
||||||
|
placeholder={t("knowledge.selectImportMode")}
|
||||||
|
searchPlaceholder={t("knowledge.searchImportMode")}
|
||||||
|
emptyText={t("knowledge.emptyImportMode")}
|
||||||
|
disabled={importing}
|
||||||
|
onChange={(value) => setMode(value as KnowledgeFAQImportMode)}
|
||||||
|
/>
|
||||||
|
<FieldDescription>
|
||||||
|
{mode === "overwrite"
|
||||||
|
? t("knowledge.importModeOverwriteDescription")
|
||||||
|
: t("knowledge.importModeAppendDescription")}
|
||||||
|
</FieldDescription>
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel htmlFor="faq-import-file">{t("knowledge.importFile")}</FieldLabel>
|
<FieldLabel htmlFor="faq-import-file">{t("knowledge.importFile")}</FieldLabel>
|
||||||
<FieldContent>
|
<FieldContent>
|
||||||
@@ -336,79 +180,76 @@ export function FAQImportDialog({
|
|||||||
id="faq-import-file"
|
id="faq-import-file"
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept=".csv,text/csv,.txt"
|
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||||
onChange={(event) => void handleFileChange(event)}
|
disabled={importing}
|
||||||
|
onChange={handleFileChange}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
disabled={importing}
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
>
|
>
|
||||||
<FileUpIcon className="size-4" />
|
<FileUpIcon className="size-4" />
|
||||||
{t("knowledge.chooseFile")}
|
{t("knowledge.chooseFile")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<FieldDescription>
|
<FieldDescription>{t("knowledge.importFileDescription")}</FieldDescription>
|
||||||
{t("knowledge.importFileDescription")}
|
|
||||||
</FieldDescription>
|
|
||||||
</FieldContent>
|
</FieldContent>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
{fileName ? (
|
{file ? (
|
||||||
<div className="rounded-md border bg-muted/20 px-3 py-2 text-sm">
|
<div className="rounded-md border bg-muted/20 px-3 py-2 text-sm">
|
||||||
{t("knowledge.currentFile", { name: fileName })}
|
{t("knowledge.currentFile", { name: file.name })}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{warnings.length > 0 ? (
|
<div className="rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">
|
||||||
<div className="rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">
|
<div className="mb-2 flex items-center gap-2 font-medium">
|
||||||
<div className="mb-2 flex items-center gap-2 font-medium">
|
<InfoIcon className="size-4" />
|
||||||
<InfoIcon className="size-4" />
|
{t("knowledge.importHint")}
|
||||||
{t("knowledge.importHint")}
|
|
||||||
</div>
|
|
||||||
<ul className="space-y-1">
|
|
||||||
{warnings.map((item, index) => (
|
|
||||||
<li key={`${item}-${index}`}>{item}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
<div>{t("knowledge.importXlsxHint")}</div>
|
||||||
|
|
||||||
<div className="rounded-md border">
|
|
||||||
<div className="border-b px-4 py-3 text-sm font-medium">
|
|
||||||
{t("knowledge.importPreview")}
|
|
||||||
</div>
|
|
||||||
{previewRows.length > 0 ? (
|
|
||||||
<ScrollArea className="max-h-80">
|
|
||||||
<div className="divide-y">
|
|
||||||
{previewRows.map((row) => (
|
|
||||||
<div key={row.rowNo} className="space-y-2 px-4 py-3 text-sm">
|
|
||||||
<div>
|
|
||||||
<span className="text-muted-foreground">{t("knowledge.rowNumber", { row: row.rowNo })}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="font-medium">{row.question}</div>
|
|
||||||
<div className="mt-1 whitespace-pre-wrap text-muted-foreground">
|
|
||||||
{row.answer}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-muted-foreground">
|
|
||||||
{t("knowledge.similarQuestionShort", {
|
|
||||||
value: row.similarQuestions.length > 0 ? row.similarQuestions.join(" / ") : t("knowledge.none"),
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<div className="text-muted-foreground">{t("knowledge.remark")}:{row.remark || t("knowledge.none")}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
) : (
|
|
||||||
<div className="px-4 py-12 text-center text-sm text-muted-foreground">
|
|
||||||
{t("knowledge.previewAfterUpload")}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{result ? (
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<div className="border-b px-4 py-3 text-sm font-medium">
|
||||||
|
{t("knowledge.importResult")}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3 p-4 text-sm sm:grid-cols-5">
|
||||||
|
<ImportMetric label={t("knowledge.importTotal")} value={result.total} />
|
||||||
|
<ImportMetric label={t("knowledge.importCreated")} value={result.created} />
|
||||||
|
<ImportMetric label={t("knowledge.importUpdated")} value={result.updated} />
|
||||||
|
<ImportMetric label={t("knowledge.importSkipped")} value={result.skipped} />
|
||||||
|
<ImportMetric label={t("knowledge.importFailedCount")} value={result.failed} />
|
||||||
|
</div>
|
||||||
|
{result.errors.length > 0 ? (
|
||||||
|
<ScrollArea className="max-h-64 border-t">
|
||||||
|
<ul className="divide-y text-sm">
|
||||||
|
{result.errors.map((item, index) => (
|
||||||
|
<li key={`${item.row}-${index}`} className="px-4 py-2">
|
||||||
|
{t("knowledge.importRowFailed", {
|
||||||
|
row: item.row,
|
||||||
|
message: item.message,
|
||||||
|
})}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</ScrollArea>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
</ProjectDialog>
|
</ProjectDialog>
|
||||||
);
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImportMetric({ label, value }: { label: string; value: number }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md bg-muted/30 px-3 py-2">
|
||||||
|
<div className="text-xs text-muted-foreground">{label}</div>
|
||||||
|
<div className="mt-1 text-lg font-semibold">{value}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
} from "@/components/ui/sheet"
|
} from "@/components/ui/sheet"
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
import type { KnowledgeBase } from "@/lib/api/admin"
|
import type { KnowledgeBase } from "@/lib/api/admin"
|
||||||
|
import { exportKnowledgeFAQs } from "@/lib/api/admin"
|
||||||
import { useI18n } from "@/i18n/provider"
|
import { useI18n } from "@/i18n/provider"
|
||||||
import {
|
import {
|
||||||
Bug,
|
Bug,
|
||||||
@@ -18,9 +19,11 @@ import {
|
|||||||
PanelLeftCloseIcon,
|
PanelLeftCloseIcon,
|
||||||
PanelLeftOpenIcon,
|
PanelLeftOpenIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
RefreshCwIcon
|
RefreshCwIcon,
|
||||||
|
UploadIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
|
import { toast } from "sonner"
|
||||||
import { DebugPanel } from "./_components/debug-panel"
|
import { DebugPanel } from "./_components/debug-panel"
|
||||||
import { DocumentList, type DocumentListActionState } from "./_components/document-list"
|
import { DocumentList, type DocumentListActionState } from "./_components/document-list"
|
||||||
import { FAQList, type FAQListActionState } from "./_components/faq-list"
|
import { FAQList, type FAQListActionState } from "./_components/faq-list"
|
||||||
@@ -35,8 +38,24 @@ export default function DashboardKnowledgeDocumentsPage() {
|
|||||||
const [activeTab, setActiveTab] = useState("documents")
|
const [activeTab, setActiveTab] = useState("documents")
|
||||||
const [documentActionState, setDocumentActionState] = useState<DocumentListActionState | null>(null)
|
const [documentActionState, setDocumentActionState] = useState<DocumentListActionState | null>(null)
|
||||||
const [faqActionState, setFAQActionState] = useState<FAQListActionState | null>(null)
|
const [faqActionState, setFAQActionState] = useState<FAQListActionState | null>(null)
|
||||||
|
const [exportingFAQ, setExportingFAQ] = useState(false)
|
||||||
const isFAQKnowledgeBase = selectedKnowledgeBase?.knowledgeType === "faq"
|
const isFAQKnowledgeBase = selectedKnowledgeBase?.knowledgeType === "faq"
|
||||||
|
|
||||||
|
async function handleExportFAQ() {
|
||||||
|
if (!selectedKnowledgeBase || exportingFAQ) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setExportingFAQ(true)
|
||||||
|
try {
|
||||||
|
await exportKnowledgeFAQs(selectedKnowledgeBase.id)
|
||||||
|
toast.success(t("knowledge.exportFAQSuccess"))
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("knowledge.exportFAQFailed"))
|
||||||
|
} finally {
|
||||||
|
setExportingFAQ(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[calc(100vh-4rem)]">
|
<div className="flex h-[calc(100vh-4rem)]">
|
||||||
<div
|
<div
|
||||||
@@ -142,7 +161,17 @@ export default function DashboardKnowledgeDocumentsPage() {
|
|||||||
disabled={faqActionState.importing}
|
disabled={faqActionState.importing}
|
||||||
aria-label={t("knowledge.importFAQ")}
|
aria-label={t("knowledge.importFAQ")}
|
||||||
>
|
>
|
||||||
<DownloadIcon className="size-4" />
|
<UploadIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-7"
|
||||||
|
onClick={() => void handleExportFAQ()}
|
||||||
|
disabled={exportingFAQ}
|
||||||
|
aria-label={t("knowledge.exportFAQ")}
|
||||||
|
>
|
||||||
|
<DownloadIcon className={exportingFAQ ? "size-4 animate-pulse" : "size-4"} />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
+53
-1
@@ -1,5 +1,5 @@
|
|||||||
import { readSession } from "@/lib/auth"
|
import { readSession } from "@/lib/auth"
|
||||||
import { request } from "@/lib/api/client"
|
import { request, requestBlob } from "@/lib/api/client"
|
||||||
import { createWebSocketBaseUrl } from "@/lib/api/websocket"
|
import { createWebSocketBaseUrl } from "@/lib/api/websocket"
|
||||||
import { translateCurrentMessage } from "@/i18n/messages"
|
import { translateCurrentMessage } from "@/i18n/messages"
|
||||||
|
|
||||||
@@ -1524,6 +1524,22 @@ export type UpdateKnowledgeFAQPayload = CreateKnowledgeFAQPayload & {
|
|||||||
id: number
|
id: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type KnowledgeFAQImportMode = "append" | "overwrite"
|
||||||
|
|
||||||
|
export type KnowledgeFAQImportError = {
|
||||||
|
row: number
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type KnowledgeFAQImportResult = {
|
||||||
|
total: number
|
||||||
|
created: number
|
||||||
|
updated: number
|
||||||
|
skipped: number
|
||||||
|
failed: number
|
||||||
|
errors: KnowledgeFAQImportError[]
|
||||||
|
}
|
||||||
|
|
||||||
export function fetchKnowledgeBases(
|
export function fetchKnowledgeBases(
|
||||||
query?: Record<string, string | number | undefined>
|
query?: Record<string, string | number | undefined>
|
||||||
) {
|
) {
|
||||||
@@ -1652,6 +1668,42 @@ export function deleteKnowledgeFAQ(id: number) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function downloadBlob(blob: Blob, filename: string) {
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement("a")
|
||||||
|
link.href = url
|
||||||
|
link.download = filename
|
||||||
|
link.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadKnowledgeFAQImportTemplate() {
|
||||||
|
const result = await requestBlob("/api/dashboard/knowledge-faq/import_template")
|
||||||
|
downloadBlob(result.blob, result.filename || "knowledge-faq-import-template.xlsx")
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportKnowledgeFAQs(knowledgeBaseId: number) {
|
||||||
|
const result = await requestBlob(
|
||||||
|
`/api/dashboard/knowledge-faq/export${toQueryString({ knowledgeBaseId })}`
|
||||||
|
)
|
||||||
|
downloadBlob(result.blob, result.filename || `knowledge-faq-${knowledgeBaseId}.xlsx`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function importKnowledgeFAQs(payload: {
|
||||||
|
knowledgeBaseId: number
|
||||||
|
mode: KnowledgeFAQImportMode
|
||||||
|
file: File
|
||||||
|
}) {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.set("knowledgeBaseId", String(payload.knowledgeBaseId))
|
||||||
|
formData.set("mode", payload.mode)
|
||||||
|
formData.set("file", payload.file)
|
||||||
|
return request<KnowledgeFAQImportResult>("/api/dashboard/knowledge-faq/import", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function buildKnowledgeFAQIndex(faqId: number) {
|
export function buildKnowledgeFAQIndex(faqId: number) {
|
||||||
return request<void>("/api/dashboard/knowledge-retrieve/build", {
|
return request<void>("/api/dashboard/knowledge-retrieve/build", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
+65
-9
@@ -18,6 +18,11 @@ type RequestOptions = RequestInit & {
|
|||||||
onResponse?: (response: Response) => void
|
onResponse?: (response: Response) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type BlobResponse = {
|
||||||
|
blob: Blob
|
||||||
|
filename: string
|
||||||
|
}
|
||||||
|
|
||||||
async function parseResult<T>(response: Response) {
|
async function parseResult<T>(response: Response) {
|
||||||
const payload = (await response.json()) as JsonResult<T>
|
const payload = (await response.json()) as JsonResult<T>
|
||||||
if (!response.ok || !payload.success) {
|
if (!response.ok || !payload.success) {
|
||||||
@@ -31,13 +36,7 @@ async function parseResult<T>(response: Response) {
|
|||||||
return payload.data
|
return payload.data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function request<T>(
|
function buildRequestHeaders(headers: HeadersInit | undefined, skipAuth?: boolean, body?: BodyInit | null) {
|
||||||
path: string,
|
|
||||||
options: RequestOptions = {}
|
|
||||||
): Promise<T> {
|
|
||||||
const { headers, skipAuth, baseUrl, onResponse, ...rest } = options
|
|
||||||
delete (rest as RequestOptions).baseUrl
|
|
||||||
delete (rest as RequestOptions).onResponse
|
|
||||||
const session = readSession()
|
const session = readSession()
|
||||||
const authHeaders = new Headers(headers)
|
const authHeaders = new Headers(headers)
|
||||||
|
|
||||||
@@ -46,14 +45,37 @@ export async function request<T>(
|
|||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
!authHeaders.has("Content-Type") &&
|
!authHeaders.has("Content-Type") &&
|
||||||
rest.body &&
|
body &&
|
||||||
!(typeof FormData !== "undefined" && rest.body instanceof FormData)
|
!(typeof FormData !== "undefined" && body instanceof FormData)
|
||||||
) {
|
) {
|
||||||
authHeaders.set("Content-Type", "application/json")
|
authHeaders.set("Content-Type", "application/json")
|
||||||
}
|
}
|
||||||
const locale = readStoredLocale()
|
const locale = readStoredLocale()
|
||||||
authHeaders.set("Accept-Language", locale)
|
authHeaders.set("Accept-Language", locale)
|
||||||
authHeaders.set("X-Locale", locale)
|
authHeaders.set("X-Locale", locale)
|
||||||
|
return authHeaders
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFilename(contentDisposition: string | null) {
|
||||||
|
if (!contentDisposition) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
const utf8Match = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i)
|
||||||
|
if (utf8Match?.[1]) {
|
||||||
|
return decodeURIComponent(utf8Match[1])
|
||||||
|
}
|
||||||
|
const match = contentDisposition.match(/filename="?([^";]+)"?/i)
|
||||||
|
return match?.[1] ? decodeURIComponent(match[1]) : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function request<T>(
|
||||||
|
path: string,
|
||||||
|
options: RequestOptions = {}
|
||||||
|
): Promise<T> {
|
||||||
|
const { headers, skipAuth, baseUrl, onResponse, ...rest } = options
|
||||||
|
delete (rest as RequestOptions).baseUrl
|
||||||
|
delete (rest as RequestOptions).onResponse
|
||||||
|
const authHeaders = buildRequestHeaders(headers, skipAuth, rest.body)
|
||||||
|
|
||||||
const requestBaseUrl = baseUrl !== undefined ? baseUrl : API_BASE_URL
|
const requestBaseUrl = baseUrl !== undefined ? baseUrl : API_BASE_URL
|
||||||
const response = await fetch(`${requestBaseUrl}${path}`, {
|
const response = await fetch(`${requestBaseUrl}${path}`, {
|
||||||
@@ -65,3 +87,37 @@ export async function request<T>(
|
|||||||
|
|
||||||
return parseResult<T>(response)
|
return parseResult<T>(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function requestBlob(
|
||||||
|
path: string,
|
||||||
|
options: RequestOptions = {}
|
||||||
|
): Promise<BlobResponse> {
|
||||||
|
const { headers, skipAuth, baseUrl, onResponse, ...rest } = options
|
||||||
|
delete (rest as RequestOptions).baseUrl
|
||||||
|
delete (rest as RequestOptions).onResponse
|
||||||
|
const authHeaders = buildRequestHeaders(headers, skipAuth, rest.body)
|
||||||
|
const requestBaseUrl = baseUrl !== undefined ? baseUrl : API_BASE_URL
|
||||||
|
const response = await fetch(`${requestBaseUrl}${path}`, {
|
||||||
|
...rest,
|
||||||
|
headers: authHeaders,
|
||||||
|
cache: "no-store",
|
||||||
|
})
|
||||||
|
onResponse?.(response)
|
||||||
|
|
||||||
|
const contentType = response.headers.get("Content-Type") ?? ""
|
||||||
|
if (contentType.includes("application/json")) {
|
||||||
|
try {
|
||||||
|
await parseResult<never>(response)
|
||||||
|
} catch (error) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
throw new Error(translateCurrentMessage("api.requestFailed"))
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(response.statusText || translateCurrentMessage("api.requestFailed"))
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
blob: await response.blob(),
|
||||||
|
filename: parseFilename(response.headers.get("Content-Disposition")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+23
-15
@@ -1894,32 +1894,40 @@
|
|||||||
"answer": "Answer",
|
"answer": "Answer",
|
||||||
"answerPlaceholder": "Enter the FAQ answer",
|
"answerPlaceholder": "Enter the FAQ answer",
|
||||||
"similarQuestionsPlaceholder": "One similar question per line",
|
"similarQuestionsPlaceholder": "One similar question per line",
|
||||||
"fileEmpty": "The file is empty.",
|
|
||||||
"missingFAQColumns": "The import template must include question and answer columns.",
|
|
||||||
"skipRowMissingFAQ": "Row {row} is missing a question or answer and was skipped.",
|
|
||||||
"importNoRows": "No FAQ rows to import.",
|
|
||||||
"parsedFAQRows": "{count} FAQ rows parsed.",
|
|
||||||
"parseImportFailed": "Could not parse the import file.",
|
|
||||||
"importRowFailed": "Row {row}: {message}",
|
"importRowFailed": "Row {row}: {message}",
|
||||||
"importFailed": "Import failed.",
|
"importFailed": "Import failed.",
|
||||||
"importSuccess": "{count} FAQs imported.",
|
|
||||||
"importSomeFailed": "{count} FAQs failed to import.",
|
|
||||||
"importFAQTitle": "Import FAQ",
|
"importFAQTitle": "Import FAQ",
|
||||||
"importFAQDescription": "Upload a CSV file to import FAQs in bulk. Required columns: question and answer. Separate similarQuestions with | or line breaks.",
|
"importFAQDescription": "Upload an Excel file to import FAQs in bulk. Required columns: primary question and answer. Put one similar question per line in the same cell.",
|
||||||
"downloadTemplate": "Download Template",
|
"downloadTemplate": "Download Template",
|
||||||
|
"downloadTemplateFailed": "Could not download the template.",
|
||||||
"importing": "Importing...",
|
"importing": "Importing...",
|
||||||
"startImport": "Start Import",
|
"startImport": "Start Import",
|
||||||
"startImportWithCount": "Start Import ({count})",
|
|
||||||
"importFile": "Import File",
|
"importFile": "Import File",
|
||||||
"chooseFile": "Choose File",
|
"chooseFile": "Choose File",
|
||||||
"importFileDescription": "Supports UTF-8 CSV or tab-delimited text files.",
|
"importFileDescription": "Only .xlsx files are supported.",
|
||||||
"currentFile": "Current file: {name}",
|
"currentFile": "Current file: {name}",
|
||||||
"importHint": "Import Notes",
|
"importHint": "Import Notes",
|
||||||
"importPreview": "Import Preview",
|
"importXlsxHint": "The template columns are primary question, answer, similar questions, and remark. Put one similar question per line inside the cell.",
|
||||||
"rowNumber": "Row {row}",
|
"importXlsxOnly": "Only .xlsx files are supported.",
|
||||||
"similarQuestionShort": "Similar: {value}",
|
"importMode": "Import Mode",
|
||||||
|
"selectImportMode": "Select import mode",
|
||||||
|
"searchImportMode": "Search import mode",
|
||||||
|
"emptyImportMode": "No import modes found",
|
||||||
|
"importModeAppend": "Append Import",
|
||||||
|
"importModeOverwrite": "Overwrite Import",
|
||||||
|
"importModeAppendDescription": "Only creates primary questions that do not already exist. Existing primary questions are skipped.",
|
||||||
|
"importModeOverwriteDescription": "Matches by primary question and updates existing FAQs. Missing primary questions are created.",
|
||||||
|
"importResult": "Import Result",
|
||||||
|
"importTotal": "Total",
|
||||||
|
"importCreated": "Created",
|
||||||
|
"importUpdated": "Updated",
|
||||||
|
"importSkipped": "Skipped",
|
||||||
|
"importFailedCount": "Failed",
|
||||||
|
"importFAQResultToast": "Created {created}, updated {updated}, skipped {skipped}, failed {failed}",
|
||||||
|
"exportFAQ": "Export FAQ",
|
||||||
|
"exportFAQSuccess": "FAQ export download started.",
|
||||||
|
"exportFAQFailed": "Could not export FAQs.",
|
||||||
"none": "None",
|
"none": "None",
|
||||||
"previewAfterUpload": "Upload a file to preview the first 5 FAQs.",
|
|
||||||
"allChannels": "All channels",
|
"allChannels": "All channels",
|
||||||
"channelIM": "Support chat",
|
"channelIM": "Support chat",
|
||||||
"channelAgentAssist": "Agent assist",
|
"channelAgentAssist": "Agent assist",
|
||||||
|
|||||||
+24
-16
@@ -1893,33 +1893,41 @@
|
|||||||
"questionPlaceholder": "请输入标准问题",
|
"questionPlaceholder": "请输入标准问题",
|
||||||
"answer": "答案",
|
"answer": "答案",
|
||||||
"answerPlaceholder": "请输入FAQ答案",
|
"answerPlaceholder": "请输入FAQ答案",
|
||||||
"similarQuestionsPlaceholder": "一行一个相似问题"
|
"similarQuestionsPlaceholder": "一行一个相似问题",
|
||||||
,"fileEmpty": "文件内容为空",
|
|
||||||
"missingFAQColumns": "导入模板缺少 question/answer 列",
|
|
||||||
"skipRowMissingFAQ": "第 {row} 行缺少问题或答案,已跳过",
|
|
||||||
"importNoRows": "没有可导入的FAQ记录",
|
|
||||||
"parsedFAQRows": "已解析 {count} 条FAQ",
|
|
||||||
"parseImportFailed": "解析导入文件失败",
|
|
||||||
"importRowFailed": "第 {row} 行:{message}",
|
"importRowFailed": "第 {row} 行:{message}",
|
||||||
"importFailed": "导入失败",
|
"importFailed": "导入失败",
|
||||||
"importSuccess": "成功导入 {count} 条FAQ",
|
|
||||||
"importSomeFailed": "有 {count} 条FAQ导入失败",
|
|
||||||
"importFAQTitle": "导入FAQ",
|
"importFAQTitle": "导入FAQ",
|
||||||
"importFAQDescription": "上传 CSV 文件批量导入 FAQ。必填列为 question、answer;similarQuestions 使用 | 或换行分隔。",
|
"importFAQDescription": "上传 Excel 文件批量导入 FAQ。必填列为标准问题和答案;相似问在同一个单元格内一行一个。",
|
||||||
"downloadTemplate": "下载模板",
|
"downloadTemplate": "下载模板",
|
||||||
|
"downloadTemplateFailed": "下载模板失败",
|
||||||
"importing": "导入中...",
|
"importing": "导入中...",
|
||||||
"startImport": "开始导入",
|
"startImport": "开始导入",
|
||||||
"startImportWithCount": "开始导入 ({count})",
|
|
||||||
"importFile": "导入文件",
|
"importFile": "导入文件",
|
||||||
"chooseFile": "选择文件",
|
"chooseFile": "选择文件",
|
||||||
"importFileDescription": "支持 UTF-8 编码的 CSV 或制表符文本文件。",
|
"importFileDescription": "仅支持 .xlsx 文件。",
|
||||||
"currentFile": "当前文件:{name}",
|
"currentFile": "当前文件:{name}",
|
||||||
"importHint": "导入提示",
|
"importHint": "导入提示",
|
||||||
"importPreview": "导入预览",
|
"importXlsxHint": "模板列为标准问题、答案、相似问、备注;相似问请在单元格内一行填写一个。",
|
||||||
"rowNumber": "第 {row} 行",
|
"importXlsxOnly": "仅支持 .xlsx 文件",
|
||||||
"similarQuestionShort": "相似问:{value}",
|
"importMode": "导入模式",
|
||||||
|
"selectImportMode": "请选择导入模式",
|
||||||
|
"searchImportMode": "搜索导入模式",
|
||||||
|
"emptyImportMode": "没有可用的导入模式",
|
||||||
|
"importModeAppend": "追加导入",
|
||||||
|
"importModeOverwrite": "覆盖导入",
|
||||||
|
"importModeAppendDescription": "只新增不存在的标准问题;已存在的标准问题会跳过。",
|
||||||
|
"importModeOverwriteDescription": "按标准问题匹配并更新已存在 FAQ;不存在的标准问题会新增。",
|
||||||
|
"importResult": "导入结果",
|
||||||
|
"importTotal": "总行数",
|
||||||
|
"importCreated": "新增",
|
||||||
|
"importUpdated": "更新",
|
||||||
|
"importSkipped": "跳过",
|
||||||
|
"importFailedCount": "失败",
|
||||||
|
"importFAQResultToast": "新增 {created},更新 {updated},跳过 {skipped},失败 {failed}",
|
||||||
|
"exportFAQ": "导出FAQ",
|
||||||
|
"exportFAQSuccess": "FAQ 导出已开始下载",
|
||||||
|
"exportFAQFailed": "导出 FAQ 失败",
|
||||||
"none": "无",
|
"none": "无",
|
||||||
"previewAfterUpload": "上传文件后可预览前 5 条FAQ",
|
|
||||||
"allChannels": "全部渠道",
|
"allChannels": "全部渠道",
|
||||||
"channelIM": "客服会话",
|
"channelIM": "客服会话",
|
||||||
"channelAgentAssist": "客服助手",
|
"channelAgentAssist": "客服助手",
|
||||||
|
|||||||
Reference in New Issue
Block a user