refactor: support i18n

This commit is contained in:
mlogclub
2026-05-25 12:06:15 +08:00
parent 309ac1fe9e
commit 988f55c80d
179 changed files with 10968 additions and 3763 deletions
+3 -2
View File
@@ -19,8 +19,10 @@ require (
github.com/mlogclub/codegen v1.0.3 github.com/mlogclub/codegen v1.0.3
github.com/mlogclub/simple v1.2.40 github.com/mlogclub/simple v1.2.40
github.com/modelcontextprotocol/go-sdk v1.4.1 github.com/modelcontextprotocol/go-sdk v1.4.1
github.com/nicksnyder/go-i18n/v2 v2.6.1
github.com/openai/openai-go/v3 v3.28.0 github.com/openai/openai-go/v3 v3.28.0
github.com/panjf2000/ants/v2 v2.12.0 github.com/panjf2000/ants/v2 v2.12.0
github.com/pelletier/go-toml/v2 v2.2.4
github.com/qdrant/go-client v1.17.1 github.com/qdrant/go-client v1.17.1
github.com/robfig/cron/v3 v3.0.1 github.com/robfig/cron/v3 v3.0.1
github.com/silenceper/wechat/v2 v2.1.12 github.com/silenceper/wechat/v2 v2.1.12
@@ -29,6 +31,7 @@ require (
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
golang.org/x/text v0.35.0
golang.org/x/tools v0.42.0 golang.org/x/tools v0.42.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/mysql v1.5.7 gorm.io/driver/mysql v1.5.7
@@ -79,7 +82,6 @@ require (
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/nikolalohinski/gonja v1.5.3 // indirect github.com/nikolalohinski/gonja v1.5.3 // indirect
github.com/nxadm/tail v1.4.11 // indirect github.com/nxadm/tail v1.4.11 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pkg/errors v0.9.1 // indirect github.com/pkg/errors v0.9.1 // indirect
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
@@ -104,7 +106,6 @@ require (
golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.20.0 // indirect golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect
golang.org/x/time v0.14.0 // indirect golang.org/x/time v0.14.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect
google.golang.org/grpc v1.78.0 // indirect google.golang.org/grpc v1.78.0 // indirect
+6
View File
@@ -1,3 +1,5 @@
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/airbrake/gobrake v3.6.1+incompatible/go.mod h1:wM4gu3Cn0W0K7GUuVWnlXZU11AGBXMILnrdOU8Kn00o= github.com/airbrake/gobrake v3.6.1+incompatible/go.mod h1:wM4gu3Cn0W0K7GUuVWnlXZU11AGBXMILnrdOU8Kn00o=
github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a h1:HbKu58rmZpUGpz5+4FfNmIU+FmZg2P3Xaj2v2bfNWmk= github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a h1:HbKu58rmZpUGpz5+4FfNmIU+FmZg2P3Xaj2v2bfNWmk=
github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc= github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc=
@@ -195,6 +197,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms= github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms=
github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ=
github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA=
github.com/nikolalohinski/gonja v1.5.3 h1:GsA+EEaZDZPGJ8JtpeGN78jidhOlxeJROpqMT9fTj9c= github.com/nikolalohinski/gonja v1.5.3 h1:GsA+EEaZDZPGJ8JtpeGN78jidhOlxeJROpqMT9fTj9c=
github.com/nikolalohinski/gonja v1.5.3/go.mod h1:RmjwxNiXAEqcq1HeK5SSMmqFJvKOfTfXhkJv6YBtPa4= github.com/nikolalohinski/gonja v1.5.3/go.mod h1:RmjwxNiXAEqcq1HeK5SSMmqFJvKOfTfXhkJv6YBtPa4=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
@@ -318,6 +322,8 @@ go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4Len
go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
+3 -1
View File
@@ -12,6 +12,7 @@ import (
"cs-agent/internal/pkg/config" "cs-agent/internal/pkg/config"
"cs-agent/internal/pkg/ginx" "cs-agent/internal/pkg/ginx"
"cs-agent/internal/pkg/httpx" "cs-agent/internal/pkg/httpx"
"cs-agent/internal/pkg/i18nx"
"cs-agent/internal/services" "cs-agent/internal/services"
webspa "cs-agent/web" webspa "cs-agent/web"
@@ -32,6 +33,7 @@ func NewServer() (*gin.Engine, error) {
app.Use(gin.Recovery()) app.Use(gin.Recovery())
app.Use(requestLogMiddleware()) app.Use(requestLogMiddleware())
app.Use(maxBodySizeMiddleware(cfg.Storage.MaxRequestBodySizeBytes())) app.Use(maxBodySizeMiddleware(cfg.Storage.MaxRequestBodySizeBytes()))
app.Use(i18nx.Middleware())
addRouter(app) addRouter(app)
@@ -51,7 +53,7 @@ func NewServer() (*gin.Engine, error) {
}, },
NotFoundPrefixes: notFoundPrefixes, NotFoundPrefixes: notFoundPrefixes,
NotFoundHandler: func(ctx *gin.Context) { NotFoundHandler: func(ctx *gin.Context) {
httpx.WriteHttpStatusJSON(ctx, http.StatusNotFound, web.JsonErrorCode(http.StatusNotFound, "Not found")) httpx.WriteHttpStatusJSON(ctx, http.StatusNotFound, web.JsonErrorCode(http.StatusNotFound, i18nx.T(ctx, "error.notFound", nil)))
}, },
}) })
+51 -4
View File
@@ -6,6 +6,7 @@ import (
"cs-agent/internal/models" "cs-agent/internal/models"
"cs-agent/internal/pkg/dto/response" "cs-agent/internal/pkg/dto/response"
"cs-agent/internal/pkg/enums" "cs-agent/internal/pkg/enums"
"cs-agent/internal/pkg/i18nx"
"cs-agent/internal/pkg/utils" "cs-agent/internal/pkg/utils"
"cs-agent/internal/services" "cs-agent/internal/services"
@@ -13,6 +14,10 @@ import (
) )
func BuildConversation(item *models.Conversation) response.ConversationResponse { func BuildConversation(item *models.Conversation) response.ConversationResponse {
return BuildConversationWithLocale(item, i18nx.LocaleZhCN)
}
func BuildConversationWithLocale(item *models.Conversation, locale string) response.ConversationResponse {
agentReadState, customerReadState := services.ConversationReadStateService.GetConversationReadStates(item.ID) agentReadState, customerReadState := services.ConversationReadStateService.GetConversationReadStates(item.ID)
ret := response.ConversationResponse{ ret := response.ConversationResponse{
ID: item.ID, ID: item.ID,
@@ -28,7 +33,7 @@ func BuildConversation(item *models.Conversation) response.ConversationResponse
LastMessageID: item.LastMessageID, LastMessageID: item.LastMessageID,
LastMessageAt: utils.FormatTime(item.LastMessageAt), LastMessageAt: utils.FormatTime(item.LastMessageAt),
LastActiveAt: utils.FormatTime(item.LastActiveAt), LastActiveAt: utils.FormatTime(item.LastActiveAt),
LastMessageSummary: item.LastMessageSummary, LastMessageSummary: localizeConversationSummary(locale, item.LastMessageSummary),
CustomerUnreadCount: item.CustomerUnreadCount, CustomerUnreadCount: item.CustomerUnreadCount,
AgentUnreadCount: item.AgentUnreadCount, AgentUnreadCount: item.AgentUnreadCount,
CustomerLastReadMessageID: readStateMessageID(customerReadState), CustomerLastReadMessageID: readStateMessageID(customerReadState),
@@ -68,6 +73,26 @@ func BuildConversation(item *models.Conversation) response.ConversationResponse
return ret return ret
} }
func localizeConversationSummary(locale string, summary string) string {
if i18nx.NormalizeLocale(locale) != i18nx.LocaleEnUS {
return summary
}
switch {
case summary == "[图片]":
return "[Image]"
case strings.HasPrefix(summary, "[图片] "):
return "[Image] " + strings.TrimPrefix(summary, "[图片] ")
case summary == "[附件]":
return "[Attachment]"
case strings.HasPrefix(summary, "[附件] "):
return "[Attachment] " + strings.TrimPrefix(summary, "[附件] ")
case summary == "该消息已撤回":
return "This message was recalled."
default:
return summary
}
}
func BuildParticipantResponses(conversationID int64) []response.ConversationParticipantResponse { func BuildParticipantResponses(conversationID int64) []response.ConversationParticipantResponse {
list := services.ConversationParticipantService.Find(sqls.NewCnd().Eq("conversation_id", conversationID).Asc("id")) list := services.ConversationParticipantService.Find(sqls.NewCnd().Eq("conversation_id", conversationID).Asc("id"))
if len(list) == 0 { if len(list) == 0 {
@@ -89,6 +114,10 @@ func BuildParticipantResponses(conversationID int64) []response.ConversationPart
} }
func BuildMessages(list []models.Message) []response.MessageResponse { func BuildMessages(list []models.Message) []response.MessageResponse {
return BuildMessagesWithLocale(list, i18nx.LocaleZhCN)
}
func BuildMessagesWithLocale(list []models.Message, locale string) []response.MessageResponse {
if len(list) == 0 { if len(list) == 0 {
return nil return nil
} }
@@ -97,17 +126,25 @@ func BuildMessages(list []models.Message) []response.MessageResponse {
agentProfiles := collectAgentProfilesByMessages(list) agentProfiles := collectAgentProfilesByMessages(list)
ret := make([]response.MessageResponse, 0, len(list)) ret := make([]response.MessageResponse, 0, len(list))
for i := range list { for i := range list {
ret = append(ret, BuildMessageWithReadStates(&list[i], agentReadState, customerReadState, aiSenderNames, userSenderNames, agentProfiles)) ret = append(ret, BuildMessageWithReadStatesAndLocale(&list[i], agentReadState, customerReadState, aiSenderNames, userSenderNames, agentProfiles, locale))
} }
return ret return ret
} }
func BuildMessage(item *models.Message) response.MessageResponse { func BuildMessage(item *models.Message) response.MessageResponse {
return BuildMessageWithLocale(item, i18nx.LocaleZhCN)
}
func BuildMessageWithLocale(item *models.Message, locale string) response.MessageResponse {
agentReadState, customerReadState := services.ConversationReadStateService.GetConversationReadStates(item.ConversationID) agentReadState, customerReadState := services.ConversationReadStateService.GetConversationReadStates(item.ConversationID)
return BuildMessageWithReadStates(item, agentReadState, customerReadState, nil, nil, nil) return BuildMessageWithReadStatesAndLocale(item, agentReadState, customerReadState, nil, nil, nil, locale)
} }
func BuildMessageWithReadStates(item *models.Message, agentReadState, customerReadState *models.ConversationReadState, aiSenderNames, userSenderNames map[int64]string, agentProfiles map[int64]*models.AgentProfile) response.MessageResponse { func BuildMessageWithReadStates(item *models.Message, agentReadState, customerReadState *models.ConversationReadState, aiSenderNames, userSenderNames map[int64]string, agentProfiles map[int64]*models.AgentProfile) response.MessageResponse {
return BuildMessageWithReadStatesAndLocale(item, agentReadState, customerReadState, aiSenderNames, userSenderNames, agentProfiles, i18nx.LocaleZhCN)
}
func BuildMessageWithReadStatesAndLocale(item *models.Message, agentReadState, customerReadState *models.ConversationReadState, aiSenderNames, userSenderNames map[int64]string, agentProfiles map[int64]*models.AgentProfile, locale string) response.MessageResponse {
content, payload := utils.BuildRenderableMessage(item) content, payload := utils.BuildRenderableMessage(item)
ret := response.MessageResponse{ ret := response.MessageResponse{
ID: item.ID, ID: item.ID,
@@ -116,7 +153,7 @@ func BuildMessageWithReadStates(item *models.Message, agentReadState, customerRe
SenderType: item.SenderType, SenderType: item.SenderType,
SenderID: item.SenderID, SenderID: item.SenderID,
MessageType: item.MessageType, MessageType: item.MessageType,
Content: content, Content: localizeRenderableMessageContent(locale, content),
Payload: payload, Payload: payload,
SeqNo: item.SeqNo, SeqNo: item.SeqNo,
SendStatus: item.SendStatus, SendStatus: item.SendStatus,
@@ -169,6 +206,16 @@ func BuildMessageWithReadStates(item *models.Message, agentReadState, customerRe
return ret return ret
} }
func localizeRenderableMessageContent(locale string, content string) string {
if i18nx.NormalizeLocale(locale) != i18nx.LocaleEnUS {
return content
}
if content == "该消息已撤回" {
return "This message was recalled."
}
return content
}
func collectAgentProfilesByMessages(list []models.Message) map[int64]*models.AgentProfile { func collectAgentProfilesByMessages(list []models.Message) map[int64]*models.AgentProfile {
var agentUserIDs []int64 var agentUserIDs []int64
seen := make(map[int64]struct{}) seen := make(map[int64]struct{})
@@ -0,0 +1,99 @@
package builders
import (
"testing"
"cs-agent/internal/pkg/i18nx"
)
func TestLocalizeConversationSummary(t *testing.T) {
t.Parallel()
tests := []struct {
name string
locale string
summary string
want string
}{
{
name: "image summary in english",
locale: i18nx.LocaleEnUS,
summary: "[图片]",
want: "[Image]",
},
{
name: "attachment summary in english",
locale: i18nx.LocaleEnUS,
summary: "[附件] spec.pdf",
want: "[Attachment] spec.pdf",
},
{
name: "recalled message in english",
locale: i18nx.LocaleEnUS,
summary: "该消息已撤回",
want: "This message was recalled.",
},
{
name: "business text is not translated",
locale: i18nx.LocaleEnUS,
summary: "客户反馈无法登录",
want: "客户反馈无法登录",
},
{
name: "chinese locale keeps existing summary",
locale: i18nx.LocaleZhCN,
summary: "[图片]",
want: "[图片]",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := localizeConversationSummary(tt.locale, tt.summary); got != tt.want {
t.Fatalf("localizeConversationSummary() = %q, want %q", got, tt.want)
}
})
}
}
func TestLocalizeRenderableMessageContent(t *testing.T) {
t.Parallel()
tests := []struct {
name string
locale string
content string
want string
}{
{
name: "recalled message in english",
locale: i18nx.LocaleEnUS,
content: "该消息已撤回",
want: "This message was recalled.",
},
{
name: "normal customer message is not translated",
locale: i18nx.LocaleEnUS,
content: "客户反馈无法登录",
want: "客户反馈无法登录",
},
{
name: "chinese locale keeps content",
locale: i18nx.LocaleZhCN,
content: "该消息已撤回",
want: "该消息已撤回",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := localizeRenderableMessageContent(tt.locale, tt.content); got != tt.want {
t.Fatalf("localizeRenderableMessageContent() = %q, want %q", got, tt.want)
}
})
}
}
+96 -3
View File
@@ -3,18 +3,32 @@ package builders
import ( import (
"cs-agent/internal/models" "cs-agent/internal/models"
"cs-agent/internal/pkg/dto/response" "cs-agent/internal/pkg/dto/response"
"cs-agent/internal/pkg/i18nx"
"cs-agent/internal/pkg/utils" "cs-agent/internal/pkg/utils"
"fmt"
"regexp"
"strings"
)
var (
ticketAssignedNotificationPattern = regexp.MustCompile(`^工单 (.+) 已指派给你$`)
conversationAssignedNotificationPattern = regexp.MustCompile(`^会话 #([0-9]+) 已分配给你$`)
) )
func BuildNotification(item *models.Notification) *response.NotificationResponse { func BuildNotification(item *models.Notification) *response.NotificationResponse {
return BuildNotificationWithLocale(item, i18nx.LocaleZhCN)
}
func BuildNotificationWithLocale(item *models.Notification, locale string) *response.NotificationResponse {
if item == nil { if item == nil {
return nil return nil
} }
title, content := localizeNotificationText(item, locale)
return &response.NotificationResponse{ return &response.NotificationResponse{
ID: item.ID, ID: item.ID,
RecipientUserID: item.RecipientUserID, RecipientUserID: item.RecipientUserID,
Title: item.Title, Title: title,
Content: item.Content, Content: content,
NotificationType: item.NotificationType, NotificationType: item.NotificationType,
BizType: item.BizType, BizType: item.BizType,
BizID: item.BizID, BizID: item.BizID,
@@ -25,11 +39,90 @@ func BuildNotification(item *models.Notification) *response.NotificationResponse
} }
func BuildNotificationList(list []models.Notification) []response.NotificationResponse { func BuildNotificationList(list []models.Notification) []response.NotificationResponse {
return BuildNotificationListWithLocale(list, i18nx.LocaleZhCN)
}
func BuildNotificationListWithLocale(list []models.Notification, locale string) []response.NotificationResponse {
results := make([]response.NotificationResponse, 0, len(list)) results := make([]response.NotificationResponse, 0, len(list))
for i := range list { for i := range list {
if item := BuildNotification(&list[i]); item != nil { if item := BuildNotificationWithLocale(&list[i], locale); item != nil {
results = append(results, *item) results = append(results, *item)
} }
} }
return results return results
} }
func localizeNotificationText(item *models.Notification, locale string) (string, string) {
title := item.Title
content := item.Content
if i18nx.NormalizeLocale(locale) != i18nx.LocaleEnUS {
return title, content
}
switch strings.TrimSpace(item.NotificationType) {
case "ticket_assigned":
return localizeTicketAssignedNotification(title, content)
case "conversation_assigned":
return localizeConversationAssignedNotification(title, content)
default:
return title, content
}
}
func localizeTicketAssignedNotification(title string, content string) (string, string) {
lines := splitNotificationLines(content)
if len(lines) == 0 {
return localizeNotificationTitle(title), content
}
if matches := ticketAssignedNotificationPattern.FindStringSubmatch(lines[0]); len(matches) == 2 {
lines[0] = fmt.Sprintf("Ticket %s has been assigned to you.", matches[1])
}
for i, line := range lines[1:] {
if reason, ok := strings.CutPrefix(line, "指派原因: "); ok {
lines[i+1] = "Assignment reason: " + reason
}
}
return localizeNotificationTitle(title), strings.Join(lines, "\n")
}
func localizeConversationAssignedNotification(title string, content string) (string, string) {
lines := splitNotificationLines(content)
if len(lines) == 0 {
return localizeNotificationTitle(title), content
}
if matches := conversationAssignedNotificationPattern.FindStringSubmatch(lines[0]); len(matches) == 2 {
lines[0] = fmt.Sprintf("Conversation #%s has been assigned to you.", matches[1])
}
for i, line := range lines[1:] {
if reason, ok := strings.CutPrefix(line, "分配原因: "); ok {
lines[i+1] = "Assignment reason: " + reason
continue
}
if reason, ok := strings.CutPrefix(line, "转接原因: "); ok {
lines[i+1] = "Transfer reason: " + reason
}
}
return localizeNotificationTitle(title), strings.Join(lines, "\n")
}
func localizeNotificationTitle(title string) string {
switch strings.TrimSpace(title) {
case "工单指派提醒":
return "Ticket assigned"
case "会话转接提醒":
return "Conversation transferred"
case "会话自动分配提醒":
return "Conversation auto-assigned"
case "会话分配提醒":
return "Conversation assigned"
default:
return title
}
}
func splitNotificationLines(content string) []string {
normalized := strings.TrimSpace(content)
if normalized == "" {
return nil
}
return strings.Split(normalized, "\n")
}
@@ -4,6 +4,7 @@ import (
"testing" "testing"
"cs-agent/internal/models" "cs-agent/internal/models"
"cs-agent/internal/pkg/i18nx"
) )
func TestBuildNotificationListReturnsEmptySlice(t *testing.T) { func TestBuildNotificationListReturnsEmptySlice(t *testing.T) {
@@ -16,3 +17,22 @@ func TestBuildNotificationListReturnsEmptySlice(t *testing.T) {
t.Fatalf("expected empty slice, got %d items", len(results)) t.Fatalf("expected empty slice, got %d items", len(results))
} }
} }
func TestBuildNotificationLocalizesKnownSystemNotification(t *testing.T) {
result := BuildNotificationWithLocale(&models.Notification{
Title: "工单指派提醒",
Content: "工单 TK-100 已指派给你\n无法登录后台\n指派原因: 优先处理",
NotificationType: "ticket_assigned",
}, i18nx.LocaleEnUS)
if result == nil {
t.Fatalf("expected notification response")
}
if result.Title != "Ticket assigned" {
t.Fatalf("title = %q", result.Title)
}
wantContent := "Ticket TK-100 has been assigned to you.\n无法登录后台\nAssignment reason: 优先处理"
if result.Content != wantContent {
t.Fatalf("content = %q, want %q", result.Content, wantContent)
}
}
@@ -5,6 +5,7 @@ import (
"cs-agent/internal/pkg/dto/request" "cs-agent/internal/pkg/dto/request"
"cs-agent/internal/pkg/dto/response" "cs-agent/internal/pkg/dto/response"
"cs-agent/internal/pkg/httpx" "cs-agent/internal/pkg/httpx"
"cs-agent/internal/pkg/i18nx"
"cs-agent/internal/services" "cs-agent/internal/services"
"cs-agent/internal/pkg/httpx/params" "cs-agent/internal/pkg/httpx/params"
@@ -39,7 +40,7 @@ func ConversationGetBy(ctx *gin.Context) {
} }
detail := response.ConversationDetailResponse{ detail := response.ConversationDetailResponse{
ConversationResponse: builders.BuildConversation(item), ConversationResponse: builders.BuildConversationWithLocale(item, i18nx.Locale(ctx)),
Participants: builders.BuildParticipantResponses(id), Participants: builders.BuildParticipantResponses(id),
} }
httpx.WriteJSON(ctx, detail) httpx.WriteJSON(ctx, detail)
@@ -62,7 +63,7 @@ func ConversationPostCreate_or_match(ctx *gin.Context) {
httpx.WriteJSON(ctx, err) httpx.WriteJSON(ctx, err)
return return
} }
httpx.WriteJSON(ctx, builders.BuildConversation(item)) httpx.WriteJSON(ctx, builders.BuildConversationWithLocale(item, i18nx.Locale(ctx)))
} }
func ConversationPostClose(ctx *gin.Context) { func ConversationPostClose(ctx *gin.Context) {
+3 -2
View File
@@ -5,6 +5,7 @@ import (
"cs-agent/internal/pkg/dto/request" "cs-agent/internal/pkg/dto/request"
"cs-agent/internal/pkg/enums" "cs-agent/internal/pkg/enums"
"cs-agent/internal/pkg/httpx" "cs-agent/internal/pkg/httpx"
"cs-agent/internal/pkg/i18nx"
"cs-agent/internal/services" "cs-agent/internal/services"
"strconv" "strconv"
"strings" "strings"
@@ -51,7 +52,7 @@ func MessageAnyList(ctx *gin.Context) {
list, nextCursor, hasMore := services.MessageService.FindByConversationIDCursor( list, nextCursor, hasMore := services.MessageService.FindByConversationIDCursor(
conversationID, cursor, limit, senderType, messageType, conversationID, cursor, limit, senderType, messageType,
) )
results := builders.BuildMessages(list) results := builders.BuildMessagesWithLocale(list, i18nx.Locale(ctx))
httpx.WriteJSON(ctx, httpx.CursorData(results, cast.ToString(nextCursor), hasMore)) httpx.WriteJSON(ctx, httpx.CursorData(results, cast.ToString(nextCursor), hasMore))
} }
@@ -77,7 +78,7 @@ func MessagePostSend(ctx *gin.Context) {
httpx.WriteJSON(ctx, err) httpx.WriteJSON(ctx, err)
return return
} }
httpx.WriteJSON(ctx, builders.BuildMessage(item)) httpx.WriteJSON(ctx, builders.BuildMessageWithLocale(item, i18nx.Locale(ctx)))
} }
func MessagePostRead(ctx *gin.Context) { func MessagePostRead(ctx *gin.Context) {
@@ -10,6 +10,7 @@ import (
"cs-agent/internal/pkg/dto/request" "cs-agent/internal/pkg/dto/request"
"cs-agent/internal/pkg/dto/response" "cs-agent/internal/pkg/dto/response"
"cs-agent/internal/pkg/enums" "cs-agent/internal/pkg/enums"
"cs-agent/internal/pkg/i18nx"
"cs-agent/internal/pkg/toolx" "cs-agent/internal/pkg/toolx"
"cs-agent/internal/pkg/utils" "cs-agent/internal/pkg/utils"
"cs-agent/internal/services" "cs-agent/internal/services"
@@ -34,7 +35,7 @@ func AIAgentAnyList(ctx *gin.Context) {
list, paging := services.AIAgentService.FindPageByCnd(cnd) list, paging := services.AIAgentService.FindPageByCnd(cnd)
results := make([]response.AIAgentResponse, 0, len(list)) results := make([]response.AIAgentResponse, 0, len(list))
for _, item := range list { for _, item := range list {
results = append(results, buildAIAgentResponse(&item)) results = append(results, buildAIAgentResponseWithLocale(&item, i18nx.Locale(ctx)))
} }
httpx.WriteJSON(ctx, &web.PageResult{Results: results, Page: paging}) httpx.WriteJSON(ctx, &web.PageResult{Results: results, Page: paging})
} }
@@ -47,7 +48,7 @@ func AIAgentGetList_all(ctx *gin.Context) {
list := services.AIAgentService.Find(sqls.NewCnd().Where("status = ?", enums.StatusOk).Desc("sort_no").Desc("id")) list := services.AIAgentService.Find(sqls.NewCnd().Where("status = ?", enums.StatusOk).Desc("sort_no").Desc("id"))
results := make([]response.AIAgentResponse, 0, len(list)) results := make([]response.AIAgentResponse, 0, len(list))
for _, item := range list { for _, item := range list {
results = append(results, buildAIAgentResponse(&item)) results = append(results, buildAIAgentResponseWithLocale(&item, i18nx.Locale(ctx)))
} }
httpx.WriteJSON(ctx, results) httpx.WriteJSON(ctx, results)
} }
@@ -66,7 +67,7 @@ func AIAgentGetBy(ctx *gin.Context) {
httpx.WriteJSON(ctx, web.JsonErrorMsg("AI Agent 不存在")) httpx.WriteJSON(ctx, web.JsonErrorMsg("AI Agent 不存在"))
return return
} }
httpx.WriteJSON(ctx, buildAIAgentResponse(item)) httpx.WriteJSON(ctx, buildAIAgentResponseWithLocale(item, i18nx.Locale(ctx)))
} }
func AIAgentPostCreate(ctx *gin.Context) { func AIAgentPostCreate(ctx *gin.Context) {
@@ -85,7 +86,7 @@ func AIAgentPostCreate(ctx *gin.Context) {
httpx.WriteJSON(ctx, err) httpx.WriteJSON(ctx, err)
return return
} }
httpx.WriteJSON(ctx, buildAIAgentResponse(item)) httpx.WriteJSON(ctx, buildAIAgentResponseWithLocale(item, i18nx.Locale(ctx)))
} }
func AIAgentPostUpdate(ctx *gin.Context) { func AIAgentPostUpdate(ctx *gin.Context) {
@@ -160,6 +161,10 @@ func AIAgentPostUpdate_status(ctx *gin.Context) {
} }
func buildAIAgentResponse(item *models.AIAgent) response.AIAgentResponse { func buildAIAgentResponse(item *models.AIAgent) response.AIAgentResponse {
return buildAIAgentResponseWithLocale(item, i18nx.LocaleZhCN)
}
func buildAIAgentResponseWithLocale(item *models.AIAgent, locale string) response.AIAgentResponse {
ret := response.AIAgentResponse{ ret := response.AIAgentResponse{
ID: item.ID, ID: item.ID,
Name: item.Name, Name: item.Name,
@@ -242,13 +247,13 @@ func buildAIAgentResponse(item *models.AIAgent) response.AIAgentResponse {
} }
title := strings.TrimSpace(tool.Title) title := strings.TrimSpace(tool.Title)
if title == "" { if title == "" {
if registeredTitle := toolx.GetRegisteredToolTitle(toolCode); registeredTitle != "" { if registeredTitle := toolx.GetRegisteredToolTitleLocale(toolCode, locale); registeredTitle != "" {
title = registeredTitle title = registeredTitle
} }
} }
description := strings.TrimSpace(tool.Description) description := strings.TrimSpace(tool.Description)
if description == "" { if description == "" {
if registeredDescription := toolx.GetRegisteredToolDescription(toolCode); registeredDescription != "" { if registeredDescription := toolx.GetRegisteredToolDescriptionLocale(toolCode, locale); registeredDescription != "" {
description = registeredDescription description = registeredDescription
} }
} }
@@ -7,6 +7,7 @@ import (
"cs-agent/internal/pkg/dto/response" "cs-agent/internal/pkg/dto/response"
"cs-agent/internal/pkg/enums" "cs-agent/internal/pkg/enums"
"cs-agent/internal/pkg/httpx" "cs-agent/internal/pkg/httpx"
"cs-agent/internal/pkg/i18nx"
"cs-agent/internal/services" "cs-agent/internal/services"
"strconv" "strconv"
"strings" "strings"
@@ -65,7 +66,7 @@ func ConversationAnyList(ctx *gin.Context) {
list, paging := services.ConversationService.FindPageByCnd(cnd) list, paging := services.ConversationService.FindPageByCnd(cnd)
results := make([]response.ConversationResponse, 0, len(list)) results := make([]response.ConversationResponse, 0, len(list))
for _, item := range list { for _, item := range list {
results = append(results, builders.BuildConversation(&item)) results = append(results, builders.BuildConversationWithLocale(&item, i18nx.Locale(ctx)))
} }
httpx.WriteJSON(ctx, &web.PageResult{Results: results, Page: paging}) httpx.WriteJSON(ctx, &web.PageResult{Results: results, Page: paging})
} }
@@ -94,7 +95,7 @@ func ConversationAnyConversations(ctx *gin.Context) {
results := make([]response.ConversationResponse, 0, len(list)) results := make([]response.ConversationResponse, 0, len(list))
for _, item := range list { for _, item := range list {
results = append(results, builders.BuildConversation(&item)) results = append(results, builders.BuildConversationWithLocale(&item, i18nx.Locale(ctx)))
} }
httpx.WriteJSON(ctx, &web.PageResult{Results: results, Page: paging}) httpx.WriteJSON(ctx, &web.PageResult{Results: results, Page: paging})
} }
@@ -116,7 +117,7 @@ func ConversationGetBy(ctx *gin.Context) {
} }
detail := response.ConversationDetailResponse{ detail := response.ConversationDetailResponse{
ConversationResponse: builders.BuildConversation(item), ConversationResponse: builders.BuildConversationWithLocale(item, i18nx.Locale(ctx)),
Participants: builders.BuildParticipantResponses(id), Participants: builders.BuildParticipantResponses(id),
} }
httpx.WriteJSON(ctx, detail) httpx.WriteJSON(ctx, detail)
@@ -143,7 +144,7 @@ func ConversationAnyMessage_list(ctx *gin.Context) {
list, nextCursor, hasMore := services.MessageService.FindByConversationIDCursor( list, nextCursor, hasMore := services.MessageService.FindByConversationIDCursor(
conversationID, cursor, limit, senderType, messageType, conversationID, cursor, limit, senderType, messageType,
) )
results := builders.BuildMessages(list) results := builders.BuildMessagesWithLocale(list, i18nx.Locale(ctx))
httpx.WriteJSON(ctx, httpx.CursorData(results, cast.ToString(nextCursor), hasMore)) httpx.WriteJSON(ctx, httpx.CursorData(results, cast.ToString(nextCursor), hasMore))
} }
@@ -259,7 +260,7 @@ func ConversationPostSend_message(ctx *gin.Context) {
httpx.WriteJSON(ctx, err) httpx.WriteJSON(ctx, err)
return return
} }
httpx.WriteJSON(ctx, builders.BuildMessage(item)) httpx.WriteJSON(ctx, builders.BuildMessageWithLocale(item, i18nx.Locale(ctx)))
} }
func ConversationPostRecall_message(ctx *gin.Context) { func ConversationPostRecall_message(ctx *gin.Context) {
@@ -279,7 +280,7 @@ func ConversationPostRecall_message(ctx *gin.Context) {
httpx.WriteJSON(ctx, err) httpx.WriteJSON(ctx, err)
return return
} }
httpx.WriteJSON(ctx, builders.BuildMessage(item)) httpx.WriteJSON(ctx, builders.BuildMessageWithLocale(item, i18nx.Locale(ctx)))
} }
func ConversationPostRead(ctx *gin.Context) { func ConversationPostRead(ctx *gin.Context) {
@@ -5,11 +5,12 @@ import (
"cs-agent/internal/services" "cs-agent/internal/services"
"cs-agent/internal/pkg/httpx/params" "cs-agent/internal/pkg/httpx/params"
"cs-agent/internal/pkg/i18nx"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
func DashboardGetOverview(ctx *gin.Context) { func DashboardGetOverview(ctx *gin.Context) {
rangeValue, _ := params.Get(ctx, "range") rangeValue, _ := params.Get(ctx, "range")
httpx.WriteJSON(ctx, services.DashboardService.GetOverview(rangeValue)) httpx.WriteJSON(ctx, services.DashboardService.GetOverview(rangeValue, i18nx.Locale(ctx)))
} }
+2 -1
View File
@@ -7,6 +7,7 @@ import (
"cs-agent/internal/pkg/constants" "cs-agent/internal/pkg/constants"
"cs-agent/internal/pkg/dto/request" "cs-agent/internal/pkg/dto/request"
"cs-agent/internal/pkg/dto/response" "cs-agent/internal/pkg/dto/response"
"cs-agent/internal/pkg/i18nx"
"cs-agent/internal/services" "cs-agent/internal/services"
"cs-agent/internal/pkg/httpx/params" "cs-agent/internal/pkg/httpx/params"
@@ -27,7 +28,7 @@ func MCPAnyCatalog(ctx *gin.Context) {
httpx.WriteJSON(ctx, err) httpx.WriteJSON(ctx, err)
return return
} }
items, err := services.ToolCatalogService.ListMCPTools(context.Background()) items, err := services.ToolCatalogService.ListMCPToolsWithLocale(context.Background(), i18nx.Locale(ctx))
if err != nil { if err != nil {
httpx.WriteJSON(ctx, err) httpx.WriteJSON(ctx, err)
return return
@@ -12,6 +12,7 @@ import (
"cs-agent/internal/services" "cs-agent/internal/services"
"cs-agent/internal/pkg/httpx/params" "cs-agent/internal/pkg/httpx/params"
"cs-agent/internal/pkg/i18nx"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/mlogclub/simple/web" "github.com/mlogclub/simple/web"
@@ -39,7 +40,7 @@ func NotificationAnyList(ctx *gin.Context) {
list, paging := services.NotificationService.FindPageByCnd(cnd) list, paging := services.NotificationService.FindPageByCnd(cnd)
httpx.WriteJSON(ctx, &web.PageResult{ httpx.WriteJSON(ctx, &web.PageResult{
Results: builders.BuildNotificationList(list), Results: builders.BuildNotificationListWithLocale(list, i18nx.Locale(ctx)),
Page: paging, Page: paging,
}) })
} }
+4 -1
View File
@@ -1,6 +1,7 @@
package middleware package middleware
import ( import (
"cs-agent/internal/pkg/i18nx"
"cs-agent/internal/services" "cs-agent/internal/services"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -16,7 +17,9 @@ func AuthMiddleware(ctx *gin.Context) {
func authenticateRequest(ctx *gin.Context) bool { func authenticateRequest(ctx *gin.Context) bool {
if _, err := services.AuthService.Authenticate(ctx); err != nil { if _, err := services.AuthService.Authenticate(ctx); err != nil {
ctx.JSON(200, web.JsonError(err)) result := web.JsonError(err)
result.Message = i18nx.T(ctx, "error.auth.expired", nil)
ctx.JSON(200, result)
ctx.Abort() ctx.Abort()
return false return false
} }
+2 -1
View File
@@ -1,6 +1,7 @@
package httpx package httpx
import ( import (
"cs-agent/internal/pkg/i18nx"
"net/http" "net/http"
"strconv" "strconv"
@@ -11,7 +12,7 @@ import (
func GetPathInt64(ctx *gin.Context, name string) (int64, bool) { func GetPathInt64(ctx *gin.Context, name string) (int64, bool) {
value, err := strconv.ParseInt(ctx.Param(name), 10, 64) value, err := strconv.ParseInt(ctx.Param(name), 10, 64)
if err != nil { if err != nil {
WriteHttpStatusJSON(ctx, http.StatusBadRequest, web.JsonErrorMsg("路径参数错误")) WriteHttpStatusJSON(ctx, http.StatusBadRequest, web.JsonErrorMsg(i18nx.T(ctx, "error.path.invalid", nil)))
return 0, false return 0, false
} }
return value, true return value, true
+11 -2
View File
@@ -1,6 +1,7 @@
package httpx package httpx
import ( import (
"cs-agent/internal/pkg/i18nx"
"net/http" "net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -28,11 +29,19 @@ func PageData(results any, paging *sqls.Paging) any {
} }
func WriteJSON(ctx *gin.Context, result any) { func WriteJSON(ctx *gin.Context, result any) {
ctx.JSON(http.StatusOK, buildJSONResult(result)) ctx.JSON(http.StatusOK, localizeJSONResult(ctx, buildJSONResult(result)))
} }
func WriteHttpStatusJSON(ctx *gin.Context, statusCode int, result any) { func WriteHttpStatusJSON(ctx *gin.Context, statusCode int, result any) {
ctx.JSON(statusCode, buildJSONResult(result)) ctx.JSON(statusCode, localizeJSONResult(ctx, buildJSONResult(result)))
}
func localizeJSONResult(ctx *gin.Context, result *web.JsonResult) *web.JsonResult {
if result == nil || result.Success || result.Message == "" {
return result
}
result.Message = i18nx.TranslateKnownMessage(i18nx.Locale(ctx), result.Message)
return result
} }
func buildJSONResult(result any) *web.JsonResult { func buildJSONResult(result any) *web.JsonResult {
+17
View File
@@ -1,6 +1,7 @@
package httpx package httpx
import ( import (
"cs-agent/internal/pkg/i18nx"
"encoding/json" "encoding/json"
"errors" "errors"
"net/http" "net/http"
@@ -67,6 +68,22 @@ func TestWriteJSONWrapsCommonResultTypes(t *testing.T) {
} }
} }
func TestWriteJSONLocalizesKnownErrorMessages(t *testing.T) {
gin.SetMode(gin.TestMode)
ctx, recorder := testContext()
i18nx.SetLocale(ctx, i18nx.LocaleEnUS)
WriteJSON(ctx, web.JsonErrorMsg("会话不存在"))
var got web.JsonResult
if err := json.Unmarshal(recorder.Body.Bytes(), &got); err != nil {
t.Fatalf("decode response: %v", err)
}
if got.Message != "Conversation not found." {
t.Fatalf("message = %q, want %q", got.Message, "Conversation not found.")
}
}
func TestWriteHttpStatusJSONUsesProvidedStatus(t *testing.T) { func TestWriteHttpStatusJSONUsesProvidedStatus(t *testing.T) {
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
ctx, recorder := testContext() ctx, recorder := testContext()
+36
View File
@@ -0,0 +1,36 @@
package i18nx
import (
"embed"
"log/slog"
"sync"
"github.com/nicksnyder/go-i18n/v2/i18n"
"github.com/pelletier/go-toml/v2"
"golang.org/x/text/language"
)
//go:embed locales/*.toml
var messageFiles embed.FS
var (
bundleOnce sync.Once
bundle *i18n.Bundle
)
func Bundle() *i18n.Bundle {
bundleOnce.Do(func() {
b := i18n.NewBundle(language.SimplifiedChinese)
b.RegisterUnmarshalFunc("toml", toml.Unmarshal)
for _, name := range []string{
"locales/active.zh-CN.toml",
"locales/active.en-US.toml",
} {
if _, err := b.LoadMessageFileFS(messageFiles, name); err != nil {
slog.Error("load i18n message file failed", "file", name, "err", err)
}
}
bundle = b
})
return bundle
}
+27
View File
@@ -0,0 +1,27 @@
package i18nx
import "github.com/gin-gonic/gin"
const contextLocaleKey = "i18nx.locale"
func Locale(ctx *gin.Context) string {
if ctx == nil {
return LocaleZhCN
}
value, ok := ctx.Get(contextLocaleKey)
if !ok {
return LocaleZhCN
}
locale, ok := value.(string)
if !ok {
return LocaleZhCN
}
return NormalizeLocale(locale)
}
func SetLocale(ctx *gin.Context, locale string) {
if ctx == nil {
return
}
ctx.Set(contextLocaleKey, NormalizeLocale(locale))
}
+140
View File
@@ -0,0 +1,140 @@
package i18nx
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
func TestNormalizeLocale(t *testing.T) {
t.Parallel()
tests := []struct {
name string
in string
want string
}{
{name: "default for blank", in: "", want: LocaleZhCN},
{name: "exact chinese", in: "zh-CN", want: LocaleZhCN},
{name: "underscore chinese", in: "zh_CN", want: LocaleZhCN},
{name: "short chinese", in: "zh", want: LocaleZhCN},
{name: "exact english", in: "en-US", want: LocaleEnUS},
{name: "underscore english", in: "en_US", want: LocaleEnUS},
{name: "short english", in: "en", want: LocaleEnUS},
{name: "unsupported falls back", in: "fr-FR", want: LocaleZhCN},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := NormalizeLocale(tt.in); got != tt.want {
t.Fatalf("NormalizeLocale(%q) = %q, want %q", tt.in, got, tt.want)
}
})
}
}
func TestResolveLocaleFromHeaders(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(http.MethodGet, "/api/dashboard/user/list", nil)
req.Header.Set("Accept-Language", "fr-FR, en-US;q=0.9, zh-CN;q=0.8")
if got := ResolveRequestLocale(req); got != LocaleEnUS {
t.Fatalf("ResolveRequestLocale() = %q, want %q", got, LocaleEnUS)
}
}
func TestResolveLocalePrefersXLocale(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(http.MethodGet, "/api/dashboard/user/list", nil)
req.Header.Set("X-Locale", "en-US")
req.Header.Set("Accept-Language", "zh-CN")
if got := ResolveRequestLocale(req); got != LocaleEnUS {
t.Fatalf("ResolveRequestLocale() = %q, want %q", got, LocaleEnUS)
}
}
func TestTranslateFallsBackToChinese(t *testing.T) {
t.Parallel()
if got := TLocale(LocaleEnUS, "error.auth.expired", nil); got != "Your session has expired. Please sign in again." {
t.Fatalf("english translation = %q", got)
}
if got := TLocale("fr-FR", "error.auth.expired", nil); got != "未登录或登录已过期" {
t.Fatalf("fallback translation = %q", got)
}
}
func TestTranslateKnownMessageSupportsFormattedMessages(t *testing.T) {
t.Parallel()
tests := []struct {
name string
message string
want string
}{
{
name: "batch limit",
message: "单次最多生成 31 条排班",
want: "You can generate at most 31 schedule entries at a time.",
},
{
name: "start date",
message: "开始时间格式错误,请使用 yyyy-MM-dd",
want: "Invalid start time format. Use yyyy-MM-dd.",
},
{
name: "end date time",
message: "结束时间格式错误,请使用 yyyy-MM-dd HH:mm:ss 或 RFC3339",
want: "Invalid end time format. Use yyyy-MM-dd HH:mm:ss or RFC3339.",
},
{
name: "oidc issuer config",
message: "OIDC issuer 未配置",
want: "OIDC issuer is not configured.",
},
{
name: "oidc login result",
message: "登录结果不能为空",
want: "Sign-in result is required.",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := TranslateKnownMessage(LocaleEnUS, tt.message); got != tt.want {
t.Fatalf("TranslateKnownMessage() = %q, want %q", got, tt.want)
}
})
}
}
func TestMiddlewareStoresLocale(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(Middleware())
router.GET("/ping", func(ctx *gin.Context) {
ctx.String(http.StatusOK, Locale(ctx))
})
req := httptest.NewRequest(http.MethodGet, "/ping", nil)
req.Header.Set("X-Locale", "en-US")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusOK)
}
if got := recorder.Body.String(); got != LocaleEnUS {
t.Fatalf("middleware locale = %q, want %q", got, LocaleEnUS)
}
}
+352
View File
@@ -0,0 +1,352 @@
package i18nx
import (
"fmt"
"regexp"
"strings"
)
var maxScheduleBatchMessagePattern = regexp.MustCompile(`^单次最多生成 ([0-9]+) 条排班$`)
var knownMessages = map[string]map[string]string{
LocaleEnUS: {
"未登录或登录已过期": "Your session has expired. Please sign in again.",
"无权限执行该操作": "You do not have permission to perform this action.",
"无权限操作": "You do not have permission to perform this action.",
"参数不合法": "Invalid request parameters.",
"路径参数错误": "Invalid path parameter.",
"状态值不合法": "Invalid status value.",
"用户名和密码不能为空": "Enter both username and password.",
"用户名或密码错误": "The username or password is incorrect.",
"登录失败次数过多,请稍后再试": "Too many failed sign-in attempts. Please try again later.",
"用户不存在": "User not found.",
"用户不存在或已被禁用": "The user does not exist or has been disabled.",
"用户名不能为空": "Enter a username.",
"用户名已存在": "This username is already in use.",
"手机号已存在": "This phone number is already in use.",
"邮箱已存在": "This email address is already in use.",
"新密码不能为空": "Enter a new password.",
"禁用角色不允许分配": "Disabled roles cannot be assigned.",
"角色不存在": "Role not found.",
"角色名称和编码不能为空": "Enter both role name and role code.",
"角色编码已存在": "This role code is already in use.",
"系统内置角色不允许删除": "Built-in system roles cannot be deleted.",
"角色已被用户使用,无法删除": "This role is assigned to users and cannot be deleted.",
"权限不存在": "Permission not found.",
"会话不存在": "Conversation not found.",
"会话筛选项不合法": "Invalid conversation filter.",
"只有待接入会话允许分配": "Only queued conversations can be assigned.",
"只有待接入会话允许自动分配": "Only queued conversations can be auto-assigned.",
"当前会话已分配客服": "This conversation has already been assigned.",
"当前暂不在人工客服服务时间内": "Human support is currently outside service hours.",
"目标客服不能为空": "Select a target agent.",
"目标客服不存在": "Target agent not found.",
"无权转接该会话": "You do not have permission to transfer this conversation.",
"只有处理中会话允许转接": "Only active conversations can be transferred.",
"当前会话未分配客服": "This conversation is not assigned to an agent.",
"目标客服不能与当前指派人相同": "The target agent must be different from the current assignee.",
"无权访问该会话": "You do not have permission to access this conversation.",
"当前状态不允许关闭会话": "This conversation cannot be closed in its current status.",
"关闭原因不能为空": "Enter a close reason.",
"无权关闭该会话": "You do not have permission to close this conversation.",
"已关闭的会话无法关联客户": "Closed conversations cannot be linked to customers.",
"无权限关联该会话": "You do not have permission to link this conversation.",
"外部用户标识不能为空": "External user ID is required.",
"接入渠道未初始化": "The channel is not initialized.",
"接入渠道不存在或已停用": "The channel does not exist or has been disabled.",
"接入渠道异常": "Channel error.",
"channel not found": "Channel not found.",
"该渠道不支持开放客服配置": "This channel does not support public support configuration.",
"外部身份未初始化": "External identity is not initialized.",
"conversationId不能为空": "Conversation ID is required.",
"请选择上传图片": "Choose an image to upload.",
"仅支持上传图片文件": "Only image files are supported.",
"请选择上传附件": "Choose an attachment to upload.",
"请选择上传文件": "Choose a file to upload.",
"文件不存在": "File not found.",
"文件不可访问": "This file cannot be accessed.",
"上传文件超过大小限制": "The uploaded file exceeds the size limit.",
"图片资源不存在": "Image asset not found.",
"当前暂不支持该存储类型的文件读取": "Reading files from this storage provider is not supported yet.",
"客户不存在": "Customer not found.",
"客户名称不能为空": "Enter a customer name.",
"所属公司不存在": "Company not found.",
"公司不存在": "Company not found.",
"公司名称不能为空": "Enter a company name.",
"公司名称已存在": "This company name is already in use.",
"工单不存在": "Ticket not found.",
"工单标题不能为空": "Enter a ticket title.",
"工单描述不能为空": "Enter a ticket description.",
"工单来源不合法": "Invalid ticket source.",
"工单状态不合法": "Invalid ticket status.",
"处理进展不能为空": "Enter a progress update.",
"负责人不存在": "Assignee not found.",
"会话与客户不匹配": "The conversation does not belong to this customer.",
"视图名称不能为空": "Enter a view name.",
"视图筛选条件格式不正确": "The view filter format is invalid.",
"视图不存在": "View not found.",
"标签不存在": "Tag not found.",
"标签名称不能为空": "Enter a tag name.",
"父标签不存在": "Parent tag not found.",
"同级下已存在相同名称的标签": "A tag with this name already exists at the same level.",
"不能将标签设为自己的子标签": "A tag cannot be moved under itself.",
"该标签下存在子标签,无法删除": "This tag has child tags and cannot be deleted.",
"该标签已关联会话,无法删除": "This tag is linked to conversations and cannot be deleted.",
"该标签已关联工单,无法删除": "This tag is linked to tickets and cannot be deleted.",
"快捷回复不存在": "Quick reply not found.",
"客服档案不存在": "Agent profile not found.",
"请选择关联用户": "Select a user to link.",
"关联用户不存在": "Linked user not found.",
"请选择所属客服组": "Select an agent team.",
"所属客服组不存在": "Agent team not found.",
"客服工号和展示名不能为空": "Enter both agent ID and display name.",
"该用户已存在客服档案": "This user already has an agent profile.",
"客服工号已存在": "This agent ID is already in use.",
"客服状态不合法": "Invalid agent status.",
"最大并发接待数不能小于 0": "Maximum concurrent conversations cannot be less than 0.",
"客服组不存在": "Agent team not found.",
"客服组排班不存在": "Agent team schedule not found.",
"知识库不存在": "Knowledge base not found.",
"FAQ不存在": "FAQ not found.",
"FAQ知识库不支持文档": "FAQ knowledge bases do not support documents.",
"文档不存在": "Document not found.",
"内容类型不支持": "Unsupported content type.",
"问题不能为空": "Enter a question.",
"答案不能为空": "Enter an answer.",
"相似问格式不合法": "Invalid similar-question format.",
"当前知识库不是FAQ知识库": "This knowledge base is not an FAQ knowledge base.",
"documentId或faqId不能为空": "Document ID or FAQ ID is required.",
"检索日志不存在": "Retrieval log not found.",
"indexStatus参数不合法": "Invalid index status.",
"AI配置不存在": "AI configuration not found.",
"启用中的AI配置不允许删除": "Enabled AI configurations cannot be deleted.",
"配置名称不能为空": "Enter a configuration name.",
"供应商不能为空": "Select a provider.",
"基础地址不能为空": "Enter a base URL.",
"模型类型不能为空": "Select a model type.",
"模型名称不能为空": "Enter a model name.",
"AI Agent 不存在": "AI Agent not found.",
"AI Agent不存在或未启用": "AI Agent not found or not enabled.",
"AI Agent 不存在或已停用": "AI Agent not found or disabled.",
"AI Agent关联的AI配置不存在": "The AI configuration linked to this AI Agent does not exist.",
"CheckPoint 不存在": "Checkpoint not found.",
"CheckPoint 与 AI Agent 不匹配": "The checkpoint does not belong to this AI Agent.",
"会话与 AI Agent 不匹配": "The conversation does not belong to this AI Agent.",
"Skill 不存在或未启用": "Skill not found or not enabled.",
"Skill 不存在": "Skill not found.",
"Skill ID 不合法": "Invalid Skill ID.",
"Skill 编码已存在": "This Skill code is already in use.",
"Skill 编码不能为空": "Enter a Skill code.",
"Skill 名称不能为空": "Enter a Skill name.",
"技能说明不能为空": "Enter a Skill description.",
"JSON 数组格式不合法": "Invalid JSON array format.",
"已删除的 Skill 不能直接修改状态,请先恢复": "Restore this deleted Skill before changing its status.",
"请使用删除接口处理删除状态": "Use the delete endpoint to mark a Skill as deleted.",
"仅已删除的 Skill 支持恢复": "Only deleted Skills can be restored.",
"MCP未启用": "MCP is not enabled.",
"serverCode不能为空": "Server code is required.",
"MCP endpoint不能为空": "MCP endpoint is required.",
"MCP服务配置不存在": "MCP server configuration not found.",
"MCP服务未启用": "MCP server is not enabled.",
"toolName不能为空": "Tool name is required.",
"toolCode不能为空": "Tool code is required.",
"toolCode格式不合法": "Invalid tool code format.",
"toolCode 绑定的 MCP 服务不存在或未启用": "The MCP server bound to this tool code does not exist or is not enabled.",
"文本内容不能为空": "Text content is required.",
"文本列表不能为空": "Text list is required.",
"密码长度不合法": "Invalid password length.",
"AI Agent 不存在或未启用": "AI Agent not found or not enabled.",
"AI Agent 名称不能为空": "Enter an AI Agent name.",
"AI Agent 名称已存在": "This AI Agent name is already in use.",
"AI 配置不存在": "AI configuration not found.",
"AI 配置不能为空": "Select an AI configuration.",
"AI 配置未启用": "The AI configuration is not enabled.",
"Agent 运行日志不存在": "Agent run log not found.",
"Direct Tool 的 toolCode 与 serverCode 不一致": "Direct Tool toolCode does not match serverCode.",
"Direct Tool 的 toolCode 与 toolName 不一致": "Direct Tool toolCode does not match toolName.",
"Direct Tool 的 toolCode 格式不合法": "Invalid Direct Tool toolCode format.",
"Direct Tool 的 toolCode、serverCode 和 toolName 不能为空": "Direct Tool toolCode, serverCode, and toolName are required.",
"Direct Tools 仅允许配置 MCP 工具": "Direct Tools can only contain MCP tools.",
"Direct Tools 配置格式不合法": "Invalid Direct Tools configuration format.",
"Graph Tools 仅允许配置 Graph Tool": "Graph Tools can only contain Graph tools.",
"Graph Tools 配置格式不合法": "Invalid Graph Tools configuration format.",
"HTML消息中的图片必须使用已上传文件": "Images in HTML messages must use uploaded files.",
"OIDC id_token 不存在": "OIDC id_token is missing.",
"OIDC clientId 未配置": "OIDC clientId is not configured.",
"OIDC clientSecret 未配置": "OIDC clientSecret is not configured.",
"OIDC issuer 未配置": "OIDC issuer is not configured.",
"OIDC redirectUrl 未配置": "OIDC redirectUrl is not configured.",
"OIDC 授权 code 不能为空": "OIDC authorization code is required.",
"OIDC 用户信息不存在": "OIDC user information not found.",
"OIDC 用户标识不存在": "OIDC user identifier not found.",
"OIDC 登录密钥未配置": "OIDC sign-in secret is not configured.",
"OIDC 登录未启用": "OIDC sign-in is not enabled.",
"OIDC 登录状态无效或已过期": "OIDC sign-in state is invalid or has expired.",
"OIDC 账号绑定的系统用户不存在": "The system user linked to this OIDC account does not exist.",
"OSS accessKeyId 未配置": "OSS accessKeyId is not configured.",
"OSS accessKeySecret 未配置": "OSS accessKeySecret is not configured.",
"OSS bucket 未配置": "OSS bucket is not configured.",
"OSS endpoint 未配置": "OSS endpoint is not configured.",
"Skill 未启用": "Skill is not enabled.",
"Web渠道配置 position 只能为 left 或 right": "Web channel position must be left or right.",
"Web渠道配置不合法": "Invalid web channel configuration.",
"aiAgentId不能为空": "AI Agent ID is required.",
"checkPointId不能为空": "Checkpoint ID is required.",
"customerId 必填": "Customer ID is required.",
"openKfID不能为空": "openKfID is required.",
"openKfId 已被其他渠道使用": "This openKfId is already used by another channel.",
"skillCode不能为空": "Skill code is required.",
"ticket 不能为空": "Ticket is required.",
"userMessage不能为空": "User message is required.",
"登录结果不能为空": "Sign-in result is required.",
"不支持的发送人类型": "Unsupported sender type.",
"不支持的已读操作类型": "Unsupported read operation type.",
"不支持的文件存储类型": "Unsupported file storage type.",
"不能添加或修改历史日期的排班": "Schedules cannot be added or changed for past dates.",
"事务上下文不能为空": "Transaction context is required.",
"仅允许撤回自己发送的消息": "You can only recall messages you sent.",
"仅支持撤回客服消息": "Only agent messages can be recalled.",
"仅能指定一条主联系方式": "Only one primary contact method can be set.",
"企业微信 openKfID 不能为空": "WeCom openKfID is required.",
"企业微信媒体ID不能为空": "WeCom media ID is required.",
"企业微信客户ID不能为空": "WeCom customer ID is required.",
"企业微信手机号已被系统用户占用": "This WeCom phone number is already used by a system user.",
"企业微信接入渠道未绑定AI Agent": "The WeCom channel is not linked to an AI Agent.",
"企业微信接入渠道绑定的AI Agent不存在或已禁用": "The AI Agent linked to this WeCom channel does not exist or has been disabled.",
"企业微信未启用或配置不完整": "WeCom is not enabled or its configuration is incomplete.",
"企业微信消息ID不能为空": "WeCom message ID is required.",
"企业微信渠道配置不合法": "Invalid WeCom channel configuration.",
"企业微信渠道配置缺少 openKfId": "WeCom channel configuration is missing openKfId.",
"企业微信用户ID已被系统用户名占用": "This WeCom user ID is already used as a system username.",
"企业微信用户ID获取失败": "Failed to get the WeCom user ID.",
"企业微信用户信息不存在": "WeCom user information not found.",
"企业微信登录密钥未配置": "WeCom sign-in secret is not configured.",
"企业微信登录未启用": "WeCom sign-in is not enabled.",
"企业微信登录状态无效或已过期": "WeCom sign-in state is invalid or has expired.",
"企业微信账号绑定的系统用户不存在": "The system user linked to this WeCom account does not exist.",
"企业微信邮箱已被系统用户占用": "This WeCom email address is already used by a system user.",
"会话已关闭": "This conversation is closed.",
"会话未分配客服,暂不允许发送消息": "This conversation has not been assigned to an agent, so messages cannot be sent yet.",
"兜底策略不合法": "Invalid fallback policy.",
"分块策略不支持": "Unsupported chunking strategy.",
"创建会话失败": "Failed to create the conversation.",
"单条排班记录不能跨天": "A single schedule entry cannot span multiple days.",
"只有待接入未分配会话允许自动分配": "Only queued unassigned conversations can be auto-assigned.",
"回复超时秒数不能小于 0": "Reply timeout seconds cannot be less than 0.",
"存在冲突排班,请先处理冲突": "There are conflicting schedules. Resolve the conflicts first.",
"存在无效工单标签": "Some ticket tags are invalid.",
"存在未启用的工单标签": "Some ticket tags are disabled.",
"客服会话不能为空": "Support session is required.",
"客服会话参数不完整": "Support session parameters are incomplete.",
"客服会话密钥未配置": "Support session secret is not configured.",
"客服会话已过期": "The support session has expired.",
"客服会话校验失败": "Support session verification failed.",
"客服组下仍有关联 AI Agent,无法删除": "This agent team is linked to AI Agents and cannot be deleted.",
"客服组下仍有关联客服档案,无法删除": "This agent team has linked agent profiles and cannot be deleted.",
"客服组下仍有关联组排班,无法删除": "This agent team has schedules and cannot be deleted.",
"客服组名称不能为空": "Enter an agent team name.",
"客服组名称已存在": "This agent team name is already in use.",
"客服组未启用": "Agent team is not enabled.",
"客服组状态不合法": "Invalid agent team status.",
"已有接入渠道绑定该 AI Agent,无法删除": "This AI Agent is linked to channels and cannot be deleted.",
"当前 OIDC 绑定已停用": "The current OIDC binding has been disabled.",
"当前企业微信绑定已停用": "The current WeCom binding has been disabled.",
"当前会话不处于 AI 接待状态": "This conversation is not currently handled by AI.",
"当前会话已分配给其他客服": "This conversation has been assigned to another agent.",
"当前会话已由人工客服接管": "This conversation has already been taken over by a human agent.",
"当前渠道不支持用户 JWT Secret": "This channel does not support a user JWT secret.",
"当前系统账号已被禁用": "The current system account has been disabled.",
"微信公众号渠道配置不合法": "Invalid WeChat Official Account channel configuration.",
"接入渠道不存在": "Channel not found.",
"接收人不能为空": "Recipient is required.",
"文档知识库不能使用FAQ分块策略": "Document knowledge bases cannot use the FAQ chunking strategy.",
"时间格式错误": "Invalid time format.",
"星期必须在 1 到 7 之间": "Weekday must be between 1 and 7.",
"服务模式不合法": "Invalid service mode.",
"未找到匹配的企业微信接入渠道": "No matching WeCom channel was found.",
"未生成任何排班": "No schedules were generated.",
"未配置可用的 AI 配置": "No available AI configuration is configured.",
"未配置可用的 Embedding 模型": "No available embedding model is configured.",
"标题和内容不能为空": "Enter both title and content.",
"消息不存在": "Message not found.",
"消息内容不能为空": "Message content is required.",
"消息已撤回": "This message has been recalled.",
"渠道名称不能为空": "Enter a channel name.",
"渠道标识已存在": "This channel code is already in use.",
"渠道状态不合法": "Invalid channel status.",
"渠道类型不合法": "Invalid channel type.",
"用户名称不能为空": "User name is required.",
"用户标识不能为空": "User ID is required.",
"用户身份不能为空": "User identity is required.",
"用户身份已过期": "User identity has expired.",
"用户身份校验失败": "User identity verification failed.",
"用户身份校验未配置": "User identity verification is not configured.",
"登录票据无效或已过期": "The sign-in ticket is invalid or has expired.",
"知识库下存在FAQ,无法删除": "This knowledge base contains FAQs and cannot be deleted.",
"知识库下存在文档,无法删除": "This knowledge base contains documents and cannot be deleted.",
"知识库不能为空": "Knowledge base is required.",
"知识库未启用": "Knowledge base is not enabled.",
"知识库类型不支持": "Unsupported knowledge base type.",
"组长用户不存在": "Team lead user not found.",
"结束日期必须晚于或等于开始日期": "End date must be later than or equal to start date.",
"结束时间必须晚于开始时间": "End time must be later than start time.",
"联系方式不存在": "Contact method not found.",
"联系方式不能为空": "Contact method is required.",
"联系方式类型不合法": "Invalid contact method type.",
"该客服组在所选时间段已存在排班": "This agent team already has a schedule in the selected time period.",
"该联系方式已存在": "This contact method already exists.",
"请至少选择一个知识库": "Select at least one knowledge base.",
"请选择 AI Agent": "Select an AI Agent.",
"请选择客服组": "Select an agent team.",
"请选择星期": "Select a weekday.",
"转人工模式不合法": "Invalid human handoff mode.",
"通知不存在": "Notification not found.",
"附件不存在": "Attachment not found.",
"附件尚未上传完成": "The attachment has not finished uploading.",
"附件消息 payload 格式错误": "Invalid attachment message payload format.",
"附件消息缺少 assetId": "Attachment message is missing assetId.",
"附件消息缺少 payload": "Attachment message is missing payload.",
"默认客服组待接入池模式必须至少选择一个客服组": "Default team queue mode requires at least one agent team.",
},
}
func TranslateKnownMessage(locale string, message string) string {
normalized := NormalizeLocale(locale)
if normalized == LocaleZhCN {
return message
}
if translations, ok := knownMessages[normalized]; ok {
if translated, exists := translations[message]; exists {
return translated
}
}
if translated, ok := translateKnownPattern(normalized, message); ok {
return translated
}
return message
}
func translateKnownPattern(locale string, message string) (string, bool) {
if locale != LocaleEnUS {
return "", false
}
if matches := maxScheduleBatchMessagePattern.FindStringSubmatch(message); len(matches) == 2 {
return fmt.Sprintf("You can generate at most %s schedule entries at a time.", matches[1]), true
}
if strings.HasPrefix(message, "开始时间格式错误,请使用 ") {
return translateTimeFormatMessage(message, "开始时间格式错误,请使用 ", "start time")
}
if strings.HasPrefix(message, "结束时间格式错误,请使用 ") {
return translateTimeFormatMessage(message, "结束时间格式错误,请使用 ", "end time")
}
return "", false
}
func translateTimeFormatMessage(message string, prefix string, field string) (string, bool) {
format := strings.TrimPrefix(message, prefix)
if format == message || format == "" {
return "", false
}
format = strings.ReplaceAll(format, " 或 ", " or ")
return fmt.Sprintf("Invalid %s format. Use %s.", field, format), true
}
@@ -0,0 +1,8 @@
["error.auth.expired"]
other = "Your session has expired. Please sign in again."
["error.notFound"]
other = "Not found"
["error.path.invalid"]
other = "Invalid path parameter."
@@ -0,0 +1,8 @@
["error.auth.expired"]
other = "未登录或登录已过期"
["error.notFound"]
other = "Not found"
["error.path.invalid"]
other = "路径参数错误"
+44
View File
@@ -0,0 +1,44 @@
package i18nx
import (
"github.com/gin-gonic/gin"
"github.com/nicksnyder/go-i18n/v2/i18n"
)
func T(ctx *gin.Context, messageID string, data map[string]any) string {
if ctx != nil {
if value, exists := ctx.Get(contextLocaleKey); exists {
if locale, ok := value.(string); ok {
return TLocale(locale, messageID, data)
}
}
}
return TLocale(LocaleZhCN, messageID, data)
}
func TLocale(locale string, messageID string, data map[string]any) string {
normalized := NormalizeLocale(locale)
message := localize(normalized, messageID, data)
if message != "" {
return message
}
if normalized != LocaleZhCN {
message = localize(LocaleZhCN, messageID, data)
if message != "" {
return message
}
}
return messageID
}
func localize(locale string, messageID string, data map[string]any) string {
localizer := i18n.NewLocalizer(Bundle(), locale, LocaleZhCN)
message, err := localizer.Localize(&i18n.LocalizeConfig{
MessageID: messageID,
TemplateData: data,
})
if err != nil {
return ""
}
return message
}
+86
View File
@@ -0,0 +1,86 @@
package i18nx
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"golang.org/x/text/language"
)
const (
LocaleZhCN = "zh-CN"
LocaleEnUS = "en-US"
)
var supportedLocales = map[string]string{
"zh": LocaleZhCN,
"zh-cn": LocaleZhCN,
"zh_cn": LocaleZhCN,
"zh-hans": LocaleZhCN,
"en": LocaleEnUS,
"en-us": LocaleEnUS,
"en_us": LocaleEnUS,
}
func NormalizeLocale(value string) string {
key := strings.ToLower(strings.TrimSpace(value))
if key == "" {
return LocaleZhCN
}
if locale, ok := supportedLocales[key]; ok {
return locale
}
return LocaleZhCN
}
func ResolveRequestLocale(req *http.Request) string {
if req == nil {
return LocaleZhCN
}
if locale := normalizeSupportedLocale(req.Header.Get("X-Locale")); locale != "" {
return locale
}
if locale := resolveAcceptLanguage(req.Header.Get("Accept-Language")); locale != "" {
return locale
}
if locale := normalizeSupportedLocale(req.URL.Query().Get("locale")); locale != "" {
return locale
}
return LocaleZhCN
}
func Middleware() gin.HandlerFunc {
return func(ctx *gin.Context) {
SetLocale(ctx, ResolveRequestLocale(ctx.Request))
ctx.Next()
}
}
func normalizeSupportedLocale(value string) string {
key := strings.ToLower(strings.TrimSpace(value))
if key == "" {
return ""
}
if locale, ok := supportedLocales[key]; ok {
return locale
}
return ""
}
func resolveAcceptLanguage(value string) string {
tags, _, err := language.ParseAcceptLanguage(value)
if err != nil {
return ""
}
for _, tag := range tags {
if locale := normalizeSupportedLocale(tag.String()); locale != "" {
return locale
}
base, _ := tag.Base()
if locale := normalizeSupportedLocale(base.String()); locale != "" {
return locale
}
}
return ""
}
+71
View File
@@ -4,6 +4,7 @@ import (
"strings" "strings"
"cs-agent/internal/pkg/enums" "cs-agent/internal/pkg/enums"
"cs-agent/internal/pkg/i18nx"
) )
type ToolSpec struct { type ToolSpec struct {
@@ -218,6 +219,20 @@ func GetRegisteredToolTitle(toolCode string) string {
return spec.Title return spec.Title
} }
func GetRegisteredToolTitleLocale(toolCode string, locale string) string {
spec, ok := GetRegisteredToolSpec(toolCode)
if !ok {
return ""
}
if i18nx.NormalizeLocale(locale) != i18nx.LocaleEnUS {
return spec.Title
}
if text := registeredToolEnglishTitle(spec.Code); text != "" {
return text
}
return spec.Title
}
func GetRegisteredToolDescription(toolCode string) string { func GetRegisteredToolDescription(toolCode string) string {
spec, ok := GetRegisteredToolSpec(toolCode) spec, ok := GetRegisteredToolSpec(toolCode)
if !ok { if !ok {
@@ -226,6 +241,62 @@ func GetRegisteredToolDescription(toolCode string) string {
return spec.Description return spec.Description
} }
func GetRegisteredToolDescriptionLocale(toolCode string, locale string) string {
spec, ok := GetRegisteredToolSpec(toolCode)
if !ok {
return ""
}
if i18nx.NormalizeLocale(locale) != i18nx.LocaleEnUS {
return spec.Description
}
if text := registeredToolEnglishDescription(spec.Code); text != "" {
return text
}
return spec.Description
}
func registeredToolEnglishTitle(toolCode string) string {
switch toolCode {
case BuiltinToolSearch.Code:
return "Search and Run Dynamic Tools"
case BuiltinSkill.Code:
return "Load Skill Instructions"
case GraphTriageServiceRequest.Code:
return "Route Service Request"
case GraphAnalyzeConversation.Code:
return "Analyze Conversation Risk and Summary"
case GraphPrepareTicketDraft.Code:
return "Prepare Ticket Draft"
case GraphCreateTicketConfirm.Code:
return "Create Ticket With Confirmation"
case GraphHandoffConversation.Code:
return "Handoff to Human With Confirmation"
default:
return ""
}
}
func registeredToolEnglishDescription(toolCode string) string {
switch toolCode {
case BuiltinToolSearch.Code:
return "Searches the MCP tools currently available to the agent and runs the selected tool after its toolCode is confirmed. Best for long-tail tools; it should not replace fixed built-in workflow tools."
case BuiltinSkill.Code:
return "Loads specialized skill instructions for the current agent when extra task-specific guidance is needed."
case GraphTriageServiceRequest.Code:
return "Analyzes the current conversation to decide whether to keep answering, prepare a ticket draft, or hand off to a human, including a ticket draft when ticket creation is appropriate."
case GraphAnalyzeConversation.Code:
return "Summarizes the current conversation, identifies risk signals, and recommends whether to keep answering, create a ticket, or hand off to a human."
case GraphPrepareTicketDraft.Code:
return "Turns the current conversation and collected details into a ticket draft with a suggested title, description, missing fields, and follow-up questions."
case GraphCreateTicketConfirm.Code:
return "Guides ticket creation with parameter preparation, customer confirmation, actual ticket creation, and final result delivery."
case GraphHandoffConversation.Code:
return "Guides human handoff with reason preparation, customer confirmation, actual transfer, and final result delivery."
default:
return ""
}
}
func GetRegisteredToolIdentity(toolCode string) (serverCode, toolName string, ok bool) { func GetRegisteredToolIdentity(toolCode string) (serverCode, toolName string, ok bool) {
spec, ok := GetRegisteredToolSpec(toolCode) spec, ok := GetRegisteredToolSpec(toolCode)
if !ok { if !ok {
+30 -1
View File
@@ -1,6 +1,10 @@
package toolx package toolx
import "testing" import (
"testing"
"cs-agent/internal/pkg/i18nx"
)
func TestResolveToolMetadata(t *testing.T) { func TestResolveToolMetadata(t *testing.T) {
item := ResolveToolMetadata("builtin/create_ticket_with_confirmation", "") item := ResolveToolMetadata("builtin/create_ticket_with_confirmation", "")
@@ -33,3 +37,28 @@ func TestResolveToolMetadataFallsBackToName(t *testing.T) {
t.Fatalf("unexpected source type: %s", item.SourceType) t.Fatalf("unexpected source type: %s", item.SourceType)
} }
} }
func TestRegisteredToolTextUsesEnglishLocale(t *testing.T) {
title := GetRegisteredToolTitleLocale(GraphCreateTicketConfirm.Code, i18nx.LocaleEnUS)
if title != "Create Ticket With Confirmation" {
t.Fatalf("unexpected english title: %q", title)
}
description := GetRegisteredToolDescriptionLocale(GraphCreateTicketConfirm.Code, i18nx.LocaleEnUS)
want := "Guides ticket creation with parameter preparation, customer confirmation, actual ticket creation, and final result delivery."
if description != want {
t.Fatalf("unexpected english description: %q", description)
}
}
func TestRegisteredToolTextKeepsChineseLocale(t *testing.T) {
title := GetRegisteredToolTitleLocale(GraphCreateTicketConfirm.Code, i18nx.LocaleZhCN)
if title != GraphCreateTicketConfirm.Title {
t.Fatalf("unexpected chinese title: %q", title)
}
description := GetRegisteredToolDescriptionLocale(GraphCreateTicketConfirm.Code, i18nx.LocaleZhCN)
if description != GraphCreateTicketConfirm.Description {
t.Fatalf("unexpected chinese description: %q", description)
}
}
+95 -21
View File
@@ -4,6 +4,7 @@ import (
"cs-agent/internal/models" "cs-agent/internal/models"
"cs-agent/internal/pkg/dto/response" "cs-agent/internal/pkg/dto/response"
"cs-agent/internal/pkg/enums" "cs-agent/internal/pkg/enums"
"cs-agent/internal/pkg/i18nx"
"cs-agent/internal/repositories" "cs-agent/internal/repositories"
"fmt" "fmt"
"sort" "sort"
@@ -23,7 +24,8 @@ func newDashboardService() *dashboardService {
type dashboardService struct { type dashboardService struct {
} }
func (s *dashboardService) GetOverview(rangeValue string) response.DashboardOverviewResponse { func (s *dashboardService) GetOverview(rangeValue string, locale string) response.DashboardOverviewResponse {
locale = i18nx.NormalizeLocale(locale)
now := time.Now() now := time.Now()
normalizedRange, trendDays := normalizeDashboardRange(rangeValue) normalizedRange, trendDays := normalizeDashboardRange(rangeValue)
todayStart := startOfDay(now) todayStart := startOfDay(now)
@@ -78,7 +80,7 @@ func (s *dashboardService) GetOverview(rangeValue string) response.DashboardOver
enabledAIAgents := repositories.DashboardRepository.ListAIAgents(db, func(tx *gorm.DB) *gorm.DB { enabledAIAgents := repositories.DashboardRepository.ListAIAgents(db, func(tx *gorm.DB) *gorm.DB {
return tx.Where("status = ?", enums.StatusOk) return tx.Where("status = ?", enums.StatusOk)
}) })
alerts := s.buildAlerts(now, db, enabledAIAgents, agentTeams, activeSchedules) alerts := s.buildAlerts(now, db, enabledAIAgents, agentTeams, activeSchedules, locale)
return response.DashboardOverviewResponse{ return response.DashboardOverviewResponse{
Range: normalizedRange, Range: normalizedRange,
@@ -91,7 +93,7 @@ func (s *dashboardService) GetOverview(rangeValue string) response.DashboardOver
AIServiceRate: calcAIServiceRate(activeConversations), AIServiceRate: calcAIServiceRate(activeConversations),
}, },
ConversationStats: response.DashboardSectionStatsResponse{ ConversationStats: response.DashboardSectionStatsResponse{
StatusDistribution: buildConversationStatusDistribution(db), StatusDistribution: buildConversationStatusDistribution(db, locale),
Trend: buildConversationTrend(db, trendStart), Trend: buildConversationTrend(db, trendStart),
}, },
AgentStats: response.DashboardAgentStatsResponse{ AgentStats: response.DashboardAgentStatsResponse{
@@ -110,7 +112,7 @@ func (s *dashboardService) GetOverview(rangeValue string) response.DashboardOver
TodayAIHandoffCount: aiHandoffCount, TodayAIHandoffCount: aiHandoffCount,
}, },
Alerts: alerts, Alerts: alerts,
QuickLinks: buildDashboardQuickLinks(), QuickLinks: buildDashboardQuickLinks(locale),
} }
} }
@@ -215,7 +217,7 @@ func (s *dashboardService) buildAgentStats(now time.Time, teams []models.AgentTe
return onlineAgents, busyAgents, offlineAgents, teamLoads return onlineAgents, busyAgents, offlineAgents, teamLoads
} }
func (s *dashboardService) buildAlerts(now time.Time, db *gorm.DB, aiAgents []models.AIAgent, teams []models.AgentTeam, schedules []models.AgentTeamSchedule) []response.DashboardAlertResponse { func (s *dashboardService) buildAlerts(now time.Time, db *gorm.DB, aiAgents []models.AIAgent, teams []models.AgentTeam, schedules []models.AgentTeamSchedule, locale string) []response.DashboardAlertResponse {
alerts := make([]response.DashboardAlertResponse, 0, 4) alerts := make([]response.DashboardAlertResponse, 0, 4)
pendingTimeout := now.Add(-10 * time.Minute) pendingTimeout := now.Add(-10 * time.Minute)
activeTimeout := now.Add(-30 * time.Minute) activeTimeout := now.Add(-30 * time.Minute)
@@ -227,8 +229,8 @@ func (s *dashboardService) buildAlerts(now time.Time, db *gorm.DB, aiAgents []mo
alerts = append(alerts, response.DashboardAlertResponse{ alerts = append(alerts, response.DashboardAlertResponse{
ID: "pending-long-wait", ID: "pending-long-wait",
Level: "warning", Level: "warning",
Title: "待接入会话堆积", Title: dashboardText(locale, "alert.pendingLongWait.title"),
Description: "存在超过 10 分钟仍未接入的会话,建议优先处理分配。", Description: dashboardText(locale, "alert.pendingLongWait.description"),
Count: pendingLongWaitCount, Count: pendingLongWaitCount,
Link: "/dashboard/conversations", Link: "/dashboard/conversations",
}) })
@@ -244,8 +246,8 @@ func (s *dashboardService) buildAlerts(now time.Time, db *gorm.DB, aiAgents []mo
alerts = append(alerts, response.DashboardAlertResponse{ alerts = append(alerts, response.DashboardAlertResponse{
ID: "stale-processing", ID: "stale-processing",
Level: "warning", Level: "warning",
Title: "处理中会话长时间无响应", Title: dashboardText(locale, "alert.staleProcessing.title"),
Description: "部分处理中会话已超过 30 分钟没有最新消息,需要确认跟进状态。", Description: dashboardText(locale, "alert.staleProcessing.description"),
Count: staleProcessingCount, Count: staleProcessingCount,
Link: "/dashboard/conversations", Link: "/dashboard/conversations",
}) })
@@ -265,8 +267,8 @@ func (s *dashboardService) buildAlerts(now time.Time, db *gorm.DB, aiAgents []mo
alerts = append(alerts, response.DashboardAlertResponse{ alerts = append(alerts, response.DashboardAlertResponse{
ID: "team-no-schedule", ID: "team-no-schedule",
Level: "info", Level: "info",
Title: "客服组当前无生效排班", Title: dashboardText(locale, "alert.teamNoSchedule.title"),
Description: "部分启用中的客服组当前没有生效排班,可能影响自动分配。", Description: dashboardText(locale, "alert.teamNoSchedule.description"),
Count: scheduleMissingCount, Count: scheduleMissingCount,
Link: "/dashboard/agent-team-schedules", Link: "/dashboard/agent-team-schedules",
}) })
@@ -282,8 +284,8 @@ func (s *dashboardService) buildAlerts(now time.Time, db *gorm.DB, aiAgents []mo
alerts = append(alerts, response.DashboardAlertResponse{ alerts = append(alerts, response.DashboardAlertResponse{
ID: "ai-no-knowledge", ID: "ai-no-knowledge",
Level: "info", Level: "info",
Title: "AI Agent 未绑定知识库", Title: dashboardText(locale, "alert.aiNoKnowledge.title"),
Description: "部分启用中的 AI Agent 尚未绑定知识库,回答质量可能不稳定。", Description: dashboardText(locale, "alert.aiNoKnowledge.description"),
Count: aiAgentWithoutKnowledgeCount, Count: aiAgentWithoutKnowledgeCount,
Link: "/dashboard/ai-agents", Link: "/dashboard/ai-agents",
}) })
@@ -299,12 +301,12 @@ func (s *dashboardService) buildAlerts(now time.Time, db *gorm.DB, aiAgents []mo
return alerts return alerts
} }
func buildConversationStatusDistribution(db *gorm.DB) []response.DashboardStatusDistributionItem { func buildConversationStatusDistribution(db *gorm.DB, locale string) []response.DashboardStatusDistributionItem {
ret := make([]response.DashboardStatusDistributionItem, 0, len(enums.IMConversationStatusValues)) ret := make([]response.DashboardStatusDistributionItem, 0, len(enums.IMConversationStatusValues))
for _, status := range enums.IMConversationStatusValues { for _, status := range enums.IMConversationStatusValues {
ret = append(ret, response.DashboardStatusDistributionItem{ ret = append(ret, response.DashboardStatusDistributionItem{
Status: int(status), Status: int(status),
Label: labelOrDefault(enums.GetIMConversationStatusLabel(status), fmt.Sprintf("状态 %d", status)), Label: conversationStatusLabel(status, locale),
Count: repositories.DashboardRepository.CountConversations(db, func(tx *gorm.DB) *gorm.DB { Count: repositories.DashboardRepository.CountConversations(db, func(tx *gorm.DB) *gorm.DB {
return tx.Where("status = ?", status) return tx.Where("status = ?", status)
}), }),
@@ -364,13 +366,13 @@ func flattenTrendMap(series map[string]*response.DashboardTrendItem) []response.
return ret return ret
} }
func buildDashboardQuickLinks() []response.DashboardQuickLinkResponse { func buildDashboardQuickLinks(locale string) []response.DashboardQuickLinkResponse {
return []response.DashboardQuickLinkResponse{ return []response.DashboardQuickLinkResponse{
{Title: "会话管理", Description: "查看待接入与处理中会话", Link: "/dashboard/conversations"}, {Title: dashboardText(locale, "quick.conversations.title"), Description: dashboardText(locale, "quick.conversations.description"), Link: "/dashboard/conversations"},
{Title: "客服档案", Description: "查看客服状态与分组配置", Link: "/dashboard/agents"}, {Title: dashboardText(locale, "quick.agents.title"), Description: dashboardText(locale, "quick.agents.description"), Link: "/dashboard/agents"},
{Title: "知识库", Description: "维护文档与查看检索日志", Link: "/dashboard/knowledge"}, {Title: dashboardText(locale, "quick.knowledge.title"), Description: dashboardText(locale, "quick.knowledge.description"), Link: "/dashboard/knowledge"},
{Title: "AI Agent", Description: "配置 AI 接待策略与知识绑定", Link: "/dashboard/ai-agents"}, {Title: dashboardText(locale, "quick.aiAgents.title"), Description: dashboardText(locale, "quick.aiAgents.description"), Link: "/dashboard/ai-agents"},
{Title: "接入渠道", Description: "管理接入渠道与默认 Agent", Link: "/dashboard/channels"}, {Title: dashboardText(locale, "quick.channels.title"), Description: dashboardText(locale, "quick.channels.description"), Link: "/dashboard/channels"},
} }
} }
@@ -415,3 +417,75 @@ func labelOrDefault(value, fallback string) string {
} }
return fallback return fallback
} }
func conversationStatusLabel(status enums.IMConversationStatus, locale string) string {
if i18nx.NormalizeLocale(locale) == i18nx.LocaleEnUS {
switch status {
case enums.IMConversationStatusAIServing:
return "AI active"
case enums.IMConversationStatusPending:
return "Queued"
case enums.IMConversationStatusActive:
return "In progress"
case enums.IMConversationStatusClosed:
return "Closed"
default:
return fmt.Sprintf("Status %d", status)
}
}
return labelOrDefault(enums.GetIMConversationStatusLabel(status), fmt.Sprintf("状态 %d", status))
}
func dashboardText(locale string, key string) string {
if i18nx.NormalizeLocale(locale) == i18nx.LocaleEnUS {
if value, ok := dashboardEnUS[key]; ok {
return value
}
}
if value, ok := dashboardZhCN[key]; ok {
return value
}
return key
}
var dashboardZhCN = map[string]string{
"alert.pendingLongWait.title": "待接入会话堆积",
"alert.pendingLongWait.description": "存在超过 10 分钟仍未接入的会话,建议优先处理分配。",
"alert.staleProcessing.title": "处理中会话长时间无响应",
"alert.staleProcessing.description": "部分处理中会话已超过 30 分钟没有最新消息,需要确认跟进状态。",
"alert.teamNoSchedule.title": "客服组当前无生效排班",
"alert.teamNoSchedule.description": "部分启用中的客服组当前没有生效排班,可能影响自动分配。",
"alert.aiNoKnowledge.title": "AI Agent 未绑定知识库",
"alert.aiNoKnowledge.description": "部分启用中的 AI Agent 尚未绑定知识库,回答质量可能不稳定。",
"quick.conversations.title": "会话管理",
"quick.conversations.description": "查看待接入与处理中会话",
"quick.agents.title": "客服档案",
"quick.agents.description": "查看客服状态与分组配置",
"quick.knowledge.title": "知识库",
"quick.knowledge.description": "维护文档与查看检索日志",
"quick.aiAgents.title": "AI Agent",
"quick.aiAgents.description": "配置 AI 接待策略与知识绑定",
"quick.channels.title": "接入渠道",
"quick.channels.description": "管理接入渠道与默认 Agent",
}
var dashboardEnUS = map[string]string{
"alert.pendingLongWait.title": "Queued conversations are piling up",
"alert.pendingLongWait.description": "Some conversations have been waiting for more than 10 minutes. Prioritize assignment.",
"alert.staleProcessing.title": "Active conversations need attention",
"alert.staleProcessing.description": "Some active conversations have had no new messages for over 30 minutes. Check their follow-up status.",
"alert.teamNoSchedule.title": "Agent teams have no active schedule",
"alert.teamNoSchedule.description": "Some enabled agent teams do not have an active schedule right now, which may affect automatic assignment.",
"alert.aiNoKnowledge.title": "AI Agents are missing knowledge bases",
"alert.aiNoKnowledge.description": "Some enabled AI Agents are not linked to a knowledge base yet, which may reduce answer quality.",
"quick.conversations.title": "Conversations",
"quick.conversations.description": "Review queued and active conversations",
"quick.agents.title": "Agents",
"quick.agents.description": "Check agent status and team setup",
"quick.knowledge.title": "Knowledge base",
"quick.knowledge.description": "Manage documents and review retrieval logs",
"quick.aiAgents.title": "AI Agents",
"quick.aiAgents.description": "Configure AI service policies and knowledge bindings",
"quick.channels.title": "Channels",
"quick.channels.description": "Manage channels and default agents",
}
@@ -0,0 +1,29 @@
package services
import (
"cs-agent/internal/pkg/enums"
"cs-agent/internal/pkg/i18nx"
"testing"
)
func TestDashboardTextUsesEnglishLocale(t *testing.T) {
t.Parallel()
if got := dashboardText(i18nx.LocaleEnUS, "alert.pendingLongWait.title"); got != "Queued conversations are piling up" {
t.Fatalf("dashboardText() = %q", got)
}
if got := dashboardText(i18nx.LocaleZhCN, "alert.pendingLongWait.title"); got != "待接入会话堆积" {
t.Fatalf("dashboardText() = %q", got)
}
}
func TestConversationStatusLabelUsesEnglishLocale(t *testing.T) {
t.Parallel()
if got := conversationStatusLabel(enums.IMConversationStatusPending, i18nx.LocaleEnUS); got != "Queued" {
t.Fatalf("conversationStatusLabel() = %q", got)
}
if got := conversationStatusLabel(enums.IMConversationStatusPending, i18nx.LocaleZhCN); got != "待接入" {
t.Fatalf("conversationStatusLabel() = %q", got)
}
}
+7 -2
View File
@@ -9,6 +9,7 @@ import (
"cs-agent/internal/pkg/config" "cs-agent/internal/pkg/config"
"cs-agent/internal/pkg/enums" "cs-agent/internal/pkg/enums"
"cs-agent/internal/pkg/errorsx" "cs-agent/internal/pkg/errorsx"
"cs-agent/internal/pkg/i18nx"
"cs-agent/internal/pkg/toolx" "cs-agent/internal/pkg/toolx"
) )
@@ -33,6 +34,10 @@ type MCPToolCatalogItem struct {
} }
func (s *toolCatalogService) ListMCPTools(ctx context.Context) ([]MCPToolCatalogItem, error) { func (s *toolCatalogService) ListMCPTools(ctx context.Context) ([]MCPToolCatalogItem, error) {
return s.ListMCPToolsWithLocale(ctx, i18nx.LocaleZhCN)
}
func (s *toolCatalogService) ListMCPToolsWithLocale(ctx context.Context, locale string) ([]MCPToolCatalogItem, error) {
cfg := config.Current() cfg := config.Current()
ret := make([]MCPToolCatalogItem, 0, 3) ret := make([]MCPToolCatalogItem, 0, 3)
for _, spec := range toolx.ListAgentDirectToolSpecs() { for _, spec := range toolx.ListAgentDirectToolSpecs() {
@@ -45,8 +50,8 @@ func (s *toolCatalogService) ListMCPTools(ctx context.Context) ([]MCPToolCatalog
ToolName: spec.Name, ToolName: spec.Name,
SourceType: spec.SourceType, SourceType: spec.SourceType,
AutoInjected: spec.AutoInjected, AutoInjected: spec.AutoInjected,
Title: spec.Title, Title: toolx.GetRegisteredToolTitleLocale(spec.Code, locale),
Description: spec.Description, Description: toolx.GetRegisteredToolDescriptionLocale(spec.Code, locale),
}) })
} }
if !cfg.MCP.Enabled { if !cfg.MCP.Enabled {
+7 -4
View File
@@ -5,6 +5,7 @@ import { ArrowRightIcon, ShieldAlertIcon } from "lucide-react"
import type { DashboardAlert } from "@/lib/api/dashboard" import type { DashboardAlert } from "@/lib/api/dashboard"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { useI18n } from "@/i18n/provider"
import { import {
Card, Card,
CardContent, CardContent,
@@ -28,19 +29,21 @@ function getAlertBadgeVariant(level: DashboardAlert["level"]) {
} }
export function AlertList({ alerts }: AlertListProps) { export function AlertList({ alerts }: AlertListProps) {
const t = useI18n()
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle></CardTitle> <CardTitle>{t("dashboardHome.riskAlerts")}</CardTitle>
<CardDescription> AI </CardDescription> <CardDescription>{t("dashboardHome.riskAlertsDescription")}</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
{alerts.length === 0 ? ( {alerts.length === 0 ? (
<div className="rounded-2xl border border-dashed px-4 py-10 text-center"> <div className="rounded-2xl border border-dashed px-4 py-10 text-center">
<ShieldAlertIcon className="mx-auto mb-3 size-8 text-muted-foreground" /> <ShieldAlertIcon className="mx-auto mb-3 size-8 text-muted-foreground" />
<div className="text-sm font-medium"></div> <div className="text-sm font-medium">{t("dashboardHome.noRiskTitle")}</div>
<div className="mt-1 text-sm text-muted-foreground"> <div className="mt-1 text-sm text-muted-foreground">
AI {t("dashboardHome.noRiskDescription")}
</div> </div>
</div> </div>
) : ( ) : (
@@ -7,6 +7,7 @@ import { toast } from "sonner"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Card, CardContent } from "@/components/ui/card" import { Card, CardContent } from "@/components/ui/card"
import { useI18n } from "@/i18n/provider"
import { import {
fetchDashboardOverview, fetchDashboardOverview,
type DashboardOverview, type DashboardOverview,
@@ -17,12 +18,6 @@ import { TrendPanel } from "./trend-panel"
import { TeamLoadPanel } from "./team-load-panel" import { TeamLoadPanel } from "./team-load-panel"
import { AlertList } from "./alert-list" import { AlertList } from "./alert-list"
const rangeOptions: Array<{ value: DashboardRange; label: string }> = [
{ value: "today", label: "今天" },
{ value: "7d", label: "近 7 天" },
{ value: "30d", label: "近 30 天" },
]
function LoadingCards() { function LoadingCards() {
return ( return (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-6"> <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-6">
@@ -40,6 +35,7 @@ function LoadingCards() {
} }
export function DashboardHome() { export function DashboardHome() {
const t = useI18n()
const [range, setRange] = useState<DashboardRange>("7d") const [range, setRange] = useState<DashboardRange>("7d")
const [data, setData] = useState<DashboardOverview | null>(null) const [data, setData] = useState<DashboardOverview | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@@ -56,27 +52,33 @@ export function DashboardHome() {
const result = await fetchDashboardOverview(nextRange) const result = await fetchDashboardOverview(nextRange)
setData(result) setData(result)
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "加载首页概览失败") toast.error(error instanceof Error ? error.message : t("dashboardHome.loadFailed"))
} finally { } finally {
setLoading(false) setLoading(false)
setRefreshing(false) setRefreshing(false)
} }
}, },
[] [t]
) )
useEffect(() => { useEffect(() => {
void loadData(range) void loadData(range)
}, [loadData, range]) }, [loadData, range])
const rangeOptions: Array<{ value: DashboardRange; label: string }> = [
{ value: "today", label: t("dashboardHome.rangeToday") },
{ value: "7d", label: t("dashboardHome.range7d") },
{ value: "30d", label: t("dashboardHome.range30d") },
]
return ( return (
<div className="flex flex-1 flex-col gap-6 p-4 lg:p-6"> <div className="flex flex-1 flex-col gap-6 p-4 lg:p-6">
<div className="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between"> <div className="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
<div> <div>
<h1 className="text-2xl font-semibold tracking-tight"></h1> <h1 className="text-2xl font-semibold tracking-tight">{t("dashboardHome.title")}</h1>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
AI {t("dashboardHome.description")}
{data ? `,更新于 ${data.generatedAt}` : ""} {data ? t("dashboardHome.updatedAt", { time: data.generatedAt }) : ""}
</p> </p>
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
@@ -99,7 +101,7 @@ export function DashboardHome() {
disabled={loading || refreshing} disabled={loading || refreshing}
> >
<RefreshCwIcon className={refreshing ? "mr-2 size-4 animate-spin" : "mr-2 size-4"} /> <RefreshCwIcon className={refreshing ? "mr-2 size-4 animate-spin" : "mr-2 size-4"} />
{t("dashboardHome.refresh")}
</Button> </Button>
</div> </div>
</div> </div>
@@ -111,8 +113,8 @@ export function DashboardHome() {
<SummaryCards summary={data.summary} /> <SummaryCards summary={data.summary} />
<TrendPanel <TrendPanel
title="会话趋势" title={t("dashboardHome.conversationTrend")}
description="观察新增与关闭会话变化,快速判断接待压力是否持续上升" description={t("dashboardHome.conversationTrendDescription")}
trend={data.conversationStats.trend} trend={data.conversationStats.trend}
distribution={data.conversationStats.statusDistribution} distribution={data.conversationStats.statusDistribution}
/> />
@@ -123,33 +125,33 @@ export function DashboardHome() {
<Card> <Card>
<CardContent className="grid gap-4 p-6 sm:grid-cols-2"> <CardContent className="grid gap-4 p-6 sm:grid-cols-2">
<div className="rounded-2xl border bg-muted/30 p-4"> <div className="rounded-2xl border bg-muted/30 p-4">
<div className="text-sm text-muted-foreground"> AI Agent</div> <div className="text-sm text-muted-foreground">{t("dashboardHome.enabledAiAgents")}</div>
<div className="mt-2 text-3xl font-semibold">{data.aiStats.enabledAiAgents}</div> <div className="mt-2 text-3xl font-semibold">{data.aiStats.enabledAiAgents}</div>
</div> </div>
<div className="rounded-2xl border bg-muted/30 p-4"> <div className="rounded-2xl border bg-muted/30 p-4">
<div className="text-sm text-muted-foreground"></div> <div className="text-sm text-muted-foreground">{t("dashboardHome.enabledChannels")}</div>
<div className="mt-2 text-3xl font-semibold">{data.aiStats.enabledChannels}</div> <div className="mt-2 text-3xl font-semibold">{data.aiStats.enabledChannels}</div>
</div> </div>
<div className="rounded-2xl border bg-muted/30 p-4"> <div className="rounded-2xl border bg-muted/30 p-4">
<div className="text-sm text-muted-foreground"></div> <div className="text-sm text-muted-foreground">{t("dashboardHome.todayKnowledgeRetrieves")}</div>
<div className="mt-2 text-3xl font-semibold"> <div className="mt-2 text-3xl font-semibold">
{data.aiStats.todayKnowledgeRetrieves} {data.aiStats.todayKnowledgeRetrieves}
</div> </div>
</div> </div>
<div className="rounded-2xl border bg-muted/30 p-4"> <div className="rounded-2xl border bg-muted/30 p-4">
<div className="text-sm text-muted-foreground"></div> <div className="text-sm text-muted-foreground">{t("dashboardHome.todayKnowledgeRetrieveFailRate")}</div>
<div className="mt-2 text-3xl font-semibold"> <div className="mt-2 text-3xl font-semibold">
{data.aiStats.todayKnowledgeRetrieveFailRate.toFixed(1)}% {data.aiStats.todayKnowledgeRetrieveFailRate.toFixed(1)}%
</div> </div>
</div> </div>
<div className="rounded-2xl border bg-muted/30 p-4"> <div className="rounded-2xl border bg-muted/30 p-4">
<div className="text-sm text-muted-foreground"> Skill </div> <div className="text-sm text-muted-foreground">{t("dashboardHome.todaySkillRunFailCount")}</div>
<div className="mt-2 text-3xl font-semibold"> <div className="mt-2 text-3xl font-semibold">
{data.aiStats.todaySkillRunFailCount} {data.aiStats.todaySkillRunFailCount}
</div> </div>
</div> </div>
<div className="rounded-2xl border bg-muted/30 p-4"> <div className="rounded-2xl border bg-muted/30 p-4">
<div className="text-sm text-muted-foreground"> AI </div> <div className="text-sm text-muted-foreground">{t("dashboardHome.todayAiHandoffCount")}</div>
<div className="mt-2 text-3xl font-semibold"> <div className="mt-2 text-3xl font-semibold">
{data.aiStats.todayAiHandoffCount} {data.aiStats.todayAiHandoffCount}
</div> </div>
@@ -163,7 +165,7 @@ export function DashboardHome() {
) : ( ) : (
<Card> <Card>
<CardContent className="flex min-h-60 items-center justify-center p-6 text-sm text-muted-foreground"> <CardContent className="flex min-h-60 items-center justify-center p-6 text-sm text-muted-foreground">
{t("dashboardHome.empty")}
</CardContent> </CardContent>
</Card> </Card>
)} )}
+17 -14
View File
@@ -17,6 +17,7 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card" } from "@/components/ui/card"
import { useI18n } from "@/i18n/provider"
type SummaryCardsProps = { type SummaryCardsProps = {
summary: DashboardOverview["summary"] summary: DashboardOverview["summary"]
@@ -24,8 +25,8 @@ type SummaryCardsProps = {
type SummaryCardItem = { type SummaryCardItem = {
key: keyof DashboardOverview["summary"] key: keyof DashboardOverview["summary"]
title: string titleKey: string
description: string descriptionKey: string
link: string link: string
icon: typeof BotMessageSquareIcon icon: typeof BotMessageSquareIcon
format?: (value: number) => string format?: (value: number) => string
@@ -34,36 +35,36 @@ type SummaryCardItem = {
const cards: SummaryCardItem[] = [ const cards: SummaryCardItem[] = [
{ {
key: "todayNewConversations", key: "todayNewConversations",
title: "今日新增会话", titleKey: "dashboardHome.summaryTodayNewConversations",
description: "今日进入系统的新增咨询量", descriptionKey: "dashboardHome.summaryTodayNewConversationsDescription",
link: "/dashboard/conversations", link: "/dashboard/conversations",
icon: BotMessageSquareIcon, icon: BotMessageSquareIcon,
}, },
{ {
key: "processingConversations", key: "processingConversations",
title: "当前处理中", titleKey: "dashboardHome.summaryProcessingConversations",
description: "正在由 AI 或人工接待的会话", descriptionKey: "dashboardHome.summaryProcessingConversationsDescription",
link: "/dashboard/conversations", link: "/dashboard/conversations",
icon: WavesIcon, icon: WavesIcon,
}, },
{ {
key: "pendingDispatchConversations", key: "pendingDispatchConversations",
title: "待分配会话", titleKey: "dashboardHome.summaryPendingDispatchConversations",
description: "仍在待接入池中等待分配", descriptionKey: "dashboardHome.summaryPendingDispatchConversationsDescription",
link: "/dashboard/conversations", link: "/dashboard/conversations",
icon: CircleDashedIcon, icon: CircleDashedIcon,
}, },
{ {
key: "onlineAgents", key: "onlineAgents",
title: "在线客服", titleKey: "dashboardHome.summaryOnlineAgents",
description: "近 15 分钟内仍有活跃心跳的客服", descriptionKey: "dashboardHome.summaryOnlineAgentsDescription",
link: "/dashboard/agents", link: "/dashboard/agents",
icon: HeadsetIcon, icon: HeadsetIcon,
}, },
{ {
key: "aiServiceRate", key: "aiServiceRate",
title: "AI 接待占比", titleKey: "dashboardHome.summaryAiServiceRate",
description: "当前活跃会话中 AI 参与服务比例", descriptionKey: "dashboardHome.summaryAiServiceRateDescription",
link: "/dashboard/ai-agents", link: "/dashboard/ai-agents",
icon: SparklesIcon, icon: SparklesIcon,
format: (value: number) => `${value.toFixed(1)}%`, format: (value: number) => `${value.toFixed(1)}%`,
@@ -71,6 +72,8 @@ const cards: SummaryCardItem[] = [
] ]
export function SummaryCards({ summary }: SummaryCardsProps) { export function SummaryCards({ summary }: SummaryCardsProps) {
const t = useI18n()
return ( return (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-6"> <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-6">
{cards.map((item) => { {cards.map((item) => {
@@ -86,8 +89,8 @@ export function SummaryCards({ summary }: SummaryCardsProps) {
<Card className="h-full transition-colors hover:border-primary/40"> <Card className="h-full transition-colors hover:border-primary/40">
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-3"> <CardHeader className="flex flex-row items-start justify-between space-y-0 pb-3">
<div className="space-y-1"> <div className="space-y-1">
<CardTitle className="text-sm font-medium">{item.title}</CardTitle> <CardTitle className="text-sm font-medium">{t(item.titleKey)}</CardTitle>
<CardDescription>{item.description}</CardDescription> <CardDescription>{t(item.descriptionKey)}</CardDescription>
</div> </div>
<div className="rounded-full bg-primary/10 p-2 text-primary"> <div className="rounded-full bg-primary/10 p-2 text-primary">
<Icon className="size-4" /> <Icon className="size-4" />
@@ -1,6 +1,7 @@
"use client" "use client"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { useI18n } from "@/i18n/provider"
import { import {
Card, Card,
CardContent, CardContent,
@@ -25,18 +26,24 @@ function getLoadTone(loadRate: number) {
} }
export function TeamLoadPanel({ agentStats }: TeamLoadPanelProps) { export function TeamLoadPanel({ agentStats }: TeamLoadPanelProps) {
const t = useI18n()
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle></CardTitle> <CardTitle>{t("dashboardHome.teamLoad")}</CardTitle>
<CardDescription> <CardDescription>
线 {agentStats.onlineAgents} {agentStats.busyAgents}线 {agentStats.offlineAgents} {t("dashboardHome.teamLoadDescription", {
online: agentStats.onlineAgents,
busy: agentStats.busyAgents,
offline: agentStats.offlineAgents,
})}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{agentStats.teamLoads.length === 0 ? ( {agentStats.teamLoads.length === 0 ? (
<div className="rounded-xl border border-dashed px-4 py-8 text-center text-sm text-muted-foreground"> <div className="rounded-xl border border-dashed px-4 py-8 text-center text-sm text-muted-foreground">
{t("dashboardHome.noTeamData")}
</div> </div>
) : ( ) : (
agentStats.teamLoads.map((item) => ( agentStats.teamLoads.map((item) => (
@@ -46,20 +53,27 @@ export function TeamLoadPanel({ agentStats }: TeamLoadPanelProps) {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="font-medium">{item.teamName}</div> <div className="font-medium">{item.teamName}</div>
{item.hasScheduleNow ? ( {item.hasScheduleNow ? (
<Badge variant="secondary"></Badge> <Badge variant="secondary">{t("dashboardHome.scheduled")}</Badge>
) : ( ) : (
<Badge variant="outline"></Badge> <Badge variant="outline">{t("dashboardHome.notScheduled")}</Badge>
)} )}
</div> </div>
<div className="mt-1 text-sm text-muted-foreground"> <div className="mt-1 text-sm text-muted-foreground">
{item.totalAgents}线 {item.onlineAgents} {item.busyAgents}线{" "} {t("dashboardHome.teamStats", {
{item.offlineAgents} total: item.totalAgents,
online: item.onlineAgents,
busy: item.busyAgents,
offline: item.offlineAgents,
})}
</div> </div>
</div> </div>
<div className="text-right"> <div className="text-right">
<div className="text-2xl font-semibold">{item.loadRate.toFixed(1)}%</div> <div className="text-2xl font-semibold">{item.loadRate.toFixed(1)}%</div>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
{item.processingConversations}/{item.maxConcurrentCapacity || 0} {t("dashboardHome.load", {
processing: item.processingConversations,
capacity: item.maxConcurrentCapacity || 0,
})}
</div> </div>
</div> </div>
</div> </div>
@@ -73,19 +87,19 @@ export function TeamLoadPanel({ agentStats }: TeamLoadPanelProps) {
<div className="mt-4 grid gap-3 text-sm md:grid-cols-2 xl:grid-cols-4"> <div className="mt-4 grid gap-3 text-sm md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-xl bg-muted/40 px-3 py-2"> <div className="rounded-xl bg-muted/40 px-3 py-2">
<div className="text-muted-foreground"></div> <div className="text-muted-foreground">{t("dashboardHome.waiting")}</div>
<div className="mt-1 text-lg font-semibold">{item.waitingConversations}</div> <div className="mt-1 text-lg font-semibold">{item.waitingConversations}</div>
</div> </div>
<div className="rounded-xl bg-muted/40 px-3 py-2"> <div className="rounded-xl bg-muted/40 px-3 py-2">
<div className="text-muted-foreground"></div> <div className="text-muted-foreground">{t("dashboardHome.processing")}</div>
<div className="mt-1 text-lg font-semibold">{item.processingConversations}</div> <div className="mt-1 text-lg font-semibold">{item.processingConversations}</div>
</div> </div>
<div className="rounded-xl bg-muted/40 px-3 py-2"> <div className="rounded-xl bg-muted/40 px-3 py-2">
<div className="text-muted-foreground"></div> <div className="text-muted-foreground">{t("dashboardHome.capacity")}</div>
<div className="mt-1 text-lg font-semibold">{item.maxConcurrentCapacity}</div> <div className="mt-1 text-lg font-semibold">{item.maxConcurrentCapacity}</div>
</div> </div>
<div className="rounded-xl bg-muted/40 px-3 py-2"> <div className="rounded-xl bg-muted/40 px-3 py-2">
<div className="text-muted-foreground"></div> <div className="text-muted-foreground">{t("dashboardHome.busyAgents")}</div>
<div className="mt-1 text-lg font-semibold">{item.busyAgents}</div> <div className="mt-1 text-lg font-semibold">{item.busyAgents}</div>
</div> </div>
</div> </div>
+48 -24
View File
@@ -16,6 +16,7 @@ import {
ChartTooltipContent, ChartTooltipContent,
type ChartConfig, type ChartConfig,
} from "@/components/ui/chart" } from "@/components/ui/chart"
import { useI18n } from "@/i18n/provider"
type TrendPanelProps = { type TrendPanelProps = {
title: string title: string
@@ -24,33 +25,37 @@ type TrendPanelProps = {
distribution: DashboardStatusDistributionItem[] distribution: DashboardStatusDistributionItem[]
} }
const trendConfig = {
newCount: {
label: "新增",
color: "hsl(24 95% 53%)",
},
closedCount: {
label: "关闭",
color: "hsl(190 95% 39%)",
},
} satisfies ChartConfig
const distributionConfig = {
count: {
label: "数量",
theme: {
light: "hsl(222 47% 11%)",
dark: "hsl(210 40% 98%)",
},
},
} satisfies ChartConfig
export function TrendPanel({ export function TrendPanel({
title, title,
description, description,
trend, trend,
distribution, distribution,
}: TrendPanelProps) { }: TrendPanelProps) {
const t = useI18n()
const trendConfig = {
newCount: {
label: t("dashboardHome.chartNew"),
color: "hsl(24 95% 53%)",
},
closedCount: {
label: t("dashboardHome.chartClosed"),
color: "hsl(190 95% 39%)",
},
} satisfies ChartConfig
const distributionConfig = {
count: {
label: t("dashboardHome.chartCount"),
theme: {
light: "hsl(222 47% 11%)",
dark: "hsl(210 40% 98%)",
},
},
} satisfies ChartConfig
const localizedDistribution = distribution.map((item) => ({
...item,
label: getStatusLabel(item.status, item.label, t),
}))
return ( return (
<div className="grid gap-4 xl:grid-cols-[1.5fr_0.9fr]"> <div className="grid gap-4 xl:grid-cols-[1.5fr_0.9fr]">
<Card> <Card>
@@ -87,12 +92,12 @@ export function TrendPanel({
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle></CardTitle> <CardTitle>{t("dashboardHome.statusDistribution")}</CardTitle>
<CardDescription></CardDescription> <CardDescription>{t("dashboardHome.statusDistributionDescription")}</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<ChartContainer config={distributionConfig} className="h-72 w-full"> <ChartContainer config={distributionConfig} className="h-72 w-full">
<BarChart data={distribution} layout="vertical" margin={{ left: 20 }}> <BarChart data={localizedDistribution} layout="vertical" margin={{ left: 20 }}>
<CartesianGrid horizontal={false} /> <CartesianGrid horizontal={false} />
<YAxis <YAxis
type="category" type="category"
@@ -115,3 +120,22 @@ export function TrendPanel({
</div> </div>
) )
} }
function getStatusLabel(
status: number,
fallback: string,
t: (key: string) => string
) {
switch (status) {
case 1:
return t("dashboardHome.statusAiServing")
case 2:
return t("dashboardHome.statusPending")
case 3:
return t("dashboardHome.statusActive")
case 4:
return t("dashboardHome.statusClosed")
default:
return fallback
}
}
@@ -9,6 +9,7 @@ import { JsonTreeViewer } from "@/components/json-tree-viewer"
import { ProjectDialog } from "@/components/project-dialog" import { ProjectDialog } from "@/components/project-dialog"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { fetchAgentRunLog, type AgentRunLog } from "@/lib/api/admin" import { fetchAgentRunLog, type AgentRunLog } from "@/lib/api/admin"
import { useI18n } from "@/i18n/provider"
import { formatDateTime } from "@/lib/utils" import { formatDateTime } from "@/lib/utils"
type AgentRunLogDetailDialogProps = { type AgentRunLogDetailDialogProps = {
@@ -17,11 +18,14 @@ type AgentRunLogDetailDialogProps = {
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
} }
type TFunction = (key: string, values?: Record<string, string | number>) => string
export function AgentRunLogDetailDialog({ export function AgentRunLogDetailDialog({
open, open,
logId, logId,
onOpenChange, onOpenChange,
}: AgentRunLogDetailDialogProps) { }: AgentRunLogDetailDialogProps) {
const t = useI18n()
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [activeLog, setActiveLog] = useState<AgentRunLog | null>(null) const [activeLog, setActiveLog] = useState<AgentRunLog | null>(null)
@@ -42,7 +46,7 @@ export function AgentRunLogDetailDialog({
} }
} catch (error) { } catch (error) {
if (!cancelled) { if (!cancelled) {
toast.error(error instanceof Error ? error.message : "加载日志详情失败") toast.error(error instanceof Error ? error.message : t("agentRunLog.loadDetailFailed"))
onOpenChange(false) onOpenChange(false)
} }
} finally { } finally {
@@ -57,7 +61,7 @@ export function AgentRunLogDetailDialog({
return () => { return () => {
cancelled = true cancelled = true
} }
}, [logId, onOpenChange, open]) }, [logId, onOpenChange, open, t])
useEffect(() => { useEffect(() => {
if (open) { if (open) {
@@ -87,35 +91,35 @@ export function AgentRunLogDetailDialog({
title={ title={
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<WorkflowIcon className="size-4" /> <WorkflowIcon className="size-4" />
Agent {t("agentRunLog.detailTitle")}
</span> </span>
} }
description="查看 planner 选择、最终动作、回复内容与错误信息。" description={t("agentRunLog.detailDescription")}
size="xl" size="xl"
allowFullscreen allowFullscreen
defaultFullscreen defaultFullscreen
bodyClassName="min-h-0" bodyClassName="min-h-0"
footer={ footer={
<Button variant="outline" onClick={() => onOpenChange(false)}> <Button variant="outline" onClick={() => onOpenChange(false)}>
{t("agentRunLog.close")}
</Button> </Button>
} }
> >
{loading ? ( {loading ? (
<div className="py-10 text-sm text-muted-foreground">...</div> <div className="py-10 text-sm text-muted-foreground">{t("agentRunLog.loading")}</div>
) : activeLog ? ( ) : activeLog ? (
<> <>
<MetaStrip <MetaStrip
items={[ items={[
{ label: "日志ID", value: String(activeLog.id) }, { label: t("agentRunLog.logId"), value: String(activeLog.id) },
{ label: "会话ID", value: String(activeLog.conversationId || "-") }, { label: t("agentRunLog.conversationId"), value: String(activeLog.conversationId || "-") },
{ label: "消息ID", value: String(activeLog.messageId || "-") }, { label: t("agentRunLog.messageId"), value: String(activeLog.messageId || "-") },
{ label: "AI Agent", value: String(activeLog.aiAgentId || "-") }, { label: "AI Agent", value: String(activeLog.aiAgentId || "-") },
]} ]}
/> />
<InfoBlock <InfoBlock
title="规划阶段" title={t("agentRunLog.planningStage")}
lines={[ lines={[
`plannedAction: ${activeLog.plannedAction || "-"}`, `plannedAction: ${activeLog.plannedAction || "-"}`,
`plannedSkillCode: ${activeLog.plannedSkillCode || "-"}`, `plannedSkillCode: ${activeLog.plannedSkillCode || "-"}`,
@@ -131,15 +135,15 @@ export function AgentRunLogDetailDialog({
]} ]}
/> />
<InfoBlock <InfoBlock
title="HITL 状态" title={t("agentRunLog.hitlStatus")}
lines={[ lines={[
`hitlStatus: ${activeLog.hitlStatus || "-"}`, `hitlStatus: ${activeLog.hitlStatus || "-"}`,
`hitlStatusName: ${activeLog.hitlStatusName || "-"}`, `hitlStatusName: ${getHitlStatusLabel(activeLog.hitlStatus, t) || "-"}`,
`hitlSummary: ${activeLog.hitlSummary || "-"}`, `hitlSummary: ${getHitlSummary(activeLog.hitlStatus, t) || "-"}`,
]} ]}
/> />
<InfoBlock <InfoBlock
title="执行结果" title={t("agentRunLog.executionResult")}
lines={[ lines={[
`finalAction: ${activeLog.finalAction || "-"}`, `finalAction: ${activeLog.finalAction || "-"}`,
`finalStatus: ${activeLog.finalStatus || "-"}`, `finalStatus: ${activeLog.finalStatus || "-"}`,
@@ -151,40 +155,74 @@ export function AgentRunLogDetailDialog({
/> />
<JsonBlock <JsonBlock
title="动态工具选择" title={t("agentRunLog.dynamicTools")}
jsonValue={activeToolSearchTrace} jsonValue={activeToolSearchTrace}
fallbackValue={activeLog.toolSearchTrace} fallbackValue={activeLog.toolSearchTrace}
/> />
<JsonBlock <JsonBlock
title="Graph Tool 调用" title={t("agentRunLog.graphToolCall")}
jsonValue={activeGraphToolTrace} jsonValue={activeGraphToolTrace}
fallbackValue={activeLog.graphToolTrace} fallbackValue={activeLog.graphToolTrace}
/> />
<TextBlock <TextBlock
icon={<BotMessageSquareIcon className="size-4" />} icon={<BotMessageSquareIcon className="size-4" />}
title="用户问题" title={t("agentRunLog.userMessage")}
value={activeLog.userMessage} value={activeLog.userMessage}
renderAsHtml renderAsHtml
/> />
<TextBlock <TextBlock
icon={<WorkflowIcon className="size-4" />} icon={<WorkflowIcon className="size-4" />}
title="机器人回复" title={t("agentRunLog.botReply")}
value={activeLog.replyText} value={activeLog.replyText}
/> />
<TextBlock title="错误信息" value={activeLog.errorMessage} tone="danger" /> <TextBlock title={t("agentRunLog.errorMessage")} value={activeLog.errorMessage} tone="danger" />
<JsonBlock <JsonBlock
title="链路 Trace" title={t("agentRunLog.trace")}
jsonValue={activeTraceData} jsonValue={activeTraceData}
fallbackValue={activeLog.traceData} fallbackValue={activeLog.traceData}
/> />
</> </>
) : ( ) : (
<div className="py-10 text-sm text-muted-foreground"></div> <div className="py-10 text-sm text-muted-foreground">{t("agentRunLog.notFound")}</div>
)} )}
</ProjectDialog> </ProjectDialog>
) )
} }
function getHitlStatusLabel(status: string | undefined, t: TFunction) {
switch (status) {
case "pending":
return t("agentRunLog.hitlPending")
case "confirmed":
return t("agentRunLog.hitlConfirmed")
case "cancelled":
return t("agentRunLog.hitlCancelled")
case "expired":
return t("agentRunLog.hitlExpired")
case "triggered":
return t("agentRunLog.hitlTriggered")
default:
return ""
}
}
function getHitlSummary(status: string | undefined, t: TFunction) {
switch (status) {
case "pending":
return t("agentRunLog.hitlPendingSummary")
case "confirmed":
return t("agentRunLog.hitlConfirmedSummary")
case "cancelled":
return t("agentRunLog.hitlCancelledSummary")
case "expired":
return t("agentRunLog.hitlExpiredSummary")
case "triggered":
return t("agentRunLog.hitlTriggeredSummary")
default:
return ""
}
}
function safeParseJSON(value: string) { function safeParseJSON(value: string) {
if (!value.trim()) { if (!value.trim()) {
return null return null
+88 -58
View File
@@ -22,36 +22,62 @@ import {
type AgentRunLog, type AgentRunLog,
type PageResult, type PageResult,
} from "@/lib/api/admin" } from "@/lib/api/admin"
import { useI18n } from "@/i18n/provider"
import { formatDateTime } from "@/lib/utils" import { formatDateTime } from "@/lib/utils"
const actionOptions = [ type TFunction = (key: string, values?: Record<string, string | number>) => string
{ value: "all", label: "全部动作" },
function getActionOptions(t: TFunction) {
return [
{ value: "all", label: t("agentRunLog.allActions") },
{ value: "rag", label: "RAG" }, { value: "rag", label: "RAG" },
{ value: "skill", label: "Skill" }, { value: "skill", label: "Skill" },
{ value: "tool", label: "Tool" }, { value: "tool", label: "Tool" },
{ value: "graph", label: "Graph" }, { value: "graph", label: "Graph" },
{ value: "handoff", label: "转人工" }, { value: "handoff", label: t("agentRunLog.handoff") },
{ value: "reply", label: "回复" }, { value: "reply", label: t("agentRunLog.reply") },
{ value: "fallback", label: "兜底" }, { value: "fallback", label: t("agentRunLog.fallback") },
] ]
}
const finalStatusOptions = [ function getFinalStatusOptions(t: TFunction) {
{ value: "all", label: "全部状态" }, return [
{ value: "all", label: t("agentRunLog.allStatus") },
{ value: "completed", label: "completed" }, { value: "completed", label: "completed" },
{ value: "interrupted", label: "interrupted" }, { value: "interrupted", label: "interrupted" },
{ value: "expired", label: "expired" }, { value: "expired", label: "expired" },
{ value: "error", label: "error" }, { value: "error", label: "error" },
{ value: "fallback", label: "fallback" }, { value: "fallback", label: "fallback" },
] ]
}
const hitlStatusOptions = [ function getHitlStatusOptions(t: TFunction) {
{ value: "all", label: "全部 HITL" }, return [
{ value: "pending", label: "等待确认" }, { value: "all", label: t("agentRunLog.allHitl") },
{ value: "confirmed", label: "已确认" }, { value: "pending", label: t("agentRunLog.hitlPending") },
{ value: "cancelled", label: "已取消" }, { value: "confirmed", label: t("agentRunLog.hitlConfirmed") },
{ value: "expired", label: "已过期" }, { value: "cancelled", label: t("agentRunLog.hitlCancelled") },
{ value: "triggered", label: "已触发" }, { value: "expired", label: t("agentRunLog.hitlExpired") },
{ value: "triggered", label: t("agentRunLog.hitlTriggered") },
] ]
}
function getHitlStatusLabel(status: string | undefined, t: TFunction) {
switch (status) {
case "pending":
return t("agentRunLog.hitlPending")
case "confirmed":
return t("agentRunLog.hitlConfirmed")
case "cancelled":
return t("agentRunLog.hitlCancelled")
case "expired":
return t("agentRunLog.hitlExpired")
case "triggered":
return t("agentRunLog.hitlTriggered")
default:
return ""
}
}
function actionBadgeVariant(action: string) { function actionBadgeVariant(action: string) {
switch (action) { switch (action) {
@@ -73,6 +99,7 @@ function actionBadgeVariant(action: string) {
} }
export default function DashboardAgentRunLogsPage() { export default function DashboardAgentRunLogsPage() {
const t = useI18n()
const [keywordInput, setKeywordInput] = useState("") const [keywordInput, setKeywordInput] = useState("")
const [plannedActionInput, setPlannedActionInput] = useState("all") const [plannedActionInput, setPlannedActionInput] = useState("all")
const [finalActionInput, setFinalActionInput] = useState("all") const [finalActionInput, setFinalActionInput] = useState("all")
@@ -95,16 +122,19 @@ export default function DashboardAgentRunLogsPage() {
page: { page: 1, limit: 20, total: 0 }, page: { page: 1, limit: 20, total: 0 },
}) })
const [aiAgents, setAiAgents] = useState<AIAgent[]>([]) const [aiAgents, setAiAgents] = useState<AIAgent[]>([])
const actionOptions = useMemo(() => getActionOptions(t), [t])
const finalStatusOptions = useMemo(() => getFinalStatusOptions(t), [t])
const hitlStatusOptions = useMemo(() => getHitlStatusOptions(t), [t])
const aiAgentOptions = useMemo( const aiAgentOptions = useMemo(
() => [ () => [
{ value: "all", label: "全部 Agent" }, { value: "all", label: t("agentRunLog.allAgents") },
...aiAgents.map((item) => ({ ...aiAgents.map((item) => ({
value: String(item.id), value: String(item.id),
label: item.name, label: item.name,
})), })),
], ],
[aiAgents] [aiAgents, t]
) )
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
@@ -122,11 +152,11 @@ export default function DashboardAgentRunLogsPage() {
}) })
setResult(data) setResult(data)
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "加载 Agent 运行日志失败") toast.error(error instanceof Error ? error.message : t("agentRunLog.loadFailed"))
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, [aiAgentId, finalAction, finalStatus, hitlStatus, keyword, limit, page, plannedAction]) }, [aiAgentId, finalAction, finalStatus, hitlStatus, keyword, limit, page, plannedAction, t])
useEffect(() => { useEffect(() => {
void loadData() void loadData()
@@ -138,11 +168,11 @@ export default function DashboardAgentRunLogsPage() {
const data = await fetchAIAgentsAll() const data = await fetchAIAgentsAll()
setAiAgents(data) setAiAgents(data)
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "加载 AI Agent 列表失败") toast.error(error instanceof Error ? error.message : t("agentRunLog.loadAgentsFailed"))
} }
} }
void loadAIAgents() void loadAIAgents()
}, []) }, [t])
function applyFilters() { function applyFilters() {
setKeyword(keywordInput) setKeyword(keywordInput)
@@ -174,7 +204,7 @@ export default function DashboardAgentRunLogsPage() {
className="w-full xl:w-auto" className="w-full xl:w-auto"
> >
<RefreshCwIcon className={loading ? "animate-spin" : ""} /> <RefreshCwIcon className={loading ? "animate-spin" : ""} />
{t("agentRunLog.refresh")}
</Button> </Button>
} }
> >
@@ -184,7 +214,7 @@ export default function DashboardAgentRunLogsPage() {
value={keywordInput} value={keywordInput}
onChange={(event) => setKeywordInput(event.target.value)} onChange={(event) => setKeywordInput(event.target.value)}
onKeyDown={handleFilterKeyDown} onKeyDown={handleFilterKeyDown}
placeholder="按用户问题筛选" placeholder={t("agentRunLog.filterUserMessage")}
className="pl-9" className="pl-9"
/> />
</div> </div>
@@ -192,9 +222,9 @@ export default function DashboardAgentRunLogsPage() {
<OptionCombobox <OptionCombobox
value={plannedActionInput} value={plannedActionInput}
options={actionOptions} options={actionOptions}
placeholder="规划动作" placeholder={t("agentRunLog.plannedAction")}
searchPlaceholder="搜索动作" searchPlaceholder={t("agentRunLog.searchAction")}
emptyText="未找到动作" emptyText={t("agentRunLog.emptyAction")}
onChange={(value) => setPlannedActionInput(value || "all")} onChange={(value) => setPlannedActionInput(value || "all")}
/> />
</div> </div>
@@ -202,9 +232,9 @@ export default function DashboardAgentRunLogsPage() {
<OptionCombobox <OptionCombobox
value={finalActionInput} value={finalActionInput}
options={actionOptions} options={actionOptions}
placeholder="最终动作" placeholder={t("agentRunLog.finalAction")}
searchPlaceholder="搜索动作" searchPlaceholder={t("agentRunLog.searchAction")}
emptyText="未找到动作" emptyText={t("agentRunLog.emptyAction")}
onChange={(value) => setFinalActionInput(value || "all")} onChange={(value) => setFinalActionInput(value || "all")}
/> />
</div> </div>
@@ -212,9 +242,9 @@ export default function DashboardAgentRunLogsPage() {
<OptionCombobox <OptionCombobox
value={finalStatusInput} value={finalStatusInput}
options={finalStatusOptions} options={finalStatusOptions}
placeholder="最终状态" placeholder={t("agentRunLog.finalStatus")}
searchPlaceholder="搜索状态" searchPlaceholder={t("agentRunLog.searchStatus")}
emptyText="未找到状态" emptyText={t("agentRunLog.emptyStatus")}
onChange={(value) => setFinalStatusInput(value || "all")} onChange={(value) => setFinalStatusInput(value || "all")}
/> />
</div> </div>
@@ -222,9 +252,9 @@ export default function DashboardAgentRunLogsPage() {
<OptionCombobox <OptionCombobox
value={hitlStatusInput} value={hitlStatusInput}
options={hitlStatusOptions} options={hitlStatusOptions}
placeholder="HITL 状态" placeholder={t("agentRunLog.hitlStatus")}
searchPlaceholder="搜索 HITL 状态" searchPlaceholder={t("agentRunLog.searchHitl")}
emptyText="未找到状态" emptyText={t("agentRunLog.emptyStatus")}
onChange={(value) => setHitlStatusInput(value || "all")} onChange={(value) => setHitlStatusInput(value || "all")}
/> />
</div> </div>
@@ -232,15 +262,15 @@ export default function DashboardAgentRunLogsPage() {
<OptionCombobox <OptionCombobox
value={aiAgentIdInput} value={aiAgentIdInput}
options={aiAgentOptions} options={aiAgentOptions}
placeholder="选择 Agent" placeholder={t("agentRunLog.selectAgent")}
searchPlaceholder="搜索 Agent" searchPlaceholder={t("agentRunLog.searchAgent")}
emptyText="未找到 Agent" emptyText={t("agentRunLog.emptyAgent")}
onChange={(value) => setAiAgentIdInput(value || "all")} onChange={(value) => setAiAgentIdInput(value || "all")}
/> />
</div> </div>
<Button variant="outline" onClick={applyFilters} disabled={loading} className="w-full xl:w-auto"> <Button variant="outline" onClick={applyFilters} disabled={loading} className="w-full xl:w-auto">
<SearchIcon /> <SearchIcon />
{t("agentRunLog.query")}
</Button> </Button>
</DashboardToolbar> </DashboardToolbar>
@@ -261,18 +291,18 @@ export default function DashboardAgentRunLogsPage() {
> >
{!loading && result.results.length === 0 ? ( {!loading && result.results.length === 0 ? (
<div className="py-14 text-center text-sm text-muted-foreground"> <div className="py-14 text-center text-sm text-muted-foreground">
Agent {t("agentRunLog.emptyRows")}
</div> </div>
) : ( ) : (
<div> <div>
<div className="hidden grid-cols-[160px_minmax(0,1.8fr)_110px_minmax(0,1.2fr)_130px_90px_76px] gap-3 border-b bg-muted/40 px-4 py-3 text-sm text-muted-foreground lg:grid"> <div className="hidden grid-cols-[160px_minmax(0,1.8fr)_110px_minmax(0,1.2fr)_130px_90px_76px] gap-3 border-b bg-muted/40 px-4 py-3 text-sm text-muted-foreground lg:grid">
<div></div> <div>{t("agentRunLog.time")}</div>
<div></div> <div>{t("agentRunLog.userMessage")}</div>
<div></div> <div>{t("agentRunLog.plannedAction")}</div>
<div>Skill / Tool</div> <div>{t("agentRunLog.skillTool")}</div>
<div></div> <div>{t("agentRunLog.finalStatus")}</div>
<div className="text-right"></div> <div className="text-right">{t("agentRunLog.duration")}</div>
<div className="text-right"></div> <div className="text-right">{t("agentRunLog.actions")}</div>
</div> </div>
<div className="divide-y"> <div className="divide-y">
@@ -286,7 +316,7 @@ export default function DashboardAgentRunLogsPage() {
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<UserMessagePreview value={item.userMessage} /> <UserMessagePreview value={item.userMessage} t={t} />
{item.errorMessage ? ( {item.errorMessage ? (
<div className="truncate text-xs text-destructive">{item.errorMessage}</div> <div className="truncate text-xs text-destructive">{item.errorMessage}</div>
) : null} ) : null}
@@ -310,13 +340,13 @@ export default function DashboardAgentRunLogsPage() {
</div> </div>
) : item.handoffReason ? ( ) : item.handoffReason ? (
<div className="truncate text-xs text-muted-foreground"> <div className="truncate text-xs text-muted-foreground">
{item.handoffReason} {t("agentRunLog.handoffReason", { reason: item.handoffReason })}
</div> </div>
) : item.recommendedAction ? ( ) : item.recommendedAction ? (
<div className="truncate text-xs text-muted-foreground"> <div className="truncate text-xs text-muted-foreground">
{item.recommendedAction} {t("agentRunLog.routingRecommendation", { action: item.recommendedAction })}
{item.riskLevel ? ` / ${item.riskLevel} risk` : ""} {item.riskLevel ? ` / ${item.riskLevel} risk` : ""}
{item.ticketDraftReady ? " / 草稿已就绪" : ""} {item.ticketDraftReady ? ` / ${t("agentRunLog.draftReady")}` : ""}
</div> </div>
) : null} ) : null}
</div> </div>
@@ -331,8 +361,8 @@ export default function DashboardAgentRunLogsPage() {
{item.finalAction || "-"} {item.finalAction || "-"}
</Badge> </Badge>
<div className="truncate text-xs text-muted-foreground"> <div className="truncate text-xs text-muted-foreground">
{item.hitlStatusName {getHitlStatusLabel(item.hitlStatus, t)
? `${item.hitlStatusName} / ${item.finalStatus || "-"}` ? `${getHitlStatusLabel(item.hitlStatus, t)} / ${item.finalStatus || "-"}`
: item.finalStatus || "-"} : item.finalStatus || "-"}
</div> </div>
</div> </div>
@@ -351,7 +381,7 @@ export default function DashboardAgentRunLogsPage() {
setDetailOpen(true) setDetailOpen(true)
}} }}
> >
{t("agentRunLog.detail")}
</Button> </Button>
</div> </div>
</article> </article>
@@ -375,8 +405,8 @@ export default function DashboardAgentRunLogsPage() {
) )
} }
function UserMessagePreview({ value }: { value?: string }) { function UserMessagePreview({ value, t }: { value?: string; t: TFunction }) {
const preview = useMemo(() => summarizeUserMessage(value), [value]) const preview = useMemo(() => summarizeUserMessage(value, t), [value, t])
return ( return (
<div className="truncate text-sm text-foreground"> <div className="truncate text-sm text-foreground">
@@ -385,7 +415,7 @@ function UserMessagePreview({ value }: { value?: string }) {
) )
} }
function summarizeUserMessage(value?: string) { function summarizeUserMessage(value: string | undefined, t: TFunction) {
const normalized = value?.trim() const normalized = value?.trim()
if (!normalized) { if (!normalized) {
return "-" return "-"
@@ -396,9 +426,9 @@ function summarizeUserMessage(value?: string) {
} }
if (containsHTML(normalized)) { if (containsHTML(normalized)) {
if (/<img[\s>]/i.test(normalized)) { if (/<img[\s>]/i.test(normalized)) {
return "[图片]" return t("agentRunLog.imageMessage")
} }
return "[富文本消息]" return t("agentRunLog.richMessage")
} }
return normalized return normalized
} }
@@ -34,6 +34,7 @@ import {
type AdminAgentTeamScheduleBatchPreview, type AdminAgentTeamScheduleBatchPreview,
type BatchAdminAgentTeamSchedulePayload, type BatchAdminAgentTeamSchedulePayload,
} from "@/lib/api/admin" } from "@/lib/api/admin"
import { useI18n } from "@/i18n/provider"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
type BatchScheduleDialogProps = { type BatchScheduleDialogProps = {
@@ -42,15 +43,24 @@ type BatchScheduleDialogProps = {
onSuccess: (created: number) => void | Promise<void> onSuccess: (created: number) => void | Promise<void>
} }
const weekdayOptions = [ type TFunction = (key: string, values?: Record<string, string | number>) => string
{ value: 1, label: "周一" },
{ value: 2, label: "周二" }, const weekdayKeys = [
{ value: 3, label: "周三" }, "weekdayMon",
{ value: 4, label: "周四" }, "weekdayTue",
{ value: 5, label: "周五" }, "weekdayWed",
{ value: 6, label: "周六" }, "weekdayThu",
{ value: 7, label: "周日" }, "weekdayFri",
] "weekdaySat",
"weekdaySun",
] as const
function getWeekdayOptions(t: TFunction) {
return weekdayKeys.map((key, index) => ({
value: index + 1,
label: t(`agentTeamSchedule.${key}`),
}))
}
function todayDateValue() { function todayDateValue() {
const today = new Date() const today = new Date()
@@ -88,32 +98,33 @@ function buildPayload(form: BatchFormState): BatchAdminAgentTeamSchedulePayload
} }
} }
function getWeekdayLabel(value: number) { function getWeekdayLabel(value: number, t: TFunction) {
return weekdayOptions.find((option) => option.value === value)?.label ?? `${value}` const weekdayOptions = getWeekdayOptions(t)
return weekdayOptions.find((option) => option.value === value)?.label ?? String(value)
} }
function validateForm(form: BatchFormState) { function validateForm(form: BatchFormState, t: TFunction) {
const today = todayDateValue() const today = todayDateValue()
if (form.selectedTeamIds.length === 0) { if (form.selectedTeamIds.length === 0) {
return "请选择至少一个客服组" return t("agentTeamSchedule.selectAtLeastOneTeam")
} }
if (!form.startDate || !form.endDate) { if (!form.startDate || !form.endDate) {
return "请选择日期范围" return t("agentTeamSchedule.selectDateRange")
} }
if (form.startDate < today) { if (form.startDate < today) {
return "开始日期不能早于今天" return t("agentTeamSchedule.startBeforeToday")
} }
if (form.endDate < form.startDate) { if (form.endDate < form.startDate) {
return "结束日期不能早于开始日期" return t("agentTeamSchedule.endBeforeStart")
} }
if (form.weekdays.length === 0) { if (form.weekdays.length === 0) {
return "请选择至少一个星期" return t("agentTeamSchedule.selectAtLeastOneWeekday")
} }
if (!form.startTime || !form.endTime) { if (!form.startTime || !form.endTime) {
return "请选择开始和结束时间" return t("agentTeamSchedule.selectStartEndTime")
} }
if (form.endTime <= form.startTime) { if (form.endTime <= form.startTime) {
return "结束时间必须晚于开始时间" return t("agentTeamSchedule.endAfterStart")
} }
return "" return ""
} }
@@ -123,6 +134,7 @@ export function BatchScheduleDialog({
onOpenChange, onOpenChange,
onSuccess, onSuccess,
}: BatchScheduleDialogProps) { }: BatchScheduleDialogProps) {
const t = useI18n()
const [teams, setTeams] = useState<AdminAgentTeam[]>([]) const [teams, setTeams] = useState<AdminAgentTeam[]>([])
const [form, setForm] = useState(defaultFormState) const [form, setForm] = useState(defaultFormState)
const [step, setStep] = useState<DialogStep>("form") const [step, setStep] = useState<DialogStep>("form")
@@ -134,6 +146,7 @@ export function BatchScheduleDialog({
const openRef = useRef(open) const openRef = useRef(open)
const previewRequestIdRef = useRef(0) const previewRequestIdRef = useRef(0)
const busy = loadingTeams || previewing || submitting const busy = loadingTeams || previewing || submitting
const weekdayOptions = useMemo(() => getWeekdayOptions(t), [t])
const teamOptions = useMemo( const teamOptions = useMemo(
() => () =>
@@ -181,7 +194,7 @@ export function BatchScheduleDialog({
} }
} catch (error) { } catch (error) {
if (!ignore) { if (!ignore) {
toast.error(error instanceof Error ? error.message : "加载客服组选项失败") toast.error(error instanceof Error ? error.message : t("agentTeamSchedule.loadTeamsFailed"))
} }
} finally { } finally {
if (!ignore) { if (!ignore) {
@@ -194,7 +207,7 @@ export function BatchScheduleDialog({
return () => { return () => {
ignore = true ignore = true
} }
}, [open]) }, [open, t])
function updateForm(values: Partial<BatchFormState>) { function updateForm(values: Partial<BatchFormState>) {
previewRequestIdRef.current += 1 previewRequestIdRef.current += 1
@@ -234,7 +247,7 @@ export function BatchScheduleDialog({
} }
async function handlePreview() { async function handlePreview() {
const validationMessage = validateForm(form) const validationMessage = validateForm(form, t)
if (validationMessage) { if (validationMessage) {
toast.error(validationMessage) toast.error(validationMessage)
return return
@@ -254,7 +267,7 @@ export function BatchScheduleDialog({
setStep("preview") setStep("preview")
} catch (error) { } catch (error) {
if (openRef.current && previewRequestIdRef.current === requestId) { if (openRef.current && previewRequestIdRef.current === requestId) {
toast.error(error instanceof Error ? error.message : "预览批量排班失败") toast.error(error instanceof Error ? error.message : t("agentTeamSchedule.previewFailed"))
} }
} finally { } finally {
if (openRef.current && previewRequestIdRef.current === requestId) { if (openRef.current && previewRequestIdRef.current === requestId) {
@@ -265,11 +278,11 @@ export function BatchScheduleDialog({
async function handleSubmit() { async function handleSubmit() {
if (!preview || !previewPayload) { if (!preview || !previewPayload) {
toast.error("请先预览批量排班") toast.error(t("agentTeamSchedule.previewFirst"))
return return
} }
if (preview.conflict) { if (preview.conflict) {
toast.error("存在冲突排班,不能提交") toast.error(t("agentTeamSchedule.conflictCannotSubmit"))
return return
} }
@@ -277,17 +290,17 @@ export function BatchScheduleDialog({
setSubmitting(true) setSubmitting(true)
try { try {
const data = await generateAgentTeamScheduleBatch(payload) const data = await generateAgentTeamScheduleBatch(payload)
toast.success(`已创建 ${data.created} 条客服组排班`) toast.success(t("agentTeamSchedule.batchCreated", { count: data.created }))
setPreview(null) setPreview(null)
setPreviewPayload(null) setPreviewPayload(null)
onOpenChange(false) onOpenChange(false)
try { try {
await onSuccess(data.created) await onSuccess(data.created)
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? `排班已生成,但刷新列表失败:${error.message}` : "排班已生成,但刷新列表失败") toast.error(error instanceof Error ? t("agentTeamSchedule.batchRefreshFailed", { message: error.message }) : t("agentTeamSchedule.batchRefreshFailedFallback"))
} }
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "生成批量排班失败") toast.error(error instanceof Error ? error.message : t("agentTeamSchedule.generateFailed"))
} finally { } finally {
setSubmitting(false) setSubmitting(false)
} }
@@ -297,9 +310,9 @@ export function BatchScheduleDialog({
<Dialog open={open} onOpenChange={handleOpenChange}> <Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="flex max-h-[calc(100vh-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-4xl"> <DialogContent className="flex max-h-[calc(100vh-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-4xl">
<DialogHeader className="shrink-0 px-6 pt-6"> <DialogHeader className="shrink-0 px-6 pt-6">
<DialogTitle></DialogTitle> <DialogTitle>{t("agentTeamSchedule.batch")}</DialogTitle>
<DialogDescription> <DialogDescription>
{t("agentTeamSchedule.batchDescription")}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -307,15 +320,15 @@ export function BatchScheduleDialog({
{step === "form" ? ( {step === "form" ? (
<div className="space-y-5"> <div className="space-y-5">
<div className="space-y-2"> <div className="space-y-2">
<Label></Label> <Label>{t("agentTeamSchedule.team")}</Label>
<div className="flex gap-2"> <div className="flex gap-2">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<OptionCombobox <OptionCombobox
value="" value=""
options={teamOptions} options={teamOptions}
placeholder={loadingTeams ? "加载客服组中..." : "添加客服组"} placeholder={loadingTeams ? t("agentTeamSchedule.loadingTeams") : t("agentTeamSchedule.addTeam")}
searchPlaceholder="搜索客服组" searchPlaceholder={t("agentTeamSchedule.searchTeam")}
emptyText={teams.length === 0 ? "暂无客服组" : "已选择全部客服组"} emptyText={teams.length === 0 ? t("agentTeamSchedule.noTeams") : t("agentTeamSchedule.allTeamsSelected")}
disabled={loadingTeams} disabled={loadingTeams}
onChange={handleTeamSelect} onChange={handleTeamSelect}
/> />
@@ -332,7 +345,7 @@ export function BatchScheduleDialog({
size="icon-sm" size="icon-sm"
className="size-5 rounded-sm" className="size-5 rounded-sm"
onClick={() => removeTeam(team.id)} onClick={() => removeTeam(team.id)}
aria-label={`移除${team.name}`} aria-label={t("agentTeamSchedule.removeTeam", { name: team.name })}
> >
<XIcon className="size-3" /> <XIcon className="size-3" />
</Button> </Button>
@@ -340,13 +353,13 @@ export function BatchScheduleDialog({
))} ))}
</div> </div>
) : ( ) : (
<div className="text-sm text-muted-foreground"></div> <div className="text-sm text-muted-foreground">{t("agentTeamSchedule.noSelectedTeams")}</div>
)} )}
</div> </div>
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="batch-schedule-start-date"></Label> <Label htmlFor="batch-schedule-start-date">{t("agentTeamSchedule.startDate")}</Label>
<Input <Input
id="batch-schedule-start-date" id="batch-schedule-start-date"
type="date" type="date"
@@ -356,7 +369,7 @@ export function BatchScheduleDialog({
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="batch-schedule-end-date"></Label> <Label htmlFor="batch-schedule-end-date">{t("agentTeamSchedule.endDate")}</Label>
<Input <Input
id="batch-schedule-end-date" id="batch-schedule-end-date"
type="date" type="date"
@@ -368,7 +381,7 @@ export function BatchScheduleDialog({
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label></Label> <Label>{t("agentTeamSchedule.weekday")}</Label>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{weekdayOptions.map((option) => { {weekdayOptions.map((option) => {
const selected = selectedWeekdays.has(option.value) const selected = selectedWeekdays.has(option.value)
@@ -391,7 +404,7 @@ export function BatchScheduleDialog({
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="batch-schedule-start-time"></Label> <Label htmlFor="batch-schedule-start-time">{t("agentTeamSchedule.startTime")}</Label>
<Input <Input
id="batch-schedule-start-time" id="batch-schedule-start-time"
type="time" type="time"
@@ -400,7 +413,7 @@ export function BatchScheduleDialog({
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="batch-schedule-end-time"></Label> <Label htmlFor="batch-schedule-end-time">{t("agentTeamSchedule.endTime")}</Label>
<Input <Input
id="batch-schedule-end-time" id="batch-schedule-end-time"
type="time" type="time"
@@ -411,11 +424,11 @@ export function BatchScheduleDialog({
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="batch-schedule-remark"></Label> <Label htmlFor="batch-schedule-remark">{t("agentTeamSchedule.remark")}</Label>
<Textarea <Textarea
id="batch-schedule-remark" id="batch-schedule-remark"
rows={4} rows={4}
placeholder="请输入备注" placeholder={t("agentTeamSchedule.remarkPlaceholder")}
value={form.remark} value={form.remark}
onChange={(event) => updateForm({ remark: event.target.value })} onChange={(event) => updateForm({ remark: event.target.value })}
/> />
@@ -425,13 +438,14 @@ export function BatchScheduleDialog({
<div className="space-y-4"> <div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{preview?.total ?? 0} {hasConflict
{hasConflict ? ",存在冲突,请返回调整" : ",确认无冲突后可生成"} ? t("agentTeamSchedule.previewSummaryConflict", { total: preview?.total ?? 0 })
: t("agentTeamSchedule.previewSummaryReady", { total: preview?.total ?? 0 })}
</div> </div>
{hasConflict ? ( {hasConflict ? (
<Badge variant="destructive"></Badge> <Badge variant="destructive">{t("agentTeamSchedule.hasConflict")}</Badge>
) : ( ) : (
<Badge variant="secondary"></Badge> <Badge variant="secondary">{t("agentTeamSchedule.noConflict")}</Badge>
)} )}
</div> </div>
<div className="overflow-x-auto rounded-lg border"> <div className="overflow-x-auto rounded-lg border">
@@ -439,12 +453,12 @@ export function BatchScheduleDialog({
<Table> <Table>
<TableHeader className="bg-muted/40"> <TableHeader className="bg-muted/40">
<TableRow> <TableRow>
<TableHead></TableHead> <TableHead>{t("agentTeamSchedule.team")}</TableHead>
<TableHead></TableHead> <TableHead>{t("agentTeamSchedule.date")}</TableHead>
<TableHead></TableHead> <TableHead>{t("agentTeamSchedule.weekday")}</TableHead>
<TableHead></TableHead> <TableHead>{t("agentTeamSchedule.time")}</TableHead>
<TableHead></TableHead> <TableHead>{t("agentTeamSchedule.remark")}</TableHead>
<TableHead></TableHead> <TableHead>{t("agentTeamSchedule.status")}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -456,11 +470,11 @@ export function BatchScheduleDialog({
)} )}
> >
<TableCell> <TableCell>
<div className="font-medium">{item.teamName || `客服组#${item.teamId}`}</div> <div className="font-medium">{item.teamName || t("agentTeamSchedule.teamFallback", { id: item.teamId })}</div>
<div className="text-xs text-muted-foreground">ID{item.teamId}</div> <div className="text-xs text-muted-foreground">{t("agentTeamSchedule.teamId", { id: item.teamId })}</div>
</TableCell> </TableCell>
<TableCell>{item.date}</TableCell> <TableCell>{item.date}</TableCell>
<TableCell>{getWeekdayLabel(item.weekday)}</TableCell> <TableCell>{getWeekdayLabel(item.weekday, t)}</TableCell>
<TableCell> <TableCell>
{item.startAt.slice(11, 16)} - {item.endAt.slice(11, 16)} {item.startAt.slice(11, 16)} - {item.endAt.slice(11, 16)}
</TableCell> </TableCell>
@@ -468,10 +482,10 @@ export function BatchScheduleDialog({
<TableCell> <TableCell>
{item.conflict ? ( {item.conflict ? (
<span className="font-medium"> <span className="font-medium">
{item.conflictReason || "排班冲突"} {item.conflictReason || t("agentTeamSchedule.conflictFallback")}
</span> </span>
) : ( ) : (
<span className="text-muted-foreground"></span> <span className="text-muted-foreground">{t("agentTeamSchedule.canGenerate")}</span>
)} )}
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -479,7 +493,7 @@ export function BatchScheduleDialog({
{preview && preview.items.length === 0 ? ( {preview && preview.items.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={6} className="py-10 text-center text-muted-foreground"> <TableCell colSpan={6} className="py-10 text-center text-muted-foreground">
{t("agentTeamSchedule.emptyPreview")}
</TableCell> </TableCell>
</TableRow> </TableRow>
) : null} ) : null}
@@ -500,7 +514,7 @@ export function BatchScheduleDialog({
disabled={submitting} disabled={submitting}
> >
<ArrowLeftIcon /> <ArrowLeftIcon />
{t("agentTeamSchedule.backToEdit")}
</Button> </Button>
) : null} ) : null}
<Button <Button
@@ -509,12 +523,12 @@ export function BatchScheduleDialog({
onClick={() => handleOpenChange(false)} onClick={() => handleOpenChange(false)}
disabled={busy} disabled={busy}
> >
{t("agentTeamSchedule.cancel")}
</Button> </Button>
{step === "form" ? ( {step === "form" ? (
<Button type="button" onClick={() => void handlePreview()} disabled={busy}> <Button type="button" onClick={() => void handlePreview()} disabled={busy}>
{previewing ? <Loader2Icon className="animate-spin" /> : <CheckIcon />} {previewing ? <Loader2Icon className="animate-spin" /> : <CheckIcon />}
{t("agentTeamSchedule.preview")}
</Button> </Button>
) : ( ) : (
<Button <Button
@@ -523,7 +537,7 @@ export function BatchScheduleDialog({
disabled={submitting || hasConflict || !preview || !previewPayload || preview.items.length === 0} disabled={submitting || hasConflict || !preview || !previewPayload || preview.items.length === 0}
> >
{submitting ? <Loader2Icon className="animate-spin" /> : <CheckIcon />} {submitting ? <Loader2Icon className="animate-spin" /> : <CheckIcon />}
{t("agentTeamSchedule.generate")}
</Button> </Button>
)} )}
</DialogFooter> </DialogFooter>
@@ -58,7 +58,7 @@ export function formatDateTimeValue(date: Date) {
} }
export function formatMonthTitle(monthStart: Date) { export function formatMonthTitle(monthStart: Date) {
return `${monthStart.getFullYear()}${String(monthStart.getMonth() + 1).padStart(2, "0")}` return `${monthStart.getFullYear()}-${String(monthStart.getMonth() + 1).padStart(2, "0")}`
} }
function formatDate(date: Date) { function formatDate(date: Date) {
@@ -13,7 +13,17 @@ import { cn, formatDateTime } from "@/lib/utils"
import { isSameLocalDay } from "./calendar-date-range" import { isSameLocalDay } from "./calendar-date-range"
import { buildDayTimeLayout } from "./calendar-time-layout" import { buildDayTimeLayout } from "./calendar-time-layout"
const weekDayNames = ["一", "二", "三", "四", "五", "六", "日"] type TFunction = (key: string, values?: Record<string, string | number>) => string
const weekDayKeys = [
"weekdayShortMon",
"weekdayShortTue",
"weekdayShortWed",
"weekdayShortThu",
"weekdayShortFri",
"weekdayShortSat",
"weekdayShortSun",
] as const
const dayMs = 24 * 60 * 60 * 1000 const dayMs = 24 * 60 * 60 * 1000
const minuteMs = 60 * 1000 const minuteMs = 60 * 1000
const minDurationMs = 15 * minuteMs const minDurationMs = 15 * minuteMs
@@ -31,6 +41,7 @@ type ScheduleCalendarProps = {
onEdit: (item: AdminAgentTeamSchedule) => void onEdit: (item: AdminAgentTeamSchedule) => void
onMove: (payload: UpdateAdminAgentTeamSchedulePayload) => Promise<void> onMove: (payload: UpdateAdminAgentTeamSchedulePayload) => Promise<void>
onResize: (payload: UpdateAdminAgentTeamSchedulePayload) => Promise<void> onResize: (payload: UpdateAdminAgentTeamSchedulePayload) => Promise<void>
t: TFunction
} }
type DragState = type DragState =
@@ -226,6 +237,7 @@ export function ScheduleCalendar({
onEdit, onEdit,
onMove, onMove,
onResize, onResize,
t,
}: ScheduleCalendarProps) { }: ScheduleCalendarProps) {
const days = buildCalendarDays(calendarStart, calendarEnd) const days = buildCalendarDays(calendarStart, calendarEnd)
const defaultTeamID = teams[0]?.id ?? 0 const defaultTeamID = teams[0]?.id ?? 0
@@ -250,7 +262,7 @@ export function ScheduleCalendar({
return { return {
itemId: state.item.id, itemId: state.item.id,
date: null, date: null,
label: "拖到日历日期格内", label: t("agentTeamSchedule.dropInsideCalendar"),
invalid: true, invalid: true,
x: pointerEvent.clientX, x: pointerEvent.clientX,
y: pointerEvent.clientY, y: pointerEvent.clientY,
@@ -265,7 +277,7 @@ export function ScheduleCalendar({
return { return {
itemId: state.item.id, itemId: state.item.id,
date, date,
label: "不能修改历史日期", label: t("agentTeamSchedule.historyReadonly"),
invalid: true, invalid: true,
x: pointerEvent.clientX, x: pointerEvent.clientX,
y: pointerEvent.clientY, y: pointerEvent.clientY,
@@ -274,11 +286,11 @@ export function ScheduleCalendar({
const point = { x: pointerEvent.clientX, y: pointerEvent.clientY } const point = { x: pointerEvent.clientX, y: pointerEvent.clientY }
if (state.type === "move") { if (state.type === "move") {
return buildPreviewFromPayload(state.item.id, date, buildMovePayload(state.item, date), point, "无法移动到这里") return buildPreviewFromPayload(state.item.id, date, buildMovePayload(state.item, date), point, t("agentTeamSchedule.cannotMoveHere"))
} }
const payload = buildResizePayload(state.item, state.edge, getPointerDateInCell(pointerEvent, cell)) const payload = buildResizePayload(state.item, state.edge, getPointerDateInCell(pointerEvent, cell))
return buildPreviewFromPayload(state.item.id, date, payload, point, "不能跨天或少于 15 分钟") return buildPreviewFromPayload(state.item.id, date, payload, point, t("agentTeamSchedule.resizeInvalid"))
} }
function cleanupPointerInteraction( function cleanupPointerInteraction(
@@ -365,7 +377,7 @@ export function ScheduleCalendar({
if (teams.length === 0 && !loading) { if (teams.length === 0 && !loading) {
return ( return (
<div className="flex min-h-64 items-center justify-center rounded-lg border bg-background text-sm text-muted-foreground"> <div className="flex min-h-64 items-center justify-center rounded-lg border bg-background text-sm text-muted-foreground">
{t("agentTeamSchedule.noTeamsCalendar")}
</div> </div>
) )
} }
@@ -426,7 +438,7 @@ export function ScheduleCalendar({
<div className="flex shrink-0 items-center gap-1"> <div className="flex shrink-0 items-center gap-1">
{today ? ( {today ? (
<span className="rounded-sm bg-primary px-1.5 py-0.5 text-[10px] font-medium leading-none text-primary-foreground"> <span className="rounded-sm bg-primary px-1.5 py-0.5 text-[10px] font-medium leading-none text-primary-foreground">
{t("agentTeamSchedule.today")}
</span> </span>
) : null} ) : null}
{historical ? null : <CalendarPlusIcon className="size-3.5 text-muted-foreground" />} {historical ? null : <CalendarPlusIcon className="size-3.5 text-muted-foreground" />}
@@ -434,7 +446,7 @@ export function ScheduleCalendar({
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
{daySchedules.slice(0, 5).map((item) => { {daySchedules.slice(0, 5).map((item) => {
const teamName = item.teamName || teams.find((team) => team.id === item.teamId)?.name || `客服组#${item.teamId}` const teamName = item.teamName || teams.find((team) => team.id === item.teamId)?.name || t("agentTeamSchedule.teamFallback", { id: item.teamId })
const busy = savingId === item.id const busy = savingId === item.id
const active = interactionPreview?.itemId === item.id const active = interactionPreview?.itemId === item.id
const timeLayout = dayTimeLayout.items.get(item.id) const timeLayout = dayTimeLayout.items.get(item.id)
@@ -512,7 +524,7 @@ export function ScheduleCalendar({
) )
})} })}
{daySchedules.length > 5 ? ( {daySchedules.length > 5 ? (
<div className="text-xs text-muted-foreground"> {daySchedules.length - 5} </div> <div className="text-xs text-muted-foreground">{t("agentTeamSchedule.moreItems", { count: daySchedules.length - 5 })}</div>
) : null} ) : null}
</div> </div>
</div> </div>
@@ -528,7 +540,7 @@ export function ScheduleCalendar({
return ( return (
<div key={date} className="grid grid-cols-[112px_minmax(0,1fr)]"> <div key={date} className="grid grid-cols-[112px_minmax(0,1fr)]">
<div className="border-r bg-muted/40 px-3 py-3 text-sm font-medium"> <div className="border-r bg-muted/40 px-3 py-3 text-sm font-medium">
<div>{weekDayNames[dayIndex] ?? ""}</div> <div>{t(`agentTeamSchedule.${weekDayKeys[dayIndex] ?? "weekdayShortMon"}`)}</div>
<div className="mt-1 text-xs font-normal text-muted-foreground">{date.slice(5)}</div> <div className="mt-1 text-xs font-normal text-muted-foreground">{date.slice(5)}</div>
</div> </div>
{renderDayCell(day, dayIndex, { {renderDayCell(day, dayIndex, {
@@ -562,9 +574,9 @@ export function ScheduleCalendar({
return ( return (
<div className="min-w-[960px] overflow-hidden rounded-lg border bg-background"> <div className="min-w-[960px] overflow-hidden rounded-lg border bg-background">
<div className="grid grid-cols-7 border-b bg-muted/40"> <div className="grid grid-cols-7 border-b bg-muted/40">
{weekDayNames.map((name) => ( {weekDayKeys.map((key) => (
<div key={name} className="flex h-10 items-center justify-center border-l first:border-l-0 text-sm font-medium"> <div key={key} className="flex h-10 items-center justify-center border-l first:border-l-0 text-sm font-medium">
{name} {t(`agentTeamSchedule.${key}`)}
</div> </div>
))} ))}
</div> </div>
@@ -1,7 +1,7 @@
"use client" "use client"
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod"
import { useCallback, useEffect, useState } from "react" import { useCallback, useEffect, useMemo, useState } from "react"
import { Controller, Resolver, useForm } from "react-hook-form" import { Controller, type Resolver, useForm } from "react-hook-form"
import { toast } from "sonner" import { toast } from "sonner"
import { z } from "zod/v4" import { z } from "zod/v4"
@@ -27,8 +27,11 @@ import {
type AdminAgentTeamSchedule, type AdminAgentTeamSchedule,
type CreateAdminAgentTeamSchedulePayload, type CreateAdminAgentTeamSchedulePayload,
fetchAgentTeamSchedule, fetchAgentTeamSchedule,
fetchAgentTeamsAll fetchAgentTeamsAll,
} from "@/lib/api/admin" } from "@/lib/api/admin"
import { useI18n } from "@/i18n/provider"
type TFunction = (key: string, values?: Record<string, string | number>) => string
type ScheduleEditDialogProps = { type ScheduleEditDialogProps = {
open: boolean open: boolean
@@ -47,10 +50,18 @@ const emptyForm: EditForm = {
remark: "", remark: "",
} }
const editFormSchema = z.object({ type EditForm = {
teamId: z.string().trim().regex(/^\d+$/, "请选择客服组"), teamId: string
startAt: z.string().trim().min(1, "开始时间不能为空"), startAt: string
endAt: z.string().trim().min(1, "结束时间不能为空"), endAt: string
remark: string
}
function createEditFormSchema(t: TFunction) {
return z.object({
teamId: z.string().trim().regex(/^\d+$/, t("agentTeamSchedule.teamRequired")),
startAt: z.string().trim().min(1, t("agentTeamSchedule.startRequired")),
endAt: z.string().trim().min(1, t("agentTeamSchedule.endRequired")),
remark: z.string().trim(), remark: z.string().trim(),
}).superRefine((value, ctx) => { }).superRefine((value, ctx) => {
const startAt = parseDateTimeLocal(value.startAt) const startAt = parseDateTimeLocal(value.startAt)
@@ -62,7 +73,7 @@ const editFormSchema = z.object({
ctx.addIssue({ ctx.addIssue({
code: "custom", code: "custom",
path: ["endAt"], path: ["endAt"],
message: "结束时间必须晚于开始时间", message: t("agentTeamSchedule.endAfterStart"),
}) })
return return
} }
@@ -70,24 +81,18 @@ const editFormSchema = z.object({
ctx.addIssue({ ctx.addIssue({
code: "custom", code: "custom",
path: ["endAt"], path: ["endAt"],
message: "单条排班记录不能跨天", message: t("agentTeamSchedule.singleDayOnly"),
}) })
} }
if (startAt < startOfLocalDay(new Date())) { if (startAt < startOfLocalDay(new Date())) {
ctx.addIssue({ ctx.addIssue({
code: "custom", code: "custom",
path: ["startAt"], path: ["startAt"],
message: "不能添加或修改历史日期的排班", message: t("agentTeamSchedule.historyReadonly"),
}) })
} }
}) })
}
type EditForm = z.infer<typeof editFormSchema>
const editFormResolver = zodResolver(editFormSchema as never) as Resolver<
z.input<typeof editFormSchema>,
undefined,
z.output<typeof editFormSchema>
>
function toDateTimeLocal(value?: string) { function toDateTimeLocal(value?: string) {
if (!value) { if (!value) {
@@ -180,6 +185,7 @@ function ScheduleEditDialogBody({
onSubmit, onSubmit,
onDelete, onDelete,
}: ScheduleEditDialogBodyProps) { }: ScheduleEditDialogBodyProps) {
const t = useI18n()
const [teams, setTeams] = useState<AdminAgentTeam[]>([]) const [teams, setTeams] = useState<AdminAgentTeam[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const loadOptions = useCallback(async () => { const loadOptions = useCallback(async () => {
@@ -187,14 +193,15 @@ function ScheduleEditDialogBody({
const teamsData = await fetchAgentTeamsAll() const teamsData = await fetchAgentTeamsAll()
setTeams(teamsData) setTeams(teamsData)
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "加载选项失败") toast.error(error instanceof Error ? error.message : t("agentTeamSchedule.loadOptionsFailed"))
} }
}, []) }, [t])
const form = useForm< const editFormSchema = useMemo(() => createEditFormSchema(t), [t])
z.input<typeof editFormSchema>, const editFormResolver = useMemo(
undefined, () => zodResolver(editFormSchema) as Resolver<EditForm>,
z.output<typeof editFormSchema> [editFormSchema],
>({ )
const form = useForm<EditForm>({
resolver: editFormResolver, resolver: editFormResolver,
defaultValues: emptyForm, defaultValues: emptyForm,
}) })
@@ -218,13 +225,13 @@ function ScheduleEditDialogBody({
const data = await fetchAgentTeamSchedule(itemId) const data = await fetchAgentTeamSchedule(itemId)
reset(buildForm(data)) reset(buildForm(data))
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "加载客服组排班详情失败") toast.error(error instanceof Error ? error.message : t("agentTeamSchedule.loadDetailFailed"))
} finally { } finally {
setLoading(false) setLoading(false)
} }
} }
void loadDetail() void loadDetail()
}, [defaultValues, itemId, reset]) }, [defaultValues, itemId, reset, t])
useEffect(() => { useEffect(() => {
void loadOptions() void loadOptions()
@@ -237,18 +244,18 @@ function ScheduleEditDialogBody({
return ( return (
<DialogContent className="max-w-xl gap-0 p-0 sm:max-w-xl"> <DialogContent className="max-w-xl gap-0 p-0 sm:max-w-xl">
<DialogHeader className="px-6 pt-6"> <DialogHeader className="px-6 pt-6">
<DialogTitle>{itemId ? "编辑客服组排班" : "新建客服组排班"}</DialogTitle> <DialogTitle>{itemId ? t("agentTeamSchedule.editTitle") : t("agentTeamSchedule.createTitle")}</DialogTitle>
</DialogHeader> </DialogHeader>
{loading ? ( {loading ? (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<div className="text-muted-foreground">...</div> <div className="text-muted-foreground">{t("agentTeamSchedule.loading")}</div>
</div> </div>
) : ( ) : (
<form onSubmit={handleSubmit(onFormSubmit)}> <form onSubmit={handleSubmit(onFormSubmit)}>
<div className="space-y-4 p-6"> <div className="space-y-4 p-6">
<div className="grid grid-cols-1 gap-4"> <div className="grid grid-cols-1 gap-4">
<Field data-invalid={!!errors.teamId}> <Field data-invalid={!!errors.teamId}>
<FieldLabel></FieldLabel> <FieldLabel>{t("agentTeamSchedule.team")}</FieldLabel>
<FieldContent> <FieldContent>
<Controller <Controller
control={control} control={control}
@@ -260,8 +267,9 @@ function ScheduleEditDialogBody({
value: String(team.id), value: String(team.id),
label: team.name, label: team.name,
}))} }))}
placeholder="请选择客服组" placeholder={t("agentTeamSchedule.teamRequired")}
searchPlaceholder="搜索客服组" searchPlaceholder={t("agentTeamSchedule.searchTeam")}
emptyText={t("agentTeamSchedule.emptyTeam")}
onChange={field.onChange} onChange={field.onChange}
/> />
)} )}
@@ -272,14 +280,14 @@ function ScheduleEditDialogBody({
</div> </div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field data-invalid={!!errors.startAt}> <Field data-invalid={!!errors.startAt}>
<FieldLabel htmlFor="agent-team-schedule-start-at"></FieldLabel> <FieldLabel htmlFor="agent-team-schedule-start-at">{t("agentTeamSchedule.startTime")}</FieldLabel>
<FieldContent> <FieldContent>
<Input id="agent-team-schedule-start-at" type="datetime-local" min={minDateTime} {...register("startAt")} /> <Input id="agent-team-schedule-start-at" type="datetime-local" min={minDateTime} {...register("startAt")} />
<FieldError errors={[errors.startAt]} /> <FieldError errors={[errors.startAt]} />
</FieldContent> </FieldContent>
</Field> </Field>
<Field data-invalid={!!errors.endAt}> <Field data-invalid={!!errors.endAt}>
<FieldLabel htmlFor="agent-team-schedule-end-at"></FieldLabel> <FieldLabel htmlFor="agent-team-schedule-end-at">{t("agentTeamSchedule.endTime")}</FieldLabel>
<FieldContent> <FieldContent>
<Input id="agent-team-schedule-end-at" type="datetime-local" min={minDateTime} {...register("endAt")} /> <Input id="agent-team-schedule-end-at" type="datetime-local" min={minDateTime} {...register("endAt")} />
<FieldError errors={[errors.endAt]} /> <FieldError errors={[errors.endAt]} />
@@ -287,23 +295,23 @@ function ScheduleEditDialogBody({
</Field> </Field>
</div> </div>
<Field> <Field>
<FieldLabel htmlFor="agent-team-schedule-remark"></FieldLabel> <FieldLabel htmlFor="agent-team-schedule-remark">{t("agentTeamSchedule.remark")}</FieldLabel>
<FieldContent> <FieldContent>
<Textarea id="agent-team-schedule-remark" rows={4} placeholder="请输入备注" {...register("remark")} /> <Textarea id="agent-team-schedule-remark" rows={4} placeholder={t("agentTeamSchedule.remarkPlaceholder")} {...register("remark")} />
</FieldContent> </FieldContent>
</Field> </Field>
</div> </div>
<DialogFooter className="mx-0 mb-0 px-6 py-4"> <DialogFooter className="mx-0 mb-0 px-6 py-4">
{itemId && onDelete ? ( {itemId && onDelete ? (
<Button type="button" variant="destructive" onClick={() => void onDelete(itemId)} disabled={saving}> <Button type="button" variant="destructive" onClick={() => void onDelete(itemId)} disabled={saving}>
{t("agentTeamSchedule.delete")}
</Button> </Button>
) : null} ) : null}
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={saving}> <Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
{t("agentTeamSchedule.cancel")}
</Button> </Button>
<Button type="submit" disabled={saving || loading}> <Button type="submit" disabled={saving || loading}>
{saving ? "保存中..." : "保存"} {saving ? t("agentTeamSchedule.saving") : t("agentTeamSchedule.save")}
</Button> </Button>
</DialogFooter> </DialogFooter>
</form> </form>
+51 -44
View File
@@ -49,6 +49,7 @@ import {
type PageResult, type PageResult,
type UpdateAdminAgentTeamSchedulePayload, type UpdateAdminAgentTeamSchedulePayload,
} from "@/lib/api/admin" } from "@/lib/api/admin"
import { useI18n } from "@/i18n/provider"
import { formatDateTime } from "@/lib/utils" import { formatDateTime } from "@/lib/utils"
import { BatchScheduleDialog } from "./_components/batch-schedule-dialog" import { BatchScheduleDialog } from "./_components/batch-schedule-dialog"
import { ScheduleCalendar } from "./_components/calendar" import { ScheduleCalendar } from "./_components/calendar"
@@ -56,7 +57,6 @@ import {
addDays, addDays,
addMonths, addMonths,
formatDateTimeValue, formatDateTimeValue,
formatMonthTitle,
formatWeekTitle, formatWeekTitle,
startOfDay, startOfDay,
startOfMonth, startOfMonth,
@@ -79,6 +79,7 @@ function isHistoricalSchedule(item: AdminAgentTeamSchedule) {
} }
export default function DashboardAgentTeamSchedulesPage() { export default function DashboardAgentTeamSchedulesPage() {
const t = useI18n()
const [viewMode, setViewMode] = useState<ViewMode>("month") const [viewMode, setViewMode] = useState<ViewMode>("month")
const [teamFilterInput, setTeamFilterInput] = useState("all") const [teamFilterInput, setTeamFilterInput] = useState("all")
const [teamFilter, setTeamFilter] = useState("all") const [teamFilter, setTeamFilter] = useState("all")
@@ -120,11 +121,11 @@ export default function DashboardAgentTeamSchedulesPage() {
}) })
setResult(data) setResult(data)
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "加载客服组排班失败") toast.error(error instanceof Error ? error.message : t("agentTeamSchedule.loadFailed"))
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, [limit, page, teamFilter]) }, [limit, page, t, teamFilter])
const loadCalendarData = useCallback(async () => { const loadCalendarData = useCallback(async () => {
setCalendarLoading(true) setCalendarLoading(true)
@@ -138,20 +139,20 @@ export default function DashboardAgentTeamSchedulesPage() {
}) })
setCalendarItems(data) setCalendarItems(data)
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "加载客服组排班日历失败") toast.error(error instanceof Error ? error.message : t("agentTeamSchedule.loadCalendarFailed"))
} finally { } finally {
setCalendarLoading(false) setCalendarLoading(false)
} }
}, [monthStart, teamFilter, viewMode, weekStart]) }, [monthStart, t, teamFilter, viewMode, weekStart])
const loadTeams = useCallback(async () => { const loadTeams = useCallback(async () => {
try { try {
const data = await fetchAgentTeamsAll() const data = await fetchAgentTeamsAll()
setTeams(data) setTeams(data)
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "加载客服组选项失败") toast.error(error instanceof Error ? error.message : t("agentTeamSchedule.loadTeamsFailed"))
} }
}, []) }, [t])
const refreshActiveView = useCallback(async () => { const refreshActiveView = useCallback(async () => {
await Promise.all([ await Promise.all([
@@ -194,7 +195,7 @@ export default function DashboardAgentTeamSchedulesPage() {
function openEditDialog(item: AdminAgentTeamSchedule) { function openEditDialog(item: AdminAgentTeamSchedule) {
if (isHistoricalSchedule(item)) { if (isHistoricalSchedule(item)) {
toast.error("不能修改历史日期的排班") toast.error(t("agentTeamSchedule.historyReadonly"))
return return
} }
setDialogDefaults(null) setDialogDefaults(null)
@@ -221,17 +222,17 @@ export default function DashboardAgentTeamSchedulesPage() {
try { try {
if (editingItem) { if (editingItem) {
await updateAgentTeamSchedule({ id: editingItem.id, ...payload }) await updateAgentTeamSchedule({ id: editingItem.id, ...payload })
toast.success("已更新客服组排班") toast.success(t("agentTeamSchedule.updated"))
} else { } else {
await createAgentTeamSchedule(payload) await createAgentTeamSchedule(payload)
toast.success("已创建客服组排班") toast.success(t("agentTeamSchedule.created"))
} }
setDialogOpen(false) setDialogOpen(false)
setEditingItem(null) setEditingItem(null)
setDialogDefaults(null) setDialogDefaults(null)
await refreshActiveView() await refreshActiveView()
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "保存客服组排班失败") toast.error(error instanceof Error ? error.message : t("agentTeamSchedule.saveFailed"))
} finally { } finally {
setSaving(false) setSaving(false)
} }
@@ -245,13 +246,13 @@ export default function DashboardAgentTeamSchedulesPage() {
setActionLoadingId(id) setActionLoadingId(id)
try { try {
await deleteAgentTeamSchedule(id) await deleteAgentTeamSchedule(id)
toast.success("已删除客服组排班") toast.success(t("agentTeamSchedule.deleted"))
setDialogOpen(false) setDialogOpen(false)
setEditingItem(null) setEditingItem(null)
setDialogDefaults(null) setDialogDefaults(null)
await refreshActiveView() await refreshActiveView()
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "删除客服组排班失败") toast.error(error instanceof Error ? error.message : t("agentTeamSchedule.deleteFailed"))
} finally { } finally {
setActionLoadingId(null) setActionLoadingId(null)
} }
@@ -264,16 +265,16 @@ export default function DashboardAgentTeamSchedulesPage() {
async function handleCalendarUpdate(payload: UpdateAdminAgentTeamSchedulePayload) { async function handleCalendarUpdate(payload: UpdateAdminAgentTeamSchedulePayload) {
const startAt = parseLocalDateTime(payload.startAt) const startAt = parseLocalDateTime(payload.startAt)
if (startAt && startAt < startOfDay(new Date())) { if (startAt && startAt < startOfDay(new Date())) {
toast.error("不能修改历史日期的排班") toast.error(t("agentTeamSchedule.historyReadonly"))
return return
} }
setActionLoadingId(payload.id) setActionLoadingId(payload.id)
try { try {
await updateAgentTeamSchedule(payload) await updateAgentTeamSchedule(payload)
toast.success("已更新客服组排班") toast.success(t("agentTeamSchedule.updated"))
await loadCalendarData() await loadCalendarData()
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "更新客服组排班失败") toast.error(error instanceof Error ? error.message : t("agentTeamSchedule.updateFailed"))
await loadCalendarData() await loadCalendarData()
} finally { } finally {
setActionLoadingId(null) setActionLoadingId(null)
@@ -298,7 +299,7 @@ export default function DashboardAgentTeamSchedulesPage() {
onClick={() => setViewMode("month")} onClick={() => setViewMode("month")}
> >
<CalendarDaysIcon /> <CalendarDaysIcon />
{t("agentTeamSchedule.month")}
</Button> </Button>
<Button <Button
variant={viewMode === "week" ? "default" : "outline"} variant={viewMode === "week" ? "default" : "outline"}
@@ -306,7 +307,7 @@ export default function DashboardAgentTeamSchedulesPage() {
onClick={() => setViewMode("week")} onClick={() => setViewMode("week")}
> >
<CalendarRangeIcon /> <CalendarRangeIcon />
{t("agentTeamSchedule.week")}
</Button> </Button>
<Button <Button
variant={viewMode === "list" ? "default" : "outline"} variant={viewMode === "list" ? "default" : "outline"}
@@ -314,37 +315,42 @@ export default function DashboardAgentTeamSchedulesPage() {
onClick={() => setViewMode("list")} onClick={() => setViewMode("list")}
> >
<ListIcon /> <ListIcon />
{t("agentTeamSchedule.list")}
</Button> </Button>
</ButtonGroup> </ButtonGroup>
{viewMode === "month" ? ( {viewMode === "month" ? (
<ButtonGroup> <ButtonGroup>
<Button variant="outline" size="icon-sm" onClick={() => setMonthStart(addMonths(monthStart, -1))} aria-label="上一月"> <Button variant="outline" size="icon-sm" onClick={() => setMonthStart(addMonths(monthStart, -1))} aria-label={t("agentTeamSchedule.prevMonth")}>
<ChevronLeftIcon /> <ChevronLeftIcon />
</Button> </Button>
<Button variant="outline" size="sm" onClick={() => setMonthStart(startOfMonth(new Date()))}> <Button variant="outline" size="sm" onClick={() => setMonthStart(startOfMonth(new Date()))}>
{t("agentTeamSchedule.thisMonth")}
</Button> </Button>
<Button variant="outline" size="icon-sm" onClick={() => setMonthStart(addMonths(monthStart, 1))} aria-label="下一月"> <Button variant="outline" size="icon-sm" onClick={() => setMonthStart(addMonths(monthStart, 1))} aria-label={t("agentTeamSchedule.nextMonth")}>
<ChevronRightIcon /> <ChevronRightIcon />
</Button> </Button>
</ButtonGroup> </ButtonGroup>
) : null} ) : null}
{viewMode === "week" ? ( {viewMode === "week" ? (
<ButtonGroup> <ButtonGroup>
<Button variant="outline" size="icon-sm" onClick={() => setWeekStart(addDays(weekStart, -7))} aria-label="上一周"> <Button variant="outline" size="icon-sm" onClick={() => setWeekStart(addDays(weekStart, -7))} aria-label={t("agentTeamSchedule.prevWeek")}>
<ChevronLeftIcon /> <ChevronLeftIcon />
</Button> </Button>
<Button variant="outline" size="sm" onClick={() => setWeekStart(startOfWeek(new Date()))}> <Button variant="outline" size="sm" onClick={() => setWeekStart(startOfWeek(new Date()))}>
{t("agentTeamSchedule.thisWeek")}
</Button> </Button>
<Button variant="outline" size="icon-sm" onClick={() => setWeekStart(addDays(weekStart, 7))} aria-label="下一周"> <Button variant="outline" size="icon-sm" onClick={() => setWeekStart(addDays(weekStart, 7))} aria-label={t("agentTeamSchedule.nextWeek")}>
<ChevronRightIcon /> <ChevronRightIcon />
</Button> </Button>
</ButtonGroup> </ButtonGroup>
) : null} ) : null}
{viewMode === "month" ? ( {viewMode === "month" ? (
<div className="text-sm text-muted-foreground">{formatMonthTitle(monthStart)}</div> <div className="text-sm text-muted-foreground">
{t("agentTeamSchedule.monthTitle", {
year: monthStart.getFullYear(),
month: String(monthStart.getMonth() + 1).padStart(2, "0"),
})}
</div>
) : null} ) : null}
{viewMode === "week" ? ( {viewMode === "week" ? (
<div className="text-sm text-muted-foreground">{formatWeekTitle(weekStart)}</div> <div className="text-sm text-muted-foreground">{formatWeekTitle(weekStart)}</div>
@@ -352,7 +358,7 @@ export default function DashboardAgentTeamSchedulesPage() {
{viewMode !== "list" ? ( {viewMode !== "list" ? (
<Button variant="outline" size="sm" onClick={goToToday}> <Button variant="outline" size="sm" onClick={goToToday}>
<CalendarSearchIcon /> <CalendarSearchIcon />
{t("agentTeamSchedule.today")}
</Button> </Button>
) : null} ) : null}
</div> </div>
@@ -362,18 +368,18 @@ export default function DashboardAgentTeamSchedulesPage() {
<OptionCombobox <OptionCombobox
value={teamFilterInput} value={teamFilterInput}
options={[ options={[
{ value: "all", label: "全部客服组" }, { value: "all", label: t("agentTeamSchedule.allTeams") },
...teams.map((team) => ({ value: String(team.id), label: team.name })), ...teams.map((team) => ({ value: String(team.id), label: team.name })),
]} ]}
placeholder="筛选客服组" placeholder={t("agentTeamSchedule.filterTeam")}
searchPlaceholder="搜索客服组" searchPlaceholder={t("agentTeamSchedule.searchTeam")}
emptyText="未找到客服组" emptyText={t("agentTeamSchedule.emptyTeam")}
onChange={(value) => setTeamFilterInput(value)} onChange={(value) => setTeamFilterInput(value)}
/> />
</div> </div>
<Button variant="outline" onClick={applyFilters} disabled={refreshing}> <Button variant="outline" onClick={applyFilters} disabled={refreshing}>
<SearchIcon /> <SearchIcon />
{t("agentTeamSchedule.query")}
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
@@ -381,15 +387,15 @@ export default function DashboardAgentTeamSchedulesPage() {
disabled={refreshing} disabled={refreshing}
> >
<RefreshCwIcon className={refreshing ? "animate-spin" : ""} /> <RefreshCwIcon className={refreshing ? "animate-spin" : ""} />
{t("agentTeamSchedule.refresh")}
</Button> </Button>
<Button variant="outline" onClick={() => setBatchDialogOpen(true)}> <Button variant="outline" onClick={() => setBatchDialogOpen(true)}>
<LayersIcon /> <LayersIcon />
{t("agentTeamSchedule.batch")}
</Button> </Button>
<Button onClick={() => openCreateDialog()}> <Button onClick={() => openCreateDialog()}>
<PlusIcon /> <PlusIcon />
{t("agentTeamSchedule.new")}
</Button> </Button>
</div> </div>
</div> </div>
@@ -409,6 +415,7 @@ export default function DashboardAgentTeamSchedulesPage() {
onEdit={openEditDialog} onEdit={openEditDialog}
onMove={handleCalendarUpdate} onMove={handleCalendarUpdate}
onResize={handleCalendarUpdate} onResize={handleCalendarUpdate}
t={t}
/> />
</div> </div>
) : ( ) : (
@@ -417,9 +424,9 @@ export default function DashboardAgentTeamSchedulesPage() {
<Table> <Table>
<TableHeader className="bg-muted/40"> <TableHeader className="bg-muted/40">
<TableRow> <TableRow>
<TableHead></TableHead> <TableHead>{t("agentTeamSchedule.team")}</TableHead>
<TableHead></TableHead> <TableHead>{t("agentTeamSchedule.timeRange")}</TableHead>
<TableHead className="w-[92px] text-right"></TableHead> <TableHead className="w-[92px] text-right">{t("agentTeamSchedule.actions")}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -431,8 +438,8 @@ export default function DashboardAgentTeamSchedulesPage() {
<CalendarClockIcon className="size-4" /> <CalendarClockIcon className="size-4" />
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<div className="font-medium">{item.teamName || `客服组#${item.teamId}`}</div> <div className="font-medium">{item.teamName || t("agentTeamSchedule.teamFallback", { id: item.teamId })}</div>
<div className="text-xs text-muted-foreground">ID{item.teamId}</div> <div className="text-xs text-muted-foreground">{t("agentTeamSchedule.teamId", { id: item.teamId })}</div>
</div> </div>
</div> </div>
</TableCell> </TableCell>
@@ -443,12 +450,12 @@ export default function DashboardAgentTeamSchedulesPage() {
<TableCell className="text-right"> <TableCell className="text-right">
<ButtonGroup className="ml-auto"> <ButtonGroup className="ml-auto">
<Button variant="outline" size="sm" onClick={() => openEditDialog(item)} disabled={isHistoricalSchedule(item)}> <Button variant="outline" size="sm" onClick={() => openEditDialog(item)} disabled={isHistoricalSchedule(item)}>
{t("agentTeamSchedule.edit")}
</Button> </Button>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
render={<Button variant="outline" size="icon-sm" />} render={<Button variant="outline" size="icon-sm" />}
aria-label={`更多操作 ${item.startAt}`} aria-label={t("agentTeamSchedule.moreActions", { name: item.startAt })}
> >
<MoreHorizontalIcon /> <MoreHorizontalIcon />
</DropdownMenuTrigger> </DropdownMenuTrigger>
@@ -458,7 +465,7 @@ export default function DashboardAgentTeamSchedulesPage() {
className="text-destructive focus:text-destructive" className="text-destructive focus:text-destructive"
> >
<Trash2Icon /> <Trash2Icon />
{actionLoadingId === item.id ? "删除中..." : "删除"} {actionLoadingId === item.id ? t("agentTeamSchedule.deleting") : t("agentTeamSchedule.delete")}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@@ -469,7 +476,7 @@ export default function DashboardAgentTeamSchedulesPage() {
{!loading && result.results.length === 0 ? ( {!loading && result.results.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={3} className="py-12 text-center text-muted-foreground"> <TableCell colSpan={3} className="py-12 text-center text-muted-foreground">
{t("agentTeamSchedule.emptyRows")}
</TableCell> </TableCell>
</TableRow> </TableRow>
) : null} ) : null}
+77 -84
View File
@@ -2,12 +2,13 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { CheckIcon, ChevronsUpDownIcon } from "lucide-react"; import { CheckIcon, ChevronsUpDownIcon } from "lucide-react";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { Controller, Resolver, useForm } from "react-hook-form"; import { Controller, type Resolver, useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod/v4"; import { z } from "zod/v4";
import { ImageInput } from "@/components/image-input"; import { ImageInput } from "@/components/image-input";
import { OptionCombobox } from "@/components/option-combobox";
import { ProjectDialog } from "@/components/project-dialog"; import { ProjectDialog } from "@/components/project-dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -30,13 +31,6 @@ import {
PopoverContent, PopoverContent,
PopoverTrigger, PopoverTrigger,
} from "@/components/ui/popover"; } from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { import {
@@ -44,13 +38,12 @@ import {
fetchUsersAll, fetchUsersAll,
type AdminAgentProfile, type AdminAgentProfile,
type AdminUser, type AdminUser,
type CreateAdminAgentProfilePayload type CreateAdminAgentProfilePayload,
} from "@/lib/api/admin"; } from "@/lib/api/admin";
import { import { useI18n } from "@/i18n/provider";
ServiceStatus, import { ServiceStatus } from "@/lib/generated/enums";
ServiceStatusLabels,
} from "@/lib/generated/enums"; type TFunction = (key: string, values?: Record<string, string | number>) => string;
import { getEnumLabel, getEnumOptions } from "@/lib/enums";
type AgentEditDialogProps = { type AgentEditDialogProps = {
open: boolean; open: boolean;
@@ -61,7 +54,6 @@ type AgentEditDialogProps = {
onSubmit: (payload: CreateAdminAgentProfilePayload) => Promise<void>; onSubmit: (payload: CreateAdminAgentProfilePayload) => Promise<void>;
}; };
const serviceStatusOptions = getEnumOptions(ServiceStatusLabels);
const emptyForm: EditForm = { const emptyForm: EditForm = {
userId: "", userId: "",
teamId: "", teamId: "",
@@ -76,40 +68,49 @@ const emptyForm: EditForm = {
remark: "", remark: "",
}; };
const editFormSchema = z.object({ type EditForm = {
userId: z.string().trim().min(1, "请选择关联用户"), userId: string;
teamId: z.string().trim().min(1, "请选择所属客服组"), teamId: string;
agentCode: z.string().trim().min(1, "客服工号不能为空"), agentCode: string;
displayName: z.string().trim().min(1, "展示名不能为空"), displayName: string;
avatar: string;
serviceStatus: "0" | "1";
maxConcurrentCount: string;
priorityLevel: string;
autoAssignEnabled: boolean;
receiveOfflineMessage: boolean;
remark: string;
};
function createEditFormSchema(t: TFunction) {
return z.object({
userId: z.string().trim().min(1, t("agentProfile.userRequired")),
teamId: z.string().trim().min(1, t("agentProfile.teamRequired")),
agentCode: z.string().trim().min(1, t("agentProfile.agentCodeRequired")),
displayName: z.string().trim().min(1, t("agentProfile.displayNameRequired")),
avatar: z.string().trim(), avatar: z.string().trim(),
serviceStatus: z.enum(["0", "1"], { serviceStatus: z.enum(["0", "1"], {
message: "请选择客服状态", message: t("agentProfile.statusRequired"),
}), }),
maxConcurrentCount: z maxConcurrentCount: z
.string() .string()
.trim() .trim()
.regex(/^\d+$/, "最大并发必须是大于等于 0 的整数"), .regex(/^\d+$/, t("agentProfile.maxConcurrentInvalid")),
priorityLevel: z priorityLevel: z
.string() .string()
.trim() .trim()
.regex(/^-?\d+$/, "优先级必须是整数"), .regex(/^-?\d+$/, t("agentProfile.priorityInvalid")),
autoAssignEnabled: z.boolean(), autoAssignEnabled: z.boolean(),
receiveOfflineMessage: z.boolean(), receiveOfflineMessage: z.boolean(),
remark: z.string().trim(), remark: z.string().trim(),
}); });
}
type EditForm = z.infer<typeof editFormSchema>; function getServiceStatusOptions(t: TFunction) {
const editFormResolver = zodResolver(editFormSchema as never) as Resolver< return [
z.input<typeof editFormSchema>, { value: String(ServiceStatus.Idle), label: t("agentProfile.statusIdle") },
undefined, { value: String(ServiceStatus.Busy), label: t("agentProfile.statusBusy") },
z.output<typeof editFormSchema> ];
>;
function getStatusLabel(value: string) {
return getEnumLabel(
ServiceStatusLabels,
Number(value) as ServiceStatus,
);
} }
function buildForm(item: AdminAgentProfile | null): EditForm { function buildForm(item: AdminAgentProfile | null): EditForm {
@@ -203,6 +204,7 @@ function AgentEditDialogBody({
onOpenChange, onOpenChange,
onSubmit, onSubmit,
}: AgentEditDialogBodyProps) { }: AgentEditDialogBodyProps) {
const t = useI18n();
const [users, setUsers] = useState<AdminUser[]>([]); const [users, setUsers] = useState<AdminUser[]>([]);
const [userSelectOpen, setUserSelectOpen] = useState(false); const [userSelectOpen, setUserSelectOpen] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -210,6 +212,7 @@ function AgentEditDialogBody({
value: String(user.id), value: String(user.id),
label: `${user.nickname || user.username} (${user.username})`, label: `${user.nickname || user.username} (${user.username})`,
})); }));
const serviceStatusOptions = useMemo(() => getServiceStatusOptions(t), [t]);
const loadOptions = useCallback(async () => { const loadOptions = useCallback(async () => {
try { try {
const [usersData] = await Promise.all([ const [usersData] = await Promise.all([
@@ -217,14 +220,15 @@ function AgentEditDialogBody({
]); ]);
setUsers(usersData); setUsers(usersData);
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "加载选项失败"); toast.error(error instanceof Error ? error.message : t("agentProfile.loadOptionsFailed"));
} }
}, []); }, [t]);
const form = useForm< const editFormSchema = useMemo(() => createEditFormSchema(t), [t]);
z.input<typeof editFormSchema>, const editFormResolver = useMemo(
undefined, () => zodResolver(editFormSchema) as Resolver<EditForm>,
z.output<typeof editFormSchema> [editFormSchema],
>({ );
const form = useForm<EditForm>({
resolver: editFormResolver, resolver: editFormResolver,
defaultValues: buildFormWithDefaultTeam(null, defaultTeamId), defaultValues: buildFormWithDefaultTeam(null, defaultTeamId),
}); });
@@ -247,13 +251,13 @@ function AgentEditDialogBody({
const data = await fetchAgentProfile(itemId); const data = await fetchAgentProfile(itemId);
reset(buildForm(data)); reset(buildForm(data));
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "加载客服档案详情失败"); toast.error(error instanceof Error ? error.message : t("agentProfile.loadDetailFailed"));
} finally { } finally {
setLoading(false); setLoading(false);
} }
} }
void loadDetail(); void loadDetail();
}, [itemId, defaultTeamId, reset]); }, [itemId, defaultTeamId, reset, t]);
useEffect(() => { useEffect(() => {
if (open) { if (open) {
@@ -271,7 +275,7 @@ function AgentEditDialogBody({
<ProjectDialog <ProjectDialog
open={open} open={open}
onOpenChange={onOpenChange} onOpenChange={onOpenChange}
title={itemId ? "编辑客服档案" : "新建客服档案"} title={itemId ? t("agentProfile.editTitle") : t("agentProfile.createTitle")}
size="lg" size="lg"
footer={ footer={
<> <>
@@ -281,17 +285,17 @@ function AgentEditDialogBody({
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
disabled={saving} disabled={saving}
> >
{t("agentProfile.cancel")}
</Button> </Button>
<Button type="submit" form={formId} disabled={saving || loading}> <Button type="submit" form={formId} disabled={saving || loading}>
{saving ? "保存中..." : "保存"} {saving ? t("agentProfile.saving") : t("agentProfile.save")}
</Button> </Button>
</> </>
} }
> >
{loading ? ( {loading ? (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<div className="text-muted-foreground">...</div> <div className="text-muted-foreground">{t("agentProfile.loading")}</div>
</div> </div>
) : ( ) : (
<form <form
@@ -301,7 +305,7 @@ function AgentEditDialogBody({
> >
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field data-invalid={!!errors.userId}> <Field data-invalid={!!errors.userId}>
<FieldLabel></FieldLabel> <FieldLabel>{t("agentProfile.linkedUser")}</FieldLabel>
<FieldContent> <FieldContent>
<Controller <Controller
control={control} control={control}
@@ -324,7 +328,7 @@ function AgentEditDialogBody({
<span className="truncate"> <span className="truncate">
{userOptions.find( {userOptions.find(
(option) => option.value === field.value, (option) => option.value === field.value,
)?.label ?? "请选择用户"} )?.label ?? t("agentProfile.selectUser")}
</span> </span>
<ChevronsUpDownIcon className="ml-2 size-4 shrink-0 opacity-50" /> <ChevronsUpDownIcon className="ml-2 size-4 shrink-0 opacity-50" />
</PopoverTrigger> </PopoverTrigger>
@@ -333,9 +337,9 @@ function AgentEditDialogBody({
align="start" align="start"
> >
<Command> <Command>
<CommandInput placeholder="搜索用户..." /> <CommandInput placeholder={t("agentProfile.searchUser")} />
<CommandList> <CommandList>
<CommandEmpty></CommandEmpty> <CommandEmpty>{t("agentProfile.emptyUser")}</CommandEmpty>
<CommandGroup> <CommandGroup>
{userOptions.map((option) => ( {userOptions.map((option) => (
<CommandItem <CommandItem
@@ -367,11 +371,11 @@ function AgentEditDialogBody({
</FieldContent> </FieldContent>
</Field> </Field>
<Field data-invalid={!!errors.displayName}> <Field data-invalid={!!errors.displayName}>
<FieldLabel htmlFor="agent-display-name"></FieldLabel> <FieldLabel htmlFor="agent-display-name">{t("agentProfile.displayName")}</FieldLabel>
<FieldContent> <FieldContent>
<Input <Input
id="agent-display-name" id="agent-display-name"
placeholder="请输入展示名" placeholder={t("agentProfile.displayNamePlaceholder")}
{...register("displayName")} {...register("displayName")}
/> />
<FieldError errors={[errors.displayName]} /> <FieldError errors={[errors.displayName]} />
@@ -381,11 +385,11 @@ function AgentEditDialogBody({
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field data-invalid={!!errors.agentCode}> <Field data-invalid={!!errors.agentCode}>
<FieldLabel htmlFor="agent-code"></FieldLabel> <FieldLabel htmlFor="agent-code">{t("agentProfile.agentCodeLabel")}</FieldLabel>
<FieldContent> <FieldContent>
<Input <Input
id="agent-code" id="agent-code"
placeholder="例如:A1001" placeholder={t("agentProfile.agentCodePlaceholder")}
{...register("agentCode")} {...register("agentCode")}
/> />
<FieldError errors={[errors.agentCode]} /> <FieldError errors={[errors.agentCode]} />
@@ -393,7 +397,7 @@ function AgentEditDialogBody({
</Field> </Field>
<Field className="min-h-32"> <Field className="min-h-32">
<FieldLabel></FieldLabel> <FieldLabel>{t("agentProfile.avatar")}</FieldLabel>
<FieldContent> <FieldContent>
<Controller <Controller
control={control} control={control}
@@ -404,7 +408,7 @@ function AgentEditDialogBody({
onChange={field.onChange} onChange={field.onChange}
disabled={saving} disabled={saving}
prefix="avatar" prefix="avatar"
placeholder="上传头像" placeholder={t("agentProfile.avatarUpload")}
className="size-16 rounded-full" className="size-16 rounded-full"
/> />
)} )}
@@ -415,31 +419,20 @@ function AgentEditDialogBody({
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field data-invalid={!!errors.serviceStatus}> <Field data-invalid={!!errors.serviceStatus}>
<FieldLabel></FieldLabel> <FieldLabel>{t("agentProfile.serviceStatus")}</FieldLabel>
<FieldContent> <FieldContent>
<Controller <Controller
control={control} control={control}
name="serviceStatus" name="serviceStatus"
render={({ field }) => ( render={({ field }) => (
<Select <OptionCombobox
options={serviceStatusOptions}
value={field.value} value={field.value}
onValueChange={field.onChange} onChange={field.onChange}
modal={false} placeholder={t("agentProfile.selectStatus")}
> searchPlaceholder={t("agentProfile.searchStatus")}
<SelectTrigger className="w-full"> emptyText={t("agentProfile.emptyStatus")}
<SelectValue>{getStatusLabel(field.value)}</SelectValue> />
</SelectTrigger>
<SelectContent>
{serviceStatusOptions.map((option) => (
<SelectItem
key={option.value}
value={String(option.value)}
>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)} )}
/> />
<FieldError errors={[errors.serviceStatus]} /> <FieldError errors={[errors.serviceStatus]} />
@@ -450,7 +443,7 @@ function AgentEditDialogBody({
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field data-invalid={!!errors.maxConcurrentCount}> <Field data-invalid={!!errors.maxConcurrentCount}>
<FieldLabel htmlFor="agent-max-concurrent-count"> <FieldLabel htmlFor="agent-max-concurrent-count">
{t("agentProfile.maxConcurrent")}
</FieldLabel> </FieldLabel>
<FieldContent> <FieldContent>
<Input <Input
@@ -463,7 +456,7 @@ function AgentEditDialogBody({
</FieldContent> </FieldContent>
</Field> </Field>
<Field data-invalid={!!errors.priorityLevel}> <Field data-invalid={!!errors.priorityLevel}>
<FieldLabel htmlFor="agent-priority-level"></FieldLabel> <FieldLabel htmlFor="agent-priority-level">{t("agentProfile.priority")}</FieldLabel>
<FieldContent> <FieldContent>
<Input <Input
id="agent-priority-level" id="agent-priority-level"
@@ -478,7 +471,7 @@ function AgentEditDialogBody({
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field> <Field>
<FieldLabel></FieldLabel> <FieldLabel>{t("agentProfile.autoAssignEnabled")}</FieldLabel>
<FieldContent> <FieldContent>
<Controller <Controller
control={control} control={control}
@@ -493,7 +486,7 @@ function AgentEditDialogBody({
</FieldContent> </FieldContent>
</Field> </Field>
<Field> <Field>
<FieldLabel>线</FieldLabel> <FieldLabel>{t("agentProfile.receiveOfflineMessage")}</FieldLabel>
<FieldContent> <FieldContent>
<Controller <Controller
control={control} control={control}
@@ -510,12 +503,12 @@ function AgentEditDialogBody({
</div> </div>
<Field> <Field>
<FieldLabel htmlFor="agent-remark"></FieldLabel> <FieldLabel htmlFor="agent-remark">{t("agentProfile.remark")}</FieldLabel>
<FieldContent> <FieldContent>
<Textarea <Textarea
id="agent-remark" id="agent-remark"
rows={4} rows={4}
placeholder="请输入备注" placeholder={t("agentProfile.remarkPlaceholder")}
{...register("remark")} {...register("remark")}
/> />
</FieldContent> </FieldContent>
@@ -1,9 +1,9 @@
"use client"; "use client";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { CheckIcon, ChevronsUpDownIcon } from "lucide-react"; import { CheckIcon, ChevronsUpDownIcon } from "lucide-react";
import { Controller, Resolver, useForm } from "react-hook-form"; import { Controller, type Resolver, useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod/v4"; import { z } from "zod/v4";
@@ -14,6 +14,7 @@ import {
fetchUsersAll, fetchUsersAll,
type AdminUser, type AdminUser,
} from "@/lib/api/admin"; } from "@/lib/api/admin";
import { OptionCombobox } from "@/components/option-combobox";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Command, Command,
@@ -42,16 +43,11 @@ import {
PopoverContent, PopoverContent,
PopoverTrigger, PopoverTrigger,
} from "@/components/ui/popover"; } from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Status, StatusLabels } from "@/lib/generated/enums"; import { useI18n } from "@/i18n/provider";
import { getEnumOptions } from "@/lib/enums"; import { Status } from "@/lib/generated/enums";
type TFunction = (key: string, values?: Record<string, string | number>) => string;
type TeamEditDialogProps = { type TeamEditDialogProps = {
open: boolean; open: boolean;
@@ -61,13 +57,6 @@ type TeamEditDialogProps = {
onSubmit: (payload: CreateAdminAgentTeamPayload) => Promise<void>; onSubmit: (payload: CreateAdminAgentTeamPayload) => Promise<void>;
}; };
const statusOptions = getEnumOptions(StatusLabels)
.filter((option) => option.value !== Status.Deleted)
.map((option) => ({
value: String(option.value),
label: option.label,
}));
const emptyForm: EditForm = { const emptyForm: EditForm = {
name: "", name: "",
leaderUserId: "0", leaderUserId: "0",
@@ -76,22 +65,32 @@ const emptyForm: EditForm = {
remark: "", remark: "",
}; };
const editFormSchema = z.object({ type EditForm = {
name: z.string().trim().min(1, "客服组名称不能为空"), name: string;
leaderUserId: z.string().trim().regex(/^\d+$/, "组长用户不合法"), leaderUserId: string;
status: string;
description: string;
remark: string;
};
function createEditFormSchema(t: TFunction) {
return z.object({
name: z.string().trim().min(1, t("agentProfile.teamNameRequired")),
leaderUserId: z.string().trim().regex(/^\d+$/, t("agentProfile.leaderInvalid")),
status: z.enum([String(Status.Ok), String(Status.Disabled)], { status: z.enum([String(Status.Ok), String(Status.Disabled)], {
message: "请选择状态", message: t("agentProfile.teamStatusRequired"),
}), }),
description: z.string().trim(), description: z.string().trim(),
remark: z.string().trim(), remark: z.string().trim(),
}); });
}
type EditForm = z.infer<typeof editFormSchema>; function getStatusOptions(t: TFunction) {
const editFormResolver = zodResolver(editFormSchema as never) as Resolver< return [
z.input<typeof editFormSchema>, { value: String(Status.Ok), label: t("agentProfile.enabled") },
undefined, { value: String(Status.Disabled), label: t("agentProfile.disabled") },
z.output<typeof editFormSchema> ];
>; }
function buildForm(item: AdminAgentTeam | null): EditForm { function buildForm(item: AdminAgentTeam | null): EditForm {
if (!item) { if (!item) {
@@ -146,6 +145,7 @@ function TeamEditDialogBody({
onOpenChange, onOpenChange,
onSubmit, onSubmit,
}: TeamEditDialogBodyProps) { }: TeamEditDialogBodyProps) {
const t = useI18n();
const [users, setUsers] = useState<AdminUser[]>([]); const [users, setUsers] = useState<AdminUser[]>([]);
const [userSelectOpen, setUserSelectOpen] = useState(false); const [userSelectOpen, setUserSelectOpen] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -153,19 +153,21 @@ function TeamEditDialogBody({
value: String(user.id), value: String(user.id),
label: `${user.nickname || user.username} (${user.username})`, label: `${user.nickname || user.username} (${user.username})`,
})); }));
const statusOptions = useMemo(() => getStatusOptions(t), [t]);
const loadUsers = useCallback(async () => { const loadUsers = useCallback(async () => {
try { try {
const data = await fetchUsersAll(); const data = await fetchUsersAll();
setUsers(data); setUsers(data);
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "加载用户选项失败"); toast.error(error instanceof Error ? error.message : t("agentProfile.loadUsersFailed"));
} }
}, []); }, [t]);
const form = useForm< const editFormSchema = useMemo(() => createEditFormSchema(t), [t]);
z.input<typeof editFormSchema>, const editFormResolver = useMemo(
undefined, () => zodResolver(editFormSchema) as Resolver<EditForm>,
z.output<typeof editFormSchema> [editFormSchema],
>({ );
const form = useForm<EditForm>({
resolver: editFormResolver, resolver: editFormResolver,
defaultValues: emptyForm, defaultValues: emptyForm,
}); });
@@ -188,13 +190,13 @@ function TeamEditDialogBody({
const data = await fetchAgentTeam(itemId); const data = await fetchAgentTeam(itemId);
reset(buildForm(data)); reset(buildForm(data));
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "加载客服组详情失败"); toast.error(error instanceof Error ? error.message : t("agentProfile.loadTeamDetailFailed"));
} finally { } finally {
setLoading(false); setLoading(false);
} }
} }
void loadDetail(); void loadDetail();
}, [itemId, reset]); }, [itemId, reset, t]);
useEffect(() => { useEffect(() => {
void loadUsers(); void loadUsers();
@@ -207,28 +209,28 @@ function TeamEditDialogBody({
return ( return (
<DialogContent className="max-w-xl gap-0 p-0 sm:max-w-xl"> <DialogContent className="max-w-xl gap-0 p-0 sm:max-w-xl">
<DialogHeader className="px-6 pt-6"> <DialogHeader className="px-6 pt-6">
<DialogTitle>{itemId ? "编辑" : "新建"}</DialogTitle> <DialogTitle>{itemId ? t("agentProfile.teamEditTitle") : t("agentProfile.teamCreateTitle")}</DialogTitle>
</DialogHeader> </DialogHeader>
{loading ? ( {loading ? (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<div className="text-muted-foreground">...</div> <div className="text-muted-foreground">{t("agentProfile.loading")}</div>
</div> </div>
) : ( ) : (
<form onSubmit={handleSubmit(onFormSubmit)}> <form onSubmit={handleSubmit(onFormSubmit)}>
<div className="space-y-4 p-6"> <div className="space-y-4 p-6">
<Field data-invalid={!!errors.name}> <Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="agent-team-name"></FieldLabel> <FieldLabel htmlFor="agent-team-name">{t("agentProfile.teamName")}</FieldLabel>
<FieldContent> <FieldContent>
<Input <Input
id="agent-team-name" id="agent-team-name"
placeholder="请输入客服组名称" placeholder={t("agentProfile.teamNamePlaceholder")}
{...register("name")} {...register("name")}
/> />
<FieldError errors={[errors.name]} /> <FieldError errors={[errors.name]} />
</FieldContent> </FieldContent>
</Field> </Field>
<Field data-invalid={!!errors.leaderUserId}> <Field data-invalid={!!errors.leaderUserId}>
<FieldLabel></FieldLabel> <FieldLabel>{t("agentProfile.leader")}</FieldLabel>
<FieldContent> <FieldContent>
<Controller <Controller
control={control} control={control}
@@ -247,19 +249,19 @@ function TeamEditDialogBody({
> >
<span className="truncate"> <span className="truncate">
{field.value === "0" {field.value === "0"
? "暂不设置" ? t("agentProfile.noLeader")
: userOptions.find((option) => option.value === field.value)?.label ?? "请选择组长"} : userOptions.find((option) => option.value === field.value)?.label ?? t("agentProfile.selectLeader")}
</span> </span>
<ChevronsUpDownIcon className="ml-2 size-4 shrink-0 opacity-50" /> <ChevronsUpDownIcon className="ml-2 size-4 shrink-0 opacity-50" />
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-[var(--radix-popper-anchor-width)] p-0" align="start"> <PopoverContent className="w-[var(--radix-popper-anchor-width)] p-0" align="start">
<Command> <Command>
<CommandInput placeholder="搜索用户..." /> <CommandInput placeholder={t("agentProfile.searchUser")} />
<CommandList> <CommandList>
<CommandEmpty></CommandEmpty> <CommandEmpty>{t("agentProfile.emptyUser")}</CommandEmpty>
<CommandGroup> <CommandGroup>
<CommandItem <CommandItem
value="暂不设置" value={t("agentProfile.noLeader")}
onSelect={() => { onSelect={() => {
field.onChange("0"); field.onChange("0");
setUserSelectOpen(false); setUserSelectOpen(false);
@@ -268,7 +270,7 @@ function TeamEditDialogBody({
<CheckIcon <CheckIcon
className={`mr-2 size-4 ${field.value === "0" ? "opacity-100" : "opacity-0"}`} className={`mr-2 size-4 ${field.value === "0" ? "opacity-100" : "opacity-0"}`}
/> />
{t("agentProfile.noLeader")}
</CommandItem> </CommandItem>
{userOptions.map((option) => ( {userOptions.map((option) => (
<CommandItem <CommandItem
@@ -298,54 +300,42 @@ function TeamEditDialogBody({
</FieldContent> </FieldContent>
</Field> </Field>
<Field data-invalid={!!errors.status}> <Field data-invalid={!!errors.status}>
<FieldLabel></FieldLabel> <FieldLabel>{t("agentProfile.status")}</FieldLabel>
<FieldContent> <FieldContent>
<Controller <Controller
control={control} control={control}
name="status" name="status"
render={({ field }) => ( render={({ field }) => (
<Select <OptionCombobox
options={statusOptions}
value={field.value} value={field.value}
onValueChange={field.onChange} onChange={field.onChange}
modal={false} placeholder={t("agentProfile.selectStatus")}
> searchPlaceholder={t("agentProfile.searchStatus")}
<SelectTrigger className="w-full"> emptyText={t("agentProfile.emptyStatus")}
<SelectValue> />
{statusOptions.find(
(item) => item.value === field.value,
)?.label ?? "请选择状态"}
</SelectValue>
</SelectTrigger>
<SelectContent>
{statusOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)} )}
/> />
<FieldError errors={[errors.status]} /> <FieldError errors={[errors.status]} />
</FieldContent> </FieldContent>
</Field> </Field>
<Field> <Field>
<FieldLabel htmlFor="agent-team-description"></FieldLabel> <FieldLabel htmlFor="agent-team-description">{t("agentProfile.description")}</FieldLabel>
<FieldContent> <FieldContent>
<Input <Input
id="agent-team-description" id="agent-team-description"
placeholder="例如:负责售前咨询与线索转化" placeholder={t("agentProfile.descriptionPlaceholder")}
{...register("description")} {...register("description")}
/> />
</FieldContent> </FieldContent>
</Field> </Field>
<Field> <Field>
<FieldLabel htmlFor="agent-team-remark"></FieldLabel> <FieldLabel htmlFor="agent-team-remark">{t("agentProfile.remark")}</FieldLabel>
<FieldContent> <FieldContent>
<Textarea <Textarea
id="agent-team-remark" id="agent-team-remark"
rows={4} rows={4}
placeholder="请输入备注" placeholder={t("agentProfile.remarkPlaceholder")}
{...register("remark")} {...register("remark")}
/> />
</FieldContent> </FieldContent>
@@ -358,10 +348,10 @@ function TeamEditDialogBody({
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
disabled={saving} disabled={saving}
> >
{t("agentProfile.cancel")}
</Button> </Button>
<Button type="submit" disabled={saving || loading}> <Button type="submit" disabled={saving || loading}>
{saving ? "保存中..." : "保存"} {saving ? t("agentProfile.saving") : t("agentProfile.save")}
</Button> </Button>
</DialogFooter> </DialogFooter>
</form> </form>
@@ -31,8 +31,8 @@ import {
type AdminAgentTeam, type AdminAgentTeam,
type CreateAdminAgentTeamPayload, type CreateAdminAgentTeamPayload,
} from "@/lib/api/admin"; } from "@/lib/api/admin";
import { Status, StatusLabels } from "@/lib/generated/enums"; import { Status } from "@/lib/generated/enums";
import { getEnumLabel } from "@/lib/enums"; import { useI18n } from "@/i18n/provider";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
type AgentTeamSidebarProps = { type AgentTeamSidebarProps = {
@@ -41,17 +41,21 @@ type AgentTeamSidebarProps = {
onTeamsChange?: (teams: AdminAgentTeam[]) => void; onTeamsChange?: (teams: AdminAgentTeam[]) => void;
}; };
const statusTabs = [ function getStatusTabs(t: (key: string, values?: Record<string, string | number>) => string) {
{ value: "all", label: "全部" }, return [
{ value: String(Status.Ok), label: StatusLabels[Status.Ok] }, { value: "all", label: t("agentProfile.all") },
{ value: String(Status.Disabled), label: StatusLabels[Status.Disabled] }, { value: String(Status.Ok), label: t("agentProfile.enabled") },
{ value: String(Status.Disabled), label: t("agentProfile.disabled") },
] as const; ] as const;
}
export function AgentTeamSidebar({ export function AgentTeamSidebar({
selectedTeamId, selectedTeamId,
onSelectTeam, onSelectTeam,
onTeamsChange, onTeamsChange,
}: AgentTeamSidebarProps) { }: AgentTeamSidebarProps) {
const t = useI18n();
const statusTabs = getStatusTabs(t);
const [keyword, setKeyword] = useState(""); const [keyword, setKeyword] = useState("");
const [statusFilter, setStatusFilter] = const [statusFilter, setStatusFilter] =
useState<(typeof statusTabs)[number]["value"]>("all"); useState<(typeof statusTabs)[number]["value"]>("all");
@@ -69,11 +73,11 @@ export function AgentTeamSidebar({
setTeams(data); setTeams(data);
onTeamsChange?.(data); onTeamsChange?.(data);
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "加载客服组失败"); toast.error(error instanceof Error ? error.message : t("agentProfile.loadTeamsFailed"));
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [onTeamsChange]); }, [onTeamsChange, t]);
useEffect(() => { useEffect(() => {
void loadData(); void loadData();
@@ -135,16 +139,16 @@ export function AgentTeamSidebar({
try { try {
if (editingItem) { if (editingItem) {
await updateAgentTeam({ id: editingItem.id, ...payload }); await updateAgentTeam({ id: editingItem.id, ...payload });
toast.success(`已更新客服组:${editingItem.name}`); toast.success(t("agentProfile.teamUpdated", { name: editingItem.name }));
} else { } else {
await createAgentTeam(payload); await createAgentTeam(payload);
toast.success(`已创建客服组:${payload.name}`); toast.success(t("agentProfile.teamCreated", { name: payload.name }));
} }
setDialogOpen(false); setDialogOpen(false);
setEditingItem(null); setEditingItem(null);
await loadData(); await loadData();
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "保存客服组失败"); toast.error(error instanceof Error ? error.message : t("agentProfile.teamSaveFailed"));
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -154,10 +158,10 @@ export function AgentTeamSidebar({
setActionLoadingId(item.id); setActionLoadingId(item.id);
try { try {
await deleteAgentTeam(item.id); await deleteAgentTeam(item.id);
toast.success(`已删除客服组:${item.name}`); toast.success(t("agentProfile.teamDeleted", { name: item.name }));
await loadData(); await loadData();
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "删除客服组失败"); toast.error(error instanceof Error ? error.message : t("agentProfile.teamDeleteFailed"));
} finally { } finally {
setActionLoadingId(null); setActionLoadingId(null);
} }
@@ -169,7 +173,7 @@ export function AgentTeamSidebar({
<div className="border-b px-3 py-3"> <div className="border-b px-3 py-3">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<div> <div>
<div className="text-sm font-medium"></div> <div className="text-sm font-medium">{t("agentProfile.teamTitle")}</div>
</div> </div>
</div> </div>
<div className="relative mt-3"> <div className="relative mt-3">
@@ -177,7 +181,7 @@ export function AgentTeamSidebar({
<Input <Input
value={keyword} value={keyword}
onChange={(event) => setKeyword(event.target.value)} onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索客服组" placeholder={t("agentProfile.searchTeams")}
className="pl-9" className="pl-9"
/> />
</div> </div>
@@ -235,7 +239,7 @@ export function AgentTeamSidebar({
item.status === Status.Ok ? "secondary" : "outline" item.status === Status.Ok ? "secondary" : "outline"
} }
> >
{getEnumLabel(StatusLabels, item.status as Status)} {item.status === Status.Ok ? t("agentProfile.enabled") : t("agentProfile.disabled")}
</Badge> </Badge>
</button> </button>
<DropdownMenu> <DropdownMenu>
@@ -247,21 +251,21 @@ export function AgentTeamSidebar({
className="opacity-0 group-hover:opacity-100" className="opacity-0 group-hover:opacity-100"
/> />
} }
aria-label={`更多操作 ${item.name}`} aria-label={t("agentProfile.moreActions", { name: item.name })}
> >
<MoreHorizontalIcon /> <MoreHorizontalIcon />
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40 min-w-40"> <DropdownMenuContent align="end" className="w-40 min-w-40">
<DropdownMenuItem onClick={() => openEditDialog(item)}> <DropdownMenuItem onClick={() => openEditDialog(item)}>
<Pencil /> <Pencil />
{t("agentProfile.edit")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={() => void handleDelete(item)} onClick={() => void handleDelete(item)}
className="text-destructive focus:text-destructive" className="text-destructive focus:text-destructive"
> >
<Trash2Icon /> <Trash2Icon />
{actionLoadingId === item.id ? "删除中..." : "删除"} {actionLoadingId === item.id ? t("agentProfile.deleting") : t("agentProfile.delete")}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@@ -269,7 +273,7 @@ export function AgentTeamSidebar({
))} ))}
{!loading && filteredTeams.length === 0 ? ( {!loading && filteredTeams.length === 0 ? (
<div className="px-2 py-10 text-center text-sm text-muted-foreground"> <div className="px-2 py-10 text-center text-sm text-muted-foreground">
{t("agentProfile.noTeams")}
</div> </div>
) : null} ) : null}
</div> </div>
+62 -73
View File
@@ -21,6 +21,7 @@ import { ListPagination } from "@/components/list-pagination";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ButtonGroup } from "@/components/ui/button-group"; import { ButtonGroup } from "@/components/ui/button-group";
import { OptionCombobox } from "@/components/option-combobox";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -28,13 +29,6 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { import {
Table, Table,
TableBody, TableBody,
@@ -53,25 +47,29 @@ import {
type CreateAdminAgentProfilePayload, type CreateAdminAgentProfilePayload,
type PageResult, type PageResult,
} from "@/lib/api/admin"; } from "@/lib/api/admin";
import { getEnumLabel, getEnumOptions } from "@/lib/enums"; import { useI18n } from "@/i18n/provider";
import { import { ServiceStatus } from "@/lib/generated/enums";
ServiceStatus,
ServiceStatusLabels,
} from "@/lib/generated/enums";
import { formatDateTime } from "@/lib/utils"; import { formatDateTime } from "@/lib/utils";
import { EditDialog } from "./_components/edit"; import { EditDialog } from "./_components/edit";
import { AgentTeamSidebar } from "./_components/team-sidebar"; import { AgentTeamSidebar } from "./_components/team-sidebar";
const serviceStatusOptions = [ type TFunction = (key: string, values?: Record<string, string | number>) => string;
{ value: "all", label: "全部状态" },
...getEnumOptions(ServiceStatusLabels),
];
function getStatusLabel(value: number) { function getServiceStatusOptions(t: TFunction) {
return getEnumLabel(ServiceStatusLabels, value as ServiceStatus); return [
{ value: "all", label: t("agentProfile.allStatuses") },
{ value: String(ServiceStatus.Idle), label: t("agentProfile.statusIdle") },
{ value: String(ServiceStatus.Busy), label: t("agentProfile.statusBusy") },
];
}
function getStatusLabel(value: number, t: TFunction) {
return getServiceStatusOptions(t).find((item) => item.value === String(value))?.label ?? String(value);
} }
export default function DashboardAgentsPage() { export default function DashboardAgentsPage() {
const t = useI18n();
const serviceStatusOptions = getServiceStatusOptions(t);
const [selectedTeam, setSelectedTeam] = useState<AdminAgentTeam | null>(null); const [selectedTeam, setSelectedTeam] = useState<AdminAgentTeam | null>(null);
const [teams, setTeams] = useState<AdminAgentTeam[]>([]); const [teams, setTeams] = useState<AdminAgentTeam[]>([]);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
@@ -108,11 +106,11 @@ export default function DashboardAgentsPage() {
}); });
setResult(data); setResult(data);
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "加载客服档案失败"); toast.error(error instanceof Error ? error.message : t("agentProfile.loadFailed"));
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [agentCode, displayName, limit, page, selectedTeam?.id, statusFilter]); }, [agentCode, displayName, limit, page, selectedTeam?.id, statusFilter, t]);
useEffect(() => { useEffect(() => {
void loadData(); void loadData();
@@ -193,16 +191,16 @@ export default function DashboardAgentsPage() {
try { try {
if (editingItem) { if (editingItem) {
await updateAgentProfile({ id: editingItem.id, ...payload }); await updateAgentProfile({ id: editingItem.id, ...payload });
toast.success(`已更新客服档案:${editingItem.displayName}`); toast.success(t("agentProfile.updated", { name: editingItem.displayName }));
} else { } else {
await createAgentProfile(payload); await createAgentProfile(payload);
toast.success(`已创建客服档案:${payload.displayName}`); toast.success(t("agentProfile.created", { name: payload.displayName }));
} }
setDialogOpen(false); setDialogOpen(false);
setEditingItem(null); setEditingItem(null);
await loadData(); await loadData();
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "保存客服档案失败"); toast.error(error instanceof Error ? error.message : t("agentProfile.saveFailed"));
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -212,10 +210,10 @@ export default function DashboardAgentsPage() {
setActionLoadingId(item.id); setActionLoadingId(item.id);
try { try {
await deleteAgentProfile(item.id); await deleteAgentProfile(item.id);
toast.success(`已删除客服档案:${item.displayName}`); toast.success(t("agentProfile.deleted", { name: item.displayName }));
await loadData(); await loadData();
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "删除客服档案失败"); toast.error(error instanceof Error ? error.message : t("agentProfile.deleteFailed"));
} finally { } finally {
setActionLoadingId(null); setActionLoadingId(null);
} }
@@ -241,7 +239,7 @@ export default function DashboardAgentsPage() {
size="icon" size="icon"
className="absolute top-4 left-1/2 z-10 size-7 -translate-x-1/2 rounded-full shadow-sm" className="absolute top-4 left-1/2 z-10 size-7 -translate-x-1/2 rounded-full shadow-sm"
onClick={() => setSidebarCollapsed((value) => !value)} onClick={() => setSidebarCollapsed((value) => !value)}
aria-label={sidebarCollapsed ? "展开客服组列表" : "折叠客服组列表"} aria-label={sidebarCollapsed ? t("agentProfile.expandTeams") : t("agentProfile.collapseTeams")}
> >
{sidebarCollapsed ? ( {sidebarCollapsed ? (
<PanelLeftOpenIcon className="size-3.5" /> <PanelLeftOpenIcon className="size-3.5" />
@@ -256,7 +254,7 @@ export default function DashboardAgentsPage() {
actions={ actions={
<Button onClick={openCreateDialog}> <Button onClick={openCreateDialog}>
<PlusIcon /> <PlusIcon />
{t("agentProfile.new")}
</Button> </Button>
} }
> >
@@ -268,7 +266,7 @@ export default function DashboardAgentsPage() {
setDisplayNameInput(event.target.value) setDisplayNameInput(event.target.value)
} }
onKeyDown={handleFilterKeyDown} onKeyDown={handleFilterKeyDown}
placeholder="按展示名筛选" placeholder={t("agentProfile.filterDisplayName")}
className="pl-9" className="pl-9"
/> />
</div> </div>
@@ -276,37 +274,26 @@ export default function DashboardAgentsPage() {
value={agentCodeInput} value={agentCodeInput}
onChange={(event) => setAgentCodeInput(event.target.value)} onChange={(event) => setAgentCodeInput(event.target.value)}
onKeyDown={handleFilterKeyDown} onKeyDown={handleFilterKeyDown}
placeholder="按客服工号筛选" placeholder={t("agentProfile.filterAgentCode")}
className="w-full sm:w-44" className="w-full sm:w-44"
/> />
<Select <div className="w-full sm:w-36">
<OptionCombobox
value={statusFilterInput} value={statusFilterInput}
onValueChange={(value) => options={serviceStatusOptions}
setStatusFilterInput(value ?? "all") placeholder={t("agentProfile.allStatuses")}
} searchPlaceholder={t("agentProfile.searchStatus")}
> emptyText={t("agentProfile.emptyStatus")}
<SelectTrigger className="w-full sm:w-36"> onChange={(value) => setStatusFilterInput(value ?? "all")}
<SelectValue> />
{serviceStatusOptions.find( </div>
(item) => item.value === statusFilterInput,
)?.label ?? "全部状态"}
</SelectValue>
</SelectTrigger>
<SelectContent>
{serviceStatusOptions.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button <Button
variant="outline" variant="outline"
onClick={applyFilters} onClick={applyFilters}
disabled={loading} disabled={loading}
> >
<SearchIcon /> <SearchIcon />
{t("agentProfile.query")}
</Button> </Button>
</DashboardToolbar> </DashboardToolbar>
<DashboardTableShell <DashboardTableShell
@@ -328,12 +315,12 @@ export default function DashboardAgentsPage() {
<Table> <Table>
<TableHeader className="bg-muted/40"> <TableHeader className="bg-muted/40">
<TableRow> <TableRow>
<TableHead></TableHead> <TableHead>{t("agentProfile.columnAgent")}</TableHead>
<TableHead></TableHead> <TableHead>{t("agentProfile.columnRules")}</TableHead>
<TableHead></TableHead> <TableHead>{t("agentProfile.columnDispatch")}</TableHead>
<TableHead></TableHead> <TableHead>{t("agentProfile.columnRecent")}</TableHead>
<TableHead className="w-[92px] text-right"> <TableHead className="w-[92px] text-right">
{t("agentProfile.columnActions")}
</TableHead> </TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -360,21 +347,23 @@ export default function DashboardAgentsPage() {
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
{item.nickname || {item.nickname ||
item.username || item.username ||
`用户#${item.userId}`} t("agentProfile.userFallback", { id: item.userId })}
</div> </div>
<div className="mt-1 text-xs text-muted-foreground"> <div className="mt-1 text-xs text-muted-foreground">
{item.agentCode} {t("agentProfile.agentCode", { code: item.agentCode })}
</div> </div>
</div> </div>
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge variant="outline"> <Badge variant="outline">
{getStatusLabel(item.serviceStatus)} {getStatusLabel(item.serviceStatus, t)}
</Badge> </Badge>
<div className="mt-2 text-sm text-muted-foreground"> <div className="mt-2 text-sm text-muted-foreground">
{item.maxConcurrentCount} / {" "} {t("agentProfile.capacityPriority", {
{item.priorityLevel} capacity: item.maxConcurrentCount,
priority: item.priorityLevel,
})}
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
@@ -385,8 +374,8 @@ export default function DashboardAgentsPage() {
} }
> >
{item.autoAssignEnabled {item.autoAssignEnabled
? "自动分配" ? t("agentProfile.autoAssign")
: "不自动分配"} : t("agentProfile.noAutoAssign")}
</Badge> </Badge>
<Badge <Badge
variant={ variant={
@@ -396,17 +385,17 @@ export default function DashboardAgentsPage() {
} }
> >
{item.receiveOfflineMessage {item.receiveOfflineMessage
? "离线接收" ? t("agentProfile.offlineReceive")
: "离线不接收"} : t("agentProfile.noOfflineReceive")}
</Badge> </Badge>
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="text-sm"> <div className="text-sm">
线{formatDateTime(item.lastOnlineAt)} {t("agentProfile.onlineAt", { time: formatDateTime(item.lastOnlineAt) })}
</div> </div>
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{formatDateTime(item.lastStatusAt)} {t("agentProfile.statusAt", { time: formatDateTime(item.lastStatusAt) })}
</div> </div>
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
@@ -416,14 +405,14 @@ export default function DashboardAgentsPage() {
size="sm" size="sm"
onClick={() => openEditDialog(item)} onClick={() => openEditDialog(item)}
> >
{t("agentProfile.edit")}
</Button> </Button>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
render={ render={
<Button variant="outline" size="icon-sm" /> <Button variant="outline" size="icon-sm" />
} }
aria-label={`更多操作 ${item.displayName}`} aria-label={t("agentProfile.moreActions", { name: item.displayName })}
> >
<MoreHorizontalIcon /> <MoreHorizontalIcon />
</DropdownMenuTrigger> </DropdownMenuTrigger>
@@ -437,8 +426,8 @@ export default function DashboardAgentsPage() {
> >
<Trash2Icon /> <Trash2Icon />
{actionLoadingId === item.id {actionLoadingId === item.id
? "删除中..." ? t("agentProfile.deleting")
: "删除"} : t("agentProfile.delete")}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@@ -450,11 +439,11 @@ export default function DashboardAgentsPage() {
<DashboardTableStateRow <DashboardTableStateRow
colSpan={5} colSpan={5}
loading={loading} loading={loading}
loadingText="正在加载客服档案..." loadingText={t("agentProfile.loadingRows")}
emptyText={ emptyText={
selectedTeam selectedTeam
? "当前客服组下没有匹配的客服档案" ? t("agentProfile.emptyTeamRows")
: "没有匹配的客服档案" : t("agentProfile.emptyRows")
} }
/> />
) : null} ) : null}
+152 -139
View File
@@ -54,15 +54,12 @@ import {
type MCPToolSourceType, type MCPToolSourceType,
type SkillDefinition, type SkillDefinition,
} from "@/lib/api/admin"; } from "@/lib/api/admin";
import { getEnumOptions } from "@/lib/enums"; import { useI18n } from "@/i18n/provider";
import { import {
AIAgentFallbackMode, AIAgentFallbackMode,
AIAgentFallbackModeLabels,
AIAgentHandoffMode, AIAgentHandoffMode,
AIAgentHandoffModeLabels,
AIModelType, AIModelType,
IMConversationServiceMode, IMConversationServiceMode,
IMConversationServiceModeLabels,
Status, Status,
} from "@/lib/generated/enums"; } from "@/lib/generated/enums";
@@ -90,49 +87,43 @@ type EditDialogProps = {
onSubmit: (payload: CreateAIAgentPayload) => Promise<void>; onSubmit: (payload: CreateAIAgentPayload) => Promise<void>;
}; };
const schema = z.object({ type TFunction = (key: string, values?: Record<string, string | number>) => string;
name: z.string().trim().min(1, "名称不能为空"),
description: z.string().trim(),
aiConfigId: z.string().trim().regex(/^\d+$/, "请选择 AI 配置"),
serviceMode: z.string().trim().min(1, "请选择服务模式"),
systemPrompt: z.string().trim(),
welcomeMessage: z.string().trim(),
replyTimeoutSeconds: z
.number()
.min(0, "回复超时秒数必须是大于等于 0 的整数"),
handoffMode: z.string().trim().min(1, "请选择转人工模式"),
fallbackMode: z.string().trim().min(1, "请选择兜底策略"),
fallbackMessage: z.string().trim(),
});
type EditForm = z.infer<typeof schema>; type EditForm = {
name: string;
description: string;
aiConfigId: string;
serviceMode: string;
systemPrompt: string;
welcomeMessage: string;
replyTimeoutSeconds: number;
handoffMode: string;
fallbackMode: string;
fallbackMessage: string;
};
const resolver = zodResolver(schema as never) as Resolver< function getServiceModeOptions(t: TFunction) {
z.input<typeof schema>, return [
undefined, { value: String(IMConversationServiceMode.AIOnly), label: t("aiAgent.serviceAiOnly") },
z.output<typeof schema> { value: String(IMConversationServiceMode.HumanOnly), label: t("aiAgent.serviceHumanOnly") },
>; { value: String(IMConversationServiceMode.AIFirst), label: t("aiAgent.serviceAiFirst") },
];
}
const serviceModeOptions = getEnumOptions(IMConversationServiceModeLabels).map( function getHandoffModeOptions(t: TFunction) {
(option) => ({ return [
value: String(option.value), { value: String(AIAgentHandoffMode.WaitPool), label: t("aiAgent.handoffWaitPool") },
label: option.label, { value: String(AIAgentHandoffMode.DefaultTeamPool), label: t("aiAgent.handoffDefaultTeamPool") },
}), { value: String(AIAgentHandoffMode.AIHoldAndNotify), label: t("aiAgent.handoffAiHoldAndNotify") },
); ];
}
const handoffModeOptions = getEnumOptions(AIAgentHandoffModeLabels).map( function getFallbackModeOptions(t: TFunction) {
(option) => ({ return [
value: String(option.value), { value: String(AIAgentFallbackMode.NoAnswer), label: t("aiAgent.fallbackNoAnswer") },
label: option.label, { value: String(AIAgentFallbackMode.SuggestRetry), label: t("aiAgent.fallbackSuggestRetry") },
}), ];
); }
const fallbackModeOptions = getEnumOptions(AIAgentFallbackModeLabels).map(
(option) => ({
value: String(option.value),
label: option.label,
}),
);
function buildForm(item: AIAgent | null): EditForm { function buildForm(item: AIAgent | null): EditForm {
if (!item) { if (!item) {
@@ -220,12 +211,34 @@ function EditDialogBody({
onSubmit, onSubmit,
}: EditDialogProps) { }: EditDialogProps) {
const formId = "ai-agent-edit-form"; const formId = "ai-agent-edit-form";
const t = useI18n();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const form = useForm< const schema = useMemo(
z.input<typeof schema>, () =>
undefined, z.object({
z.output<typeof schema> name: z.string().trim().min(1, t("aiAgent.nameRequired")),
>({ description: z.string().trim(),
aiConfigId: z.string().trim().regex(/^\d+$/, t("aiAgent.aiConfigRequired")),
serviceMode: z.string().trim().min(1, t("aiAgent.serviceModeRequired")),
systemPrompt: z.string().trim(),
welcomeMessage: z.string().trim(),
replyTimeoutSeconds: z
.number()
.min(0, t("aiAgent.replyTimeoutInvalid")),
handoffMode: z.string().trim().min(1, t("aiAgent.handoffModeRequired")),
fallbackMode: z.string().trim().min(1, t("aiAgent.fallbackModeRequired")),
fallbackMessage: z.string().trim(),
}),
[t],
);
const resolver = useMemo(
() => zodResolver(schema as never) as Resolver<EditForm>,
[schema],
);
const serviceModeOptions = useMemo(() => getServiceModeOptions(t), [t]);
const handoffModeOptions = useMemo(() => getHandoffModeOptions(t), [t]);
const fallbackModeOptions = useMemo(() => getFallbackModeOptions(t), [t]);
const form = useForm<EditForm>({
resolver, resolver,
defaultValues: buildForm(null), defaultValues: buildForm(null),
}); });
@@ -296,14 +309,14 @@ function EditDialogBody({
setGraphToolToAdd(""); setGraphToolToAdd("");
} catch (error) { } catch (error) {
toast.error( toast.error(
error instanceof Error ? error.message : "加载 AI Agent 详情失败", error instanceof Error ? error.message : t("aiAgent.loadDetailFailed"),
); );
} finally { } finally {
setLoading(false); setLoading(false);
} }
} }
void loadDetail(); void loadDetail();
}, [itemId, reset]); }, [itemId, reset, t]);
useEffect(() => { useEffect(() => {
async function loadAIConfigs() { async function loadAIConfigs() {
@@ -314,12 +327,12 @@ function EditDialogBody({
setAIConfigs(data); setAIConfigs(data);
} catch (error) { } catch (error) {
toast.error( toast.error(
error instanceof Error ? error.message : "加载 AI 配置失败", error instanceof Error ? error.message : t("aiAgent.loadAiConfigFailed"),
); );
} }
} }
void loadAIConfigs(); void loadAIConfigs();
}, []); }, [t]);
useEffect(() => { useEffect(() => {
async function loadAgentTeams() { async function loadAgentTeams() {
@@ -327,11 +340,11 @@ function EditDialogBody({
const data = await fetchAgentTeamsAll(); const data = await fetchAgentTeamsAll();
setAgentTeams(data); setAgentTeams(data);
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "加载客服组失败"); toast.error(error instanceof Error ? error.message : t("aiAgent.loadTeamsFailed"));
} }
} }
void loadAgentTeams(); void loadAgentTeams();
}, []); }, [t]);
useEffect(() => { useEffect(() => {
async function loadKnowledgeBases() { async function loadKnowledgeBases() {
@@ -341,11 +354,11 @@ function EditDialogBody({
}); });
setKnowledgeBases(data); setKnowledgeBases(data);
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "加载知识库失败"); toast.error(error instanceof Error ? error.message : t("aiAgent.loadKnowledgeFailed"));
} }
} }
void loadKnowledgeBases(); void loadKnowledgeBases();
}, []); }, [t]);
useEffect(() => { useEffect(() => {
async function loadSkills() { async function loadSkills() {
@@ -355,11 +368,11 @@ function EditDialogBody({
}); });
setSkills(data); setSkills(data);
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "加载 Skills 失败"); toast.error(error instanceof Error ? error.message : t("aiAgent.loadSkillsFailed"));
} }
} }
void loadSkills(); void loadSkills();
}, []); }, [t]);
useEffect(() => { useEffect(() => {
async function loadDirectToolOptions() { async function loadDirectToolOptions() {
@@ -376,7 +389,7 @@ function EditDialogBody({
autoInjected: tool.autoInjected, autoInjected: tool.autoInjected,
groupLabel: groupLabel:
tool.sourceType === "builtin" tool.sourceType === "builtin"
? "内置工具" ? t("aiAgent.builtinTools")
: tool.serverCode, : tool.serverCode,
meta: { meta: {
toolCode: tool.toolCode, toolCode: tool.toolCode,
@@ -398,12 +411,12 @@ function EditDialogBody({
); );
} catch (error) { } catch (error) {
toast.error( toast.error(
error instanceof Error ? error.message : "加载 Direct Tools 失败", error instanceof Error ? error.message : t("aiAgent.loadDirectToolsFailed"),
); );
} }
} }
void loadDirectToolOptions(); void loadDirectToolOptions();
}, []); }, [t]);
const aiConfigOptions = useMemo( const aiConfigOptions = useMemo(
() => () =>
@@ -508,13 +521,13 @@ function EditDialogBody({
const directToolsGrouped = useMemo(() => { const directToolsGrouped = useMemo(() => {
const groups = new Map<string, DirectToolItem[]>(); const groups = new Map<string, DirectToolItem[]>();
for (const tool of directTools) { for (const tool of directTools) {
const groupLabel = tool.serverCode || "未分组"; const groupLabel = tool.serverCode || t("aiAgent.ungrouped");
const current = groups.get(groupLabel) ?? []; const current = groups.get(groupLabel) ?? [];
current.push(tool); current.push(tool);
groups.set(groupLabel, current); groups.set(groupLabel, current);
} }
return Array.from(groups.entries()); return Array.from(groups.entries());
}, [directTools]); }, [directTools, t]);
const addableGraphToolOptions = useMemo( const addableGraphToolOptions = useMemo(
() => () =>
@@ -545,7 +558,7 @@ function EditDialogBody({
const handoffMode = watch("handoffMode"); const handoffMode = watch("handoffMode");
const selectedHandoffModeLabel = const selectedHandoffModeLabel =
handoffModeOptions.find((item) => item.value === handoffMode)?.label ?? handoffModeOptions.find((item) => item.value === handoffMode)?.label ??
"未选择"; t("aiAgent.notSelected");
async function onFormSubmit(values: EditForm) { async function onFormSubmit(values: EditForm) {
await onSubmit( await onSubmit(
@@ -653,7 +666,7 @@ function EditDialogBody({
<ProjectDialog <ProjectDialog
open={open} open={open}
onOpenChange={onOpenChange} onOpenChange={onOpenChange}
title={itemId ? "编辑 AI Agent" : "新建 AI Agent"} title={itemId ? t("aiAgent.editTitle") : t("aiAgent.createTitle")}
size="xl" size="xl"
defaultFullscreen defaultFullscreen
footer={ footer={
@@ -663,17 +676,17 @@ function EditDialogBody({
variant="outline" variant="outline"
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
> >
{t("aiAgent.cancel")}
</Button> </Button>
<Button type="submit" form={formId} disabled={saving || loading}> <Button type="submit" form={formId} disabled={saving || loading}>
{saving ? "保存中..." : "保存"} {saving ? t("aiAgent.saving") : t("aiAgent.save")}
</Button> </Button>
</> </>
} }
> >
{loading ? ( {loading ? (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<div className="text-muted-foreground">...</div> <div className="text-muted-foreground">{t("aiAgent.loading")}</div>
</div> </div>
) : ( ) : (
<form <form
@@ -682,11 +695,11 @@ function EditDialogBody({
className="space-y-6" className="space-y-6"
> >
<SectionCard <SectionCard
title="基础信息" title={t("aiAgent.sectionBasic")}
> >
<div className="grid grid-cols-1 gap-4 xl:grid-cols-3"> <div className="grid grid-cols-1 gap-4 xl:grid-cols-3">
<Field data-invalid={!!errors.name}> <Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="ai-agent-name"></FieldLabel> <FieldLabel htmlFor="ai-agent-name">{t("aiAgent.name")}</FieldLabel>
<FieldContent> <FieldContent>
<Input id="ai-agent-name" {...register("name")} /> <Input id="ai-agent-name" {...register("name")} />
<FieldError errors={[errors.name]} /> <FieldError errors={[errors.name]} />
@@ -694,7 +707,7 @@ function EditDialogBody({
</Field> </Field>
<Field data-invalid={!!errors.aiConfigId}> <Field data-invalid={!!errors.aiConfigId}>
<FieldLabel>AI </FieldLabel> <FieldLabel>{t("aiAgent.aiConfig")}</FieldLabel>
<FieldContent> <FieldContent>
<Controller <Controller
control={control} control={control}
@@ -703,9 +716,9 @@ function EditDialogBody({
<OptionCombobox <OptionCombobox
value={field.value} value={field.value}
options={aiConfigOptions} options={aiConfigOptions}
placeholder="请选择 AI 配置" placeholder={t("aiAgent.selectAiConfig")}
searchPlaceholder="搜索 AI 配置" searchPlaceholder={t("aiAgent.searchAiConfig")}
emptyText="未找到 AI 配置" emptyText={t("aiAgent.emptyAiConfig")}
onChange={field.onChange} onChange={field.onChange}
/> />
)} )}
@@ -715,7 +728,7 @@ function EditDialogBody({
</Field> </Field>
<Field data-invalid={!!errors.serviceMode}> <Field data-invalid={!!errors.serviceMode}>
<FieldLabel></FieldLabel> <FieldLabel>{t("aiAgent.columnServiceMode")}</FieldLabel>
<FieldContent> <FieldContent>
<Controller <Controller
control={control} control={control}
@@ -724,9 +737,9 @@ function EditDialogBody({
<OptionCombobox <OptionCombobox
value={field.value} value={field.value}
options={serviceModeOptions} options={serviceModeOptions}
placeholder="请选择服务模式" placeholder={t("aiAgent.selectServiceMode")}
searchPlaceholder="搜索服务模式" searchPlaceholder={t("aiAgent.searchServiceMode")}
emptyText="未找到服务模式" emptyText={t("aiAgent.emptyServiceMode")}
onChange={field.onChange} onChange={field.onChange}
/> />
)} )}
@@ -737,14 +750,14 @@ function EditDialogBody({
</div> </div>
<Field data-invalid={!!errors.description}> <Field data-invalid={!!errors.description}>
<FieldLabel htmlFor="ai-agent-description"></FieldLabel> <FieldLabel htmlFor="ai-agent-description">{t("aiAgent.description")}</FieldLabel>
<FieldContent> <FieldContent>
<Textarea id="ai-agent-description" {...register("description")} /> <Textarea id="ai-agent-description" {...register("description")} />
<FieldError errors={[errors.description]} /> <FieldError errors={[errors.description]} />
</FieldContent> </FieldContent>
</Field> </Field>
<Field data-invalid={!!errors.welcomeMessage}> <Field data-invalid={!!errors.welcomeMessage}>
<FieldLabel htmlFor="ai-agent-welcome-message"></FieldLabel> <FieldLabel htmlFor="ai-agent-welcome-message">{t("aiAgent.welcomeMessage")}</FieldLabel>
<FieldContent> <FieldContent>
<Textarea <Textarea
id="ai-agent-welcome-message" id="ai-agent-welcome-message"
@@ -757,7 +770,7 @@ function EditDialogBody({
<Field data-invalid={!!errors.systemPrompt}> <Field data-invalid={!!errors.systemPrompt}>
<FieldLabel htmlFor="ai-agent-system-prompt"> <FieldLabel htmlFor="ai-agent-system-prompt">
{t("aiAgent.systemPrompt")}
</FieldLabel> </FieldLabel>
<FieldContent> <FieldContent>
<Textarea <Textarea
@@ -771,12 +784,12 @@ function EditDialogBody({
</SectionCard> </SectionCard>
<SectionCard <SectionCard
title="服务策略" title={t("aiAgent.sectionStrategy")}
> >
<div className="grid grid-cols-1 gap-4 xl:grid-cols-[minmax(0,1fr)_minmax(320px,1.15fr)]"> <div className="grid grid-cols-1 gap-4 xl:grid-cols-[minmax(0,1fr)_minmax(320px,1.15fr)]">
<div className="space-y-4"> <div className="space-y-4">
<Field data-invalid={!!errors.handoffMode}> <Field data-invalid={!!errors.handoffMode}>
<FieldLabel></FieldLabel> <FieldLabel>{t("aiAgent.handoffMode")}</FieldLabel>
<FieldContent> <FieldContent>
<Controller <Controller
control={control} control={control}
@@ -785,9 +798,9 @@ function EditDialogBody({
<OptionCombobox <OptionCombobox
value={field.value} value={field.value}
options={handoffModeOptions} options={handoffModeOptions}
placeholder="请选择转人工模式" placeholder={t("aiAgent.selectHandoffMode")}
searchPlaceholder="搜索转人工模式" searchPlaceholder={t("aiAgent.searchHandoffMode")}
emptyText="未找到转人工模式" emptyText={t("aiAgent.emptyHandoffMode")}
onChange={field.onChange} onChange={field.onChange}
/> />
)} )}
@@ -801,7 +814,7 @@ function EditDialogBody({
htmlFor="ai-agent-reply-timeout-seconds" htmlFor="ai-agent-reply-timeout-seconds"
className="flex items-center gap-1" className="flex items-center gap-1"
> >
{t("aiAgent.replyTimeoutSeconds")}
<Popover> <Popover>
<PopoverTrigger <PopoverTrigger
render={ render={
@@ -815,7 +828,7 @@ function EditDialogBody({
/> />
<PopoverContent side="top" align="start" className="max-w-xs"> <PopoverContent side="top" align="start" className="max-w-xs">
<PopoverDescription> <PopoverDescription>
AI 0 使 180 {t("aiAgent.replyTimeoutHelp")}
</PopoverDescription> </PopoverDescription>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
@@ -834,28 +847,28 @@ function EditDialogBody({
</div> </div>
<div className="rounded-lg border bg-muted/10 p-4"> <div className="rounded-lg border bg-muted/10 p-4">
<div className="mb-1 text-sm font-medium"></div> <div className="mb-1 text-sm font-medium">{t("aiAgent.teams")}</div>
<div className="mb-4 text-xs text-muted-foreground"> <div className="mb-4 text-xs text-muted-foreground">
{selectedHandoffModeLabel} {t("aiAgent.currentHandoffMode", { mode: selectedHandoffModeLabel })}
{handoffMode === {handoffMode ===
String(AIAgentHandoffMode.DefaultTeamPool) String(AIAgentHandoffMode.DefaultTeamPool)
? "。该模式要求至少配置一个客服组。" ? t("aiAgent.handoffNeedsTeam")
: "。仅在涉及转人工时生效。"} : t("aiAgent.handoffOnlyWhenNeeded")}
</div> </div>
<Field> <Field>
<FieldContent className="space-y-3"> <FieldContent className="space-y-3">
<OptionCombobox <OptionCombobox
value={teamToAdd} value={teamToAdd}
options={addableTeamOptions} options={addableTeamOptions}
placeholder="请选择客服组" placeholder={t("aiAgent.selectTeam")}
searchPlaceholder="搜索客服组" searchPlaceholder={t("aiAgent.searchTeam")}
emptyText="未找到客服组" emptyText={t("aiAgent.emptyTeam")}
onChange={handleAddTeam} onChange={handleAddTeam}
/> />
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{selectedTeamOptions.length === 0 ? ( {selectedTeamOptions.length === 0 ? (
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
{t("aiAgent.noTeams")}
</span> </span>
) : ( ) : (
selectedTeamOptions.map((option) => ( selectedTeamOptions.map((option) => (
@@ -873,7 +886,7 @@ function EditDialogBody({
onClick={() => onClick={() =>
handleRemoveTeam(Number(option.value)) handleRemoveTeam(Number(option.value))
} }
aria-label={`移除客服组 ${option.label}`} aria-label={t("aiAgent.removeTeam", { name: option.label })}
> >
<Trash2Icon className="size-3" /> <Trash2Icon className="size-3" />
</Button> </Button>
@@ -888,19 +901,19 @@ function EditDialogBody({
</SectionCard> </SectionCard>
<SectionCard <SectionCard
title="能力配置" title={t("aiAgent.sectionCapabilities")}
> >
<Tabs defaultValue="knowledge" className="gap-4"> <Tabs defaultValue="knowledge" className="gap-4">
<TabsList className="w-fit"> <TabsList className="w-fit">
<TabsTrigger value="knowledge"></TabsTrigger> <TabsTrigger value="knowledge">{t("aiAgent.knowledgeTab")}</TabsTrigger>
<TabsTrigger value="skills">Skills</TabsTrigger> <TabsTrigger value="skills">{t("aiAgent.skillsTab")}</TabsTrigger>
<TabsTrigger value="direct-tools">Direct Tools</TabsTrigger> <TabsTrigger value="direct-tools">{t("aiAgent.directToolsTab")}</TabsTrigger>
<TabsTrigger value="graph-tools">Graph Tools</TabsTrigger> <TabsTrigger value="graph-tools">{t("aiAgent.graphToolsTab")}</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="knowledge" className="space-y-4"> <TabsContent value="knowledge" className="space-y-4">
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
{t("aiAgent.knowledgeHint")}
</div> </div>
<Field data-invalid={selectedKnowledgeIds.length === 0}> <Field data-invalid={selectedKnowledgeIds.length === 0}>
<FieldContent className="space-y-3"> <FieldContent className="space-y-3">
@@ -909,9 +922,9 @@ function EditDialogBody({
<OptionCombobox <OptionCombobox
value={knowledgeToAdd} value={knowledgeToAdd}
options={addableKnowledgeOptions} options={addableKnowledgeOptions}
placeholder="选择并添加知识库" placeholder={t("aiAgent.selectKnowledge")}
searchPlaceholder="搜索知识库" searchPlaceholder={t("aiAgent.searchKnowledge")}
emptyText="没有可添加的知识库" emptyText={t("aiAgent.emptyKnowledge")}
onChange={handleAddKnowledge} onChange={handleAddKnowledge}
/> />
</div> </div>
@@ -922,12 +935,12 @@ function EditDialogBody({
onClick={() => handleAddKnowledge(knowledgeToAdd)} onClick={() => handleAddKnowledge(knowledgeToAdd)}
> >
<PlusIcon /> <PlusIcon />
{t("aiAgent.add")}
</Button> </Button>
</div> </div>
{selectedKnowledgeIds.length === 0 ? ( {selectedKnowledgeIds.length === 0 ? (
<div className="rounded-md border border-dashed px-3 py-4 text-sm text-muted-foreground"> <div className="rounded-md border border-dashed px-3 py-4 text-sm text-muted-foreground">
{t("aiAgent.knowledgeRequired")}
</div> </div>
) : ( ) : (
<div className="space-y-2 rounded-md border p-3"> <div className="space-y-2 rounded-md border p-3">
@@ -975,14 +988,14 @@ function EditDialogBody({
</div> </div>
)} )}
{selectedKnowledgeIds.length === 0 ? ( {selectedKnowledgeIds.length === 0 ? (
<FieldError errors={[{ message: "请至少选择一个知识库" }]} /> <FieldError errors={[{ message: t("aiAgent.knowledgeRequired") }]} />
) : null} ) : null}
</FieldContent> </FieldContent>
</Field> </Field>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-1"> <div className="grid grid-cols-1 gap-4 xl:grid-cols-1">
<Field data-invalid={!!errors.fallbackMode}> <Field data-invalid={!!errors.fallbackMode}>
<FieldLabel></FieldLabel> <FieldLabel>{t("aiAgent.fallbackMode")}</FieldLabel>
<FieldContent className="space-y-3"> <FieldContent className="space-y-3">
<Controller <Controller
control={control} control={control}
@@ -991,9 +1004,9 @@ function EditDialogBody({
<OptionCombobox <OptionCombobox
value={field.value} value={field.value}
options={fallbackModeOptions} options={fallbackModeOptions}
placeholder="请选择兜底策略" placeholder={t("aiAgent.selectFallbackMode")}
searchPlaceholder="搜索兜底策略" searchPlaceholder={t("aiAgent.searchFallbackMode")}
emptyText="未找到兜底策略" emptyText={t("aiAgent.emptyFallbackMode")}
onChange={field.onChange} onChange={field.onChange}
/> />
)} )}
@@ -1004,11 +1017,11 @@ function EditDialogBody({
<Field data-invalid={!!errors.fallbackMessage}> <Field data-invalid={!!errors.fallbackMessage}>
<FieldLabel htmlFor="ai-agent-fallback-message"> <FieldLabel htmlFor="ai-agent-fallback-message">
{t("aiAgent.fallbackMessage")}
</FieldLabel> </FieldLabel>
<FieldContent> <FieldContent>
<div className="mb-1 text-xs text-muted-foreground"> <div className="mb-1 text-xs text-muted-foreground">
{t("aiAgent.fallbackMessageHint")}
</div> </div>
<Textarea <Textarea
id="ai-agent-fallback-message" id="ai-agent-fallback-message"
@@ -1023,7 +1036,7 @@ function EditDialogBody({
<TabsContent value="skills" className="space-y-4"> <TabsContent value="skills" className="space-y-4">
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
{t("aiAgent.skillsHint")}
</div> </div>
<Field> <Field>
<FieldContent className="space-y-3"> <FieldContent className="space-y-3">
@@ -1032,9 +1045,9 @@ function EditDialogBody({
<OptionCombobox <OptionCombobox
value={skillToAdd} value={skillToAdd}
options={addableSkillOptions} options={addableSkillOptions}
placeholder="选择并添加 Skill" placeholder={t("aiAgent.selectSkill")}
searchPlaceholder="搜索 Skill" searchPlaceholder={t("aiAgent.searchSkill")}
emptyText="没有可添加的 Skill" emptyText={t("aiAgent.emptySkill")}
onChange={handleAddSkill} onChange={handleAddSkill}
/> />
</div> </div>
@@ -1045,13 +1058,13 @@ function EditDialogBody({
onClick={() => handleAddSkill(skillToAdd)} onClick={() => handleAddSkill(skillToAdd)}
> >
<PlusIcon /> <PlusIcon />
{t("aiAgent.add")}
</Button> </Button>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{selectedSkillOptions.length === 0 ? ( {selectedSkillOptions.length === 0 ? (
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
Skill {t("aiAgent.noSkillsHint")}
</span> </span>
) : ( ) : (
selectedSkillOptions.map((option) => ( selectedSkillOptions.map((option) => (
@@ -1069,7 +1082,7 @@ function EditDialogBody({
onClick={() => onClick={() =>
handleRemoveSkill(Number(option.value)) handleRemoveSkill(Number(option.value))
} }
aria-label={`移除 Skill ${option.label}`} aria-label={t("aiAgent.removeSkill", { name: option.label })}
> >
<Trash2Icon className="size-3" /> <Trash2Icon className="size-3" />
</Button> </Button>
@@ -1083,7 +1096,7 @@ function EditDialogBody({
<TabsContent value="direct-tools" className="space-y-4"> <TabsContent value="direct-tools" className="space-y-4">
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
MCP {t("aiAgent.directToolsHint")}
</div> </div>
<Field> <Field>
<FieldContent className="space-y-3"> <FieldContent className="space-y-3">
@@ -1092,9 +1105,9 @@ function EditDialogBody({
<OptionCombobox <OptionCombobox
value={directToolGroupToAdd} value={directToolGroupToAdd}
options={directToolGroupOptions} options={directToolGroupOptions}
placeholder="选择工具分组" placeholder={t("aiAgent.selectToolGroup")}
searchPlaceholder="搜索工具分组" searchPlaceholder={t("aiAgent.searchToolGroup")}
emptyText="没有可用的工具分组" emptyText={t("aiAgent.emptyToolGroup")}
onChange={(value) => { onChange={(value) => {
setDirectToolGroupToAdd(value); setDirectToolGroupToAdd(value);
setDirectToolToAdd(""); setDirectToolToAdd("");
@@ -1105,9 +1118,9 @@ function EditDialogBody({
<OptionCombobox <OptionCombobox
value={directToolToAdd} value={directToolToAdd}
options={addableDirectToolOptions} options={addableDirectToolOptions}
placeholder="选择 Direct Tool" placeholder={t("aiAgent.selectDirectTool")}
searchPlaceholder="搜索 Direct Tool" searchPlaceholder={t("aiAgent.searchDirectTool")}
emptyText="没有可添加的 Direct Tool" emptyText={t("aiAgent.emptyDirectTool")}
onChange={handleAddDirectTool} onChange={handleAddDirectTool}
/> />
</div> </div>
@@ -1118,13 +1131,13 @@ function EditDialogBody({
onClick={() => handleAddDirectTool(directToolToAdd)} onClick={() => handleAddDirectTool(directToolToAdd)}
> >
<PlusIcon /> <PlusIcon />
{t("aiAgent.add")}
</Button> </Button>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
{directTools.length === 0 ? ( {directTools.length === 0 ? (
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
Direct Tool Agent Skill {t("aiAgent.noDirectToolsHint")}
</span> </span>
) : ( ) : (
directToolsGrouped.map(([groupLabel, tools]) => ( directToolsGrouped.map(([groupLabel, tools]) => (
@@ -1156,7 +1169,7 @@ function EditDialogBody({
onClick={() => onClick={() =>
handleRemoveDirectTool(value) handleRemoveDirectTool(value)
} }
aria-label={`移除 Direct Tool ${value}`} aria-label={t("aiAgent.removeDirectTool", { name: value })}
> >
<Trash2Icon className="size-3" /> <Trash2Icon className="size-3" />
</Button> </Button>
@@ -1174,7 +1187,7 @@ function EditDialogBody({
<TabsContent value="graph-tools" className="space-y-4"> <TabsContent value="graph-tools" className="space-y-4">
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
Direct Tools {t("aiAgent.graphToolsHint")}
</div> </div>
<Field> <Field>
<FieldContent className="space-y-3"> <FieldContent className="space-y-3">
@@ -1183,9 +1196,9 @@ function EditDialogBody({
<OptionCombobox <OptionCombobox
value={graphToolToAdd} value={graphToolToAdd}
options={addableGraphToolOptions} options={addableGraphToolOptions}
placeholder="选择 Graph Tool" placeholder={t("aiAgent.selectGraphTool")}
searchPlaceholder="搜索 Graph Tool" searchPlaceholder={t("aiAgent.searchGraphTool")}
emptyText="没有可添加的 Graph Tool" emptyText={t("aiAgent.emptyGraphTool")}
onChange={handleAddGraphTool} onChange={handleAddGraphTool}
/> />
</div> </div>
@@ -1196,13 +1209,13 @@ function EditDialogBody({
onClick={() => handleAddGraphTool(graphToolToAdd)} onClick={() => handleAddGraphTool(graphToolToAdd)}
> >
<PlusIcon /> <PlusIcon />
{t("aiAgent.add")}
</Button> </Button>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{graphTools.length === 0 ? ( {graphTools.length === 0 ? (
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
Graph Tool Agent / {t("aiAgent.noGraphToolsHint")}
</span> </span>
) : ( ) : (
graphTools.map((toolCode) => { graphTools.map((toolCode) => {
@@ -1225,7 +1238,7 @@ function EditDialogBody({
size="icon" size="icon"
className="size-5" className="size-5"
onClick={() => handleRemoveGraphTool(toolCode)} onClick={() => handleRemoveGraphTool(toolCode)}
aria-label={`移除 Graph Tool ${toolCode}`} aria-label={t("aiAgent.removeGraphTool", { name: toolCode })}
> >
<Trash2Icon className="size-3" /> <Trash2Icon className="size-3" />
</Button> </Button>
+80 -56
View File
@@ -73,34 +73,48 @@ import {
type CreateAIAgentPayload, type CreateAIAgentPayload,
type PageResult, type PageResult,
} from "@/lib/api/admin"; } from "@/lib/api/admin";
import { import { IMConversationServiceMode, Status } from "@/lib/generated/enums";
IMConversationServiceModeLabels, import { useI18n } from "@/i18n/provider";
Status,
StatusLabels,
} from "@/lib/generated/enums";
import { getEnumLabel, getEnumOptions } from "@/lib/enums";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { EditDialog } from "./_components/edit"; import { EditDialog } from "./_components/edit";
import { ButtonGroup } from "@/components/ui/button-group"; import { ButtonGroup } from "@/components/ui/button-group";
const statusOptions = [ type TFunction = (key: string, values?: Record<string, string | number>) => string;
{ value: "all", label: "全部状态" },
...getEnumOptions(StatusLabels).map((option) => ({
value: String(option.value),
label: option.label,
})),
];
function getStatusLabel(value: string) { function getStatusOptions(t: TFunction) {
return [
{ value: "all", label: t("aiAgent.allStatuses") },
{ value: String(Status.Ok), label: t("aiAgent.enabled") },
{ value: String(Status.Disabled), label: t("aiAgent.disabled") },
{ value: String(Status.Deleted), label: t("status.deleted") },
];
}
function getStatusLabel(value: string, t: TFunction) {
return ( return (
statusOptions.find((item) => item.value === value)?.label ?? "全部状态" getStatusOptions(t).find((item) => item.value === value)?.label ??
t("aiAgent.allStatuses")
); );
} }
function getServiceModeLabel(mode: number, t: TFunction) {
switch (mode) {
case IMConversationServiceMode.AIOnly:
return t("aiAgent.serviceAiOnly");
case IMConversationServiceMode.HumanOnly:
return t("aiAgent.serviceHumanOnly");
case IMConversationServiceMode.AIFirst:
return t("aiAgent.serviceAiFirst");
default:
return "-";
}
}
type SortableAIAgentRowProps = { type SortableAIAgentRowProps = {
item: AIAgent; item: AIAgent;
disabled: boolean; disabled: boolean;
actionLoadingId: number | null; actionLoadingId: number | null;
t: TFunction;
openEditDialog: (item: AIAgent) => void; openEditDialog: (item: AIAgent) => void;
handleToggleStatus: (item: AIAgent) => void; handleToggleStatus: (item: AIAgent) => void;
handleDelete: (item: AIAgent) => void; handleDelete: (item: AIAgent) => void;
@@ -110,6 +124,7 @@ function SortableAIAgentRow({
item, item,
disabled, disabled,
actionLoadingId, actionLoadingId,
t,
openEditDialog, openEditDialog,
handleToggleStatus, handleToggleStatus,
handleDelete, handleDelete,
@@ -154,7 +169,7 @@ function SortableAIAgentRow({
size="icon" size="icon"
className="size-8 cursor-grab active:cursor-grabbing" className="size-8 cursor-grab active:cursor-grabbing"
disabled={disabled} disabled={disabled}
aria-label={`拖拽排序 ${item.name}`} aria-label={t("aiAgent.dragSort", { name: item.name })}
{...attributes} {...attributes}
{...listeners} {...listeners}
> >
@@ -175,15 +190,14 @@ function SortableAIAgentRow({
{item.aiConfigName || "-"} {item.aiConfigName || "-"}
</TableCell> </TableCell>
<TableCell> <TableCell>
{getEnumLabel( {getServiceModeLabel(item.serviceMode, t)}
IMConversationServiceModeLabels,
item.serviceMode as keyof typeof IMConversationServiceModeLabels,
)}
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{knowledgeIds.length === 0 ? ( {knowledgeIds.length === 0 ? (
<span className="text-sm text-muted-foreground"></span> <span className="text-sm text-muted-foreground">
{t("aiAgent.notConfigured")}
</span>
) : ( ) : (
knowledgeBaseNames.map((name, index) => ( knowledgeBaseNames.map((name, index) => (
<Badge key={knowledgeIds[index] ?? `${item.id}-${index}`} variant="secondary"> <Badge key={knowledgeIds[index] ?? `${item.id}-${index}`} variant="secondary">
@@ -196,7 +210,7 @@ function SortableAIAgentRow({
<TableCell> <TableCell>
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{skills.length === 0 ? ( {skills.length === 0 ? (
<span className="text-sm text-muted-foreground">RAG</span> <span className="text-sm text-muted-foreground">{t("aiAgent.ragOnly")}</span>
) : ( ) : (
skills.map((skill) => ( skills.map((skill) => (
<Badge key={skill.id} variant="outline"> <Badge key={skill.id} variant="outline">
@@ -214,7 +228,9 @@ function SortableAIAgentRow({
</div> </div>
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{directToolServerCodes.length === 0 ? ( {directToolServerCodes.length === 0 ? (
<span className="text-sm text-muted-foreground"> MCP Server</span> <span className="text-sm text-muted-foreground">
{t("aiAgent.noMcpServer")}
</span>
) : ( ) : (
directToolServerCodes.map((serverCode) => ( directToolServerCodes.map((serverCode) => (
<Badge key={serverCode} variant="outline"> <Badge key={serverCode} variant="outline">
@@ -231,12 +247,12 @@ function SortableAIAgentRow({
checked={item.status === Status.Ok} checked={item.status === Status.Ok}
disabled={actionLoadingId === item.id} disabled={actionLoadingId === item.id}
onCheckedChange={() => void handleToggleStatus(item)} onCheckedChange={() => void handleToggleStatus(item)}
aria-label={`${item.name} 状态切换`} aria-label={t("aiAgent.toggleStatus", { name: item.name })}
/> />
<Badge <Badge
variant={item.status === Status.Ok ? "default" : "secondary"} variant={item.status === Status.Ok ? "default" : "secondary"}
> >
{getStatusLabel(String(item.status))} {getStatusLabel(String(item.status), t)}
</Badge> </Badge>
</div> </div>
</TableCell> </TableCell>
@@ -247,14 +263,14 @@ function SortableAIAgentRow({
size="sm" size="sm"
onClick={() => openEditDialog(item)} onClick={() => openEditDialog(item)}
> >
{t("aiAgent.edit")}
</Button> </Button>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
render={ render={
<Button variant="outline" size="icon-sm" className="ml-auto" /> <Button variant="outline" size="icon-sm" className="ml-auto" />
} }
aria-label={`更多操作 ${item.name}`} aria-label={t("aiAgent.moreActions", { name: item.name })}
> >
<MoreHorizontalIcon /> <MoreHorizontalIcon />
</DropdownMenuTrigger> </DropdownMenuTrigger>
@@ -264,7 +280,7 @@ function SortableAIAgentRow({
onClick={() => void handleToggleStatus(item)} onClick={() => void handleToggleStatus(item)}
> >
<PowerIcon className="size-4" /> <PowerIcon className="size-4" />
{item.status === Status.Ok ? "停用" : "启用"} {item.status === Status.Ok ? t("aiAgent.stop") : t("aiAgent.enabled")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
className="text-destructive" className="text-destructive"
@@ -272,7 +288,7 @@ function SortableAIAgentRow({
onClick={() => void handleDelete(item)} onClick={() => void handleDelete(item)}
> >
<Trash2Icon className="size-4" /> <Trash2Icon className="size-4" />
{t("aiAgent.delete")}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@@ -283,6 +299,8 @@ function SortableAIAgentRow({
} }
export default function DashboardAIAgentsPage() { export default function DashboardAIAgentsPage() {
const t = useI18n();
const statusOptions = getStatusOptions(t);
const [nameInput, setNameInput] = useState(""); const [nameInput, setNameInput] = useState("");
const [statusInput, setStatusInput] = useState("all"); const [statusInput, setStatusInput] = useState("all");
const [name, setName] = useState(""); const [name, setName] = useState("");
@@ -324,12 +342,12 @@ export default function DashboardAIAgentsPage() {
setResult(data); setResult(data);
} catch (error) { } catch (error) {
toast.error( toast.error(
error instanceof Error ? error.message : "加载 AI Agent 失败", error instanceof Error ? error.message : t("aiAgent.loadFailed"),
); );
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [limit, name, page, status]); }, [limit, name, page, status, t]);
useEffect(() => { useEffect(() => {
void loadData(); void loadData();
@@ -367,17 +385,17 @@ export default function DashboardAIAgentsPage() {
try { try {
if (editingItemId) { if (editingItemId) {
await updateAIAgent({ id: editingItemId, ...payload }); await updateAIAgent({ id: editingItemId, ...payload });
toast.success(`已更新 AI Agent${payload.name}`); toast.success(t("aiAgent.updated", { name: payload.name }));
} else { } else {
const created = await createAIAgent(payload); const created = await createAIAgent(payload);
toast.success(`已创建 AI Agent${created.name}`); toast.success(t("aiAgent.created", { name: created.name }));
} }
setDialogOpen(false); setDialogOpen(false);
setEditingItemId(null); setEditingItemId(null);
await loadData(); await loadData();
} catch (error) { } catch (error) {
toast.error( toast.error(
error instanceof Error ? error.message : "保存 AI Agent 失败", error instanceof Error ? error.message : t("aiAgent.saveFailed"),
); );
} finally { } finally {
setSaving(false); setSaving(false);
@@ -391,12 +409,15 @@ export default function DashboardAIAgentsPage() {
item.status === Status.Ok ? Status.Disabled : Status.Ok; item.status === Status.Ok ? Status.Disabled : Status.Ok;
await updateAIAgentStatus(item.id, nextStatus); await updateAIAgentStatus(item.id, nextStatus);
toast.success( toast.success(
`${nextStatus === Status.Ok ? "启用" : "停用"}${item.name}`, t("aiAgent.statusChanged", {
name: item.name,
status: nextStatus === Status.Ok ? t("aiAgent.enabled") : t("aiAgent.stop"),
}),
); );
await loadData(); await loadData();
} catch (error) { } catch (error) {
toast.error( toast.error(
error instanceof Error ? error.message : "更新 AI Agent 状态失败", error instanceof Error ? error.message : t("aiAgent.statusUpdateFailed"),
); );
} finally { } finally {
setActionLoadingId(null); setActionLoadingId(null);
@@ -407,11 +428,11 @@ export default function DashboardAIAgentsPage() {
setActionLoadingId(item.id); setActionLoadingId(item.id);
try { try {
await deleteAIAgent(item.id); await deleteAIAgent(item.id);
toast.success(`已删除 AI Agent${item.name}`); toast.success(t("aiAgent.deleted", { name: item.name }));
await loadData(); await loadData();
} catch (error) { } catch (error) {
toast.error( toast.error(
error instanceof Error ? error.message : "删除 AI Agent 失败", error instanceof Error ? error.message : t("aiAgent.deleteFailed"),
); );
} finally { } finally {
setActionLoadingId(null); setActionLoadingId(null);
@@ -440,14 +461,14 @@ export default function DashboardAIAgentsPage() {
try { try {
await updateAIAgentSort(nextResults.map((item) => item.id)); await updateAIAgentSort(nextResults.map((item) => item.id));
toast.success("AI Agent 排序已更新"); toast.success(t("aiAgent.sortUpdated"));
await loadData(); await loadData();
} catch (error) { } catch (error) {
setResult((current) => ({ setResult((current) => ({
...current, ...current,
results: previousResults, results: previousResults,
})); }));
toast.error(error instanceof Error ? error.message : "更新排序失败"); toast.error(error instanceof Error ? error.message : t("aiAgent.sortUpdateFailed"));
} finally { } finally {
setSorting(false); setSorting(false);
} }
@@ -465,11 +486,11 @@ export default function DashboardAIAgentsPage() {
disabled={loading} disabled={loading}
> >
<RefreshCwIcon className={loading ? "animate-spin" : ""} /> <RefreshCwIcon className={loading ? "animate-spin" : ""} />
{t("aiAgent.refresh")}
</Button> </Button>
<Button onClick={openCreateDialog}> <Button onClick={openCreateDialog}>
<PlusIcon /> <PlusIcon />
AI Agent {t("aiAgent.new")}
</Button> </Button>
</> </>
} }
@@ -478,22 +499,22 @@ export default function DashboardAIAgentsPage() {
value={nameInput} value={nameInput}
onChange={(event) => setNameInput(event.target.value)} onChange={(event) => setNameInput(event.target.value)}
onKeyDown={handleFilterKeyDown} onKeyDown={handleFilterKeyDown}
placeholder="按名称筛选" placeholder={t("aiAgent.filterName")}
className="w-full sm:w-56" className="w-full sm:w-56"
/> />
<div className="w-full sm:w-52"> <div className="w-full sm:w-52">
<OptionCombobox <OptionCombobox
value={statusInput} value={statusInput}
options={statusOptions} options={statusOptions}
placeholder="全部状态" placeholder={t("aiAgent.allStatuses")}
searchPlaceholder="搜索状态" searchPlaceholder={t("aiAgent.searchStatus")}
emptyText="未找到状态" emptyText={t("aiAgent.emptyStatus")}
onChange={setStatusInput} onChange={setStatusInput}
/> />
</div> </div>
<Button variant="outline" onClick={applyFilters} disabled={loading}> <Button variant="outline" onClick={applyFilters} disabled={loading}>
<SearchIcon /> <SearchIcon />
{t("aiAgent.query")}
</Button> </Button>
</DashboardToolbar> </DashboardToolbar>
@@ -521,13 +542,15 @@ export default function DashboardAIAgentsPage() {
<TableRow> <TableRow>
<TableHead className="w-14"></TableHead> <TableHead className="w-14"></TableHead>
<TableHead>Agent</TableHead> <TableHead>Agent</TableHead>
<TableHead>AI配置</TableHead> <TableHead>{t("aiAgent.columnAiConfig")}</TableHead>
<TableHead></TableHead> <TableHead>{t("aiAgent.columnServiceMode")}</TableHead>
<TableHead></TableHead> <TableHead>{t("aiAgent.columnKnowledge")}</TableHead>
<TableHead>Skills</TableHead> <TableHead>{t("aiAgent.columnSkills")}</TableHead>
<TableHead></TableHead> <TableHead>{t("aiAgent.columnCapabilities")}</TableHead>
<TableHead></TableHead> <TableHead>{t("aiAgent.columnStatus")}</TableHead>
<TableHead className="w-[88px] text-right"></TableHead> <TableHead className="w-[88px] text-right">
{t("aiAgent.columnActions")}
</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -535,8 +558,8 @@ export default function DashboardAIAgentsPage() {
<DashboardTableStateRow <DashboardTableStateRow
colSpan={9} colSpan={9}
loading={loading} loading={loading}
loadingText="正在加载 AI Agent..." loadingText={t("aiAgent.loadingRows")}
emptyText="暂无 AI Agent" emptyText={t("aiAgent.emptyRows")}
/> />
) : null} ) : null}
<SortableContext <SortableContext
@@ -549,6 +572,7 @@ export default function DashboardAIAgentsPage() {
item={item} item={item}
disabled={sorting} disabled={sorting}
actionLoadingId={actionLoadingId} actionLoadingId={actionLoadingId}
t={t}
openEditDialog={openEditDialog} openEditDialog={openEditDialog}
handleToggleStatus={handleToggleStatus} handleToggleStatus={handleToggleStatus}
handleDelete={handleDelete} handleDelete={handleDelete}
@@ -1,6 +1,6 @@
"use client" "use client"
import { useEffect, useState } from "react" import { useEffect, useMemo, useState } from "react"
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod"
import { Controller, Resolver, useForm } from "react-hook-form" import { Controller, Resolver, useForm } from "react-hook-form"
import { z } from "zod/v4" import { z } from "zod/v4"
@@ -18,11 +18,9 @@ import { Textarea } from "@/components/ui/textarea"
import { type AIConfig, type CreateAIConfigPayload, fetchAIConfig } from "@/lib/api/admin" import { type AIConfig, type CreateAIConfigPayload, fetchAIConfig } from "@/lib/api/admin"
import { import {
AIModelType, AIModelType,
AIModelTypeLabels,
AIProvider, AIProvider,
AIProviderLabels,
} from "@/lib/generated/enums" } from "@/lib/generated/enums"
import { getEnumOptions } from "@/lib/enums" import { useI18n } from "@/i18n/provider"
import { OptionCombobox } from "./option-combobox" import { OptionCombobox } from "./option-combobox"
type AIConfigEditDialogProps = { type AIConfigEditDialogProps = {
@@ -33,15 +31,19 @@ type AIConfigEditDialogProps = {
onSubmit: (payload: CreateAIConfigPayload) => Promise<void> onSubmit: (payload: CreateAIConfigPayload) => Promise<void>
} }
const providerOptions = getEnumOptions(AIProviderLabels).map((option) => ({ type TFunction = (key: string, values?: Record<string, string | number>) => string
value: String(option.value),
label: option.label,
}))
const modelTypeOptions = getEnumOptions(AIModelTypeLabels).map((option) => ({ function getProviderOptions(t: TFunction) {
value: String(option.value), return [{ value: String(AIProvider.OpenAI), label: t("aiConfig.providerOpenAI") }]
label: option.label, }
}))
function getModelTypeOptions(t: TFunction) {
return [
{ value: String(AIModelType.LLM), label: t("aiConfig.modelTypeLlm") },
{ value: String(AIModelType.Embedding), label: t("aiConfig.modelTypeEmbedding") },
{ value: String(AIModelType.Rerank), label: t("aiConfig.modelTypeRerank") },
]
}
const emptyForm: EditForm = { const emptyForm: EditForm = {
name: "", name: "",
@@ -60,29 +62,22 @@ const emptyForm: EditForm = {
remark: "", remark: "",
} }
const aiConfigFormSchema = z.object({ type EditForm = {
name: z.string().trim().min(1, "配置名称不能为空"), name: string
provider: z.string().trim().min(1, "供应商不能为空"), provider: string
baseUrl: z.string().trim().min(1, "基础地址不能为空"), baseUrl: string
apiKey: z.string().trim(), apiKey: string
modelType: z.string().trim().min(1, "模型类型不能为空"), modelType: string
modelName: z.string().trim().min(1, "模型名称不能为空"), modelName: string
dimension: z.string().trim().regex(/^\d+$/, "向量维度必须是大于等于 0 的整数"), dimension: string
maxContextTokens: z.string().trim().regex(/^\d+$/, "最大上下文 Token 必须是大于等于 0 的整数"), maxContextTokens: string
maxOutputTokens: z.string().trim().regex(/^\d+$/, "最大输出 Token 必须是大于等于 0 的整数"), maxOutputTokens: string
timeoutMs: z.string().trim().regex(/^\d+$/, "超时时间必须是大于等于 0 的整数"), timeoutMs: string
maxRetryCount: z.string().trim().regex(/^\d+$/, "最大重试次数必须是大于等于 0 的整数"), maxRetryCount: string
rpmLimit: z.string().trim().regex(/^\d+$/, "RPM 限制必须是大于等于 0 的整数"), rpmLimit: string
tpmLimit: z.string().trim().regex(/^\d+$/, "TPM 限制必须是大于等于 0 的整数"), tpmLimit: string
remark: z.string().trim(), remark: string
}) }
type EditForm = z.infer<typeof aiConfigFormSchema>
const editFormResolver = zodResolver(aiConfigFormSchema as never) as Resolver<
z.input<typeof aiConfigFormSchema>,
undefined,
z.output<typeof aiConfigFormSchema>
>
function buildForm(item: AIConfig | null): EditForm { function buildForm(item: AIConfig | null): EditForm {
if (!item) { if (!item) {
@@ -159,12 +154,35 @@ function AIConfigEditDialogBody({
onSubmit, onSubmit,
}: AIConfigEditDialogBodyProps) { }: AIConfigEditDialogBodyProps) {
const formId = "ai-config-edit-form" const formId = "ai-config-edit-form"
const t = useI18n()
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const form = useForm< const providerOptions = useMemo(() => getProviderOptions(t), [t])
z.input<typeof aiConfigFormSchema>, const modelTypeOptions = useMemo(() => getModelTypeOptions(t), [t])
undefined, const aiConfigFormSchema = useMemo(
z.output<typeof aiConfigFormSchema> () =>
>({ z.object({
name: z.string().trim().min(1, t("aiConfig.nameRequired")),
provider: z.string().trim().min(1, t("aiConfig.providerRequired")),
baseUrl: z.string().trim().min(1, t("aiConfig.baseUrlRequired")),
apiKey: z.string().trim(),
modelType: z.string().trim().min(1, t("aiConfig.modelTypeRequired")),
modelName: z.string().trim().min(1, t("aiConfig.modelNameRequired")),
dimension: z.string().trim().regex(/^\d+$/, t("aiConfig.dimensionInvalid")),
maxContextTokens: z.string().trim().regex(/^\d+$/, t("aiConfig.maxContextInvalid")),
maxOutputTokens: z.string().trim().regex(/^\d+$/, t("aiConfig.maxOutputInvalid")),
timeoutMs: z.string().trim().regex(/^\d+$/, t("aiConfig.timeoutInvalid")),
maxRetryCount: z.string().trim().regex(/^\d+$/, t("aiConfig.retryInvalid")),
rpmLimit: z.string().trim().regex(/^\d+$/, t("aiConfig.rpmInvalid")),
tpmLimit: z.string().trim().regex(/^\d+$/, t("aiConfig.tpmInvalid")),
remark: z.string().trim(),
}),
[t],
)
const editFormResolver = useMemo(
() => zodResolver(aiConfigFormSchema as never) as Resolver<EditForm>,
[aiConfigFormSchema],
)
const form = useForm<EditForm>({
resolver: editFormResolver, resolver: editFormResolver,
defaultValues: emptyForm, defaultValues: emptyForm,
}) })
@@ -206,7 +224,7 @@ function AIConfigEditDialogBody({
<ProjectDialog <ProjectDialog
open={open} open={open}
onOpenChange={onOpenChange} onOpenChange={onOpenChange}
title={itemId ? "编辑 AI 配置" : "新建 AI 配置"} title={itemId ? t("aiConfig.editTitle") : t("aiConfig.createTitle")}
size="xl" size="xl"
footer={ footer={
<> <>
@@ -216,26 +234,26 @@ function AIConfigEditDialogBody({
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
disabled={saving} disabled={saving}
> >
{t("aiConfig.cancel")}
</Button> </Button>
<Button type="submit" form={formId} disabled={saving || loading}> <Button type="submit" form={formId} disabled={saving || loading}>
{saving ? "保存中..." : itemId ? "保存" : "创建"} {saving ? t("aiConfig.saving") : itemId ? t("aiConfig.save") : t("aiConfig.create")}
</Button> </Button>
</> </>
} }
> >
{loading ? ( {loading ? (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<div className="text-muted-foreground">...</div> <div className="text-muted-foreground">{t("aiConfig.loading")}</div>
</div> </div>
) : ( ) : (
<form id={formId} onSubmit={handleSubmit(onFormSubmit)} className="space-y-4"> <form id={formId} onSubmit={handleSubmit(onFormSubmit)} className="space-y-4">
<Field data-invalid={!!errors.name}> <Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="ai-config-name"></FieldLabel> <FieldLabel htmlFor="ai-config-name">{t("aiConfig.name")}</FieldLabel>
<FieldContent> <FieldContent>
<Input <Input
id="ai-config-name" id="ai-config-name"
placeholder="例如:OpenAI 主回答模型" placeholder={t("aiConfig.namePlaceholder")}
aria-invalid={!!errors.name} aria-invalid={!!errors.name}
{...register("name")} {...register("name")}
/> />
@@ -245,7 +263,7 @@ function AIConfigEditDialogBody({
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field data-invalid={!!errors.provider}> <Field data-invalid={!!errors.provider}>
<FieldLabel></FieldLabel> <FieldLabel>{t("aiConfig.provider")}</FieldLabel>
<FieldContent> <FieldContent>
<Controller <Controller
control={control} control={control}
@@ -254,9 +272,9 @@ function AIConfigEditDialogBody({
<OptionCombobox <OptionCombobox
value={field.value} value={field.value}
options={providerOptions} options={providerOptions}
placeholder="请选择供应商" placeholder={t("aiConfig.selectProvider")}
searchPlaceholder="搜索供应商" searchPlaceholder={t("aiConfig.searchProvider")}
emptyText="未找到供应商" emptyText={t("aiConfig.emptyProvider")}
onChange={field.onChange} onChange={field.onChange}
/> />
)} )}
@@ -266,7 +284,7 @@ function AIConfigEditDialogBody({
</Field> </Field>
<Field data-invalid={!!errors.modelType}> <Field data-invalid={!!errors.modelType}>
<FieldLabel></FieldLabel> <FieldLabel>{t("aiConfig.modelType")}</FieldLabel>
<FieldContent> <FieldContent>
<Controller <Controller
control={control} control={control}
@@ -275,9 +293,9 @@ function AIConfigEditDialogBody({
<OptionCombobox <OptionCombobox
value={field.value} value={field.value}
options={modelTypeOptions} options={modelTypeOptions}
placeholder="请选择模型类型" placeholder={t("aiConfig.selectModelType")}
searchPlaceholder="搜索模型类型" searchPlaceholder={t("aiConfig.searchModelType")}
emptyText="未找到模型类型" emptyText={t("aiConfig.emptyModelType")}
onChange={field.onChange} onChange={field.onChange}
/> />
)} )}
@@ -288,11 +306,11 @@ function AIConfigEditDialogBody({
</div> </div>
<Field data-invalid={!!errors.baseUrl}> <Field data-invalid={!!errors.baseUrl}>
<FieldLabel htmlFor="ai-config-base-url">Base URL</FieldLabel> <FieldLabel htmlFor="ai-config-base-url">{t("aiConfig.baseUrl")}</FieldLabel>
<FieldContent> <FieldContent>
<Input <Input
id="ai-config-base-url" id="ai-config-base-url"
placeholder="例如:https://api.openai.com/v1" placeholder={t("aiConfig.baseUrlPlaceholder")}
aria-invalid={!!errors.baseUrl} aria-invalid={!!errors.baseUrl}
{...register("baseUrl")} {...register("baseUrl")}
/> />
@@ -306,7 +324,7 @@ function AIConfigEditDialogBody({
<Input <Input
id="ai-config-api-key" id="ai-config-api-key"
type="password" type="password"
placeholder="请输入 API Key" placeholder={t("aiConfig.apiKeyPlaceholder")}
aria-invalid={!!errors.apiKey} aria-invalid={!!errors.apiKey}
{...register("apiKey")} {...register("apiKey")}
/> />
@@ -316,11 +334,11 @@ function AIConfigEditDialogBody({
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field data-invalid={!!errors.modelName}> <Field data-invalid={!!errors.modelName}>
<FieldLabel htmlFor="ai-config-model-name"></FieldLabel> <FieldLabel htmlFor="ai-config-model-name">{t("aiConfig.modelName")}</FieldLabel>
<FieldContent> <FieldContent>
<Input <Input
id="ai-config-model-name" id="ai-config-model-name"
placeholder="例如:gpt-4o-mini" placeholder={t("aiConfig.modelNamePlaceholder")}
aria-invalid={!!errors.modelName} aria-invalid={!!errors.modelName}
{...register("modelName")} {...register("modelName")}
/> />
@@ -329,7 +347,7 @@ function AIConfigEditDialogBody({
</Field> </Field>
<Field data-invalid={!!errors.dimension}> <Field data-invalid={!!errors.dimension}>
<FieldLabel htmlFor="ai-config-dimension"></FieldLabel> <FieldLabel htmlFor="ai-config-dimension">{t("aiConfig.dimensionLabel")}</FieldLabel>
<FieldContent> <FieldContent>
<Input <Input
id="ai-config-dimension" id="ai-config-dimension"
@@ -347,7 +365,7 @@ function AIConfigEditDialogBody({
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field data-invalid={!!errors.maxContextTokens}> <Field data-invalid={!!errors.maxContextTokens}>
<FieldLabel htmlFor="ai-config-max-context"> Token</FieldLabel> <FieldLabel htmlFor="ai-config-max-context">{t("aiConfig.maxContextTokens")}</FieldLabel>
<FieldContent> <FieldContent>
<Input <Input
id="ai-config-max-context" id="ai-config-max-context"
@@ -362,7 +380,7 @@ function AIConfigEditDialogBody({
</Field> </Field>
<Field data-invalid={!!errors.maxOutputTokens}> <Field data-invalid={!!errors.maxOutputTokens}>
<FieldLabel htmlFor="ai-config-max-output"> Token</FieldLabel> <FieldLabel htmlFor="ai-config-max-output">{t("aiConfig.maxOutputTokens")}</FieldLabel>
<FieldContent> <FieldContent>
<Input <Input
id="ai-config-max-output" id="ai-config-max-output"
@@ -379,7 +397,7 @@ function AIConfigEditDialogBody({
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field data-invalid={!!errors.timeoutMs}> <Field data-invalid={!!errors.timeoutMs}>
<FieldLabel htmlFor="ai-config-timeout"> (ms)</FieldLabel> <FieldLabel htmlFor="ai-config-timeout">{t("aiConfig.timeoutMs")}</FieldLabel>
<FieldContent> <FieldContent>
<Input <Input
id="ai-config-timeout" id="ai-config-timeout"
@@ -394,7 +412,7 @@ function AIConfigEditDialogBody({
</Field> </Field>
<Field data-invalid={!!errors.maxRetryCount}> <Field data-invalid={!!errors.maxRetryCount}>
<FieldLabel htmlFor="ai-config-retry"></FieldLabel> <FieldLabel htmlFor="ai-config-retry">{t("aiConfig.maxRetryCount")}</FieldLabel>
<FieldContent> <FieldContent>
<Input <Input
id="ai-config-retry" id="ai-config-retry"
@@ -411,7 +429,7 @@ function AIConfigEditDialogBody({
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<Field data-invalid={!!errors.rpmLimit}> <Field data-invalid={!!errors.rpmLimit}>
<FieldLabel htmlFor="ai-config-rpm">RPM </FieldLabel> <FieldLabel htmlFor="ai-config-rpm">{t("aiConfig.rpmLimit")}</FieldLabel>
<FieldContent> <FieldContent>
<Input <Input
id="ai-config-rpm" id="ai-config-rpm"
@@ -426,7 +444,7 @@ function AIConfigEditDialogBody({
</Field> </Field>
<Field data-invalid={!!errors.tpmLimit}> <Field data-invalid={!!errors.tpmLimit}>
<FieldLabel htmlFor="ai-config-tpm">TPM </FieldLabel> <FieldLabel htmlFor="ai-config-tpm">{t("aiConfig.tpmLimit")}</FieldLabel>
<FieldContent> <FieldContent>
<Input <Input
id="ai-config-tpm" id="ai-config-tpm"
@@ -442,11 +460,11 @@ function AIConfigEditDialogBody({
</div> </div>
<Field data-invalid={!!errors.remark}> <Field data-invalid={!!errors.remark}>
<FieldLabel htmlFor="ai-config-remark"></FieldLabel> <FieldLabel htmlFor="ai-config-remark">{t("aiConfig.remark")}</FieldLabel>
<FieldContent> <FieldContent>
<Textarea <Textarea
id="ai-config-remark" id="ai-config-remark"
placeholder="记录用途、费用、限制说明等" placeholder={t("aiConfig.remarkPlaceholder")}
rows={3} rows={3}
aria-invalid={!!errors.remark} aria-invalid={!!errors.remark}
{...register("remark")} {...register("remark")}
@@ -16,6 +16,7 @@ import {
PopoverContent, PopoverContent,
PopoverTrigger, PopoverTrigger,
} from "@/components/ui/popover" } from "@/components/ui/popover"
import { useI18n } from "@/i18n/provider"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
export type ComboboxOption = { export type ComboboxOption = {
@@ -37,11 +38,12 @@ export function OptionCombobox({
value, value,
options, options,
placeholder, placeholder,
searchPlaceholder = "请输入关键字搜索", searchPlaceholder,
emptyText = "没有可选项", emptyText,
disabled = false, disabled = false,
onChange, onChange,
}: OptionComboboxProps) { }: OptionComboboxProps) {
const t = useI18n()
const selectedLabel = const selectedLabel =
options.find((option) => option.value === value)?.label ?? placeholder options.find((option) => option.value === value)?.label ?? placeholder
@@ -62,9 +64,9 @@ export function OptionCombobox({
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-(--radix-popover-trigger-width) p-0" align="start"> <PopoverContent className="w-(--radix-popover-trigger-width) p-0" align="start">
<Command> <Command>
<CommandInput placeholder={searchPlaceholder} /> <CommandInput placeholder={searchPlaceholder ?? t("common.searchKeyword")} />
<CommandList> <CommandList>
<CommandEmpty>{emptyText}</CommandEmpty> <CommandEmpty>{emptyText ?? t("common.emptyOptions")}</CommandEmpty>
<CommandGroup> <CommandGroup>
{options.map((option) => ( {options.map((option) => (
<CommandItem <CommandItem
+103 -87
View File
@@ -26,7 +26,7 @@ import {
SearchIcon, SearchIcon,
Trash2Icon Trash2Icon
} from "lucide-react"; } from "lucide-react";
import { useCallback, useEffect, useState, type CSSProperties } from "react"; import { useCallback, useEffect, useMemo, useState, type CSSProperties } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
@@ -76,40 +76,52 @@ import {
} from "@/lib/api/admin"; } from "@/lib/api/admin";
import { import {
AIModelType, AIModelType,
AIModelTypeLabels,
AIProvider, AIProvider,
AIProviderLabels,
Status, Status,
StatusLabels
} from "@/lib/generated/enums"; } from "@/lib/generated/enums";
import { getEnumLabel, getEnumOptions } from "@/lib/enums"; import { useI18n } from "@/i18n/provider";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { EditDialog } from "./_components/edit"; import { EditDialog } from "./_components/edit";
import { OptionCombobox } from "./_components/option-combobox"; import { OptionCombobox } from "./_components/option-combobox";
const listStatusOptions = [ type TFunction = (key: string, values?: Record<string, string | number>) => string;
{ value: "all", label: "全部状态" },
...getEnumOptions(StatusLabels).map((option) => ({
value: String(option.value),
label: option.label,
})),
];
const providerFilterOptions = [ function getStatusOptions(t: TFunction) {
{ value: "all", label: "全部供应商" }, return [
...getEnumOptions(AIProviderLabels).map((option) => ({ { value: "all", label: t("aiConfig.allStatuses") },
value: String(option.value), { value: String(Status.Ok), label: t("aiConfig.enabled") },
label: option.label, { value: String(Status.Disabled), label: t("aiConfig.disabled") },
})), { value: String(Status.Deleted), label: t("aiConfig.deletedStatus") },
]; ];
}
const modelTypeFilterOptions = [ function getProviderOptions(t: TFunction, includeAll = true) {
{ value: "all", label: "全部类型" }, const options = [
...getEnumOptions(AIModelTypeLabels).map((option) => ({ { value: String(AIProvider.OpenAI), label: t("aiConfig.providerOpenAI") },
value: String(option.value),
label: option.label,
})),
]; ];
return includeAll ? [{ value: "all", label: t("aiConfig.allProviders") }, ...options] : options;
}
function getModelTypeOptions(t: TFunction, includeAll = true) {
const options = [
{ value: String(AIModelType.LLM), label: t("aiConfig.modelTypeLlm") },
{ value: String(AIModelType.Embedding), label: t("aiConfig.modelTypeEmbedding") },
{ value: String(AIModelType.Rerank), label: t("aiConfig.modelTypeRerank") },
];
return includeAll ? [{ value: "all", label: t("aiConfig.allTypes") }, ...options] : options;
}
function getStatusLabel(value: Status, t: TFunction) {
return getStatusOptions(t).find((item) => item.value === String(value))?.label ?? String(value);
}
function getProviderLabel(value: AIProvider, t: TFunction) {
return getProviderOptions(t, false).find((item) => item.value === String(value))?.label ?? String(value);
}
function getModelTypeLabel(value: AIModelType, t: TFunction) {
return getModelTypeOptions(t, false).find((item) => item.value === String(value))?.label ?? String(value);
}
function maskAPIKey(value: string) { function maskAPIKey(value: string) {
const text = value.trim(); const text = value.trim();
@@ -126,6 +138,7 @@ type SortableAIConfigRowProps = {
item: AIConfig; item: AIConfig;
disabled: boolean; disabled: boolean;
actionLoadingId: number | null; actionLoadingId: number | null;
t: TFunction;
openEditDialog: (item: AIConfig) => void; openEditDialog: (item: AIConfig) => void;
handleToggleStatus: (item: AIConfig) => void; handleToggleStatus: (item: AIConfig) => void;
handleDelete: (item: AIConfig) => void; handleDelete: (item: AIConfig) => void;
@@ -135,6 +148,7 @@ function SortableAIConfigRow({
item, item,
disabled, disabled,
actionLoadingId, actionLoadingId,
t,
openEditDialog, openEditDialog,
handleToggleStatus, handleToggleStatus,
handleDelete, handleDelete,
@@ -172,7 +186,7 @@ function SortableAIConfigRow({
size="icon" size="icon"
className="size-8 cursor-grab active:cursor-grabbing" className="size-8 cursor-grab active:cursor-grabbing"
disabled={disabled} disabled={disabled}
aria-label={`拖拽排序 ${item.name}`} aria-label={t("aiConfig.dragSort", { name: item.name })}
{...attributes} {...attributes}
{...listeners} {...listeners}
> >
@@ -184,24 +198,18 @@ function SortableAIConfigRow({
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge variant="outline"> <Badge variant="outline">
{getEnumLabel( {getProviderLabel(item.provider as AIProvider, t)}
AIProviderLabels,
item.provider as AIProvider,
)}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="space-y-1"> <div className="space-y-1">
<Badge variant="secondary"> <Badge variant="secondary">
{getEnumLabel( {getModelTypeLabel(item.modelType as AIModelType, t)}
AIModelTypeLabels,
item.modelType as AIModelType,
)}
</Badge> </Badge>
<div className="text-sm">{item.modelName}</div> <div className="text-sm">{item.modelName}</div>
{item.dimension > 0 && ( {item.dimension > 0 && (
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
{item.dimension} {t("aiConfig.dimension", { count: item.dimension })}
</div> </div>
)} )}
</div> </div>
@@ -210,16 +218,19 @@ function SortableAIConfigRow({
<div className="space-y-1 text-sm"> <div className="space-y-1 text-sm">
<div className="line-clamp-1">{item.baseUrl}</div> <div className="line-clamp-1">{item.baseUrl}</div>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
Key: {maskAPIKey(item.apiKey)} {t("aiConfig.apiKey", { key: maskAPIKey(item.apiKey) })}
</div> </div>
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="space-y-1 text-xs text-muted-foreground"> <div className="space-y-1 text-xs text-muted-foreground">
<div> {item.maxContextTokens || 0}</div> <div>{t("aiConfig.contextTokens", { count: item.maxContextTokens || 0 })}</div>
<div> {item.maxOutputTokens || 0}</div> <div>{t("aiConfig.outputTokens", { count: item.maxOutputTokens || 0 })}</div>
<div> <div>
{item.timeoutMs}ms / {item.maxRetryCount} {t("aiConfig.timeoutRetry", {
timeout: item.timeoutMs,
retries: item.maxRetryCount,
})}
</div> </div>
<div> <div>
RPM {item.rpmLimit || 0} / TPM {item.tpmLimit || 0} RPM {item.rpmLimit || 0} / TPM {item.tpmLimit || 0}
@@ -232,17 +243,14 @@ function SortableAIConfigRow({
checked={item.status === Status.Ok} checked={item.status === Status.Ok}
disabled={actionLoadingId === item.id} disabled={actionLoadingId === item.id}
onCheckedChange={() => void handleToggleStatus(item)} onCheckedChange={() => void handleToggleStatus(item)}
aria-label={`${item.name} 状态切换`} aria-label={t("aiConfig.toggleStatus", { name: item.name })}
/> />
<Badge <Badge
variant={ variant={
item.status === Status.Ok ? "default" : "outline" item.status === Status.Ok ? "default" : "outline"
} }
> >
{getEnumLabel( {getStatusLabel(item.status as Status, t)}
StatusLabels,
item.status as keyof typeof StatusLabels,
)}
</Badge> </Badge>
</div> </div>
</TableCell> </TableCell>
@@ -253,12 +261,12 @@ function SortableAIConfigRow({
size="sm" size="sm"
onClick={() => openEditDialog(item)} onClick={() => openEditDialog(item)}
> >
{t("aiConfig.edit")}
</Button> </Button>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
render={<Button variant="outline" size="icon-sm" />} render={<Button variant="outline" size="icon-sm" />}
aria-label={`更多操作 ${item.name}`} aria-label={t("aiConfig.moreActions", { name: item.name })}
> >
<MoreHorizontalIcon /> <MoreHorizontalIcon />
</DropdownMenuTrigger> </DropdownMenuTrigger>
@@ -273,10 +281,10 @@ function SortableAIConfigRow({
> >
<Trash2Icon /> <Trash2Icon />
{item.status === Status.Ok {item.status === Status.Ok
? "启用中不可删" ? t("aiConfig.deleteDisabledActive")
: actionLoadingId === item.id : actionLoadingId === item.id
? "删除中..." ? t("aiConfig.deleting")
: "删除"} : t("aiConfig.delete")}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@@ -287,6 +295,10 @@ function SortableAIConfigRow({
} }
export default function DashboardAIConfigsPage() { export default function DashboardAIConfigsPage() {
const t = useI18n();
const listStatusOptions = useMemo(() => getStatusOptions(t), [t]);
const providerFilterOptions = useMemo(() => getProviderOptions(t), [t]);
const modelTypeFilterOptions = useMemo(() => getModelTypeOptions(t), [t]);
const [keywordInput, setKeywordInput] = useState(""); const [keywordInput, setKeywordInput] = useState("");
const [statusFilterInput, setStatusFilterInput] = useState("all"); const [statusFilterInput, setStatusFilterInput] = useState("all");
const [providerFilterInput, setProviderFilterInput] = useState("all"); const [providerFilterInput, setProviderFilterInput] = useState("all");
@@ -335,11 +347,11 @@ export default function DashboardAIConfigsPage() {
}); });
setResult(data); setResult(data);
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "加载 AI 配置失败"); toast.error(error instanceof Error ? error.message : t("aiConfig.loadFailed"));
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [keyword, statusFilter, providerFilter, modelTypeFilter, page, limit]); }, [keyword, statusFilter, providerFilter, modelTypeFilter, page, limit, t]);
useEffect(() => { useEffect(() => {
void loadData(); void loadData();
@@ -405,16 +417,16 @@ export default function DashboardAIConfigsPage() {
try { try {
if (editingItem) { if (editingItem) {
await updateAIConfig({ id: editingItem.id, ...payload }); await updateAIConfig({ id: editingItem.id, ...payload });
toast.success(`已更新 AI 配置:${editingItem.name}`); toast.success(t("aiConfig.updated", { name: editingItem.name }));
} else { } else {
await createAIConfig(payload); await createAIConfig(payload);
toast.success(`已创建 AI 配置:${payload.name}`); toast.success(t("aiConfig.created", { name: payload.name }));
} }
setDialogOpen(false); setDialogOpen(false);
setEditingItem(null); setEditingItem(null);
await loadData(); await loadData();
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "保存 AI 配置失败"); toast.error(error instanceof Error ? error.message : t("aiConfig.saveFailed"));
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -429,11 +441,14 @@ export default function DashboardAIConfigsPage() {
: Status.Ok; : Status.Ok;
await updateAIConfigStatus(item.id, nextStatus); await updateAIConfigStatus(item.id, nextStatus);
toast.success( toast.success(
`${nextStatus === Status.Ok ? "启用" : "禁用"}${item.name}`, t("aiConfig.statusChanged", {
name: item.name,
status: nextStatus === Status.Ok ? t("aiConfig.enabled") : t("aiConfig.disabled"),
}),
); );
await loadData(); await loadData();
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "更新状态失败"); toast.error(error instanceof Error ? error.message : t("aiConfig.statusUpdateFailed"));
} finally { } finally {
setActionLoadingId(null); setActionLoadingId(null);
} }
@@ -441,7 +456,7 @@ export default function DashboardAIConfigsPage() {
async function handleDelete(item: AIConfig) { async function handleDelete(item: AIConfig) {
if (item.status === Status.Ok) { if (item.status === Status.Ok) {
toast.error("启用中的 AI 配置不允许删除"); toast.error(t("aiConfig.activeDeleteBlocked"));
return; return;
} }
setDeletingItem(item); setDeletingItem(item);
@@ -456,12 +471,12 @@ export default function DashboardAIConfigsPage() {
setActionLoadingId(item.id); setActionLoadingId(item.id);
try { try {
await deleteAIConfig(item.id); await deleteAIConfig(item.id);
toast.success(`已删除 AI 配置:${item.name}`); toast.success(t("aiConfig.deleted", { name: item.name }));
setDeleteDialogOpen(false); setDeleteDialogOpen(false);
setDeletingItem(null); setDeletingItem(null);
await loadData(); await loadData();
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "删除 AI 配置失败"); toast.error(error instanceof Error ? error.message : t("aiConfig.deleteFailed"));
} finally { } finally {
setActionLoadingId(null); setActionLoadingId(null);
} }
@@ -489,14 +504,14 @@ export default function DashboardAIConfigsPage() {
try { try {
await updateAIConfigSort(nextResults.map((item) => item.id)); await updateAIConfigSort(nextResults.map((item) => item.id));
toast.success("AI 配置排序已更新"); toast.success(t("aiConfig.sortUpdated"));
await loadData(); await loadData();
} catch (error) { } catch (error) {
setResult((current) => ({ setResult((current) => ({
...current, ...current,
results: previousResults, results: previousResults,
})); }));
toast.error(error instanceof Error ? error.message : "更新排序失败"); toast.error(error instanceof Error ? error.message : t("aiConfig.sortUpdateFailed"));
} finally { } finally {
setSorting(false); setSorting(false);
} }
@@ -514,11 +529,11 @@ export default function DashboardAIConfigsPage() {
disabled={loading} disabled={loading}
> >
<RefreshCwIcon className={loading ? "animate-spin" : ""} /> <RefreshCwIcon className={loading ? "animate-spin" : ""} />
{t("aiConfig.refresh")}
</Button> </Button>
<Button onClick={openCreateDialog}> <Button onClick={openCreateDialog}>
<PlusIcon /> <PlusIcon />
{t("aiConfig.new")}
</Button> </Button>
</> </>
} }
@@ -529,7 +544,7 @@ export default function DashboardAIConfigsPage() {
value={keywordInput} value={keywordInput}
onChange={(event) => setKeywordInput(event.target.value)} onChange={(event) => setKeywordInput(event.target.value)}
onKeyDown={handleFilterKeyDown} onKeyDown={handleFilterKeyDown}
placeholder="按配置名称筛选" placeholder={t("aiConfig.filterName")}
className="pl-9" className="pl-9"
/> />
</div> </div>
@@ -537,9 +552,9 @@ export default function DashboardAIConfigsPage() {
<OptionCombobox <OptionCombobox
value={modelTypeFilterInput} value={modelTypeFilterInput}
options={modelTypeFilterOptions} options={modelTypeFilterOptions}
placeholder="全部类型" placeholder={t("aiConfig.allTypes")}
searchPlaceholder="搜索模型类型" searchPlaceholder={t("aiConfig.searchModelType")}
emptyText="未找到模型类型" emptyText={t("aiConfig.emptyModelType")}
onChange={setModelTypeFilterInput} onChange={setModelTypeFilterInput}
/> />
</div> </div>
@@ -547,9 +562,9 @@ export default function DashboardAIConfigsPage() {
<OptionCombobox <OptionCombobox
value={providerFilterInput} value={providerFilterInput}
options={providerFilterOptions} options={providerFilterOptions}
placeholder="全部供应商" placeholder={t("aiConfig.allProviders")}
searchPlaceholder="搜索供应商" searchPlaceholder={t("aiConfig.searchProvider")}
emptyText="未找到供应商" emptyText={t("aiConfig.emptyProvider")}
onChange={setProviderFilterInput} onChange={setProviderFilterInput}
/> />
</div> </div>
@@ -557,15 +572,15 @@ export default function DashboardAIConfigsPage() {
<OptionCombobox <OptionCombobox
value={statusFilterInput} value={statusFilterInput}
options={listStatusOptions} options={listStatusOptions}
placeholder="全部状态" placeholder={t("aiConfig.allStatuses")}
searchPlaceholder="搜索状态" searchPlaceholder={t("aiConfig.searchStatus")}
emptyText="未找到状态" emptyText={t("aiConfig.emptyStatus")}
onChange={setStatusFilterInput} onChange={setStatusFilterInput}
/> />
</div> </div>
<Button variant="outline" onClick={applyFilters} disabled={loading}> <Button variant="outline" onClick={applyFilters} disabled={loading}>
<SearchIcon /> <SearchIcon />
{t("aiConfig.query")}
</Button> </Button>
</DashboardToolbar> </DashboardToolbar>
@@ -589,13 +604,13 @@ export default function DashboardAIConfigsPage() {
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="w-14"></TableHead> <TableHead className="w-14"></TableHead>
<TableHead></TableHead> <TableHead>{t("aiConfig.columnConfig")}</TableHead>
<TableHead></TableHead> <TableHead>{t("aiConfig.columnProvider")}</TableHead>
<TableHead></TableHead> <TableHead>{t("aiConfig.columnModel")}</TableHead>
<TableHead></TableHead> <TableHead>{t("aiConfig.columnAccess")}</TableHead>
<TableHead></TableHead> <TableHead>{t("aiConfig.columnLimits")}</TableHead>
<TableHead></TableHead> <TableHead>{t("aiConfig.columnStatus")}</TableHead>
<TableHead className="text-right"></TableHead> <TableHead className="text-right">{t("aiConfig.columnActions")}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -603,8 +618,8 @@ export default function DashboardAIConfigsPage() {
<DashboardTableStateRow <DashboardTableStateRow
colSpan={8} colSpan={8}
loading={loading} loading={loading}
loadingText="正在加载 AI 配置..." loadingText={t("aiConfig.loadingRows")}
emptyText="暂无 AI 配置数据" emptyText={t("aiConfig.emptyRows")}
/> />
) : ( ) : (
<SortableContext <SortableContext
@@ -617,6 +632,7 @@ export default function DashboardAIConfigsPage() {
item={item} item={item}
disabled={sorting} disabled={sorting}
actionLoadingId={actionLoadingId} actionLoadingId={actionLoadingId}
t={t}
openEditDialog={openEditDialog} openEditDialog={openEditDialog}
handleToggleStatus={handleToggleStatus} handleToggleStatus={handleToggleStatus}
handleDelete={handleDelete} handleDelete={handleDelete}
@@ -652,11 +668,11 @@ export default function DashboardAIConfigsPage() {
> >
<DialogContent className="max-w-md" showCloseButton={false}> <DialogContent className="max-w-md" showCloseButton={false}>
<DialogHeader> <DialogHeader>
<DialogTitle> AI </DialogTitle> <DialogTitle>{t("aiConfig.confirmDeleteTitle")}</DialogTitle>
<DialogDescription> <DialogDescription>
{deletingItem {deletingItem
? `确认删除“${deletingItem.name}”吗?此操作不可撤销。` ? t("aiConfig.confirmDeleteDescription", { name: deletingItem.name })
: "此操作不可撤销。"} : t("aiConfig.deleteIrreversible")}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
@@ -669,7 +685,7 @@ export default function DashboardAIConfigsPage() {
setDeletingItem(null); setDeletingItem(null);
}} }}
> >
{t("aiConfig.cancel")}
</Button> </Button>
<Button <Button
type="button" type="button"
@@ -677,7 +693,7 @@ export default function DashboardAIConfigsPage() {
disabled={!!actionLoadingId} disabled={!!actionLoadingId}
onClick={() => void handleConfirmDelete()} onClick={() => void handleConfirmDelete()}
> >
{actionLoadingId ? "删除中..." : "确认删除"} {actionLoadingId ? t("aiConfig.deleting") : t("aiConfig.confirmDelete")}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
+142 -110
View File
@@ -28,6 +28,7 @@ import {
fetchWxWorkKFAccounts, fetchWxWorkKFAccounts,
resetChannelUserTokenSecret, resetChannelUserTokenSecret,
} from "@/lib/api/admin" } from "@/lib/api/admin"
import { useI18n } from "@/i18n/provider"
type ChannelFormDialogProps = { type ChannelFormDialogProps = {
open: boolean open: boolean
@@ -37,16 +38,7 @@ type ChannelFormDialogProps = {
onSubmit: (payload: CreateAdminChannelPayload) => Promise<void> onSubmit: (payload: CreateAdminChannelPayload) => Promise<void>
} }
const channelTypeOptions = [ type Translate = (key: string, values?: Record<string, string | number>) => string
{ value: "web", label: "Web 站点" },
{ value: "wechat_mp", label: "微信公众号" },
{ value: "wxwork_kf", label: "企业微信客服" },
] as const
const widgetPositionOptions = [
{ value: "right", label: "右下角" },
{ value: "left", label: "左下角" },
] as const
type WebChannelConfig = { type WebChannelConfig = {
title?: string title?: string
@@ -64,20 +56,23 @@ type WechatMPChannelConfig = {
userTokenSecret?: string userTokenSecret?: string
} }
const defaultWebChannelConfig: Required<WebChannelConfig> = { function getDefaultWebChannelConfig(t: Translate): Required<WebChannelConfig> {
title: "在线客服", return {
subtitle: "欢迎咨询", title: t("channel.defaultTitleWeb"),
subtitle: t("channel.defaultSubtitle"),
themeColor: "#2563eb", themeColor: "#2563eb",
position: "right", position: "right",
width: "380px", width: "380px",
userTokenSecret: "", userTokenSecret: "",
} }
}
const schema = z function createSchema(t: Translate) {
return z
.object({ .object({
channelType: z.enum(["web", "wechat_mp", "wxwork_kf"], "请选择渠道类型"), channelType: z.enum(["web", "wechat_mp", "wxwork_kf"], t("channel.typeRequired")),
aiAgentId: z.string().trim().regex(/^\d+$/, "请选择 AI Agent"), aiAgentId: z.string().trim().regex(/^\d+$/, t("channel.agentRequired")),
name: z.string().trim().min(1, "渠道名称不能为空"), name: z.string().trim().min(1, t("channel.nameRequired")),
openKfId: z.string().trim(), openKfId: z.string().trim(),
widgetTitle: z.string().trim(), widgetTitle: z.string().trim(),
widgetSubtitle: z.string().trim(), widgetSubtitle: z.string().trim(),
@@ -92,20 +87,29 @@ const schema = z
ctx.addIssue({ ctx.addIssue({
code: "custom", code: "custom",
path: ["openKfId"], path: ["openKfId"],
message: "请选择企业微信客服账号", message: t("channel.wxworkAccountRequired"),
}) })
} }
}) })
}
type EditForm = z.infer<typeof schema> type EditForm = {
channelType: "web" | "wechat_mp" | "wxwork_kf"
aiAgentId: string
name: string
openKfId: string
widgetTitle: string
widgetSubtitle: string
widgetThemeColor: string
widgetPosition: "left" | "right"
widgetWidth: string
userTokenSecret: string
remark: string
}
const resolver = zodResolver(schema as never) as Resolver< function createEmptyForm(t: Translate): EditForm {
z.input<typeof schema>, const defaultWebChannelConfig = getDefaultWebChannelConfig(t)
undefined, return {
z.output<typeof schema>
>
const emptyForm: EditForm = {
channelType: "web", channelType: "web",
aiAgentId: "", aiAgentId: "",
name: "", name: "",
@@ -118,6 +122,7 @@ const emptyForm: EditForm = {
userTokenSecret: "", userTokenSecret: "",
remark: "", remark: "",
} }
}
function parseOpenKfId(configJson: string): string { function parseOpenKfId(configJson: string): string {
if (!configJson.trim()) { if (!configJson.trim()) {
@@ -131,7 +136,8 @@ function parseOpenKfId(configJson: string): string {
} }
} }
function parseWebChannelConfig(configJson: string): Required<WebChannelConfig> { function parseWebChannelConfig(configJson: string, t: Translate): Required<WebChannelConfig> {
const defaultWebChannelConfig = getDefaultWebChannelConfig(t)
if (!configJson.trim()) { if (!configJson.trim()) {
return defaultWebChannelConfig return defaultWebChannelConfig
} }
@@ -152,9 +158,10 @@ function parseWebChannelConfig(configJson: string): Required<WebChannelConfig> {
} }
} }
function parseWechatMPChannelConfig(configJson: string): Required<WechatMPChannelConfig> { function parseWechatMPChannelConfig(configJson: string, t: Translate): Required<WechatMPChannelConfig> {
const defaultWebChannelConfig = getDefaultWebChannelConfig(t)
const fallback = { const fallback = {
title: "公众号客服", title: t("channel.defaultTitleWechat"),
subtitle: defaultWebChannelConfig.subtitle, subtitle: defaultWebChannelConfig.subtitle,
themeColor: defaultWebChannelConfig.themeColor, themeColor: defaultWebChannelConfig.themeColor,
userTokenSecret: "", userTokenSecret: "",
@@ -176,14 +183,14 @@ function parseWechatMPChannelConfig(configJson: string): Required<WechatMPChanne
} }
} }
function buildForm(item: AdminChannel | null): EditForm { function buildForm(item: AdminChannel | null, t: Translate): EditForm {
if (!item) { if (!item) {
return emptyForm return createEmptyForm(t)
} }
const isWechatMP = item.channelType === "wechat_mp" const isWechatMP = item.channelType === "wechat_mp"
const webConfig = parseWebChannelConfig(item.configJson) const webConfig = parseWebChannelConfig(item.configJson, t)
const wechatConfig = isWechatMP const wechatConfig = isWechatMP
? parseWechatMPChannelConfig(item.configJson) ? parseWechatMPChannelConfig(item.configJson, t)
: null : null
return { return {
channelType: channelType:
@@ -205,12 +212,13 @@ function buildForm(item: AdminChannel | null): EditForm {
} }
} }
function buildPayload(form: EditForm, status: number): CreateAdminChannelPayload { function buildPayload(form: EditForm, status: number, t: Translate): CreateAdminChannelPayload {
const channelType = form.channelType const channelType = form.channelType
const defaultWebChannelConfig = getDefaultWebChannelConfig(t)
const webLikeConfig = { const webLikeConfig = {
title: title:
form.widgetTitle.trim() || form.widgetTitle.trim() ||
(channelType === "wechat_mp" ? "公众号客服" : defaultWebChannelConfig.title), (channelType === "wechat_mp" ? t("channel.defaultTitleWechat") : defaultWebChannelConfig.title),
subtitle: form.widgetSubtitle.trim(), subtitle: form.widgetSubtitle.trim(),
themeColor: themeColor:
form.widgetThemeColor.trim() || defaultWebChannelConfig.themeColor, form.widgetThemeColor.trim() || defaultWebChannelConfig.themeColor,
@@ -267,7 +275,19 @@ function ChannelFormBody({
onOpenChange, onOpenChange,
onSubmit, onSubmit,
}: ChannelFormBodyProps) { }: ChannelFormBodyProps) {
const t = useI18n()
const formId = "channel-edit-form" const formId = "channel-edit-form"
const emptyForm = useMemo(() => createEmptyForm(t), [t])
const schema = useMemo(() => createSchema(t), [t])
const resolver = useMemo(
() =>
zodResolver(schema as never) as Resolver<
z.input<typeof schema>,
undefined,
z.output<typeof schema>
>,
[schema],
)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [aiAgents, setAIAgents] = useState<AIAgent[]>([]) const [aiAgents, setAIAgents] = useState<AIAgent[]>([])
const [wxWorkKFAccounts, setWxWorkKFAccounts] = useState<WxWorkKFAccount[]>([]) const [wxWorkKFAccounts, setWxWorkKFAccounts] = useState<WxWorkKFAccount[]>([])
@@ -320,7 +340,7 @@ function ChannelFormBody({
const data = await fetchChannel(itemId) const data = await fetchChannel(itemId)
setChannelDetail(data) setChannelDetail(data)
setCurrentStatus(data.status) setCurrentStatus(data.status)
reset(buildForm(data)) reset(buildForm(data, t))
} catch (error) { } catch (error) {
console.error("Failed to load channel:", error) console.error("Failed to load channel:", error)
} finally { } finally {
@@ -328,7 +348,7 @@ function ChannelFormBody({
} }
} }
void loadDetail() void loadDetail()
}, [itemId, reset]) }, [emptyForm, itemId, reset, t])
useEffect(() => { useEffect(() => {
if ( if (
@@ -348,7 +368,7 @@ function ChannelFormBody({
} catch (error) { } catch (error) {
console.error("Failed to load WeCom KF accounts:", error) console.error("Failed to load WeCom KF accounts:", error)
setWxWorkKFAccountsError( setWxWorkKFAccountsError(
error instanceof Error ? error.message : "企业微信客服账号加载失败" error instanceof Error ? error.message : t("channel.loadWxworkAccountsFailed")
) )
} finally { } finally {
setWxWorkKFAccountsLoading(false) setWxWorkKFAccountsLoading(false)
@@ -360,6 +380,7 @@ function ChannelFormBody({
wxWorkKFAccounts.length, wxWorkKFAccounts.length,
wxWorkKFAccountsError, wxWorkKFAccountsError,
wxWorkKFAccountsLoading, wxWorkKFAccountsLoading,
t,
]) ])
const aiAgentOptions = aiAgents.map((item) => ({ const aiAgentOptions = aiAgents.map((item) => ({
@@ -370,6 +391,15 @@ function ChannelFormBody({
value: item.openKfId, value: item.openKfId,
label: item.name ? `${item.name} (${item.openKfId})` : item.openKfId, label: item.name ? `${item.name} (${item.openKfId})` : item.openKfId,
})) }))
const channelTypeOptions = [
{ value: "web", label: t("channel.typeWeb") },
{ value: "wechat_mp", label: t("channel.typeWechatMp") },
{ value: "wxwork_kf", label: t("channel.typeWxworkKf") },
] as const
const widgetPositionOptions = [
{ value: "right", label: t("channel.positionRight") },
{ value: "left", label: t("channel.positionLeft") },
] as const
if ( if (
channelType === "wxwork_kf" && channelType === "wxwork_kf" &&
openKfId && openKfId &&
@@ -382,14 +412,14 @@ function ChannelFormBody({
} }
async function onFormSubmit(values: EditForm) { async function onFormSubmit(values: EditForm) {
await onSubmit(buildPayload(values, currentStatus)) await onSubmit(buildPayload(values, currentStatus, t))
} }
async function handleResetUserTokenSecret() { async function handleResetUserTokenSecret() {
if (!itemId) { if (!itemId) {
return return
} }
if (!window.confirm("重置后旧 userToken 将在过期后失效,确认重置?")) { if (!window.confirm(t("channel.resetSecretConfirm"))) {
return return
} }
try { try {
@@ -405,9 +435,9 @@ function ChannelFormBody({
configJson: JSON.stringify(parsed), configJson: JSON.stringify(parsed),
}) })
} }
toast.success("已重置用户 JWT Secret") toast.success(t("channel.resetSecretSuccess"))
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "重置用户 JWT Secret 失败") toast.error(error instanceof Error ? error.message : t("channel.resetSecretFailed"))
} }
} }
@@ -417,9 +447,9 @@ function ChannelFormBody({
} }
try { try {
await navigator.clipboard.writeText(userTokenSecret) await navigator.clipboard.writeText(userTokenSecret)
toast.success("已复制用户 JWT Secret") toast.success(t("channel.copySecretSuccess"))
} catch { } catch {
toast.error("复制失败") toast.error(t("channel.copyFailed"))
} }
} }
@@ -427,29 +457,29 @@ function ChannelFormBody({
<ProjectDialog <ProjectDialog
open={true} open={true}
onOpenChange={onOpenChange} onOpenChange={onOpenChange}
title={itemId ? "编辑渠道" : "新建渠道"} title={itemId ? t("channel.editTitle") : t("channel.createTitle")}
size="lg" size="lg"
allowFullscreen allowFullscreen
footer={ footer={
<> <>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}> <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
{t("channel.cancel")}
</Button> </Button>
<Button type="submit" form={formId} disabled={saving || loading}> <Button type="submit" form={formId} disabled={saving || loading}>
{saving ? "保存中..." : "保存"} {saving ? t("channel.saving") : t("channel.save")}
</Button> </Button>
</> </>
} }
> >
{loading ? ( {loading ? (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<div className="text-muted-foreground">...</div> <div className="text-muted-foreground">{t("channel.loadingDetail")}</div>
</div> </div>
) : ( ) : (
<form id={formId} onSubmit={handleSubmit(onFormSubmit)} className="space-y-5"> <form id={formId} onSubmit={handleSubmit(onFormSubmit)} className="space-y-5">
<div className="grid grid-cols-1 gap-4"> <div className="grid grid-cols-1 gap-4">
<Field data-invalid={!!errors.name}> <Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="channel-name"></FieldLabel> <FieldLabel htmlFor="channel-name">{t("channel.name")}</FieldLabel>
<FieldContent> <FieldContent>
<Input id="channel-name" {...register("name")} /> <Input id="channel-name" {...register("name")} />
<FieldError errors={[errors.name]} /> <FieldError errors={[errors.name]} />
@@ -457,7 +487,7 @@ function ChannelFormBody({
</Field> </Field>
<Field data-invalid={!!errors.aiAgentId}> <Field data-invalid={!!errors.aiAgentId}>
<FieldLabel> Agent</FieldLabel> <FieldLabel>{t("channel.columnAgent")}</FieldLabel>
<FieldContent> <FieldContent>
<Controller <Controller
control={control} control={control}
@@ -466,9 +496,9 @@ function ChannelFormBody({
<OptionCombobox <OptionCombobox
value={field.value} value={field.value}
options={aiAgentOptions} options={aiAgentOptions}
placeholder="请选择 AI Agent" placeholder={t("channel.agentRequired")}
searchPlaceholder="搜索 AI Agent" searchPlaceholder={t("channel.searchAiAgent")}
emptyText="未找到 AI Agent" emptyText={t("channel.emptyAiAgent")}
onChange={field.onChange} onChange={field.onChange}
/> />
)} )}
@@ -478,7 +508,7 @@ function ChannelFormBody({
</Field> </Field>
<Field data-invalid={!!errors.channelType}> <Field data-invalid={!!errors.channelType}>
<FieldLabel></FieldLabel> <FieldLabel>{t("channel.channelType")}</FieldLabel>
<FieldContent> <FieldContent>
<Controller <Controller
control={control} control={control}
@@ -487,9 +517,9 @@ function ChannelFormBody({
<OptionCombobox <OptionCombobox
value={field.value} value={field.value}
options={[...channelTypeOptions]} options={[...channelTypeOptions]}
placeholder="请选择接入渠道" placeholder={t("channel.selectChannelType")}
searchPlaceholder="搜索接入渠道" searchPlaceholder={t("channel.searchChannelType")}
emptyText="未找到接入渠道" emptyText={t("channel.emptyChannelType")}
onChange={field.onChange} onChange={field.onChange}
/> />
)} )}
@@ -501,19 +531,19 @@ function ChannelFormBody({
<div className="space-y-4 rounded-md border p-4"> <div className="space-y-4 rounded-md border p-4">
<div> <div>
<div className="text-sm font-medium"></div> <div className="text-sm font-medium">{t("channel.configTitle")}</div>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
{channelType === "wxwork_kf" {channelType === "wxwork_kf"
? "配置企业微信客服账号,用于匹配回调消息和对外发送消息。" ? t("channel.configWxworkDescription")
: channelType === "wechat_mp" : channelType === "wechat_mp"
? "配置公众号菜单直达聊天页的展示参数。" ? t("channel.configWechatDescription")
: "配置 Web 站点客服窗口的展示参数。"} : t("channel.configWebDescription")}
</div> </div>
</div> </div>
{channelType === "wxwork_kf" ? ( {channelType === "wxwork_kf" ? (
<Field data-invalid={!!errors.openKfId}> <Field data-invalid={!!errors.openKfId}>
<FieldLabel></FieldLabel> <FieldLabel>{t("channel.wxworkAccount")}</FieldLabel>
<FieldContent> <FieldContent>
<Controller <Controller
control={control} control={control}
@@ -523,11 +553,11 @@ function ChannelFormBody({
value={field.value} value={field.value}
options={wxWorkKFAccountOptions} options={wxWorkKFAccountOptions}
placeholder={ placeholder={
wxWorkKFAccountsLoading ? "正在加载客服账号" : "请选择客服账号" wxWorkKFAccountsLoading ? t("channel.loadingWxworkAccount") : t("channel.selectWxworkAccount")
} }
searchPlaceholder="搜索客服账号" searchPlaceholder={t("channel.searchWxworkAccount")}
emptyText={ emptyText={
wxWorkKFAccountsError || "未找到企业微信客服账号" wxWorkKFAccountsError || t("channel.emptyWxworkAccount")
} }
disabled={wxWorkKFAccountsLoading} disabled={wxWorkKFAccountsLoading}
onChange={field.onChange} onChange={field.onChange}
@@ -543,7 +573,7 @@ function ChannelFormBody({
<> <>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field data-invalid={!!errors.widgetTitle}> <Field data-invalid={!!errors.widgetTitle}>
<FieldLabel htmlFor="channel-widget-title"></FieldLabel> <FieldLabel htmlFor="channel-widget-title">{t("channel.widgetTitle")}</FieldLabel>
<FieldContent> <FieldContent>
<Input id="channel-widget-title" {...register("widgetTitle")} /> <Input id="channel-widget-title" {...register("widgetTitle")} />
<FieldError errors={[errors.widgetTitle]} /> <FieldError errors={[errors.widgetTitle]} />
@@ -551,7 +581,7 @@ function ChannelFormBody({
</Field> </Field>
<Field data-invalid={!!errors.widgetSubtitle}> <Field data-invalid={!!errors.widgetSubtitle}>
<FieldLabel htmlFor="channel-widget-subtitle"></FieldLabel> <FieldLabel htmlFor="channel-widget-subtitle">{t("channel.widgetSubtitle")}</FieldLabel>
<FieldContent> <FieldContent>
<Input <Input
id="channel-widget-subtitle" id="channel-widget-subtitle"
@@ -562,7 +592,7 @@ function ChannelFormBody({
</Field> </Field>
<Field data-invalid={!!errors.widgetThemeColor}> <Field data-invalid={!!errors.widgetThemeColor}>
<FieldLabel htmlFor="channel-widget-theme-color"></FieldLabel> <FieldLabel htmlFor="channel-widget-theme-color">{t("channel.themeColor")}</FieldLabel>
<FieldContent> <FieldContent>
<Input <Input
id="channel-widget-theme-color" id="channel-widget-theme-color"
@@ -576,7 +606,7 @@ function ChannelFormBody({
{channelType === "web" ? ( {channelType === "web" ? (
<> <>
<Field data-invalid={!!errors.widgetPosition}> <Field data-invalid={!!errors.widgetPosition}>
<FieldLabel></FieldLabel> <FieldLabel>{t("channel.mountPosition")}</FieldLabel>
<FieldContent> <FieldContent>
<Controller <Controller
control={control} control={control}
@@ -585,9 +615,9 @@ function ChannelFormBody({
<OptionCombobox <OptionCombobox
value={field.value} value={field.value}
options={[...widgetPositionOptions]} options={[...widgetPositionOptions]}
placeholder="请选择挂载位置" placeholder={t("channel.selectMountPosition")}
searchPlaceholder="搜索挂载位置" searchPlaceholder={t("channel.searchMountPosition")}
emptyText="未找到挂载位置" emptyText={t("channel.emptyMountPosition")}
onChange={field.onChange} onChange={field.onChange}
/> />
)} )}
@@ -597,7 +627,7 @@ function ChannelFormBody({
</Field> </Field>
<Field data-invalid={!!errors.widgetWidth}> <Field data-invalid={!!errors.widgetWidth}>
<FieldLabel htmlFor="channel-widget-width"></FieldLabel> <FieldLabel htmlFor="channel-widget-width">{t("channel.widgetWidth")}</FieldLabel>
<FieldContent> <FieldContent>
<Input <Input
id="channel-widget-width" id="channel-widget-width"
@@ -612,14 +642,14 @@ function ChannelFormBody({
</div> </div>
<div className="space-y-3 rounded-md border p-3"> <div className="space-y-3 rounded-md border p-3">
<div> <div>
<div className="text-sm font-medium"> JWT Secret</div> <div className="text-sm font-medium">{t("channel.userJwtSecret")}</div>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
使 secret userToken {t("channel.userJwtSecretDescription")}
</div> </div>
</div> </div>
{!itemId ? ( {!itemId ? (
<div className="rounded-md bg-muted px-3 py-2 text-sm text-muted-foreground"> <div className="rounded-md bg-muted px-3 py-2 text-sm text-muted-foreground">
JWT Secret {t("channel.secretAfterSave")}
</div> </div>
) : ( ) : (
<Field data-invalid={!!errors.userTokenSecret}> <Field data-invalid={!!errors.userTokenSecret}>
@@ -640,14 +670,14 @@ function ChannelFormBody({
disabled={!userTokenSecret} disabled={!userTokenSecret}
> >
<CopyIcon className="size-4" /> <CopyIcon className="size-4" />
{t("channel.copy")}
</Button> </Button>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
onClick={() => void handleResetUserTokenSecret()} onClick={() => void handleResetUserTokenSecret()}
> >
{t("channel.reset")}
</Button> </Button>
</div> </div>
</div> </div>
@@ -666,7 +696,7 @@ function ChannelFormBody({
</div> </div>
<Field data-invalid={!!errors.remark}> <Field data-invalid={!!errors.remark}>
<FieldLabel htmlFor="channel-remark"></FieldLabel> <FieldLabel htmlFor="channel-remark">{t("channel.remark")}</FieldLabel>
<FieldContent> <FieldContent>
<Textarea id="channel-remark" rows={3} {...register("remark")} /> <Textarea id="channel-remark" rows={3} {...register("remark")} />
<FieldError errors={[errors.remark]} /> <FieldError errors={[errors.remark]} />
@@ -679,6 +709,7 @@ function ChannelFormBody({
} }
function WebAccessGuide({ channelId }: { channelId: string }) { function WebAccessGuide({ channelId }: { channelId: string }) {
const t = useI18n()
const [origin, setOrigin] = useState("") const [origin, setOrigin] = useState("")
useEffect(() => { useEffect(() => {
@@ -723,29 +754,29 @@ function WebAccessGuide({ channelId }: { channelId: string }) {
await navigator.clipboard.writeText(text) await navigator.clipboard.writeText(text)
toast.success(successMessage) toast.success(successMessage)
} catch { } catch {
toast.error("复制失败") toast.error(t("channel.copyFailed"))
} }
} }
return ( return (
<div className="space-y-4 border-t pt-4"> <div className="space-y-4 border-t pt-4">
<div> <div>
<div className="text-sm font-medium">Web </div> <div className="text-sm font-medium">{t("channel.webAccessInfo")}</div>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
{channelId {channelId
? "复制链接或嵌入代码即可接入当前 Web 渠道。" ? t("channel.webAccessReady")
: "保存渠道后生成接入链接。"} : t("channel.webAccessPending")}
</div> </div>
</div> </div>
{!channelId ? ( {!channelId ? (
<div className="rounded-md bg-muted px-3 py-2 text-sm text-muted-foreground"> <div className="rounded-md bg-muted px-3 py-2 text-sm text-muted-foreground">
channelId {t("channel.newChannelPending")}
</div> </div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground">访</div> <div className="text-xs font-medium text-muted-foreground">{t("channel.directAccessUrl")}</div>
<div className="flex flex-col gap-2 sm:flex-row"> <div className="flex flex-col gap-2 sm:flex-row">
<Input readOnly value={accessUrl} className="font-mono text-xs" /> <Input readOnly value={accessUrl} className="font-mono text-xs" />
<div className="flex gap-2"> <div className="flex gap-2">
@@ -753,8 +784,8 @@ function WebAccessGuide({ channelId }: { channelId: string }) {
type="button" type="button"
variant="outline" variant="outline"
size="icon" size="icon"
title="复制链接" title={t("channel.copyLink")}
onClick={() => copyText(accessUrl, "已复制接入链接")} onClick={() => copyText(accessUrl, t("channel.copiedAccessLink"))}
> >
<CopyIcon className="size-4" /> <CopyIcon className="size-4" />
</Button> </Button>
@@ -762,7 +793,7 @@ function WebAccessGuide({ channelId }: { channelId: string }) {
type="button" type="button"
variant="outline" variant="outline"
size="icon" size="icon"
title="打开链接" title={t("channel.openLink")}
onClick={() => window.open(accessUrl, "_blank", "noopener,noreferrer")} onClick={() => window.open(accessUrl, "_blank", "noopener,noreferrer")}
> >
<ExternalLinkIcon className="size-4" /> <ExternalLinkIcon className="size-4" />
@@ -774,16 +805,16 @@ function WebAccessGuide({ channelId }: { channelId: string }) {
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<div className="text-xs font-medium text-muted-foreground"> <div className="text-xs font-medium text-muted-foreground">
{t("channel.embeddedSnippet")}
</div> </div>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => copyText(snippet, "已复制接入代码")} onClick={() => copyText(snippet, t("channel.copiedSnippet"))}
> >
<CopyIcon className="size-4" /> <CopyIcon className="size-4" />
{t("channel.copyCode")}
</Button> </Button>
</div> </div>
<pre className="max-h-48 overflow-auto rounded-md bg-muted p-3 text-xs leading-5"> <pre className="max-h-48 overflow-auto rounded-md bg-muted p-3 text-xs leading-5">
@@ -792,11 +823,11 @@ function WebAccessGuide({ channelId }: { channelId: string }) {
</div> </div>
<div className="flex flex-col gap-2 rounded-md bg-muted px-3 py-3 text-xs text-muted-foreground"> <div className="flex flex-col gap-2 rounded-md bg-muted px-3 py-3 text-xs text-muted-foreground">
<div className="font-medium text-foreground"></div> <div className="font-medium text-foreground">{t("channel.accessGuide")}</div>
<div>1. </div> <div>{t("channel.webGuide1")}</div>
<div>2. HTML body </div> <div>{t("channel.webGuide2")}</div>
<div>3. </div> <div>{t("channel.webGuide3")}</div>
<div>4. 使访</div> <div>{t("channel.webGuide4")}</div>
<div className="pt-1"> <div className="pt-1">
<Button <Button
type="button" type="button"
@@ -805,7 +836,7 @@ function WebAccessGuide({ channelId }: { channelId: string }) {
onClick={() => window.open(testUrl, "_blank", "noopener,noreferrer")} onClick={() => window.open(testUrl, "_blank", "noopener,noreferrer")}
> >
<ExternalLinkIcon className="size-4" /> <ExternalLinkIcon className="size-4" />
{t("channel.openTestPage")}
</Button> </Button>
</div> </div>
</div> </div>
@@ -816,6 +847,7 @@ function WebAccessGuide({ channelId }: { channelId: string }) {
} }
function WechatMPAccessGuide({ channelId }: { channelId: string }) { function WechatMPAccessGuide({ channelId }: { channelId: string }) {
const t = useI18n()
const [origin, setOrigin] = useState("") const [origin, setOrigin] = useState("")
useEffect(() => { useEffect(() => {
@@ -837,32 +869,32 @@ function WechatMPAccessGuide({ channelId }: { channelId: string }) {
} }
try { try {
await navigator.clipboard.writeText(text) await navigator.clipboard.writeText(text)
toast.success("已复制公众号菜单链接") toast.success(t("channel.copiedWechatMenuUrl"))
} catch { } catch {
toast.error("复制失败") toast.error(t("channel.copyFailed"))
} }
} }
return ( return (
<div className="space-y-4 border-t pt-4"> <div className="space-y-4 border-t pt-4">
<div> <div>
<div className="text-sm font-medium"></div> <div className="text-sm font-medium">{t("channel.wechatAccessInfo")}</div>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
{channelId {channelId
? "将该链接配置到微信公众号自定义菜单,用户点击菜单后直接进入客服聊天页。" ? t("channel.wechatAccessReady")
: "保存渠道后生成公众号菜单链接。"} : t("channel.wechatAccessPending")}
</div> </div>
</div> </div>
{!channelId ? ( {!channelId ? (
<div className="rounded-md bg-muted px-3 py-2 text-sm text-muted-foreground"> <div className="rounded-md bg-muted px-3 py-2 text-sm text-muted-foreground">
channelId {t("channel.newChannelPending")}
</div> </div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground"> <div className="text-xs font-medium text-muted-foreground">
{t("channel.wechatMenuUrl")}
</div> </div>
<div className="flex flex-col gap-2 sm:flex-row"> <div className="flex flex-col gap-2 sm:flex-row">
<Input readOnly value={menuUrl} className="font-mono text-xs" /> <Input readOnly value={menuUrl} className="font-mono text-xs" />
@@ -871,7 +903,7 @@ function WechatMPAccessGuide({ channelId }: { channelId: string }) {
type="button" type="button"
variant="outline" variant="outline"
size="icon" size="icon"
title="复制链接" title={t("channel.copyLink")}
onClick={() => copyText(menuUrl)} onClick={() => copyText(menuUrl)}
> >
<CopyIcon className="size-4" /> <CopyIcon className="size-4" />
@@ -880,7 +912,7 @@ function WechatMPAccessGuide({ channelId }: { channelId: string }) {
type="button" type="button"
variant="outline" variant="outline"
size="icon" size="icon"
title="打开链接" title={t("channel.openLink")}
onClick={() => window.open(menuUrl, "_blank", "noopener,noreferrer")} onClick={() => window.open(menuUrl, "_blank", "noopener,noreferrer")}
> >
<ExternalLinkIcon className="size-4" /> <ExternalLinkIcon className="size-4" />
@@ -890,10 +922,10 @@ function WechatMPAccessGuide({ channelId }: { channelId: string }) {
</div> </div>
<div className="flex flex-col gap-2 rounded-md bg-muted px-3 py-3 text-xs text-muted-foreground"> <div className="flex flex-col gap-2 rounded-md bg-muted px-3 py-3 text-xs text-muted-foreground">
<div className="font-medium text-foreground"></div> <div className="font-medium text-foreground">{t("channel.accessGuide")}</div>
<div>1. </div> <div>{t("channel.webGuide1")}</div>
<div>2. </div> <div>{t("channel.wechatGuide2")}</div>
<div>3. </div> <div>{t("channel.wechatGuide3")}</div>
</div> </div>
</div> </div>
)} )}
+66 -54
View File
@@ -50,33 +50,29 @@ import {
TableRow, TableRow,
} from "@/components/ui/table" } from "@/components/ui/table"
import { Status, StatusLabels } from "@/lib/generated/enums" import { Status, StatusLabels } from "@/lib/generated/enums"
import { getEnumLabel, getEnumOptions } from "@/lib/enums" import { getEnumOptions } from "@/lib/enums"
import { ButtonGroup } from "@/components/ui/button-group" import { ButtonGroup } from "@/components/ui/button-group"
import { Switch } from "@/components/ui/switch" import { Switch } from "@/components/ui/switch"
import { useI18n } from "@/i18n/provider"
const statusOptions = [ function getChannelTypeLabel(channelType: string, t: (key: string) => string) {
{ value: "all", label: "全部状态" },
...getEnumOptions(StatusLabels).map((option) => ({
value: String(option.value),
label: option.label,
})),
] as const
const channelTypeOptions = [
{ value: "all", label: "全部类型" },
{ value: "web", label: "Web 站点" },
{ value: "wechat_mp", label: "微信公众号" },
{ value: "wxwork_kf", label: "企业微信客服" },
] as const
function getChannelTypeLabel(channelType: string) {
if (channelType === "wechat_mp") { if (channelType === "wechat_mp") {
return "微信公众号" return t("channel.typeWechatMp")
} }
if (channelType === "wxwork_kf") { if (channelType === "wxwork_kf") {
return "企业微信客服" return t("channel.typeWxworkKf")
} }
return "Web 站点" return t("channel.typeWeb")
}
function getStatusLabel(status: Status, t: (key: string) => string) {
if (status === Status.Disabled) {
return t("status.disabled")
}
if (status === Status.Deleted) {
return t("status.deleted")
}
return t("status.ok")
} }
function ChannelIcon({ channelType }: { channelType: string }) { function ChannelIcon({ channelType }: { channelType: string }) {
@@ -90,6 +86,7 @@ function ChannelIcon({ channelType }: { channelType: string }) {
} }
export default function DashboardChannelsPage() { export default function DashboardChannelsPage() {
const t = useI18n()
const [nameInput, setNameInput] = useState("") const [nameInput, setNameInput] = useState("")
const [channelIdInput, setChannelIdInput] = useState("") const [channelIdInput, setChannelIdInput] = useState("")
const [channelTypeInput, setChannelTypeInput] = useState("all") const [channelTypeInput, setChannelTypeInput] = useState("all")
@@ -123,11 +120,26 @@ export default function DashboardChannelsPage() {
}) })
setResult(data) setResult(data)
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "加载接入渠道失败") toast.error(error instanceof Error ? error.message : t("channel.loadFailed"))
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, [channelId, channelType, limit, name, page, status]) }, [channelId, channelType, limit, name, page, status, t])
const statusOptions = [
{ value: "all", label: t("status.all") },
...getEnumOptions(StatusLabels).map((option) => ({
value: String(option.value),
label: getStatusLabel(option.value as Status, t),
})),
]
const channelTypeOptions = [
{ value: "all", label: t("channel.allTypes") },
{ value: "web", label: t("channel.typeWeb") },
{ value: "wechat_mp", label: t("channel.typeWechatMp") },
{ value: "wxwork_kf", label: t("channel.typeWxworkKf") },
]
useEffect(() => { useEffect(() => {
void loadData() void loadData()
@@ -167,16 +179,16 @@ export default function DashboardChannelsPage() {
try { try {
if (editingItem) { if (editingItem) {
await updateChannel({ id: editingItem.id, ...payload }) await updateChannel({ id: editingItem.id, ...payload })
toast.success(`已更新接入渠道:${payload.name}`) toast.success(t("channel.updated", { name: payload.name }))
} else { } else {
const created = await createChannel(payload) const created = await createChannel(payload)
toast.success(`已创建接入渠道:${created.name}`) toast.success(t("channel.created", { name: created.name }))
} }
setDialogOpen(false) setDialogOpen(false)
setEditingItem(null) setEditingItem(null)
await loadData() await loadData()
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "保存接入渠道失败") toast.error(error instanceof Error ? error.message : t("channel.saveFailed"))
} finally { } finally {
setSaving(false) setSaving(false)
} }
@@ -187,10 +199,10 @@ export default function DashboardChannelsPage() {
try { try {
const nextStatus = item.status === Status.Ok ? Status.Disabled : Status.Ok const nextStatus = item.status === Status.Ok ? Status.Disabled : Status.Ok
await updateChannelStatus(item.id, nextStatus) await updateChannelStatus(item.id, nextStatus)
toast.success(`${nextStatus === Status.Ok ? "启用" : "禁用"}${item.name}`) toast.success(t(nextStatus === Status.Ok ? "channel.statusEnabled" : "channel.statusDisabled", { name: item.name }))
await loadData() await loadData()
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "更新渠道状态失败") toast.error(error instanceof Error ? error.message : t("channel.statusUpdateFailed"))
} finally { } finally {
setActionLoadingId(null) setActionLoadingId(null)
} }
@@ -200,10 +212,10 @@ export default function DashboardChannelsPage() {
setActionLoadingId(item.id) setActionLoadingId(item.id)
try { try {
await deleteChannel(item.id) await deleteChannel(item.id)
toast.success(`已删除接入渠道:${item.name}`) toast.success(t("channel.deleted", { name: item.name }))
await loadData() await loadData()
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "删除接入渠道失败") toast.error(error instanceof Error ? error.message : t("channel.deleteFailed"))
} finally { } finally {
setActionLoadingId(null) setActionLoadingId(null)
} }
@@ -217,11 +229,11 @@ export default function DashboardChannelsPage() {
<> <>
<Button variant="outline" onClick={() => void loadData()} disabled={loading}> <Button variant="outline" onClick={() => void loadData()} disabled={loading}>
<RefreshCwIcon className={loading ? "animate-spin" : ""} /> <RefreshCwIcon className={loading ? "animate-spin" : ""} />
{t("channel.refresh")}
</Button> </Button>
<Button onClick={openCreateDialog}> <Button onClick={openCreateDialog}>
<PlusIcon /> <PlusIcon />
{t("channel.new")}
</Button> </Button>
</> </>
} }
@@ -230,7 +242,7 @@ export default function DashboardChannelsPage() {
value={nameInput} value={nameInput}
onChange={(event) => setNameInput(event.target.value)} onChange={(event) => setNameInput(event.target.value)}
onKeyDown={handleFilterKeyDown} onKeyDown={handleFilterKeyDown}
placeholder="按渠道名称筛选" placeholder={t("channel.filterName")}
className="w-full sm:w-56" className="w-full sm:w-56"
/> />
<div className="relative w-full sm:w-72"> <div className="relative w-full sm:w-72">
@@ -239,7 +251,7 @@ export default function DashboardChannelsPage() {
value={channelIdInput} value={channelIdInput}
onChange={(event) => setChannelIdInput(event.target.value)} onChange={(event) => setChannelIdInput(event.target.value)}
onKeyDown={handleFilterKeyDown} onKeyDown={handleFilterKeyDown}
placeholder="按 channelId 筛选" placeholder={t("channel.filterChannelId")}
className="pl-9" className="pl-9"
/> />
</div> </div>
@@ -247,9 +259,9 @@ export default function DashboardChannelsPage() {
<OptionCombobox <OptionCombobox
value={channelTypeInput} value={channelTypeInput}
options={[...channelTypeOptions]} options={[...channelTypeOptions]}
placeholder="全部类型" placeholder={t("channel.allTypes")}
searchPlaceholder="搜索渠道类型" searchPlaceholder={t("channel.searchType")}
emptyText="未找到渠道类型" emptyText={t("channel.emptyType")}
onChange={setChannelTypeInput} onChange={setChannelTypeInput}
/> />
</div> </div>
@@ -257,15 +269,15 @@ export default function DashboardChannelsPage() {
<OptionCombobox <OptionCombobox
value={statusInput} value={statusInput}
options={[...statusOptions]} options={[...statusOptions]}
placeholder="全部状态" placeholder={t("status.all")}
searchPlaceholder="搜索状态" searchPlaceholder={t("channel.searchStatus")}
emptyText="未找到状态" emptyText={t("channel.emptyStatus")}
onChange={setStatusInput} onChange={setStatusInput}
/> />
</div> </div>
<Button variant="outline" onClick={applyFilters} disabled={loading}> <Button variant="outline" onClick={applyFilters} disabled={loading}>
<SearchIcon /> <SearchIcon />
{t("channel.query")}
</Button> </Button>
</DashboardToolbar> </DashboardToolbar>
@@ -287,12 +299,12 @@ export default function DashboardChannelsPage() {
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead></TableHead> <TableHead>{t("channel.columnChannel")}</TableHead>
<TableHead></TableHead> <TableHead>{t("channel.columnType")}</TableHead>
<TableHead>ChannelID</TableHead> <TableHead>ChannelID</TableHead>
<TableHead> Agent</TableHead> <TableHead>{t("channel.columnAgent")}</TableHead>
<TableHead></TableHead> <TableHead>{t("channel.columnStatus")}</TableHead>
<TableHead className="w-[88px] text-right"></TableHead> <TableHead className="w-[88px] text-right">{t("channel.columnActions")}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -300,8 +312,8 @@ export default function DashboardChannelsPage() {
<DashboardTableStateRow <DashboardTableStateRow
colSpan={6} colSpan={6}
loading={loading} loading={loading}
loadingText="正在加载接入渠道..." loadingText={t("channel.loading")}
emptyText="暂无接入渠道" emptyText={t("channel.empty")}
/> />
) : null} ) : null}
{result.results.map((item) => ( {result.results.map((item) => (
@@ -313,12 +325,12 @@ export default function DashboardChannelsPage() {
</div> </div>
<div> <div>
<div className="font-medium">{item.name}</div> <div className="font-medium">{item.name}</div>
<div className="text-xs text-muted-foreground">{getChannelTypeLabel(item.channelType)}</div> <div className="text-xs text-muted-foreground">{getChannelTypeLabel(item.channelType, t)}</div>
</div> </div>
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge variant="outline">{getChannelTypeLabel(item.channelType)}</Badge> <Badge variant="outline">{getChannelTypeLabel(item.channelType, t)}</Badge>
</TableCell> </TableCell>
<TableCell className="font-mono text-xs">{item.channelId || "-"}</TableCell> <TableCell className="font-mono text-xs">{item.channelId || "-"}</TableCell>
<TableCell>{item.aiAgentName || "-"}</TableCell> <TableCell>{item.aiAgentName || "-"}</TableCell>
@@ -328,22 +340,22 @@ export default function DashboardChannelsPage() {
checked={item.status === Status.Ok} checked={item.status === Status.Ok}
disabled={actionLoadingId === item.id} disabled={actionLoadingId === item.id}
onCheckedChange={() => void handleToggleStatus(item)} onCheckedChange={() => void handleToggleStatus(item)}
aria-label={`${item.name} 状态切换`} aria-label={t("channel.toggleStatus", { name: item.name })}
/> />
<Badge variant={item.status === Status.Ok ? "default" : "outline"}> <Badge variant={item.status === Status.Ok ? "default" : "outline"}>
{getEnumLabel(StatusLabels, item.status as Status)} {getStatusLabel(item.status as Status, t)}
</Badge> </Badge>
</div> </div>
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<ButtonGroup className="ml-auto"> <ButtonGroup className="ml-auto">
<Button variant="outline" size="sm" onClick={() => openEditDialog(item)}> <Button variant="outline" size="sm" onClick={() => openEditDialog(item)}>
{t("channel.edit")}
</Button> </Button>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
render={<Button variant="outline" size="icon-sm" className="ml-auto" />} render={<Button variant="outline" size="icon-sm" className="ml-auto" />}
aria-label={`更多操作 ${item.name}`} aria-label={t("channel.moreActions", { name: item.name })}
> >
<MoreHorizontalIcon /> <MoreHorizontalIcon />
</DropdownMenuTrigger> </DropdownMenuTrigger>
@@ -354,7 +366,7 @@ export default function DashboardChannelsPage() {
onClick={() => void handleDelete(item)} onClick={() => void handleDelete(item)}
> >
<Trash2Icon className="size-4" /> <Trash2Icon className="size-4" />
{t("channel.delete")}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@@ -1,6 +1,6 @@
"use client" "use client"
import { useEffect, useState } from "react" import { useEffect, useMemo, useState } from "react"
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod"
import type { Resolver } from "react-hook-form" import type { Resolver } from "react-hook-form"
import { useForm } from "react-hook-form" import { useForm } from "react-hook-form"
@@ -21,6 +21,7 @@ import {
type AdminCompany, type AdminCompany,
type CreateAdminCompanyPayload, type CreateAdminCompanyPayload,
} from "@/lib/api/company" } from "@/lib/api/company"
import { useI18n } from "@/i18n/provider"
type CompanyEditDialogProps = { type CompanyEditDialogProps = {
open: boolean open: boolean
@@ -31,19 +32,19 @@ type CompanyEditDialogProps = {
onSubmit: (payload: CreateAdminCompanyPayload) => Promise<void> onSubmit: (payload: CreateAdminCompanyPayload) => Promise<void>
} }
const companyFormSchema = z.object({ type EditForm = {
name: z.string().trim().min(1, "公司名称不能为空"), name: string
code: string
remark: string
}
function createSchema(t: (key: string) => string) {
return z.object({
name: z.string().trim().min(1, t("company.nameRequired")),
code: z.string().trim(), code: z.string().trim(),
remark: z.string().trim(), remark: z.string().trim(),
}) })
}
type EditForm = z.infer<typeof companyFormSchema>
const editFormResolver = zodResolver(companyFormSchema as never) as Resolver<
z.input<typeof companyFormSchema>,
undefined,
z.output<typeof companyFormSchema>
>
const emptyForm: EditForm = { const emptyForm: EditForm = {
name: "", name: "",
@@ -112,8 +113,19 @@ function CompanyEditDialogBody({
onOpenChange, onOpenChange,
onSubmit, onSubmit,
}: CompanyEditDialogBodyProps) { }: CompanyEditDialogBodyProps) {
const t = useI18n()
const formId = "company-edit-form" const formId = "company-edit-form"
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const companyFormSchema = useMemo(() => createSchema(t), [t])
const editFormResolver = useMemo(
() =>
zodResolver(companyFormSchema as never) as Resolver<
z.input<typeof companyFormSchema>,
undefined,
z.output<typeof companyFormSchema>
>,
[companyFormSchema]
)
const form = useForm< const form = useForm<
z.input<typeof companyFormSchema>, z.input<typeof companyFormSchema>,
undefined, undefined,
@@ -154,7 +166,7 @@ function CompanyEditDialogBody({
<ProjectDialog <ProjectDialog
open={open} open={open}
onOpenChange={onOpenChange} onOpenChange={onOpenChange}
title={itemId ? "编辑公司" : "新建公司"} title={itemId ? t("company.editTitle") : t("company.createTitle")}
size="md" size="md"
allowFullscreen allowFullscreen
footer={ footer={
@@ -165,26 +177,26 @@ function CompanyEditDialogBody({
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
disabled={saving} disabled={saving}
> >
{t("company.cancel")}
</Button> </Button>
<Button type="submit" form={formId} disabled={saving || loading}> <Button type="submit" form={formId} disabled={saving || loading}>
{saving ? "保存中..." : itemId ? "保存" : "创建"} {saving ? t("company.saving") : itemId ? t("company.save") : t("company.create")}
</Button> </Button>
</> </>
} }
> >
{loading ? ( {loading ? (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<div className="text-muted-foreground">...</div> <div className="text-muted-foreground">{t("company.loadingDetail")}</div>
</div> </div>
) : ( ) : (
<form id={formId} onSubmit={handleSubmit(onFormSubmit)} className="space-y-4"> <form id={formId} onSubmit={handleSubmit(onFormSubmit)} className="space-y-4">
<Field data-invalid={!!errors.name}> <Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="company-name"></FieldLabel> <FieldLabel htmlFor="company-name">{t("company.columnName")}</FieldLabel>
<FieldContent> <FieldContent>
<Input <Input
id="company-name" id="company-name"
placeholder="请输入公司名称" placeholder={t("company.namePlaceholder")}
aria-invalid={!!errors.name} aria-invalid={!!errors.name}
{...register("name")} {...register("name")}
/> />
@@ -192,11 +204,11 @@ function CompanyEditDialogBody({
</FieldContent> </FieldContent>
</Field> </Field>
<Field data-invalid={!!errors.code}> <Field data-invalid={!!errors.code}>
<FieldLabel htmlFor="company-code"></FieldLabel> <FieldLabel htmlFor="company-code">{t("company.columnCode")}</FieldLabel>
<FieldContent> <FieldContent>
<Input <Input
id="company-code" id="company-code"
placeholder="可选" placeholder={t("company.optional")}
aria-invalid={!!errors.code} aria-invalid={!!errors.code}
{...register("code")} {...register("code")}
/> />
@@ -204,11 +216,11 @@ function CompanyEditDialogBody({
</FieldContent> </FieldContent>
</Field> </Field>
<Field data-invalid={!!errors.remark}> <Field data-invalid={!!errors.remark}>
<FieldLabel htmlFor="company-remark"></FieldLabel> <FieldLabel htmlFor="company-remark">{t("company.columnRemark")}</FieldLabel>
<FieldContent> <FieldContent>
<Textarea <Textarea
id="company-remark" id="company-remark"
placeholder="可选" placeholder={t("company.remarkPlaceholder")}
rows={4} rows={4}
aria-invalid={!!errors.remark} aria-invalid={!!errors.remark}
{...register("remark")} {...register("remark")}
+51 -39
View File
@@ -50,19 +50,21 @@ import {
} from "@/lib/api/company" } from "@/lib/api/company"
import { getEnumOptions } from "@/lib/enums" import { getEnumOptions } from "@/lib/enums"
import { Status, StatusLabels } from "@/lib/generated/enums" import { Status, StatusLabels } from "@/lib/generated/enums"
import { useI18n } from "@/i18n/provider"
import { EditDialog } from "./_components/edit" import { EditDialog } from "./_components/edit"
const listStatusOptions = [ function getStatusLabel(status: Status, t: (key: string) => string) {
{ value: "all", label: "全部状态" }, if (status === Status.Disabled) {
...getEnumOptions(StatusLabels) return t("status.disabled")
.filter((item) => Number(item.value) !== Status.Deleted) }
.map((item) => ({ if (status === Status.Deleted) {
value: String(item.value), return t("status.deleted")
label: item.label, }
})), return t("status.ok")
] as const }
export default function DashboardCompaniesPage() { export default function DashboardCompaniesPage() {
const t = useI18n()
const [nameInput, setNameInput] = useState("") const [nameInput, setNameInput] = useState("")
const [codeInput, setCodeInput] = useState("") const [codeInput, setCodeInput] = useState("")
const [statusFilterInput, setStatusFilterInput] = useState("all") const [statusFilterInput, setStatusFilterInput] = useState("all")
@@ -93,11 +95,21 @@ export default function DashboardCompaniesPage() {
}) })
setResult(data) setResult(data)
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "加载公司列表失败") toast.error(error instanceof Error ? error.message : t("company.loadFailed"))
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, [code, limit, name, page, statusFilter]) }, [code, limit, name, page, statusFilter, t])
const listStatusOptions = [
{ value: "all", label: t("status.all") },
...getEnumOptions(StatusLabels)
.filter((item) => Number(item.value) !== Status.Deleted)
.map((item) => ({
value: String(item.value),
label: getStatusLabel(item.value as Status, t),
})),
]
useEffect(() => { useEffect(() => {
void loadData() void loadData()
@@ -143,16 +155,16 @@ export default function DashboardCompaniesPage() {
try { try {
if (editingItem) { if (editingItem) {
await updateCompany({ id: editingItem.id, ...payload }) await updateCompany({ id: editingItem.id, ...payload })
toast.success(`已更新公司:${editingItem.name}`) toast.success(t("company.updated", { name: editingItem.name }))
} else { } else {
await createCompany(payload) await createCompany(payload)
toast.success(`已创建公司:${payload.name}`) toast.success(t("company.created", { name: payload.name }))
} }
setDialogOpen(false) setDialogOpen(false)
setEditingItem(null) setEditingItem(null)
await loadData() await loadData()
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "保存公司失败") toast.error(error instanceof Error ? error.message : t("company.saveFailed"))
} finally { } finally {
setSaving(false) setSaving(false)
} }
@@ -163,10 +175,10 @@ export default function DashboardCompaniesPage() {
try { try {
const nextStatus = item.status === 0 ? 1 : 0 const nextStatus = item.status === 0 ? 1 : 0
await updateCompanyStatus(item.id, nextStatus) await updateCompanyStatus(item.id, nextStatus)
toast.success(`${nextStatus === 0 ? "启用" : "禁用"}${item.name}`) toast.success(t(nextStatus === 0 ? "company.enabled" : "company.disabled", { name: item.name }))
await loadData() await loadData()
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "更新状态失败") toast.error(error instanceof Error ? error.message : t("company.statusUpdateFailed"))
} finally { } finally {
setActionLoadingId(null) setActionLoadingId(null)
} }
@@ -176,10 +188,10 @@ export default function DashboardCompaniesPage() {
setActionLoadingId(item.id) setActionLoadingId(item.id)
try { try {
await deleteCompany(item.id) await deleteCompany(item.id)
toast.success(`已删除公司:${item.name}`) toast.success(t("company.deleted", { name: item.name }))
await loadData() await loadData()
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "删除公司失败") toast.error(error instanceof Error ? error.message : t("company.deleteFailed"))
} finally { } finally {
setActionLoadingId(null) setActionLoadingId(null)
} }
@@ -193,11 +205,11 @@ export default function DashboardCompaniesPage() {
<> <>
<Button variant="outline" onClick={() => void loadData()} disabled={loading}> <Button variant="outline" onClick={() => void loadData()} disabled={loading}>
<RefreshCwIcon className={loading ? "animate-spin" : ""} /> <RefreshCwIcon className={loading ? "animate-spin" : ""} />
{t("company.refresh")}
</Button> </Button>
<Button onClick={openCreateDialog}> <Button onClick={openCreateDialog}>
<PlusIcon /> <PlusIcon />
{t("company.new")}
</Button> </Button>
</> </>
} }
@@ -208,7 +220,7 @@ export default function DashboardCompaniesPage() {
value={nameInput} value={nameInput}
onChange={(event) => setNameInput(event.target.value)} onChange={(event) => setNameInput(event.target.value)}
onKeyDown={handleFilterKeyDown} onKeyDown={handleFilterKeyDown}
placeholder="按公司名称筛选" placeholder={t("company.filterName")}
className="pl-9" className="pl-9"
/> />
</div> </div>
@@ -216,20 +228,20 @@ export default function DashboardCompaniesPage() {
value={codeInput} value={codeInput}
onChange={(event) => setCodeInput(event.target.value)} onChange={(event) => setCodeInput(event.target.value)}
onKeyDown={handleFilterKeyDown} onKeyDown={handleFilterKeyDown}
placeholder="按公司编码筛选" placeholder={t("company.filterCode")}
className="w-full sm:w-44" className="w-full sm:w-44"
/> />
<div className="w-full sm:w-36"> <div className="w-full sm:w-36">
<OptionCombobox <OptionCombobox
value={statusFilterInput} value={statusFilterInput}
onChange={setStatusFilterInput} onChange={setStatusFilterInput}
placeholder="全部状态" placeholder={t("status.all")}
options={[...listStatusOptions]} options={[...listStatusOptions]}
/> />
</div> </div>
<Button variant="outline" onClick={applyFilters} disabled={loading}> <Button variant="outline" onClick={applyFilters} disabled={loading}>
<SearchIcon /> <SearchIcon />
{t("company.query")}
</Button> </Button>
</DashboardToolbar> </DashboardToolbar>
@@ -252,12 +264,12 @@ export default function DashboardCompaniesPage() {
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="w-20">ID</TableHead> <TableHead className="w-20">ID</TableHead>
<TableHead></TableHead> <TableHead>{t("company.columnName")}</TableHead>
<TableHead></TableHead> <TableHead>{t("company.columnCode")}</TableHead>
<TableHead className="w-28"></TableHead> <TableHead className="w-28">{t("company.columnCustomerCount")}</TableHead>
<TableHead className="w-24"></TableHead> <TableHead className="w-24">{t("company.columnStatus")}</TableHead>
<TableHead></TableHead> <TableHead>{t("company.columnRemark")}</TableHead>
<TableHead className="w-40"></TableHead> <TableHead className="w-40">{t("company.columnActions")}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -265,8 +277,8 @@ export default function DashboardCompaniesPage() {
<DashboardTableStateRow <DashboardTableStateRow
colSpan={7} colSpan={7}
loading={loading} loading={loading}
loadingText="正在加载公司数据..." loadingText={t("company.loading")}
emptyText="暂无公司数据" emptyText={t("company.empty")}
/> />
) : ( ) : (
result.results.map((item) => { result.results.map((item) => {
@@ -287,7 +299,7 @@ export default function DashboardCompaniesPage() {
: "secondary" : "secondary"
} }
> >
{StatusLabels[item.status as Status] ?? "未知"} {StatusLabels[item.status as Status] ? getStatusLabel(item.status as Status, t) : t("company.unknownStatus")}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className="max-w-[320px]"> <TableCell className="max-w-[320px]">
@@ -296,14 +308,14 @@ export default function DashboardCompaniesPage() {
<TableCell> <TableCell>
<ButtonGroup className="w-full justify-end"> <ButtonGroup className="w-full justify-end">
<Button variant="outline" size="sm" onClick={() => openEditDialog(item)}> <Button variant="outline" size="sm" onClick={() => openEditDialog(item)}>
{t("company.edit")}
</Button> </Button>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
render={ render={
<Button variant="outline" size="sm" disabled={actionLoading} /> <Button variant="outline" size="sm" disabled={actionLoading} />
} }
aria-label={`更多操作 ${item.name}`} aria-label={t("company.moreActions", { name: item.name })}
> >
<MoreHorizontalIcon /> <MoreHorizontalIcon />
</DropdownMenuTrigger> </DropdownMenuTrigger>
@@ -313,16 +325,16 @@ export default function DashboardCompaniesPage() {
onClick={() => void handleToggleStatus(item)} onClick={() => void handleToggleStatus(item)}
> >
{actionLoading ? ( {actionLoading ? (
"处理中..." t("company.processing")
) : item.status === Status.Ok ? ( ) : item.status === Status.Ok ? (
<> <>
<BanIcon /> <BanIcon />
{t("company.disable")}
</> </>
) : ( ) : (
<> <>
<CheckCircle2Icon /> <CheckCircle2Icon />
{t("company.enable")}
</> </>
)} )}
</DropdownMenuItem> </DropdownMenuItem>
@@ -332,7 +344,7 @@ export default function DashboardCompaniesPage() {
onClick={() => void handleDelete(item)} onClick={() => void handleDelete(item)}
> >
<Trash2Icon /> <Trash2Icon />
{t("company.delete")}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@@ -26,6 +26,7 @@ import {
renderIMMessageHTML, renderIMMessageHTML,
} from "@/lib/im-message"; } from "@/lib/im-message";
import { formatDateTime } from "@/lib/utils"; import { formatDateTime } from "@/lib/utils";
import { useI18n } from "@/i18n/provider";
type ConversationDetailDialogProps = { type ConversationDetailDialogProps = {
open: boolean; open: boolean;
@@ -34,7 +35,7 @@ type ConversationDetailDialogProps = {
item: AdminConversation | null; item: AdminConversation | null;
detail: AdminConversationDetail | null; detail: AdminConversationDetail | null;
messages?: AdminMessage[] | null; messages?: AdminMessage[] | null;
/** 是否还有更早消息(cursor 分页) */ /** Whether older messages are available through cursor pagination. */
messagesHasMore?: boolean; messagesHasMore?: boolean;
loadingMoreMessages?: boolean; loadingMoreMessages?: boolean;
onLoadMoreMessages?: () => void | Promise<void>; onLoadMoreMessages?: () => void | Promise<void>;
@@ -46,44 +47,53 @@ type ConversationDetailDialogProps = {
onOpenClose: () => void; onOpenClose: () => void;
}; };
function getStatusMeta(status: number) { function getStatusMeta(
status: number,
t: (key: string, values?: Record<string, string | number>) => string
) {
switch (status) { switch (status) {
case 1: case 1:
return { label: "AI接待中", variant: "secondary" as const }; return { label: t("conversation.filterAiServing"), variant: "secondary" as const };
case 2: case 2:
return { label: "待接入", variant: "outline" as const }; return { label: t("conversation.filterPending"), variant: "outline" as const };
case 3: case 3:
return { label: "处理中", variant: "secondary" as const }; return { label: t("conversation.filterActive"), variant: "secondary" as const };
case 4: case 4:
return { label: "已关闭", variant: "outline" as const }; return { label: t("conversation.filterClosed"), variant: "outline" as const };
default: default:
return { label: "未知", variant: "outline" as const }; return { label: t("conversationMonitor.unknown"), variant: "outline" as const };
} }
} }
function getServiceModeLabel(mode: number) { function getServiceModeLabel(
mode: number,
t: (key: string, values?: Record<string, string | number>) => string
) {
switch (mode) { switch (mode) {
case 1: case 1:
return "AI 接待"; return t("conversationMonitor.serviceAi");
case 2: case 2:
return "人工接待"; return t("conversationMonitor.serviceHuman");
case 3: case 3:
return "AI 优先"; return t("conversationMonitor.serviceAiFirst");
default: default:
return "未定义"; return t("conversationMonitor.serviceUndefined");
} }
} }
function getSenderLabel(message: AdminMessage) { function getSenderLabel(
message: AdminMessage,
t: (key: string, values?: Record<string, string | number>) => string
) {
switch (message.senderType) { switch (message.senderType) {
case "agent": case "agent":
return message.senderName || "客服"; return message.senderName || t("conversationMonitor.senderAgent");
case "customer": case "customer":
return message.senderName || "用户"; return message.senderName || t("conversationMonitor.senderCustomer");
case "ai": case "ai":
return "AI"; return "AI";
case "system": case "system":
return "系统"; return t("conversationMonitor.senderSystem");
default: default:
return message.senderType; return message.senderType;
} }
@@ -150,12 +160,13 @@ export function ConversationDetailDialog({
onRead, onRead,
onOpenClose, onOpenClose,
}: ConversationDetailDialogProps) { }: ConversationDetailDialogProps) {
const t = useI18n();
const messages = Array.isArray(rawMessages) ? rawMessages : []; const messages = Array.isArray(rawMessages) ? rawMessages : [];
const currentConversation = detail ?? item; const currentConversation = detail ?? item;
const isClosedConversation = currentConversation?.status === 4; const isClosedConversation = currentConversation?.status === 4;
const isPendingConversation = currentConversation?.status === 2; const isPendingConversation = currentConversation?.status === 2;
const statusMeta = currentConversation const statusMeta = currentConversation
? getStatusMeta(currentConversation.status) ? getStatusMeta(currentConversation.status, t)
: null; : null;
const messageBottomRef = useRef<HTMLDivElement | null>(null); const messageBottomRef = useRef<HTMLDivElement | null>(null);
const messagesScrollRootRef = useRef<HTMLDivElement | null>(null); const messagesScrollRootRef = useRef<HTMLDivElement | null>(null);
@@ -249,7 +260,7 @@ export function ConversationDetailDialog({
onOpenChange={onOpenChange} onOpenChange={onOpenChange}
title={ title={
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span>{currentConversation?.customerName || "会话详情"}</span> <span>{currentConversation?.customerName || t("conversationMonitor.detailFallbackTitle")}</span>
<div> <div>
{statusMeta ? ( {statusMeta ? (
@@ -268,8 +279,10 @@ export function ConversationDetailDialog({
<div className="flex w-full flex-wrap items-center justify-between gap-3"> <div className="flex w-full flex-wrap items-center justify-between gap-3">
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{currentConversation {currentConversation
? `最后活跃:${formatDateTime(currentConversation.lastMessageAt)}` ? t("conversationMonitor.lastActivePrefix", {
: "暂无会话信息"} time: formatDateTime(currentConversation.lastMessageAt),
})
: t("conversationMonitor.noConversationInfo")}
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button <Button
@@ -278,7 +291,7 @@ export function ConversationDetailDialog({
disabled={saving || !currentConversation || currentConversation.status !== 2} disabled={saving || !currentConversation || currentConversation.status !== 2}
> >
<MessageCircleMoreIcon /> <MessageCircleMoreIcon />
{saving ? "处理中..." : "分配会话"} {saving ? t("conversationMonitor.processing") : t("conversationMonitor.assign")}
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
@@ -288,7 +301,7 @@ export function ConversationDetailDialog({
} }
> >
<MessageCircleMoreIcon /> <MessageCircleMoreIcon />
{saving ? "处理中..." : "重试分配"} {saving ? t("conversationMonitor.processing") : t("conversationMonitor.retryDispatch")}
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
@@ -296,7 +309,7 @@ export function ConversationDetailDialog({
disabled={saving || !currentConversation} disabled={saving || !currentConversation}
> >
<CheckCheckIcon /> <CheckCheckIcon />
{saving ? "处理中..." : "标记已读"} {saving ? t("conversationMonitor.processing") : t("conversationMonitor.markRead")}
</Button> </Button>
<Button <Button
type="button" type="button"
@@ -305,7 +318,7 @@ export function ConversationDetailDialog({
disabled={saving || !currentConversation || currentConversation.status !== 3} disabled={saving || !currentConversation || currentConversation.status !== 3}
> >
<MessageCircleMoreIcon /> <MessageCircleMoreIcon />
{saving ? "处理中..." : "转接会话"} {saving ? t("conversationMonitor.processing") : t("conversationMonitor.transfer")}
</Button> </Button>
{!isClosedConversation ? ( {!isClosedConversation ? (
<Button <Button
@@ -314,7 +327,7 @@ export function ConversationDetailDialog({
disabled={saving || !currentConversation} disabled={saving || !currentConversation}
> >
<EyeIcon /> <EyeIcon />
{saving ? "处理中..." : "关闭会话"} {saving ? t("conversationMonitor.processing") : t("conversationMonitor.close")}
</Button> </Button>
) : null} ) : null}
</div> </div>
@@ -323,7 +336,7 @@ export function ConversationDetailDialog({
> >
{loading ? ( {loading ? (
<div className="flex min-h-0 flex-1 items-center justify-center text-sm text-muted-foreground"> <div className="flex min-h-0 flex-1 items-center justify-center text-sm text-muted-foreground">
... {t("conversationMonitor.loadingDetail")}
</div> </div>
) : currentConversation ? ( ) : currentConversation ? (
<div className="flex min-h-0 flex-1 flex-row overflow-hidden border-t"> <div className="flex min-h-0 flex-1 flex-row overflow-hidden border-t">
@@ -331,32 +344,32 @@ export function ConversationDetailDialog({
<div className="space-y-4 p-6"> <div className="space-y-4 p-6">
<div className="grid grid-cols-2 gap-3 text-sm"> <div className="grid grid-cols-2 gap-3 text-sm">
<InfoItem <InfoItem
label="接待模式" label={t("conversationMonitor.columnServiceMode")}
value={getServiceModeLabel(currentConversation.serviceMode)} value={getServiceModeLabel(currentConversation.serviceMode, t)}
/> />
<InfoItem <InfoItem
label="当前客服" label={t("conversationMonitor.currentAssignee")}
value={currentConversation.currentAssigneeName || "-"} value={currentConversation.currentAssigneeName || "-"}
/> />
<InfoItem <InfoItem
label="渠道ID" label={t("conversation.channelId")}
value={`${currentConversation.channelId || "-"}`} value={`${currentConversation.channelId || "-"}`}
/> />
<InfoItem <InfoItem
label="客服未读" label={t("conversationMonitor.agentUnread")}
value={`${currentConversation.agentUnreadCount}`} value={`${currentConversation.agentUnreadCount}`}
/> />
<InfoItem <InfoItem
label="用户未读" label={t("conversationMonitor.customerUnread")}
value={`${currentConversation.customerUnreadCount}`} value={`${currentConversation.customerUnreadCount}`}
/> />
<InfoItem <InfoItem
label="最后活跃" label={t("conversationMonitor.columnLastActive")}
value={formatDateTime(currentConversation.lastMessageAt)} value={formatDateTime(currentConversation.lastMessageAt)}
fullWidth fullWidth
/> />
<InfoItem <InfoItem
label="关闭时间" label={t("conversationMonitor.closedAt")}
value={formatDateTime(currentConversation.closedAt)} value={formatDateTime(currentConversation.closedAt)}
fullWidth fullWidth
/> />
@@ -369,9 +382,13 @@ export function ConversationDetailDialog({
<div className="space-y-4 p-6"> <div className="space-y-4 p-6">
<section className="space-y-3"> <section className="space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-sm font-medium"></p> <p className="text-sm font-medium">
{t("conversationMonitor.participants")}
</p>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{detail?.participants?.length ?? 0} {t("conversationMonitor.participantCount", {
count: detail?.participants?.length ?? 0,
})}
</span> </span>
</div> </div>
{detail?.participants?.length ? ( {detail?.participants?.length ? (
@@ -387,17 +404,21 @@ export function ConversationDetailDialog({
</span> </span>
</div> </div>
<div className="mt-1 text-sm text-muted-foreground"> <div className="mt-1 text-sm text-muted-foreground">
{getParticipantIdentity(participant)} {t("conversationMonitor.participantIdentity", {
value: getParticipantIdentity(participant),
})}
</div> </div>
<div className="mt-1 text-xs text-muted-foreground"> <div className="mt-1 text-xs text-muted-foreground">
{formatDateTime(participant.joinedAt)} {t("conversationMonitor.participantJoinedAt", {
time: formatDateTime(participant.joinedAt),
})}
</div> </div>
</div> </div>
))} ))}
</div> </div>
) : ( ) : (
<div className="rounded-lg border border-dashed bg-background p-4 text-sm text-muted-foreground"> <div className="rounded-lg border border-dashed bg-background p-4 text-sm text-muted-foreground">
{t("conversationMonitor.emptyParticipants")}
</div> </div>
)} )}
</section> </section>
@@ -406,16 +427,6 @@ export function ConversationDetailDialog({
</aside> </aside>
<section className="flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-background"> <section className="flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-background">
{/* <div className="flex items-center justify-between border-b px-6 py-4">
<div>
<p className="text-sm font-medium">聊天记录</p>
</div>
<div className="text-xs text-muted-foreground">
消息数:{messages.length}
{messagesHasMore ? " · 向上滑可加载更早" : ""}
</div>
</div> */}
<div ref={messagesScrollRootRef} className="min-h-0 flex-1"> <div ref={messagesScrollRootRef} className="min-h-0 flex-1">
<ScrollArea className="h-full min-h-0 bg-muted/10"> <ScrollArea className="h-full min-h-0 bg-muted/10">
<div className="space-y-4 px-6 py-5"> <div className="space-y-4 px-6 py-5">
@@ -425,8 +436,8 @@ export function ConversationDetailDialog({
className="flex min-h-8 flex-col items-center justify-center py-2 text-xs text-muted-foreground" className="flex min-h-8 flex-col items-center justify-center py-2 text-xs text-muted-foreground"
> >
{loadingMoreMessages {loadingMoreMessages
? "正在加载更早的消息…" ? t("conversationMonitor.loadingOlder")
: "继续上滑加载更早消息"} : t("conversationMonitor.loadOlderHint")}
</div> </div>
) : null} ) : null}
{messages.length ? ( {messages.length ? (
@@ -449,13 +460,13 @@ export function ConversationDetailDialog({
<div <div
className={`text-xs text-muted-foreground ${layout.metaClassName}`} className={`text-xs text-muted-foreground ${layout.metaClassName}`}
> >
<span>{getSenderLabel(message)}</span> <span>{getSenderLabel(message, t)}</span>
<span className="mx-2">·</span> <span className="mx-2">·</span>
<span>{formatDateTime(message.sentAt)}</span> <span>{formatDateTime(message.sentAt)}</span>
{isRecalled ? ( {isRecalled ? (
<> <>
<span className="mx-2">·</span> <span className="mx-2">·</span>
<span></span> <span>{t("conversationMonitor.messageRecalled")}</span>
</> </>
) : null} ) : null}
</div> </div>
@@ -463,7 +474,9 @@ export function ConversationDetailDialog({
className={`rounded-2xl border px-4 py-3 text-sm leading-6 ${layout.bubbleClassName}`} className={`rounded-2xl border px-4 py-3 text-sm leading-6 ${layout.bubbleClassName}`}
> >
{isRecalled ? ( {isRecalled ? (
<div className="text-muted-foreground"></div> <div className="text-muted-foreground">
{t("conversationMonitor.messageRecalledBody")}
</div>
) : isHtmlMessage ? ( ) : isHtmlMessage ? (
<ImMessageHTML <ImMessageHTML
html={renderIMMessageHTML(message)} html={renderIMMessageHTML(message)}
@@ -485,8 +498,14 @@ export function ConversationDetailDialog({
<div <div
className={`text-xs text-muted-foreground ${layout.metaClassName}`} className={`text-xs text-muted-foreground ${layout.metaClassName}`}
> >
{message.agentRead ? "已读" : "未读"} / {" "} {t("conversationMonitor.readStatus", {
{message.customerRead ? "已读" : "未读"} agent: message.agentRead
? t("conversationMonitor.read")
: t("conversationMonitor.unread"),
customer: message.customerRead
? t("conversationMonitor.read")
: t("conversationMonitor.unread"),
})}
</div> </div>
</div> </div>
</div> </div>
@@ -494,7 +513,7 @@ export function ConversationDetailDialog({
}) })
) : ( ) : (
<div className="flex h-full min-h-80 items-center justify-center rounded-xl border border-dashed bg-background text-sm text-muted-foreground"> <div className="flex h-full min-h-80 items-center justify-center rounded-xl border border-dashed bg-background text-sm text-muted-foreground">
{t("conversationMonitor.emptyMessages")}
</div> </div>
)} )}
<div ref={messageBottomRef} /> <div ref={messageBottomRef} />
@@ -505,7 +524,7 @@ export function ConversationDetailDialog({
</div> </div>
) : ( ) : (
<div className="flex min-h-0 flex-1 items-center justify-center text-sm text-muted-foreground"> <div className="flex min-h-0 flex-1 items-center justify-center text-sm text-muted-foreground">
{t("conversationMonitor.emptyDetail")}
</div> </div>
)} )}
</ProjectDialog> </ProjectDialog>
@@ -534,10 +553,11 @@ type MessageImageProps = {
}; };
function MessageImage({ src, alt, onPreview }: MessageImageProps) { function MessageImage({ src, alt, onPreview }: MessageImageProps) {
const t = useI18n();
if (!src) { if (!src) {
return ( return (
<div className="text-sm whitespace-pre-wrap break-words"> <div className="text-sm whitespace-pre-wrap break-words">
{alt || "[图片]"} {alt || t("conversationMonitor.imageFallback")}
</div> </div>
); );
} }
@@ -550,7 +570,7 @@ function MessageImage({ src, alt, onPreview }: MessageImageProps) {
> >
<Image <Image
src={src} src={src}
alt={alt || "消息图片"} alt={alt || t("conversationMonitor.messageImageAlt")}
width={480} width={480}
height={360} height={360}
className="max-h-64 w-auto max-w-full rounded-md object-contain" className="max-h-64 w-auto max-w-full rounded-md object-contain"
+116 -93
View File
@@ -7,7 +7,7 @@ import {
RefreshCwIcon, RefreshCwIcon,
SearchIcon, SearchIcon,
} from "lucide-react" } from "lucide-react"
import { useCallback, useEffect, useRef, useState, type KeyboardEvent } from "react" import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react"
import { toast } from "sonner" import { toast } from "sonner"
import { ConversationCloseDialog } from "@/components/conversation-actions/close-dialog" import { ConversationCloseDialog } from "@/components/conversation-actions/close-dialog"
@@ -33,13 +33,6 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu" } from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { import {
Table, Table,
TableBody, TableBody,
@@ -66,50 +59,57 @@ import {
type PageResult, type PageResult,
type TagTree, type TagTree,
} from "@/lib/api/admin" } from "@/lib/api/admin"
import { useI18n } from "@/i18n/provider"
import { formatDateTime } from "@/lib/utils" import { formatDateTime } from "@/lib/utils"
import { ConversationDetailDialog } from "./_components/detail" import { ConversationDetailDialog } from "./_components/detail"
const RECONNECT_BASE_DELAY = 2000 const RECONNECT_BASE_DELAY = 2000
const RECONNECT_MAX_DELAY = 30000 const RECONNECT_MAX_DELAY = 30000
const statusOptions = [ function getStatusMeta(
{ value: "all", label: "全部状态" }, status: number,
{ value: "1", label: "AI接待中" }, t: (key: string, values?: Record<string, string | number>) => string
{ value: "2", label: "待接入" }, ) {
{ value: "3", label: "处理中" },
{ value: "4", label: "已关闭" },
] as const
function getStatusMeta(status: number) {
switch (status) { switch (status) {
case 1: case 1:
return { label: "AI接待中", variant: "secondary" as const } return { label: t("conversation.filterAiServing"), variant: "secondary" as const }
case 2: case 2:
return { label: "待接入", variant: "outline" as const } return { label: t("conversation.filterPending"), variant: "outline" as const }
case 3: case 3:
return { label: "处理中", variant: "secondary" as const } return { label: t("conversation.filterActive"), variant: "secondary" as const }
case 4: case 4:
return { label: "已关闭", variant: "outline" as const } return { label: t("conversation.filterClosed"), variant: "outline" as const }
default: default:
return { label: "未知", variant: "outline" as const } return { label: t("conversationMonitor.unknown"), variant: "outline" as const }
} }
} }
function getServiceModeLabel(mode: number) { function getServiceModeLabel(
mode: number,
t: (key: string, values?: Record<string, string | number>) => string
) {
switch (mode) { switch (mode) {
case 1: case 1:
return "AI 接待" return t("conversationMonitor.serviceAi")
case 2: case 2:
return "人工接待" return t("conversationMonitor.serviceHuman")
case 3: case 3:
return "AI 优先" return t("conversationMonitor.serviceAiFirst")
default: default:
return "未定义" return t("conversationMonitor.serviceUndefined")
} }
} }
function getStatusLabel(value: string) { function getStatusOptions(
return statusOptions.find((item) => item.value === value)?.label ?? "全部状态" t: (key: string, values?: Record<string, string | number>) => string
) {
return [
{ value: "all", label: t("conversationMonitor.allStatuses") },
{ value: "1", label: t("conversation.filterAiServing") },
{ value: "2", label: t("conversation.filterPending") },
{ value: "3", label: t("conversation.filterActive") },
{ value: "4", label: t("conversation.filterClosed") },
]
} }
function buildTagOptions( function buildTagOptions(
@@ -131,6 +131,8 @@ function buildTagOptions(
} }
export default function DashboardConversationsPage() { export default function DashboardConversationsPage() {
const t = useI18n()
const statusOptions = useMemo(() => getStatusOptions(t), [t])
const [keywordInput, setKeywordInput] = useState("") const [keywordInput, setKeywordInput] = useState("")
const [statusFilterInput, setStatusFilterInput] = useState("all") const [statusFilterInput, setStatusFilterInput] = useState("all")
const [tagFilterInput, setTagFilterInput] = useState("0") const [tagFilterInput, setTagFilterInput] = useState("0")
@@ -144,13 +146,13 @@ export default function DashboardConversationsPage() {
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [limit, setLimit] = useState(20) const [limit, setLimit] = useState(20)
const [tagOptions, setTagOptions] = useState<ComboboxOption[]>([ const [tagOptions, setTagOptions] = useState<ComboboxOption[]>([
{ value: "0", label: "全部标签" }, { value: "0", label: t("conversationMonitor.allTags") },
]) ])
const [assigneeOptions, setAssigneeOptions] = useState<ComboboxOption[]>([ const [assigneeOptions, setAssigneeOptions] = useState<ComboboxOption[]>([
{ value: "0", label: "全部指派人" }, { value: "0", label: t("conversationMonitor.allAssignees") },
]) ])
const [agentTeamOptions, setAgentTeamOptions] = useState<ComboboxOption[]>([ const [agentTeamOptions, setAgentTeamOptions] = useState<ComboboxOption[]>([
{ value: "0", label: "全部客服组" }, { value: "0", label: t("conversationMonitor.allTeams") },
]) ])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false) const [refreshing, setRefreshing] = useState(false)
@@ -194,11 +196,11 @@ export default function DashboardConversationsPage() {
}) })
setResult(data) setResult(data)
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "加载会话列表失败") toast.error(error instanceof Error ? error.message : t("conversationMonitor.loadListFailed"))
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, [keyword, limit, page, statusFilter, tagFilter, assigneeFilter, agentTeamFilter]) }, [keyword, limit, page, statusFilter, tagFilter, assigneeFilter, agentTeamFilter, t])
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
@@ -212,18 +214,18 @@ export default function DashboardConversationsPage() {
]) ])
if (!cancelled) { if (!cancelled) {
setTagOptions([ setTagOptions([
{ value: "0", label: "全部标签" }, { value: "0", label: t("conversationMonitor.allTags") },
...buildTagOptions(tagData), ...buildTagOptions(tagData),
]) ])
setAssigneeOptions([ setAssigneeOptions([
{ value: "0", label: "全部指派人" }, { value: "0", label: t("conversationMonitor.allAssignees") },
...assigneeData.map((item: AdminAgentProfile) => ({ ...assigneeData.map((item: AdminAgentProfile) => ({
value: String(item.userId), value: String(item.userId),
label: item.displayName || item.nickname || item.username || `#${item.userId}`, label: item.displayName || item.nickname || item.username || `#${item.userId}`,
})), })),
]) ])
setAgentTeamOptions([ setAgentTeamOptions([
{ value: "0", label: "全部客服组" }, { value: "0", label: t("conversationMonitor.allTeams") },
...teamData.map((item: AdminAgentTeam) => ({ ...teamData.map((item: AdminAgentTeam) => ({
value: String(item.id), value: String(item.id),
label: item.name, label: item.name,
@@ -232,7 +234,7 @@ export default function DashboardConversationsPage() {
} }
} catch (error) { } catch (error) {
if (!cancelled) { if (!cancelled) {
toast.error(error instanceof Error ? error.message : "加载筛选项失败") toast.error(error instanceof Error ? error.message : t("conversationMonitor.loadFiltersFailed"))
} }
} }
} }
@@ -242,7 +244,7 @@ export default function DashboardConversationsPage() {
return () => { return () => {
cancelled = true cancelled = true
} }
}, []) }, [t])
useEffect(() => { useEffect(() => {
detailItemRef.current = detailItem detailItemRef.current = detailItem
@@ -290,7 +292,7 @@ export default function DashboardConversationsPage() {
try { try {
socket = new WebSocket(createAdminWebSocketUrl()) socket = new WebSocket(createAdminWebSocketUrl())
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "连接实时服务失败") toast.error(error instanceof Error ? error.message : t("conversationMonitor.realtimeConnectFailed"))
scheduleReconnect() scheduleReconnect()
return return
} }
@@ -382,7 +384,7 @@ export default function DashboardConversationsPage() {
} }
subscribedConversationIdRef.current = null subscribedConversationIdRef.current = null
} }
}, [loadConversations]) }, [loadConversations, t])
useEffect(() => { useEffect(() => {
const socket = websocketRef.current const socket = websocketRef.current
@@ -466,7 +468,7 @@ export default function DashboardConversationsPage() {
setDetailMessagesNextCursor(messages.cursor ?? "") setDetailMessagesNextCursor(messages.cursor ?? "")
setDetailMessagesHasMore(Boolean(messages.hasMore)) setDetailMessagesHasMore(Boolean(messages.hasMore))
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "加载会话详情失败") toast.error(error instanceof Error ? error.message : t("conversationMonitor.loadDetailFailed"))
} finally { } finally {
setDetailLoading(false) setDetailLoading(false)
} }
@@ -492,7 +494,7 @@ export default function DashboardConversationsPage() {
setDetailMessagesNextCursor(page.cursor ?? "") setDetailMessagesNextCursor(page.cursor ?? "")
setDetailMessagesHasMore(Boolean(page.hasMore)) setDetailMessagesHasMore(Boolean(page.hasMore))
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "加载更多消息失败") toast.error(error instanceof Error ? error.message : t("conversationMonitor.loadMoreMessagesFailed"))
} finally { } finally {
setDetailMessagesLoadingMore(false) setDetailMessagesLoadingMore(false)
} }
@@ -501,6 +503,7 @@ export default function DashboardConversationsPage() {
detailMessagesHasMore, detailMessagesHasMore,
detailMessagesLoadingMore, detailMessagesLoadingMore,
detailMessagesNextCursor, detailMessagesNextCursor,
t,
]) ])
async function openDetail(item: AdminConversation) { async function openDetail(item: AdminConversation) {
@@ -603,13 +606,13 @@ export default function DashboardConversationsPage() {
setActionLoadingId(item.id) setActionLoadingId(item.id)
try { try {
await markConversationRead(item.id) await markConversationRead(item.id)
toast.success(`已标记已读:${item.customerName || `#${item.id}`}`) toast.success(t("conversationMonitor.markedRead", { name: item.customerName || `#${item.id}` }))
await loadConversations() await loadConversations()
if (detailItem?.id === item.id) { if (detailItem?.id === item.id) {
await refreshDetail() await refreshDetail()
} }
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "标记已读失败") toast.error(error instanceof Error ? error.message : t("conversationMonitor.markReadFailed"))
} finally { } finally {
setActionLoadingId(null) setActionLoadingId(null)
} }
@@ -619,13 +622,13 @@ export default function DashboardConversationsPage() {
setActionLoadingId(item.id) setActionLoadingId(item.id)
try { try {
await dispatchConversation(item.id) await dispatchConversation(item.id)
toast.success(`已触发自动分配:${item.customerName || `#${item.id}`}`) toast.success(t("conversationMonitor.dispatchTriggered", { name: item.customerName || `#${item.id}` }))
await loadConversations() await loadConversations()
if (detailItem?.id === item.id) { if (detailItem?.id === item.id) {
await refreshDetail() await refreshDetail()
} }
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "自动分配失败") toast.error(error instanceof Error ? error.message : t("conversationMonitor.dispatchFailed"))
} finally { } finally {
setActionLoadingId(null) setActionLoadingId(null)
} }
@@ -649,7 +652,7 @@ export default function DashboardConversationsPage() {
disabled={loading || refreshing} disabled={loading || refreshing}
> >
<RefreshCwIcon className={loading || refreshing ? "animate-spin" : ""} /> <RefreshCwIcon className={loading || refreshing ? "animate-spin" : ""} />
{t("conversationMonitor.refresh")}
</Button> </Button>
} }
> >
@@ -659,29 +662,25 @@ export default function DashboardConversationsPage() {
value={keywordInput} value={keywordInput}
onChange={(event) => setKeywordInput(event.target.value)} onChange={(event) => setKeywordInput(event.target.value)}
onKeyDown={handleFilterKeyDown} onKeyDown={handleFilterKeyDown}
placeholder="按主题或摘要筛选" placeholder={t("conversationMonitor.keywordPlaceholder")}
className="pl-9" className="pl-9"
/> />
</div> </div>
<Select value={statusFilterInput} onValueChange={handleStatusFilterChange}> <div className="w-full sm:w-36">
<SelectTrigger className="w-full sm:w-36"> <OptionCombobox
<SelectValue>{getStatusLabel(statusFilterInput)}</SelectValue> value={statusFilterInput}
</SelectTrigger> options={statusOptions}
<SelectContent> placeholder={t("conversationMonitor.allStatuses")}
{statusOptions.map((item) => ( onChange={handleStatusFilterChange}
<SelectItem key={item.value} value={item.value}> />
{item.label} </div>
</SelectItem>
))}
</SelectContent>
</Select>
<div className="w-full sm:w-64"> <div className="w-full sm:w-64">
<OptionCombobox <OptionCombobox
value={tagFilterInput} value={tagFilterInput}
options={tagOptions} options={tagOptions}
placeholder="选择标签" placeholder={t("conversationMonitor.selectTag")}
searchPlaceholder="搜索标签路径" searchPlaceholder={t("conversationMonitor.searchTagPath")}
emptyText="没有匹配标签" emptyText={t("conversationMonitor.emptyTags")}
onChange={setTagFilterInput} onChange={setTagFilterInput}
/> />
</div> </div>
@@ -689,9 +688,9 @@ export default function DashboardConversationsPage() {
<OptionCombobox <OptionCombobox
value={assigneeFilterInput} value={assigneeFilterInput}
options={assigneeOptions} options={assigneeOptions}
placeholder="选择指派人" placeholder={t("conversationMonitor.selectAssignee")}
searchPlaceholder="搜索指派人" searchPlaceholder={t("conversationMonitor.searchAssignee")}
emptyText="没有匹配指派人" emptyText={t("conversationMonitor.emptyAssignees")}
onChange={setAssigneeFilterInput} onChange={setAssigneeFilterInput}
/> />
</div> </div>
@@ -699,15 +698,15 @@ export default function DashboardConversationsPage() {
<OptionCombobox <OptionCombobox
value={agentTeamFilterInput} value={agentTeamFilterInput}
options={agentTeamOptions} options={agentTeamOptions}
placeholder="选择客服组" placeholder={t("conversationMonitor.selectTeam")}
searchPlaceholder="搜索客服组" searchPlaceholder={t("conversationMonitor.searchTeam")}
emptyText="没有匹配客服组" emptyText={t("conversationMonitor.emptyTeams")}
onChange={setAgentTeamFilterInput} onChange={setAgentTeamFilterInput}
/> />
</div> </div>
<Button variant="outline" onClick={applyFilters} disabled={loading}> <Button variant="outline" onClick={applyFilters} disabled={loading}>
<SearchIcon /> <SearchIcon />
{t("conversationMonitor.query")}
</Button> </Button>
</DashboardToolbar> </DashboardToolbar>
@@ -726,39 +725,51 @@ export default function DashboardConversationsPage() {
<Table> <Table>
<TableHeader className="bg-muted/40"> <TableHeader className="bg-muted/40">
<TableRow> <TableRow>
<TableHead></TableHead> <TableHead>{t("conversationMonitor.columnConversation")}</TableHead>
<TableHead></TableHead> <TableHead>{t("conversationMonitor.columnStatus")}</TableHead>
<TableHead></TableHead> <TableHead>{t("conversationMonitor.columnServiceMode")}</TableHead>
<TableHead></TableHead> <TableHead>{t("conversationMonitor.columnAssignee")}</TableHead>
<TableHead></TableHead> <TableHead>{t("conversationMonitor.columnUnread")}</TableHead>
<TableHead></TableHead> <TableHead>{t("conversationMonitor.columnLastActive")}</TableHead>
<TableHead className="w-28 text-right"></TableHead> <TableHead className="w-28 text-right">
{t("conversationMonitor.columnActions")}
</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{result.results.map((item) => { {result.results.map((item) => {
const statusMeta = getStatusMeta(item.status) const statusMeta = getStatusMeta(item.status, t)
return ( return (
<TableRow key={item.id}> <TableRow key={item.id}>
<TableCell className="max-w-60"> <TableCell className="max-w-60">
<div className="min-w-0"> <div className="min-w-0">
<div className="font-medium">{item.customerName || `客户 #${item.customerId || item.id}`}</div> <div className="font-medium">
{item.customerName ||
t("conversationMonitor.customerFallback", {
id: item.customerId || item.id,
})}
</div>
<div className="mt-1 text-sm text-muted-foreground"> <div className="mt-1 text-sm text-muted-foreground">
ID{item.channelId || "-"} {t("conversationMonitor.channelIdValue", {
id: item.channelId || "-",
})}
</div> </div>
<div className="mt-1 line-clamp-2 text-sm text-muted-foreground"> <div className="mt-1 line-clamp-2 text-sm text-muted-foreground">
{item.lastMessageSummary || "暂无最新消息摘要"} {item.lastMessageSummary || t("conversationMonitor.noLatestSummary")}
</div> </div>
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge variant={statusMeta.variant}>{statusMeta.label}</Badge> <Badge variant={statusMeta.variant}>{statusMeta.label}</Badge>
</TableCell> </TableCell>
<TableCell>{getServiceModeLabel(item.serviceMode)}</TableCell> <TableCell>{getServiceModeLabel(item.serviceMode, t)}</TableCell>
<TableCell>{item.currentAssigneeName || "-"}</TableCell> <TableCell>{item.currentAssigneeName || "-"}</TableCell>
<TableCell> <TableCell>
{item.agentUnreadCount} / {item.customerUnreadCount} {t("conversationMonitor.unreadSummary", {
agent: item.agentUnreadCount,
customer: item.customerUnreadCount,
})}
</TableCell> </TableCell>
<TableCell>{formatDateTime(item.lastMessageAt)}</TableCell> <TableCell>{formatDateTime(item.lastMessageAt)}</TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
@@ -769,12 +780,14 @@ export default function DashboardConversationsPage() {
onClick={() => void openDetail(item)} onClick={() => void openDetail(item)}
disabled={actionLoadingId === item.id} disabled={actionLoadingId === item.id}
> >
{t("conversationMonitor.view")}
</Button> </Button>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
render={<Button variant="outline" size="icon-sm" />} render={<Button variant="outline" size="icon-sm" />}
aria-label={`更多操作 ${item.customerName || item.id}`} aria-label={t("conversationMonitor.moreActions", {
name: item.customerName || item.id,
})}
> >
<MoreHorizontalIcon /> <MoreHorizontalIcon />
</DropdownMenuTrigger> </DropdownMenuTrigger>
@@ -784,28 +797,36 @@ export default function DashboardConversationsPage() {
disabled={actionLoadingId === item.id || item.status !== 2} disabled={actionLoadingId === item.id || item.status !== 2}
> >
<MessageCircleMoreIcon /> <MessageCircleMoreIcon />
{actionLoadingId === item.id ? "处理中..." : "分配会话"} {actionLoadingId === item.id
? t("conversationMonitor.processing")
: t("conversationMonitor.assign")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={() => void handleDispatch(item)} onClick={() => void handleDispatch(item)}
disabled={actionLoadingId === item.id || item.status !== 2} disabled={actionLoadingId === item.id || item.status !== 2}
> >
<RefreshCwIcon /> <RefreshCwIcon />
{actionLoadingId === item.id ? "处理中..." : "重试分配"} {actionLoadingId === item.id
? t("conversationMonitor.processing")
: t("conversationMonitor.retryDispatch")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={() => void handleRead(item)} onClick={() => void handleRead(item)}
disabled={actionLoadingId === item.id} disabled={actionLoadingId === item.id}
> >
<CheckCheckIcon /> <CheckCheckIcon />
{actionLoadingId === item.id ? "处理中..." : "标记已读"} {actionLoadingId === item.id
? t("conversationMonitor.processing")
: t("conversationMonitor.markRead")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={() => openTransfer(item)} onClick={() => openTransfer(item)}
disabled={actionLoadingId === item.id || item.status !== 3} disabled={actionLoadingId === item.id || item.status !== 3}
> >
<MessageCircleMoreIcon /> <MessageCircleMoreIcon />
{actionLoadingId === item.id ? "处理中..." : "转接会话"} {actionLoadingId === item.id
? t("conversationMonitor.processing")
: t("conversationMonitor.transfer")}
</DropdownMenuItem> </DropdownMenuItem>
{item.status !== 4 ? ( {item.status !== 4 ? (
<DropdownMenuItem <DropdownMenuItem
@@ -813,7 +834,9 @@ export default function DashboardConversationsPage() {
disabled={actionLoadingId === item.id} disabled={actionLoadingId === item.id}
> >
<RefreshCwIcon /> <RefreshCwIcon />
{actionLoadingId === item.id ? "处理中..." : "关闭会话"} {actionLoadingId === item.id
? t("conversationMonitor.processing")
: t("conversationMonitor.close")}
</DropdownMenuItem> </DropdownMenuItem>
) : null} ) : null}
</DropdownMenuContent> </DropdownMenuContent>
@@ -827,8 +850,8 @@ export default function DashboardConversationsPage() {
<DashboardTableStateRow <DashboardTableStateRow
colSpan={7} colSpan={7}
loading={loading} loading={loading}
loadingText="正在加载会话记录..." loadingText={t("conversationMonitor.loadingRows")}
emptyText="没有匹配的会话记录" emptyText={t("conversationMonitor.emptyRows")}
/> />
) : null} ) : null}
</TableBody> </TableBody>
@@ -7,6 +7,7 @@ import {
SharedMessageEditor, SharedMessageEditor,
type UploadedMessageEditorImage, type UploadedMessageEditorImage,
} from "@/components/chat/shared-message-editor" } from "@/components/chat/shared-message-editor"
import { useI18n } from "@/i18n/provider"
import { fetchQuickReplyListAll, type AdminQuickReply } from "@/lib/api/admin" import { fetchQuickReplyListAll, type AdminQuickReply } from "@/lib/api/admin"
type AgentMessageEditorProps = { type AgentMessageEditorProps = {
@@ -24,6 +25,7 @@ export function AgentMessageEditor({
onUploadImage, onUploadImage,
onSendAttachment, onSendAttachment,
}: AgentMessageEditorProps) { }: AgentMessageEditorProps) {
const t = useI18n()
const [quickReplies, setQuickReplies] = useState<AdminQuickReply[]>([]) const [quickReplies, setQuickReplies] = useState<AdminQuickReply[]>([])
const [loadingQuickReplies, setLoadingQuickReplies] = useState(true) const [loadingQuickReplies, setLoadingQuickReplies] = useState(true)
const [quickReplyPickerOpen, setQuickReplyPickerOpen] = useState(false) const [quickReplyPickerOpen, setQuickReplyPickerOpen] = useState(false)
@@ -38,7 +40,7 @@ export function AgentMessageEditor({
}) })
.catch((error) => { .catch((error) => {
if (!cancelled) { if (!cancelled) {
toast.error(error instanceof Error ? error.message : "加载快捷回复失败") toast.error(error instanceof Error ? error.message : t("conversation.loadQuickRepliesFailed"))
} }
}) })
.finally(() => { .finally(() => {
@@ -49,7 +51,7 @@ export function AgentMessageEditor({
return () => { return () => {
cancelled = true cancelled = true
} }
}, []) }, [t])
return ( return (
<SharedMessageEditor <SharedMessageEditor
@@ -6,6 +6,7 @@ import { toast } from "sonner";
import { ConversationTransferDialog } from "@/components/conversation-actions/transfer-dialog"; import { ConversationTransferDialog } from "@/components/conversation-actions/transfer-dialog";
import { ImMessageHTML } from "@/components/im-message-html"; import { ImMessageHTML } from "@/components/im-message-html";
import { useImageLightbox } from "@/components/image-lightbox"; import { useImageLightbox } from "@/components/image-lightbox";
import { useI18n } from "@/i18n/provider";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -39,6 +40,7 @@ import { AgentMessageEditor } from "./agent-message-editor";
const EMPTY_AGENT_MESSAGES: AgentMessage[] = []; const EMPTY_AGENT_MESSAGES: AgentMessage[] = [];
export function ChatPanel() { export function ChatPanel() {
const t = useI18n();
const conversation = useAgentConversationsStore( const conversation = useAgentConversationsStore(
agentConversationSelectors.selectedConversation, agentConversationSelectors.selectedConversation,
); );
@@ -119,8 +121,8 @@ export function ChatPanel() {
}, [getViewport]); }, [getViewport]);
/** /**
* widget scrollHeight * Match the widget message list: keep scrolling for a few frames until
* * scrollHeight stabilizes, which prevents stacked scroll jumps.
*/ */
const scheduleScrollToBottom = useCallback( const scheduleScrollToBottom = useCallback(
(attempts = 4) => { (attempts = 4) => {
@@ -170,7 +172,7 @@ export function ChatPanel() {
return; return;
} }
void markSelectedConversationRead().catch((error) => { void markSelectedConversationRead().catch((error) => {
toast.error(error instanceof Error ? error.message : "设置已读失败"); toast.error(error instanceof Error ? error.message : t("conversation.markReadFailed"));
}); });
}, [ }, [
conversation, conversation,
@@ -178,6 +180,7 @@ export function ChatPanel() {
isNearBottom, isNearBottom,
loading, loading,
markSelectedConversationRead, markSelectedConversationRead,
t,
]); ]);
useEffect(() => { useEffect(() => {
@@ -282,7 +285,7 @@ export function ChatPanel() {
await loadOlderMessages(); await loadOlderMessages();
} catch (error) { } catch (error) {
prependScrollAnchorRef.current = null; prependScrollAnchorRef.current = null;
toast.error(error instanceof Error ? error.message : "加载历史消息失败"); toast.error(error instanceof Error ? error.message : t("conversation.loadHistoryFailed"));
} }
}; };
@@ -292,7 +295,7 @@ export function ChatPanel() {
shouldStickToBottomRef.current = true; shouldStickToBottomRef.current = true;
await sendMessage(html); await sendMessage(html);
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "发送消息失败"); toast.error(error instanceof Error ? error.message : t("conversation.sendMessageFailed"));
} }
}; };
@@ -300,7 +303,7 @@ export function ChatPanel() {
if (!conversation || claiming) return; if (!conversation || claiming) return;
const session = readSession(); const session = readSession();
if (!session?.user?.id) { if (!session?.user?.id) {
toast.error("未登录或登录已过期"); toast.error(t("conversation.claimRequiresSignIn"));
return; return;
} }
@@ -309,15 +312,15 @@ export function ChatPanel() {
await assignAgentConversation( await assignAgentConversation(
conversation.id, conversation.id,
session.user.id, session.user.id,
"认领会话", t("conversation.claimReason"),
); );
switchToMyActiveIfNeeded(); switchToMyActiveIfNeeded();
setClaimDialogOpen(false); setClaimDialogOpen(false);
toast.success("认领成功"); toast.success(t("conversation.claimSuccess"));
await reloadConversationData(conversation.id); await reloadConversationData(conversation.id);
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "认领会话失败"); toast.error(error instanceof Error ? error.message : t("conversation.claimFailed"));
} finally { } finally {
setClaiming(false); setClaiming(false);
} }
@@ -332,9 +335,13 @@ export function ChatPanel() {
return ( return (
<div className="mt-10 flex flex-1 items-center justify-center px-4"> <div className="mt-10 flex flex-1 items-center justify-center px-4">
<div className="text-center text-muted-foreground"> <div className="text-center text-muted-foreground">
<p className="text-lg"></p> <p className="text-lg">{t("conversation.empty")}</p>
<p className="mt-1 text-sm lg:hidden"></p> <p className="mt-1 text-sm lg:hidden">
<p className="mt-1 hidden text-sm lg:block"></p> {t("conversation.noConversationMobile")}
</p>
<p className="mt-1 hidden text-sm lg:block">
{t("conversation.selectConversationToChat")}
</p>
</div> </div>
</div> </div>
); );
@@ -355,13 +362,13 @@ export function ChatPanel() {
disabled={messagesLoadingMore} disabled={messagesLoadingMore}
onClick={() => void handleLoadOlder()} onClick={() => void handleLoadOlder()}
> >
{messagesLoadingMore ? "加载中…" : "加载更早的消息"} {messagesLoadingMore ? t("conversation.loading") : t("conversation.loadOlder")}
</Button> </Button>
</div> </div>
) : null} ) : null}
{loading ? ( {loading ? (
<div className="py-8 text-center text-sm text-muted-foreground"> <div className="py-8 text-center text-sm text-muted-foreground">
... {t("conversation.loading")}
</div> </div>
) : messages.length > 0 ? ( ) : messages.length > 0 ? (
messages.map((message) => ( messages.map((message) => (
@@ -378,7 +385,7 @@ export function ChatPanel() {
)) ))
) : ( ) : (
<div className="py-8 text-center text-sm text-muted-foreground"> <div className="py-8 text-center text-sm text-muted-foreground">
{t("conversation.emptyMessages")}
</div> </div>
)} )}
</div> </div>
@@ -389,11 +396,11 @@ export function ChatPanel() {
<div className="h-full overflow-auto border-t border-border/80 bg-card"> <div className="h-full overflow-auto border-t border-border/80 bg-card">
{isClosedConversation ? ( {isClosedConversation ? (
<div className="h-full flex justify-center items-center"> <div className="h-full flex justify-center items-center">
{t("conversation.closedNotice")}
</div> </div>
) : conversation?.status === 1 ? ( ) : conversation?.status === 1 ? (
<div className="h-full flex justify-center items-center"> <div className="h-full flex justify-center items-center">
AI {t("conversation.aiServingNotice")}
</div> </div>
) : isPendingConversation ? ( ) : isPendingConversation ? (
<div className="h-full flex justify-center items-center"> <div className="h-full flex justify-center items-center">
@@ -403,7 +410,7 @@ export function ChatPanel() {
disabled={claiming} disabled={claiming}
size="sm" size="sm"
> >
{claiming ? "认领中..." : "认领"} {claiming ? t("conversation.claiming") : t("conversation.claim")}
</Button> </Button>
</div> </div>
</div> </div>
@@ -424,7 +431,7 @@ export function ChatPanel() {
try { try {
await sendAttachment(file); await sendAttachment(file);
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "发送附件失败"); toast.error(error instanceof Error ? error.message : t("conversation.sendAttachmentFailed"));
} }
}} }}
/> />
@@ -477,11 +484,14 @@ export function ChatPanel() {
> >
<DialogContent className="max-w-md" showCloseButton={false}> <DialogContent className="max-w-md" showCloseButton={false}>
<DialogHeader> <DialogHeader>
<DialogTitle></DialogTitle> <DialogTitle>{t("conversation.claimTitle")}</DialogTitle>
<DialogDescription> <DialogDescription>
{conversation {conversation
? `确认认领“${conversation.customerName || `客户 #${conversation.customerId || conversation.id}`}”吗?认领后会话会进入我的列表。` ? `${t("conversation.claimConfirmPrefix")}${
: "确认认领当前会话吗?"} conversation.customerName ||
`${t("conversation.customerFallbackPrefix")}${conversation.customerId || conversation.id}`
}${t("conversation.claimConfirmSuffix")}`
: t("conversation.claimCurrent")}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
@@ -491,14 +501,14 @@ export function ChatPanel() {
disabled={claiming} disabled={claiming}
onClick={() => setClaimDialogOpen(false)} onClick={() => setClaimDialogOpen(false)}
> >
{t("conversation.cancel")}
</Button> </Button>
<Button <Button
type="button" type="button"
disabled={claiming} disabled={claiming}
onClick={() => void handleClaim()} onClick={() => void handleClaim()}
> >
{claiming ? "认领中..." : "确认认领"} {claiming ? t("conversation.claiming") : t("conversation.confirmClaim")}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -532,22 +542,25 @@ const MessageItem = memo(
recalling, recalling,
onRecall, onRecall,
}: MessageItemProps) { }: MessageItemProps) {
const t = useI18n();
const { open: openImageLightbox } = useImageLightbox(); const { open: openImageLightbox } = useImageLightbox();
const isCustomer = message.senderType === "customer"; const isCustomer = message.senderType === "customer";
const isAi = message.senderType === "ai"; const isAi = message.senderType === "ai";
const isAgentSide = message.senderType === "agent" || isAi; const isAgentSide = message.senderType === "agent" || isAi;
const isRecalled = Boolean(message.recalledAt) || message.sendStatus === 6; const isRecalled = Boolean(message.recalledAt) || message.sendStatus === 6;
const senderName = isCustomer const senderName = isCustomer
? message.senderName || "客户" ? message.senderName || t("conversation.customerSender")
: isAi : isAi
? "AI" ? "AI"
: message.senderName || "客服"; : message.senderName || t("conversation.agentSender");
const agentAvatarSrc = const agentAvatarSrc =
isAgentSide && !isAi && message.senderAvatar?.trim() isAgentSide && !isAi && message.senderAvatar?.trim()
? message.senderAvatar.trim() ? message.senderAvatar.trim()
: undefined; : undefined;
const avatarFallback = isAi ? "AI" : senderName.charAt(0); const avatarFallback = isAi ? "AI" : senderName.charAt(0);
const htmlContent = isRecalled ? "<p>该消息已撤回</p>" : buildMessageHTML(message); const htmlContent = isRecalled
? `<p>${t("conversation.messageRecalledHtml")}</p>`
: buildMessageHTML(message);
const bubbleClassName = isAi const bubbleClassName = isAi
? "border border-primary/15 bg-primary/5 text-foreground shadow-sm" ? "border border-primary/15 bg-primary/5 text-foreground shadow-sm"
: isAgentSide : isAgentSide
@@ -597,9 +610,13 @@ const MessageItem = memo(
</div> </div>
<div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground"> <div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
<span>{formatDateTime(message.sentAt || "")}</span> <span>{formatDateTime(message.sentAt || "")}</span>
{isRecalled ? <span></span> : null} {isRecalled ? <span>{t("conversation.messageRecalled")}</span> : null}
{message.sendStatus === 2 && !isRecalled && ( {message.sendStatus === 2 && !isRecalled && (
<span>{message.customerRead ? "客户已读" : "客户未读"}</span> <span>
{message.customerRead
? t("conversation.customerRead")
: t("conversation.customerUnread")}
</span>
)} )}
{showRecallAction ? ( {showRecallAction ? (
<Button <Button
@@ -610,11 +627,11 @@ const MessageItem = memo(
disabled={recalling} disabled={recalling}
onClick={() => { onClick={() => {
void onRecall(message.id).catch((error) => { void onRecall(message.id).catch((error) => {
toast.error(error instanceof Error ? error.message : "撤回消息失败"); toast.error(error instanceof Error ? error.message : t("conversation.recallFailed"));
}); });
}} }}
> >
{recalling ? "撤回中..." : "撤回"} {recalling ? t("conversation.recalling") : t("conversation.recall")}
</Button> </Button>
) : null} ) : null}
</div> </div>
@@ -631,7 +648,7 @@ const MessageItem = memo(
<Avatar className="size-8 shrink-0"> <Avatar className="size-8 shrink-0">
<AvatarImage src="" /> <AvatarImage src="" />
<AvatarFallback className={avatarClassName}> <AvatarFallback className={avatarClassName}>
{t("conversation.customerAvatar")}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<div className="max-w-[70%]"> <div className="max-w-[70%]">
@@ -652,7 +669,7 @@ const MessageItem = memo(
</div> </div>
<div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground"> <div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
<span>{formatDateTime(message.sentAt || "")}</span> <span>{formatDateTime(message.sentAt || "")}</span>
{isRecalled ? <span></span> : null} {isRecalled ? <span>{t("conversation.messageRecalled")}</span> : null}
</div> </div>
</div> </div>
</> </>
@@ -43,20 +43,31 @@ import {
} from "@/lib/api/customer-contact"; } from "@/lib/api/customer-contact";
import { import {
ContactType, ContactType,
ContactTypeLabels,
Gender, Gender,
GenderLabels,
} from "@/lib/generated/enums"; } from "@/lib/generated/enums";
import { useAgentConversationsStore } from "@/lib/stores/agent-conversations"; import { useAgentConversationsStore } from "@/lib/stores/agent-conversations";
import { cn, formatDateTime } from "@/lib/utils"; import { cn, formatDateTime } from "@/lib/utils";
import { useI18n } from "@/i18n/provider";
import { import {
ConversationTagBadges, ConversationTagBadges,
ConversationTagPicker, ConversationTagPicker,
} from "./conversation-tag-picker"; } from "./conversation-tag-picker";
import { TicketStatusBadge } from "../../tickets/_components/ticket-status-badge"; import { TicketStatusBadge } from "../../tickets/_components/ticket-status-badge";
function contactTypeLabel(contactType: ContactType | string) { function contactTypeLabel(
return ContactTypeLabels[contactType as ContactType] ?? contactType; contactType: ContactType | string,
t: (key: string, values?: Record<string, string | number>) => string
) {
switch (contactType) {
case ContactType.Mobile:
return t("conversation.contactMobile");
case ContactType.Email:
return t("conversation.contactEmail");
case ContactType.Other:
return t("conversation.contactOther");
default:
return String(contactType);
}
} }
function ContactTypeIcon({ contactType }: { contactType: ContactType | string }) { function ContactTypeIcon({ contactType }: { contactType: ContactType | string }) {
@@ -113,6 +124,7 @@ function SectionHeading({
} }
function UnlinkedCustomerEmpty({ conversation }: { conversation: AgentConversation }) { function UnlinkedCustomerEmpty({ conversation }: { conversation: AgentConversation }) {
const t = useI18n();
const [linkDialogOpen, setLinkDialogOpen] = useState(false); const [linkDialogOpen, setLinkDialogOpen] = useState(false);
const loadConversations = useAgentConversationsStore((s) => s.loadConversations); const loadConversations = useAgentConversationsStore((s) => s.loadConversations);
@@ -120,9 +132,11 @@ function UnlinkedCustomerEmpty({ conversation }: { conversation: AgentConversati
<div className="space-y-6 pt-2"> <div className="space-y-6 pt-2">
<div className="flex flex-col items-center justify-center rounded-xl bg-muted/35 px-4 py-8 text-center"> <div className="flex flex-col items-center justify-center rounded-xl bg-muted/35 px-4 py-8 text-center">
<UserRoundIcon className="mb-2 size-10 text-muted-foreground" aria-hidden /> <UserRoundIcon className="mb-2 size-10 text-muted-foreground" aria-hidden />
<p className="text-sm font-medium text-foreground"> CRM </p> <p className="text-sm font-medium text-foreground">
{t("conversation.unlinkedCustomerTitle")}
</p>
<p className="mt-1 max-w-xs text-xs leading-relaxed text-muted-foreground"> <p className="mt-1 max-w-xs text-xs leading-relaxed text-muted-foreground">
{t("conversation.unlinkedCustomerDescription")}
</p> </p>
<Button <Button
type="button" type="button"
@@ -130,7 +144,7 @@ function UnlinkedCustomerEmpty({ conversation }: { conversation: AgentConversati
onClick={() => setLinkDialogOpen(true)} onClick={() => setLinkDialogOpen(true)}
> >
<Link2Icon className="size-4" /> <Link2Icon className="size-4" />
{t("conversation.linkOrCreateCustomer")}
</Button> </Button>
</div> </div>
<CustomerLinkOrCreateDialog <CustomerLinkOrCreateDialog
@@ -144,6 +158,7 @@ function UnlinkedCustomerEmpty({ conversation }: { conversation: AgentConversati
} }
function MissingCustomerEmpty({ conversation }: { conversation: AgentConversation }) { function MissingCustomerEmpty({ conversation }: { conversation: AgentConversation }) {
const t = useI18n();
const [linkDialogOpen, setLinkDialogOpen] = useState(false); const [linkDialogOpen, setLinkDialogOpen] = useState(false);
const loadConversations = useAgentConversationsStore((s) => s.loadConversations); const loadConversations = useAgentConversationsStore((s) => s.loadConversations);
@@ -151,9 +166,11 @@ function MissingCustomerEmpty({ conversation }: { conversation: AgentConversatio
<div className="space-y-6 pt-2"> <div className="space-y-6 pt-2">
<div className="flex flex-col items-center justify-center rounded-xl bg-muted/35 px-4 py-8 text-center"> <div className="flex flex-col items-center justify-center rounded-xl bg-muted/35 px-4 py-8 text-center">
<UserRoundIcon className="mb-2 size-10 text-muted-foreground" aria-hidden /> <UserRoundIcon className="mb-2 size-10 text-muted-foreground" aria-hidden />
<p className="text-sm font-medium text-foreground"></p> <p className="text-sm font-medium text-foreground">
{t("conversation.missingCustomerTitle")}
</p>
<p className="mt-1 max-w-xs text-xs leading-relaxed text-muted-foreground"> <p className="mt-1 max-w-xs text-xs leading-relaxed text-muted-foreground">
{t("conversation.missingCustomerDescription")}
</p> </p>
<Button <Button
type="button" type="button"
@@ -161,14 +178,14 @@ function MissingCustomerEmpty({ conversation }: { conversation: AgentConversatio
onClick={() => setLinkDialogOpen(true)} onClick={() => setLinkDialogOpen(true)}
> >
<Link2Icon className="size-4" /> <Link2Icon className="size-4" />
{t("conversation.relinkOrCreateCustomer")}
</Button> </Button>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<SectionHeading></SectionHeading> <SectionHeading>{t("conversation.conversationOwner")}</SectionHeading>
<div className="space-y-2"> <div className="space-y-2">
<DetailRow label="渠道ID" value={conversation.channelId ? `${conversation.channelId}` : "-"} /> <DetailRow label={t("conversation.channelId")} value={conversation.channelId ? `${conversation.channelId}` : "-"} />
<DetailRow label="客户ID" value={conversation.customerId ? `${conversation.customerId}` : "-"} /> <DetailRow label={t("conversation.customerId")} value={conversation.customerId ? `${conversation.customerId}` : "-"} />
</div> </div>
</div> </div>
<CustomerLinkOrCreateDialog <CustomerLinkOrCreateDialog
@@ -192,6 +209,7 @@ export function ConversationInfoPanel({
className, className,
variant = "default", variant = "default",
}: ConversationInfoPanelProps) { }: ConversationInfoPanelProps) {
const t = useI18n();
const embedded = variant === "embedded"; const embedded = variant === "embedded";
return ( return (
@@ -205,7 +223,9 @@ export function ConversationInfoPanel({
)} )}
> >
<div className="flex h-12.5 shrink-0 items-center border-b border-border/80 bg-card px-3"> <div className="flex h-12.5 shrink-0 items-center border-b border-border/80 bg-card px-3">
<h2 className="text-sm font-medium text-foreground"></h2> <h2 className="text-sm font-medium text-foreground">
{t("conversation.conversationInfo")}
</h2>
</div> </div>
<div <div
@@ -217,8 +237,8 @@ export function ConversationInfoPanel({
{!conversation ? ( {!conversation ? (
<p className="pt-4 text-sm text-muted-foreground"> <p className="pt-4 text-sm text-muted-foreground">
{embedded {embedded
? "请选择会话以查看会话信息" ? t("conversation.selectConversationForInfo")
: "请选择左侧会话以查看会话信息"} : t("conversation.selectSidebarConversationForInfo")}
</p> </p>
) : ( ) : (
<div className="space-y-4 py-3"> <div className="space-y-4 py-3">
@@ -235,6 +255,7 @@ function ConversationTagSection({
}: { }: {
conversation: AgentConversation; conversation: AgentConversation;
}) { }) {
const t = useI18n();
const setConversationTags = useAgentConversationsStore( const setConversationTags = useAgentConversationsStore(
(state) => state.setConversationTags, (state) => state.setConversationTags,
); );
@@ -253,7 +274,7 @@ function ConversationTagSection({
} }
} catch (error) { } catch (error) {
if (!cancelled) { if (!cancelled) {
toast.error(error instanceof Error ? error.message : "加载标签失败"); toast.error(error instanceof Error ? error.message : t("conversation.loadTagsFailed"));
} }
} finally { } finally {
if (!cancelled) { if (!cancelled) {
@@ -267,7 +288,7 @@ function ConversationTagSection({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, []); }, [t]);
return ( return (
<section className="space-y-2 border-t pt-2"> <section className="space-y-2 border-t pt-2">
@@ -283,14 +304,14 @@ function ConversationTagSection({
/> />
} }
> >
{t("conversation.conversationTags")}
</SectionHeading> </SectionHeading>
<ConversationTagBadges <ConversationTagBadges
tags={conversation.tags} tags={conversation.tags}
availableTags={availableTags} availableTags={availableTags}
/> />
{!conversation.tags || conversation.tags.length === 0 ? ( {!conversation.tags || conversation.tags.length === 0 ? (
<p className="text-sm text-muted-foreground"></p> <p className="text-sm text-muted-foreground">{t("conversation.noConversationTags")}</p>
) : null} ) : null}
</section> </section>
); );
@@ -317,6 +338,7 @@ type CustomerLinkedBodyProps = {
}; };
function CustomerLinkedBody({ conversation, customerId }: CustomerLinkedBodyProps) { function CustomerLinkedBody({ conversation, customerId }: CustomerLinkedBodyProps) {
const t = useI18n();
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [customer, setCustomer] = useState<AdminCustomer | null>(null); const [customer, setCustomer] = useState<AdminCustomer | null>(null);
const [contacts, setContacts] = useState<AdminCustomerContact[]>([]); const [contacts, setContacts] = useState<AdminCustomerContact[]>([]);
@@ -337,14 +359,14 @@ function CustomerLinkedBody({ conversation, customerId }: CustomerLinkedBodyProp
const list = await fetchCustomerContacts(customerId); const list = await fetchCustomerContacts(customerId);
setContacts(Array.isArray(list) ? list : []); setContacts(Array.isArray(list) ? list : []);
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : "加载客户信息失败"; const msg = e instanceof Error ? e.message : t("conversation.loadCustomerFailed");
toast.error(msg); toast.error(msg);
setCustomer(null); setCustomer(null);
setContacts([]); setContacts([]);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [customerId]); }, [customerId, t]);
useEffect(() => { useEffect(() => {
void load(); void load();
@@ -360,7 +382,7 @@ function CustomerLinkedBody({ conversation, customerId }: CustomerLinkedBodyProp
if (loading && !customer) { if (loading && !customer) {
return ( return (
<p className="pt-4 text-sm text-muted-foreground"></p> <p className="pt-4 text-sm text-muted-foreground">{t("conversation.loadingCustomer")}</p>
); );
} }
@@ -373,18 +395,20 @@ function CustomerLinkedBody({ conversation, customerId }: CustomerLinkedBodyProp
); );
} }
const displayName = customer.name.trim() || "未填写姓名"; const displayName = customer.name.trim() || t("conversation.unnamedCustomer");
const company = customer.company ?? null; const company = customer.company ?? null;
const genderLabel = const genderLabel =
customer.gender === Gender.Male || customer.gender === Gender.Female customer.gender === Gender.Male
? GenderLabels[customer.gender as Gender] ?? String(customer.gender) ? t("conversation.genderMale")
: customer.gender === Gender.Female
? t("conversation.genderFemale")
: null; : null;
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{isProfileEmpty ? ( {isProfileEmpty ? (
<div className="rounded-lg bg-amber-500/10 px-3 py-2.5 text-xs leading-relaxed text-amber-950 dark:text-amber-100"> <div className="rounded-lg bg-amber-500/10 px-3 py-2.5 text-xs leading-relaxed text-amber-950 dark:text-amber-100">
{t("conversation.customerProfileEmpty")}
</div> </div>
) : null} ) : null}
@@ -415,29 +439,29 @@ function CustomerLinkedBody({ conversation, customerId }: CustomerLinkedBodyProp
onClick={() => setCustomerEditOpen(true)} onClick={() => setCustomerEditOpen(true)}
> >
<PencilIcon className="size-3.5" /> <PencilIcon className="size-3.5" />
{t("conversation.edit")}
</Button> </Button>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<DetailRow <DetailRow
label="最近活跃" label={t("conversation.lastActive")}
value={ value={
customer.lastActiveAt ? formatDateTime(customer.lastActiveAt) : "" customer.lastActiveAt ? formatDateTime(customer.lastActiveAt) : ""
} }
/> />
<DetailRow <DetailRow
label="备注" label={t("conversation.remark")}
value={customer.remark.trim() ? customer.remark : ""} value={customer.remark.trim() ? customer.remark : ""}
valueClassName="whitespace-pre-wrap" valueClassName="whitespace-pre-wrap"
/> />
<DetailRow <DetailRow
label="创建时间" label={t("conversation.createdAt")}
value={formatDateTime(customer.createdAt)} value={formatDateTime(customer.createdAt)}
valueClassName="whitespace-pre-wrap" valueClassName="whitespace-pre-wrap"
/> />
<DetailRow <DetailRow
label="更新时间" label={t("conversation.updatedAt")}
value={formatDateTime(customer.updatedAt)} value={formatDateTime(customer.updatedAt)}
valueClassName="whitespace-pre-wrap" valueClassName="whitespace-pre-wrap"
/> />
@@ -446,16 +470,16 @@ function CustomerLinkedBody({ conversation, customerId }: CustomerLinkedBodyProp
<section className="space-y-2"> <section className="space-y-2">
{contacts.length === 0 ? ( {contacts.length === 0 ? (
<p className="text-sm text-muted-foreground"></p> <p className="text-sm text-muted-foreground">{t("conversation.noContacts")}</p>
) : ( ) : (
<ul className="space-y-3"> <ul className="space-y-3">
{contacts.map((row) => { {contacts.map((row) => {
const tags: string[] = []; const tags: string[] = [];
if (row.isPrimary) { if (row.isPrimary) {
tags.push("主"); tags.push(t("conversation.primaryContactBadge"));
} }
if (row.isVerified) { if (row.isVerified) {
tags.push("已验证"); tags.push(t("conversation.verifiedContactBadge"));
} }
return ( return (
<li key={row.id} className="text-sm"> <li key={row.id} className="text-sm">
@@ -465,7 +489,7 @@ function CustomerLinkedBody({ conversation, customerId }: CustomerLinkedBodyProp
<p className="break-all font-medium leading-snug text-foreground"> <p className="break-all font-medium leading-snug text-foreground">
{row.contactValue} {row.contactValue}
<span className="ml-2 text-xs font-normal text-muted-foreground"> <span className="ml-2 text-xs font-normal text-muted-foreground">
{contactTypeLabel(row.contactType)} {contactTypeLabel(row.contactType, t)}
</span> </span>
{tags.length > 0 ? ( {tags.length > 0 ? (
<span className="ml-2 text-xs text-muted-foreground"> <span className="ml-2 text-xs text-muted-foreground">
@@ -516,28 +540,28 @@ function CustomerLinkedBody({ conversation, customerId }: CustomerLinkedBodyProp
onClick={() => setCompanyEditOpen(true)} onClick={() => setCompanyEditOpen(true)}
> >
<PencilIcon className="size-3.5" /> <PencilIcon className="size-3.5" />
{t("conversation.edit")}
</Button> </Button>
</div> </div>
<div className="space-y-2 pt-1"> <div className="space-y-2 pt-1">
<DetailRow <DetailRow
label="创建" label={t("conversation.createdAt")}
value={formatDateTime(company.createdAt)} value={formatDateTime(company.createdAt)}
/> />
<DetailRow <DetailRow
label="更新" label={t("conversation.updatedAt")}
value={formatDateTime(company.updatedAt)} value={formatDateTime(company.updatedAt)}
/> />
</div> </div>
<DetailRow <DetailRow
label="备注" label={t("conversation.remark")}
value={company.remark.trim() ? company.remark : ""} value={company.remark.trim() ? company.remark : ""}
valueClassName="whitespace-pre-wrap" valueClassName="whitespace-pre-wrap"
/> />
</div> </div>
) : ( ) : (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{t("conversation.companyUnavailable")}
</p> </p>
)} )}
</section> </section>
@@ -559,11 +583,11 @@ function CustomerLinkedBody({ conversation, customerId }: CustomerLinkedBodyProp
setCustomerEditSaving(true); setCustomerEditSaving(true);
try { try {
await saveCustomerProfile({ ...payload, id: customer.id }); await saveCustomerProfile({ ...payload, id: customer.id });
toast.success("已保存"); toast.success(t("conversation.saved"));
void load(); void load();
setCustomerEditOpen(false); setCustomerEditOpen(false);
} catch (e) { } catch (e) {
toast.error(e instanceof Error ? e.message : "保存失败"); toast.error(e instanceof Error ? e.message : t("conversation.saveFailed"));
} finally { } finally {
setCustomerEditSaving(false); setCustomerEditSaving(false);
} }
@@ -584,6 +608,7 @@ function CustomerLinkedBody({ conversation, customerId }: CustomerLinkedBodyProp
} }
function RelatedTicketsSection({ conversation }: { conversation: AgentConversation }) { function RelatedTicketsSection({ conversation }: { conversation: AgentConversation }) {
const t = useI18n();
const [tickets, setTickets] = useState<TicketItem[]>([]); const [tickets, setTickets] = useState<TicketItem[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -602,7 +627,7 @@ function RelatedTicketsSection({ conversation }: { conversation: AgentConversati
} }
} catch (error) { } catch (error) {
if (!cancelled) { if (!cancelled) {
toast.error(error instanceof Error ? error.message : "加载关联工单失败"); toast.error(error instanceof Error ? error.message : t("conversation.loadTicketsFailed"));
} }
} finally { } finally {
if (!cancelled) { if (!cancelled) {
@@ -614,13 +639,13 @@ function RelatedTicketsSection({ conversation }: { conversation: AgentConversati
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [conversation.id]); }, [conversation.id, t]);
return ( return (
<section className="space-y-2 border-t pt-2"> <section className="space-y-2 border-t pt-2">
<SectionHeading></SectionHeading> <SectionHeading>{t("conversation.relatedTickets")}</SectionHeading>
{loading ? ( {loading ? (
<p className="text-sm text-muted-foreground"></p> <p className="text-sm text-muted-foreground">{t("conversation.loadingTickets")}</p>
) : tickets.length > 0 ? ( ) : tickets.length > 0 ? (
<div className="space-y-2"> <div className="space-y-2">
{tickets.map((ticket) => ( {tickets.map((ticket) => (
@@ -648,7 +673,7 @@ function RelatedTicketsSection({ conversation }: { conversation: AgentConversati
))} ))}
</div> </div>
) : ( ) : (
<p className="text-sm text-muted-foreground"></p> <p className="text-sm text-muted-foreground">{t("conversation.noRelatedTickets")}</p>
)} )}
</section> </section>
); );
@@ -667,6 +692,7 @@ function CompanyEditDialog({
company, company,
onSaved, onSaved,
}: CompanyEditDialogProps) { }: CompanyEditDialogProps) {
const t = useI18n();
const [name, setName] = useState(""); const [name, setName] = useState("");
const [code, setCode] = useState(""); const [code, setCode] = useState("");
const [remark, setRemark] = useState(""); const [remark, setRemark] = useState("");
@@ -684,7 +710,7 @@ function CompanyEditDialog({
const handleSubmit = async () => { const handleSubmit = async () => {
const trimmedName = name.trim(); const trimmedName = name.trim();
if (!trimmedName) { if (!trimmedName) {
toast.error("公司名称不能为空"); toast.error(t("conversation.companyNameRequired"));
return; return;
} }
setSaving(true); setSaving(true);
@@ -695,11 +721,11 @@ function CompanyEditDialog({
code: code.trim(), code: code.trim(),
remark: remark.trim(), remark: remark.trim(),
}); });
toast.success("已保存"); toast.success(t("conversation.saved"));
onSaved(); onSaved();
onOpenChange(false); onOpenChange(false);
} catch (e) { } catch (e) {
toast.error(e instanceof Error ? e.message : "保存失败"); toast.error(e instanceof Error ? e.message : t("conversation.saveFailed"));
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -709,23 +735,23 @@ function CompanyEditDialog({
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md" showCloseButton> <DialogContent className="sm:max-w-md" showCloseButton>
<DialogHeader> <DialogHeader>
<DialogTitle></DialogTitle> <DialogTitle>{t("conversation.editCompany")}</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="flex flex-col gap-4 py-1"> <div className="flex flex-col gap-4 py-1">
<Field orientation="vertical"> <Field orientation="vertical">
<FieldLabel htmlFor="co-name"></FieldLabel> <FieldLabel htmlFor="co-name">{t("conversation.companyName")}</FieldLabel>
<FieldContent> <FieldContent>
<Input id="co-name" value={name} onChange={(e) => setName(e.target.value)} /> <Input id="co-name" value={name} onChange={(e) => setName(e.target.value)} />
</FieldContent> </FieldContent>
</Field> </Field>
<Field orientation="vertical"> <Field orientation="vertical">
<FieldLabel htmlFor="co-code"></FieldLabel> <FieldLabel htmlFor="co-code">{t("conversation.companyCode")}</FieldLabel>
<FieldContent> <FieldContent>
<Input id="co-code" value={code} onChange={(e) => setCode(e.target.value)} /> <Input id="co-code" value={code} onChange={(e) => setCode(e.target.value)} />
</FieldContent> </FieldContent>
</Field> </Field>
<Field orientation="vertical"> <Field orientation="vertical">
<FieldLabel htmlFor="co-remark"></FieldLabel> <FieldLabel htmlFor="co-remark">{t("conversation.remark")}</FieldLabel>
<FieldContent> <FieldContent>
<Textarea <Textarea
id="co-remark" id="co-remark"
@@ -738,10 +764,10 @@ function CompanyEditDialog({
</div> </div>
<DialogFooter> <DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}> <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
{t("conversation.cancel")}
</Button> </Button>
<Button type="button" disabled={saving} onClick={() => void handleSubmit()}> <Button type="button" disabled={saving} onClick={() => void handleSubmit()}>
{saving ? "保存中…" : "保存"} {saving ? t("conversation.saving") : t("conversation.save")}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -4,13 +4,10 @@ import { UserIcon } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { getEnumLabel } from "@/lib/enums"; import { IMConversationStatus } from "@/lib/generated/enums";
import {
IMConversationStatus,
IMConversationStatusLabels,
} from "@/lib/generated/enums";
import { useAgentConversationsStore } from "@/lib/stores/agent-conversations"; import { useAgentConversationsStore } from "@/lib/stores/agent-conversations";
import { formatDateTime } from "@/lib/utils"; import { formatDateTime } from "@/lib/utils";
import { useI18n } from "@/i18n/provider";
function getStatusVariant(status: number) { function getStatusVariant(status: number) {
switch (status) { switch (status) {
@@ -32,6 +29,7 @@ type ConversationListProps = {
} }
export function ConversationList({ onAfterSelect }: ConversationListProps) { export function ConversationList({ onAfterSelect }: ConversationListProps) {
const t = useI18n()
const conversations = useAgentConversationsStore((state) => state.conversations) const conversations = useAgentConversationsStore((state) => state.conversations)
const loading = useAgentConversationsStore((state) => state.conversationsLoading) const loading = useAgentConversationsStore((state) => state.conversationsLoading)
const selectedId = useAgentConversationsStore((state) => state.selectedConversationId) const selectedId = useAgentConversationsStore((state) => state.selectedConversationId)
@@ -41,7 +39,7 @@ export function ConversationList({ onAfterSelect }: ConversationListProps) {
<ScrollArea className="overflow-auto"> <ScrollArea className="overflow-auto">
{loading ? ( {loading ? (
<div className="p-6 text-center text-sm text-muted-foreground"> <div className="p-6 text-center text-sm text-muted-foreground">
... {t("conversation.loading")}
</div> </div>
) : conversations.length > 0 ? ( ) : conversations.length > 0 ? (
conversations.map((conversation) => { conversations.map((conversation) => {
@@ -72,7 +70,10 @@ export function ConversationList({ onAfterSelect }: ConversationListProps) {
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="min-w-0 flex-1 truncate font-medium text-sm leading-4"> <span className="min-w-0 flex-1 truncate font-medium text-sm leading-4">
{conversation.customerName || `客户 #${conversation.customerId || conversation.id}`} {conversation.customerName ||
t("conversation.customerFallback", {
id: conversation.customerId || conversation.id,
})}
</span> </span>
{conversation.agentUnreadCount > 0 ? ( {conversation.agentUnreadCount > 0 ? (
<div className="flex size-4.5 shrink-0 items-center justify-center rounded-full bg-primary text-[10px] text-primary-foreground"> <div className="flex size-4.5 shrink-0 items-center justify-center rounded-full bg-primary text-[10px] text-primary-foreground">
@@ -85,12 +86,12 @@ export function ConversationList({ onAfterSelect }: ConversationListProps) {
<div className="mt-0.5 text-[11px] text-muted-foreground"> <div className="mt-0.5 text-[11px] text-muted-foreground">
{conversation.lastMessageAt {conversation.lastMessageAt
? formatDateTime(conversation.lastMessageAt) ? formatDateTime(conversation.lastMessageAt)
: "暂无时间"} : t("conversation.noTime")}
</div> </div>
</div> </div>
</div> </div>
<div className="mt-0.5 truncate text-xs leading-4 text-muted-foreground"> <div className="mt-0.5 truncate text-xs leading-4 text-muted-foreground">
{conversation.lastMessageSummary || "暂无最新消息"} {conversation.lastMessageSummary || t("conversation.noLatestMessage")}
</div> </div>
<div className="mt-1 flex items-center gap-1 text-[10px] text-muted-foreground"> <div className="mt-1 flex items-center gap-1 text-[10px] text-muted-foreground">
<span <span
@@ -98,12 +99,14 @@ export function ConversationList({ onAfterSelect }: ConversationListProps) {
conversation.status conversation.status
)}`} )}`}
> >
{getEnumLabel(IMConversationStatusLabels, conversation.status)} {getConversationStatusLabel(conversation.status, t)}
</span> </span>
{conversation.status === IMConversationStatus.Pending && {conversation.status === IMConversationStatus.Pending &&
conversation.currentTeamName ? ( conversation.currentTeamName ? (
<span className="rounded-md bg-muted px-1.5 py-0.5"> <span className="rounded-md bg-muted px-1.5 py-0.5">
{conversation.currentTeamName} {t("conversation.teamOnDuty", {
name: conversation.currentTeamName,
})}
</span> </span>
) : null} ) : null}
</div> </div>
@@ -113,9 +116,27 @@ export function ConversationList({ onAfterSelect }: ConversationListProps) {
}) })
) : ( ) : (
<div className="p-6 text-center text-sm text-muted-foreground"> <div className="p-6 text-center text-sm text-muted-foreground">
{t("conversation.empty")}
</div> </div>
)} )}
</ScrollArea> </ScrollArea>
) )
} }
function getConversationStatusLabel(
status: number,
t: (key: string, values?: Record<string, string | number>) => string
) {
switch (status) {
case IMConversationStatus.AIServing:
return t("conversation.filterAiServing")
case IMConversationStatus.Pending:
return t("conversation.filterPending")
case IMConversationStatus.Active:
return t("conversation.filterActive")
case IMConversationStatus.Closed:
return t("conversation.filterClosed")
default:
return "-"
}
}
@@ -26,6 +26,7 @@ import {
type AgentConversationTag, type AgentConversationTag,
} from "@/lib/api/agent" } from "@/lib/api/agent"
import { type TagTree } from "@/lib/api/admin" import { type TagTree } from "@/lib/api/admin"
import { useI18n } from "@/i18n/provider"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
type TagNode = TagTree & { type TagNode = TagTree & {
@@ -73,6 +74,7 @@ export function ConversationTagPicker({
loading = false, loading = false,
onTagsChange, onTagsChange,
}: ConversationTagPickerProps) { }: ConversationTagPickerProps) {
const t = useI18n()
const [pendingTagId, setPendingTagId] = useState<number | null>(null) const [pendingTagId, setPendingTagId] = useState<number | null>(null)
const flattenedTags = useMemo(() => flattenTagTree(availableTags), [availableTags]) const flattenedTags = useMemo(() => flattenTagTree(availableTags), [availableTags])
@@ -106,9 +108,9 @@ export function ConversationTagPicker({
}) })
} }
onTagsChange(nextTags) onTagsChange(nextTags)
toast.success(exists ? "已移除会话标签" : "已添加会话标签") toast.success(exists ? t("conversation.tagRemoved") : t("conversation.tagAdded"))
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "更新会话标签失败") toast.error(error instanceof Error ? error.message : t("conversation.tagUpdateFailed"))
} finally { } finally {
setPendingTagId(null) setPendingTagId(null)
} }
@@ -123,12 +125,12 @@ export function ConversationTagPicker({
variant="ghost" variant="ghost"
size="sm" size="sm"
className="h-7 shrink-0 gap-1 px-2 text-xs" className="h-7 shrink-0 gap-1 px-2 text-xs"
aria-label="编辑会话标签" aria-label={t("conversation.editTags")}
/> />
} }
> >
<TagIcon className="size-3.5 text-muted-foreground" /> <TagIcon className="size-3.5 text-muted-foreground" />
{t("conversation.edit")}
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent
align="end" align="end"
@@ -136,14 +138,14 @@ export function ConversationTagPicker({
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
> >
<Command> <Command>
<CommandInput placeholder="搜索标签" /> <CommandInput placeholder={t("conversation.searchTags")} />
<CommandList> <CommandList>
{loading ? <CommandEmpty>...</CommandEmpty> : null} {loading ? <CommandEmpty>{t("conversation.loadingTags")}</CommandEmpty> : null}
{!loading && flattenedTags.length === 0 ? ( {!loading && flattenedTags.length === 0 ? (
<CommandEmpty></CommandEmpty> <CommandEmpty>{t("conversation.emptyTags")}</CommandEmpty>
) : null} ) : null}
{!loading ? ( {!loading ? (
<CommandGroup heading="标签"> <CommandGroup heading={t("conversation.tagGroup")}>
{flattenedTags.map((tag) => { {flattenedTags.map((tag) => {
const checked = selectedTagIds.has(tag.id) const checked = selectedTagIds.has(tag.id)
const pending = pendingTagId === tag.id const pending = pendingTagId === tag.id
+39 -21
View File
@@ -36,6 +36,7 @@ import {
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Sheet, SheetContent } from "@/components/ui/sheet"; import { Sheet, SheetContent } from "@/components/ui/sheet";
import { useAgentConversationRealtime } from "@/hooks/use-agent-conversation-realtime"; import { useAgentConversationRealtime } from "@/hooks/use-agent-conversation-realtime";
import { useI18n } from "@/i18n/provider";
import { import {
agentConversationFilterOptions, agentConversationFilterOptions,
agentConversationSelectors, agentConversationSelectors,
@@ -61,6 +62,7 @@ function getCustomerOnlineDotClassName(online?: boolean) {
} }
export default function ConversationsPage() { export default function ConversationsPage() {
const t = useI18n();
const conversation = useAgentConversationsStore( const conversation = useAgentConversationsStore(
agentConversationSelectors.selectedConversation, agentConversationSelectors.selectedConversation,
); );
@@ -117,11 +119,13 @@ export default function ConversationsPage() {
const currentFilterOption = const currentFilterOption =
agentConversationFilterOptions.find((opt) => opt.value === conversationFilter) ?? agentConversationFilterOptions.find((opt) => opt.value === conversationFilter) ??
agentConversationFilterOptions[0]; agentConversationFilterOptions[0];
const getFilterLabel = (labelKey: string) => t(labelKey);
useEffect(() => { useEffect(() => {
void loadConversations().catch((error) => { void loadConversations().catch((error) => {
toast.error(error instanceof Error ? error.message : "加载会话列表失败"); toast.error(error instanceof Error ? error.message : t("conversation.loadListFailed"));
}); });
}, [loadConversations, conversationFilter]); }, [loadConversations, conversationFilter, t]);
async function handleConversationChanged(conversationId: number) { async function handleConversationChanged(conversationId: number) {
await loadConversations(); await loadConversations();
@@ -181,7 +185,11 @@ export default function ConversationsPage() {
/> />
} }
> >
<span className="truncate">{currentFilterOption?.label ?? "筛选状态"}</span> <span className="truncate">
{currentFilterOption
? getFilterLabel(currentFilterOption.labelKey)
: t("conversation.filterPlaceholder")}
</span>
<ChevronsUpDown className="size-4 shrink-0 text-muted-foreground" /> <ChevronsUpDown className="size-4 shrink-0 text-muted-foreground" />
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-44 min-w-44"> <DropdownMenuContent align="start" className="w-44 min-w-44">
@@ -193,7 +201,7 @@ export default function ConversationsPage() {
> >
{agentConversationFilterOptions.map((opt) => ( {agentConversationFilterOptions.map((opt) => (
<DropdownMenuRadioItem key={opt.value} value={opt.value}> <DropdownMenuRadioItem key={opt.value} value={opt.value}>
{opt.label} {getFilterLabel(opt.labelKey)}
</DropdownMenuRadioItem> </DropdownMenuRadioItem>
))} ))}
</DropdownMenuRadioGroup> </DropdownMenuRadioGroup>
@@ -216,7 +224,7 @@ export default function ConversationsPage() {
value={opt.value} value={opt.value}
className="shrink-0 px-2.5 text-xs sm:text-sm" className="shrink-0 px-2.5 text-xs sm:text-sm"
> >
{opt.label} {getFilterLabel(opt.labelKey)}
</TabsTrigger> </TabsTrigger>
))} ))}
</TabsList> </TabsList>
@@ -233,7 +241,7 @@ export default function ConversationsPage() {
key={opt.value} key={opt.value}
className="shrink-0 px-2.5 text-xs sm:text-sm" className="shrink-0 px-2.5 text-xs sm:text-sm"
> >
{opt.label} {getFilterLabel(opt.labelKey)}
</span> </span>
))} ))}
</div> </div>
@@ -281,13 +289,16 @@ export default function ConversationsPage() {
<Avatar className="size-8 shrink-0 lg:size-9"> <Avatar className="size-8 shrink-0 lg:size-9">
<AvatarImage src="" /> <AvatarImage src="" />
<AvatarFallback className="bg-primary/10 text-sm text-primary"> <AvatarFallback className="bg-primary/10 text-sm text-primary">
{t("conversation.customerAvatar")}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<p className="min-w-0 truncate text-sm font-medium leading-tight"> <p className="min-w-0 truncate text-sm font-medium leading-tight">
{conversation.customerName || `客户 #${conversation.customerId || conversation.id}`} {conversation.customerName ||
t("conversation.customerFallback", {
id: conversation.customerId || conversation.id,
})}
</p> </p>
<span <span
className={`inline-flex shrink-0 items-center gap-1 rounded-md border px-1.5 py-0.5 text-[11px] leading-none ${getCustomerOnlineClassName( className={`inline-flex shrink-0 items-center gap-1 rounded-md border px-1.5 py-0.5 text-[11px] leading-none ${getCustomerOnlineClassName(
@@ -299,15 +310,17 @@ export default function ConversationsPage() {
conversation.customerOnline, conversation.customerOnline,
)}`} )}`}
/> />
{conversation.customerOnline ? "用户在线" : "用户离线"} {conversation.customerOnline
? t("conversation.customerOnline")
: t("conversation.customerOffline")}
</span> </span>
</div> </div>
<p className="mt-0.5 truncate text-xs text-muted-foreground"> <p className="mt-0.5 truncate text-xs text-muted-foreground">
<span> #{conversation.channelId || "-"}</span> <span>{t("conversation.channelNumber", { id: conversation.channelId || "-" })}</span>
{conversation.customerId ? ( {conversation.customerId ? (
<> <>
<span className="text-muted-foreground/60"> / </span> <span className="text-muted-foreground/60"> / </span>
<span></span> <span>{t("conversation.linkedCustomer")}</span>
</> </>
) : null} ) : null}
</p> </p>
@@ -315,12 +328,14 @@ export default function ConversationsPage() {
</> </>
) : ( ) : (
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate font-medium text-[14px] leading-tight"></p> <p className="truncate font-medium text-[14px] leading-tight">
{t("conversation.workbenchTitle")}
</p>
<p className="mt-0.5 truncate text-[14px] text-muted-foreground sm:text-[14px] lg:hidden"> <p className="mt-0.5 truncate text-[14px] text-muted-foreground sm:text-[14px] lg:hidden">
{t("conversation.openMenuSelectConversation")}
</p> </p>
<p className="mt-0.5 hidden truncate text-[12px] text-muted-foreground lg:block"> <p className="mt-0.5 hidden truncate text-[12px] text-muted-foreground lg:block">
{t("conversation.selectConversationFromSidebar")}
</p> </p>
</div> </div>
)} )}
@@ -331,7 +346,7 @@ export default function ConversationsPage() {
size="icon" size="icon"
className={`${workbenchIconButtonClassName} lg:hidden`} className={`${workbenchIconButtonClassName} lg:hidden`}
disabled={!conversation} disabled={!conversation}
aria-label="会话信息" aria-label={t("conversation.conversationInfo")}
onClick={() => setMobileCustomerSheetOpen(true)} onClick={() => setMobileCustomerSheetOpen(true)}
> >
<CircleUserRoundIcon className="size-4" /> <CircleUserRoundIcon className="size-4" />
@@ -355,21 +370,21 @@ export default function ConversationsPage() {
disabled={!conversation} disabled={!conversation}
> >
<FilePlus2Icon /> <FilePlus2Icon />
{t("conversation.createTicket")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={() => setTransferOpen(true)} onClick={() => setTransferOpen(true)}
disabled={!conversation || conversation.status !== 3} disabled={!conversation || conversation.status !== 3}
> >
<ArrowRightLeftIcon /> <ArrowRightLeftIcon />
{t("conversation.transferConversation")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={() => setCloseOpen(true)} onClick={() => setCloseOpen(true)}
disabled={!conversation || conversation.status === 4} disabled={!conversation || conversation.status === 4}
> >
<CircleXIcon /> <CircleXIcon />
{t("conversation.closeConversation")}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@@ -378,7 +393,11 @@ export default function ConversationsPage() {
size="icon" size="icon"
className={`${workbenchIconButtonClassName} hidden lg:flex`} className={`${workbenchIconButtonClassName} hidden lg:flex`}
onClick={handleInfoPanelToggle} onClick={handleInfoPanelToggle}
aria-label={infoPanelCollapsed ? "展开会话信息" : "收起会话信息"} aria-label={
infoPanelCollapsed
? t("conversation.expandConversationInfo")
: t("conversation.collapseConversationInfo")
}
> >
{infoPanelCollapsed ? ( {infoPanelCollapsed ? (
<ChevronLeft className="size-4" /> <ChevronLeft className="size-4" />
@@ -396,11 +415,10 @@ export default function ConversationsPage() {
return ( return (
<div className="flex h-[calc(100dvh-var(--header-height))] min-h-0 w-full min-w-0 flex-col overflow-hidden lg:h-full"> <div className="flex h-[calc(100dvh-var(--header-height))] min-h-0 w-full min-w-0 flex-col overflow-hidden lg:h-full">
{/* H5 无左侧导航:顶栏 h-12、left-0lg 起有 w-14 侧栏,与 layout 一致 */}
{mobileMenuOpen && ( {mobileMenuOpen && (
<button <button
type="button" type="button"
aria-label="关闭会话列表" aria-label={t("conversation.closeConversationList")}
className="fixed top-12 right-0 bottom-0 left-0 z-30 bg-black/50 lg:hidden" className="fixed top-12 right-0 bottom-0 left-0 z-30 bg-black/50 lg:hidden"
onClick={() => setMobileMenuOpen(false)} onClick={() => setMobileMenuOpen(false)}
/> />
+69 -100
View File
@@ -33,13 +33,6 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { import {
Table, Table,
TableBody, TableBody,
@@ -57,41 +50,20 @@ import {
updateCustomerStatus, updateCustomerStatus,
type AdminCustomer, type AdminCustomer,
} from "@/lib/api/customer"; } from "@/lib/api/customer";
import { getEnumLabel, getEnumOptions } from "@/lib/enums"; import { Gender, Status } from "@/lib/generated/enums";
import { import { useI18n } from "@/i18n/provider";
Gender,
GenderLabels,
Status,
StatusLabels,
} from "@/lib/generated/enums";
import { EditDialog } from "./_components/edit"; import { EditDialog } from "./_components/edit";
const listStatusOptions = [
{ value: "all", label: "全部状态" },
...getEnumOptions(StatusLabels)
.filter((item) => Number(item.value) !== Status.Deleted)
.map((item) => ({
value: String(item.value),
label: item.label,
})),
] as const;
const genderOptions = [
{ value: "all", label: "全部性别" },
...getEnumOptions(GenderLabels).map((item) => ({
value: String(item.value),
label: item.label,
})),
] as const;
function getLabel( function getLabel(
value: string, value: string,
options: ReadonlyArray<{ value: string; label: string }>, options: ReadonlyArray<{ value: string; label: string }>,
fallback: string,
) { ) {
return options.find((item) => item.value === value)?.label ?? "请选择"; return options.find((item) => item.value === value)?.label ?? fallback;
} }
export default function DashboardCustomersPage() { export default function DashboardCustomersPage() {
const t = useI18n();
const [keywordInput, setKeywordInput] = useState(""); const [keywordInput, setKeywordInput] = useState("");
const [statusFilterInput, setStatusFilterInput] = useState("all"); const [statusFilterInput, setStatusFilterInput] = useState("all");
const [genderFilterInput, setGenderFilterInput] = useState("all"); const [genderFilterInput, setGenderFilterInput] = useState("all");
@@ -103,7 +75,7 @@ export default function DashboardCustomersPage() {
const [companyFilter, setCompanyFilter] = useState("0"); const [companyFilter, setCompanyFilter] = useState("0");
const [companyOptions, setCompanyOptions] = useState<ComboboxOption[]>([ const [companyOptions, setCompanyOptions] = useState<ComboboxOption[]>([
{ value: "0", label: "全部公司" }, { value: "0", label: t("customer.allCompanies") },
]); ]);
const [companyNameMap, setCompanyNameMap] = useState<Record<number, string>>( const [companyNameMap, setCompanyNameMap] = useState<Record<number, string>>(
{}, {},
@@ -120,13 +92,30 @@ export default function DashboardCustomersPage() {
results: [], results: [],
page: { page: 1, limit: 20, total: 0 }, page: { page: 1, limit: 20, total: 0 },
}); });
const listStatusOptions = useMemo(
() => [
{ value: "all", label: t("status.all") },
{ value: String(Status.Ok), label: t("status.ok") },
{ value: String(Status.Disabled), label: t("status.disabled") },
],
[t],
);
const genderOptions = useMemo(
() => [
{ value: "all", label: t("customer.allGenders") },
{ value: String(Gender.Unknown), label: t("customerForm.genderUnknown") },
{ value: String(Gender.Male), label: t("customerForm.genderMale") },
{ value: String(Gender.Female), label: t("customerForm.genderFemale") },
],
[t],
);
useEffect(() => { useEffect(() => {
async function loadCompanies() { async function loadCompanies() {
try { try {
const data = await fetchCompanies({ status: 0, page: 1, limit: 500 }); const data = await fetchCompanies({ status: 0, page: 1, limit: 500 });
const opts: ComboboxOption[] = [ const opts: ComboboxOption[] = [
{ value: "0", label: "全部公司" }, { value: "0", label: t("customer.allCompanies") },
...data.results.map((item) => ({ ...data.results.map((item) => ({
value: String(item.id), value: String(item.id),
label: item.name, label: item.name,
@@ -143,7 +132,7 @@ export default function DashboardCustomersPage() {
} }
} }
void loadCompanies(); void loadCompanies();
}, []); }, [t]);
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -158,11 +147,11 @@ export default function DashboardCustomersPage() {
}); });
setResult(data); setResult(data);
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "加载客户列表失败"); toast.error(error instanceof Error ? error.message : t("customer.loadFailed"));
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [companyFilter, genderFilter, keyword, limit, page, statusFilter]); }, [companyFilter, genderFilter, keyword, limit, page, statusFilter, t]);
useEffect(() => { useEffect(() => {
void loadData(); void loadData();
@@ -171,9 +160,9 @@ export default function DashboardCustomersPage() {
const companyFilterLabel = useMemo(() => { const companyFilterLabel = useMemo(() => {
return ( return (
companyOptions.find((item) => item.value === companyFilterInput)?.label ?? companyOptions.find((item) => item.value === companyFilterInput)?.label ??
"全部公司" t("customer.allCompanies")
); );
}, [companyFilterInput, companyOptions]); }, [companyFilterInput, companyOptions, t]);
function applyFilters() { function applyFilters() {
setKeyword(keywordInput); setKeyword(keywordInput);
@@ -217,14 +206,14 @@ export default function DashboardCustomersPage() {
await saveCustomerProfile(payload); await saveCustomerProfile(payload);
toast.success( toast.success(
editingItem editingItem
? `已更新客户:${editingItem.name}` ? t("customer.updated", { name: editingItem.name })
: `已创建客户:${payload.name}`, : t("customer.created", { name: payload.name }),
); );
setDialogOpen(false); setDialogOpen(false);
setEditingItem(null); setEditingItem(null);
await loadData(); await loadData();
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "保存客户失败"); toast.error(error instanceof Error ? error.message : t("customer.saveFailed"));
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -235,10 +224,10 @@ export default function DashboardCustomersPage() {
try { try {
const nextStatus = item.status === 0 ? 1 : 0; const nextStatus = item.status === 0 ? 1 : 0;
await updateCustomerStatus(item.id, nextStatus); await updateCustomerStatus(item.id, nextStatus);
toast.success(`${nextStatus === 0 ? "启用" : "禁用"}${item.name}`); toast.success(t(nextStatus === 0 ? "customer.enabled" : "customer.disabled", { name: item.name }));
await loadData(); await loadData();
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "更新状态失败"); toast.error(error instanceof Error ? error.message : t("customer.statusUpdateFailed"));
} finally { } finally {
setActionLoadingId(null); setActionLoadingId(null);
} }
@@ -248,17 +237,19 @@ export default function DashboardCustomersPage() {
setActionLoadingId(item.id); setActionLoadingId(item.id);
try { try {
await deleteCustomer(item.id); await deleteCustomer(item.id);
toast.success(`已删除客户:${item.name}`); toast.success(t("customer.deleted", { name: item.name }));
await loadData(); await loadData();
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "删除客户失败"); toast.error(error instanceof Error ? error.message : t("customer.deleteFailed"));
} finally { } finally {
setActionLoadingId(null); setActionLoadingId(null);
} }
} }
function getGenderText(gender: number) { function getGenderText(gender: number) {
return getEnumLabel(GenderLabels, gender as Gender); if (gender === Gender.Male) return t("customerForm.genderMale");
if (gender === Gender.Female) return t("customerForm.genderFemale");
return t("customerForm.genderUnknown");
} }
return ( return (
@@ -268,7 +259,7 @@ export default function DashboardCustomersPage() {
actions={ actions={
<Button onClick={openCreateDialog}> <Button onClick={openCreateDialog}>
<PlusIcon /> <PlusIcon />
{t("customer.new")}
</Button> </Button>
} }
> >
@@ -278,60 +269,38 @@ export default function DashboardCustomersPage() {
value={keywordInput} value={keywordInput}
onChange={(event) => setKeywordInput(event.target.value)} onChange={(event) => setKeywordInput(event.target.value)}
onKeyDown={handleFilterKeyDown} onKeyDown={handleFilterKeyDown}
placeholder="姓名、手机、邮箱、公司、联系方式" placeholder={t("customer.keywordPlaceholder")}
className="pl-9" className="pl-9"
/> />
</div> </div>
<Select <OptionCombobox
value={genderFilterInput} value={genderFilterInput}
onValueChange={(v) => setGenderFilterInput(v ?? "all")} options={genderOptions}
> placeholder={getLabel(genderFilterInput, genderOptions, t("customer.select"))}
<SelectTrigger className="w-full xl:w-28"> onChange={(v) => setGenderFilterInput(v)}
<SelectValue> />
{getLabel(genderFilterInput, genderOptions)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{genderOptions.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="w-full xl:w-56"> <div className="w-full xl:w-56">
<OptionCombobox <OptionCombobox
value={companyFilterInput} value={companyFilterInput}
options={companyOptions} options={companyOptions}
placeholder={companyFilterLabel} placeholder={companyFilterLabel}
searchPlaceholder="搜索公司名称" searchPlaceholder={t("customer.searchCompany")}
onChange={(v) => setCompanyFilterInput(v)} onChange={(v) => setCompanyFilterInput(v)}
/> />
</div> </div>
<Select <OptionCombobox
value={statusFilterInput} value={statusFilterInput}
onValueChange={(v) => setStatusFilterInput(v ?? "all")} options={listStatusOptions}
> placeholder={getLabel(statusFilterInput, listStatusOptions, t("customer.select"))}
<SelectTrigger className="w-full xl:w-28"> onChange={(v) => setStatusFilterInput(v)}
<SelectValue> />
{getLabel(statusFilterInput, listStatusOptions)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{listStatusOptions.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button variant="outline" onClick={applyFilters} disabled={loading}> <Button variant="outline" onClick={applyFilters} disabled={loading}>
<SearchIcon /> <SearchIcon />
{t("customer.query")}
</Button> </Button>
</DashboardToolbar> </DashboardToolbar>
@@ -354,13 +323,13 @@ export default function DashboardCustomersPage() {
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="w-20">ID</TableHead> <TableHead className="w-20">ID</TableHead>
<TableHead></TableHead> <TableHead>{t("customer.columnName")}</TableHead>
<TableHead className="w-20"></TableHead> <TableHead className="w-20">{t("customer.columnGender")}</TableHead>
<TableHead></TableHead> <TableHead>{t("customer.columnCompany")}</TableHead>
<TableHead></TableHead> <TableHead>{t("customer.columnMobile")}</TableHead>
<TableHead></TableHead> <TableHead>{t("customer.columnEmail")}</TableHead>
<TableHead className="w-24"></TableHead> <TableHead className="w-24">{t("customer.columnStatus")}</TableHead>
<TableHead className="w-40"></TableHead> <TableHead className="w-40">{t("customer.columnActions")}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -368,8 +337,8 @@ export default function DashboardCustomersPage() {
<DashboardTableStateRow <DashboardTableStateRow
colSpan={8} colSpan={8}
loading={loading} loading={loading}
loadingText="正在加载客户数据..." loadingText={t("customer.loading")}
emptyText="暂无客户数据" emptyText={t("customer.empty")}
/> />
) : ( ) : (
result.results.map((item) => { result.results.map((item) => {
@@ -397,7 +366,7 @@ export default function DashboardCustomersPage() {
<Badge <Badge
variant={item.status === 0 ? "default" : "secondary"} variant={item.status === 0 ? "default" : "secondary"}
> >
{item.status === 0 ? "启用" : "禁用"} {item.status === 0 ? t("status.ok") : t("status.disabled")}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell> <TableCell>
@@ -407,7 +376,7 @@ export default function DashboardCustomersPage() {
size="sm" size="sm"
onClick={() => openEditDialog(item)} onClick={() => openEditDialog(item)}
> >
{t("customer.edit")}
</Button> </Button>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
@@ -418,7 +387,7 @@ export default function DashboardCustomersPage() {
disabled={actionLoading} disabled={actionLoading}
/> />
} }
aria-label={`更多操作 ${item.name}`} aria-label={t("customer.moreActions", { name: item.name })}
> >
<MoreHorizontalIcon /> <MoreHorizontalIcon />
</DropdownMenuTrigger> </DropdownMenuTrigger>
@@ -431,16 +400,16 @@ export default function DashboardCustomersPage() {
onClick={() => void handleToggleStatus(item)} onClick={() => void handleToggleStatus(item)}
> >
{actionLoadingId === item.id ? ( {actionLoadingId === item.id ? (
"处理中..." t("customer.processing")
) : item.status === 0 ? ( ) : item.status === 0 ? (
<> <>
<BanIcon /> <BanIcon />
{t("customer.disable")}
</> </>
) : ( ) : (
<> <>
<CheckCircle2Icon /> <CheckCircle2Icon />
{t("customer.enable")}
</> </>
)} )}
</DropdownMenuItem> </DropdownMenuItem>
@@ -450,7 +419,7 @@ export default function DashboardCustomersPage() {
onClick={() => void handleDelete(item)} onClick={() => void handleDelete(item)}
> >
<Trash2Icon /> <Trash2Icon />
{t("customer.delete")}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
+10 -5
View File
@@ -1,15 +1,20 @@
"use client"
import { DashboardPlaceholder } from "@/components/dashboard-placeholder" import { DashboardPlaceholder } from "@/components/dashboard-placeholder"
import { useI18n } from "@/i18n/provider"
export default function DashboardHelpPage() { export default function DashboardHelpPage() {
const t = useI18n()
return ( return (
<DashboardPlaceholder <DashboardPlaceholder
eyebrow="Help" eyebrow="Help"
title="帮助中心骨架" title={t("help.title")}
description="帮助中心用于沉淀产品说明、权限约定、渠道接入流程与常见问题。" description={t("help.description")}
nextSteps={[ nextSteps={[
"汇总项目开发规范与后台使用说明。", t("help.step1"),
"补充第三方接入流程图和配置校验清单。", t("help.step2"),
"后续可接入 markdown 或文档中心能力。", t("help.step3"),
]} ]}
/> />
) )
@@ -17,12 +17,15 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { useI18n } from "@/i18n/provider";
import { getKnowledgeAnswerStatusLabel } from "@/lib/knowledge-i18n";
type DebugPanelProps = { type DebugPanelProps = {
knowledgeBaseId: number | null; knowledgeBaseId: number | null;
}; };
export function DebugPanel({ knowledgeBaseId }: DebugPanelProps) { export function DebugPanel({ knowledgeBaseId }: DebugPanelProps) {
const t = useI18n();
const [question, setQuestion] = useState(""); const [question, setQuestion] = useState("");
const [topK, setTopK] = useState("5"); const [topK, setTopK] = useState("5");
const [scoreThreshold, setScoreThreshold] = useState("0.2"); const [scoreThreshold, setScoreThreshold] = useState("0.2");
@@ -34,11 +37,11 @@ export function DebugPanel({ knowledgeBaseId }: DebugPanelProps) {
async function handleSearch() { async function handleSearch() {
if (!knowledgeBaseId) { if (!knowledgeBaseId) {
toast.error("请先选择知识库"); toast.error(t("knowledge.selectKnowledgeBaseFirst"));
return; return;
} }
if (!question.trim()) { if (!question.trim()) {
toast.error("请输入调试问题"); toast.error(t("knowledge.debugQuestionRequired"));
return; return;
} }
@@ -52,9 +55,9 @@ export function DebugPanel({ knowledgeBaseId }: DebugPanelProps) {
rerankLimit: Number(rerankLimit) || undefined, rerankLimit: Number(rerankLimit) || undefined,
}); });
setSearchResult(data); setSearchResult(data);
toast.success(`检索完成,命中 ${data.hitCount}`); toast.success(t("knowledge.searchCompleted", { count: data.hitCount }));
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "检索失败"); toast.error(error instanceof Error ? error.message : t("knowledge.searchFailed"));
} finally { } finally {
setSearching(false); setSearching(false);
} }
@@ -62,11 +65,11 @@ export function DebugPanel({ knowledgeBaseId }: DebugPanelProps) {
async function handleAnswer() { async function handleAnswer() {
if (!knowledgeBaseId) { if (!knowledgeBaseId) {
toast.error("请先选择知识库"); toast.error(t("knowledge.selectKnowledgeBaseFirst"));
return; return;
} }
if (!question.trim()) { if (!question.trim()) {
toast.error("请输入调试问题"); toast.error(t("knowledge.debugQuestionRequired"));
return; return;
} }
@@ -80,9 +83,11 @@ export function DebugPanel({ knowledgeBaseId }: DebugPanelProps) {
rerankLimit: Number(rerankLimit) || undefined, rerankLimit: Number(rerankLimit) || undefined,
}); });
setAnswerResult(data); setAnswerResult(data);
toast.success(`问答完成,状态:${data.answerStatusName}`); toast.success(t("knowledge.answerCompleted", {
status: getKnowledgeAnswerStatusLabel(data.answerStatus, data.answerStatusName, t),
}));
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "问答失败"); toast.error(error instanceof Error ? error.message : t("knowledge.answerFailed"));
} finally { } finally {
setAnswering(false); setAnswering(false);
} }
@@ -94,32 +99,32 @@ export function DebugPanel({ knowledgeBaseId }: DebugPanelProps) {
<Textarea <Textarea
value={question} value={question}
onChange={(event) => setQuestion(event.target.value)} onChange={(event) => setQuestion(event.target.value)}
placeholder="输入问题,测试知识库召回和回答效果" placeholder={t("knowledge.debugQuestionPlaceholder")}
rows={5} rows={5}
className="text-sm" className="text-sm"
/> />
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="topk" className="text-xs">TopK</Label> <Label htmlFor="topk" className="text-xs">TopK</Label>
<Input id="topk" value={topK} onChange={(event) => setTopK(event.target.value)} placeholder="召回数量" className="h-8" /> <Input id="topk" value={topK} onChange={(event) => setTopK(event.target.value)} placeholder={t("knowledge.topKPlaceholder")} className="h-8" />
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="threshold" className="text-xs"></Label> <Label htmlFor="threshold" className="text-xs">{t("knowledge.scoreThreshold")}</Label>
<Input id="threshold" value={scoreThreshold} onChange={(event) => setScoreThreshold(event.target.value)} placeholder="最低分数" className="h-8" /> <Input id="threshold" value={scoreThreshold} onChange={(event) => setScoreThreshold(event.target.value)} placeholder={t("knowledge.scoreThresholdPlaceholder")} className="h-8" />
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="rerank" className="text-xs"></Label> <Label htmlFor="rerank" className="text-xs">{t("knowledge.rerankLimitLabel")}</Label>
<Input id="rerank" value={rerankLimit} onChange={(event) => setRerankLimit(event.target.value)} placeholder="重排条数" className="h-8" /> <Input id="rerank" value={rerankLimit} onChange={(event) => setRerankLimit(event.target.value)} placeholder={t("knowledge.rerankLimitPlaceholder")} className="h-8" />
</div> </div>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button className="flex-1" variant="outline" onClick={() => void handleSearch()} disabled={searching}> <Button className="flex-1" variant="outline" onClick={() => void handleSearch()} disabled={searching}>
<SearchIcon className="mr-2 size-4" /> <SearchIcon className="mr-2 size-4" />
{searching ? "检索中..." : "调试检索"} {searching ? t("knowledge.searching") : t("knowledge.debugSearch")}
</Button> </Button>
<Button className="flex-1" onClick={() => void handleAnswer()} disabled={answering}> <Button className="flex-1" onClick={() => void handleAnswer()} disabled={answering}>
<SparklesIcon className="mr-2 size-4" /> <SparklesIcon className="mr-2 size-4" />
{answering ? "生成中..." : "调试问答"} {answering ? t("knowledge.generating") : t("knowledge.debugAnswer")}
</Button> </Button>
</div> </div>
</div> </div>
@@ -131,12 +136,14 @@ export function DebugPanel({ knowledgeBaseId }: DebugPanelProps) {
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm"> <CardTitle className="flex items-center gap-2 text-sm">
<BotIcon className="size-4" /> <BotIcon className="size-4" />
{t("knowledge.answerResult")}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-3 text-sm"> <CardContent className="space-y-3 text-sm">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary">{answerResult.answerStatusName}</Badge> <Badge variant="secondary">
{getKnowledgeAnswerStatusLabel(answerResult.answerStatus, answerResult.answerStatusName, t)}
</Badge>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{answerResult.latencyMs}ms · {answerResult.modelName || "fallback"} {answerResult.latencyMs}ms · {answerResult.modelName || "fallback"}
</span> </span>
@@ -146,7 +153,7 @@ export function DebugPanel({ knowledgeBaseId }: DebugPanelProps) {
</div> </div>
{answerResult.citations.length > 0 ? ( {answerResult.citations.length > 0 ? (
<div className="space-y-2"> <div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground"></div> <div className="text-xs font-medium text-muted-foreground">{t("knowledge.citationSources")}</div>
{answerResult.citations.map((citation) => ( {answerResult.citations.map((citation) => (
<div <div
key={`${citation.documentId}-${citation.chunkNo}-${citation.sectionPath}`} key={`${citation.documentId}-${citation.chunkNo}-${citation.sectionPath}`}
@@ -175,11 +182,11 @@ export function DebugPanel({ knowledgeBaseId }: DebugPanelProps) {
{searchResult ? ( {searchResult ? (
<Card> <Card>
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="text-sm"></CardTitle> <CardTitle className="text-sm">{t("knowledge.searchHits")}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
{searchResult.hitCount} · {searchResult.latencyMs}ms {t("knowledge.hitsSummary", { count: searchResult.hitCount, latency: searchResult.latencyMs })}
</div> </div>
{searchResult.results.map((item) => ( {searchResult.results.map((item) => (
<div key={`${item.chunkId}-${item.documentId}`} className="rounded-md border bg-background p-3"> <div key={`${item.chunkId}-${item.documentId}`} className="rounded-md border bg-background p-3">
@@ -221,5 +228,5 @@ function getSearchResultLabel(item: {
if (item.faqId && item.faqId > 0) { if (item.faqId && item.faqId > 0) {
return `FAQ ${item.faqId}` return `FAQ ${item.faqId}`
} }
return `文档 ${item.documentId ?? 0}` return `Document ${item.documentId ?? 0}`
} }
@@ -1,8 +1,8 @@
"use client" "use client"
import { useEffect, useState } from "react" import { useEffect, useMemo, useState } from "react"
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod"
import { Controller, Resolver, useForm } from "react-hook-form" import { Controller, type Resolver, useForm } from "react-hook-form"
import { z } from "zod/v4" import { z } from "zod/v4"
import { ProjectDialog } from "@/components/project-dialog" import { ProjectDialog } from "@/components/project-dialog"
@@ -23,6 +23,7 @@ import {
import { import {
KnowledgeDocumentContentType, KnowledgeDocumentContentType,
} from "@/lib/generated/enums" } from "@/lib/generated/enums"
import { useI18n } from "@/i18n/provider"
type DocumentEditDialogProps = { type DocumentEditDialogProps = {
open: boolean open: boolean
@@ -39,18 +40,21 @@ const emptyForm: EditForm = {
content: "", content: "",
} }
const knowledgeDocumentFormSchema = z.object({ type TFunction = (key: string, values?: Record<string, string | number>) => string
title: z.string().trim().min(1, "标题不能为空").max(255, "标题最多255个字符"),
contentType: z.string().trim().min(1, "请选择内容类型"),
content: z.string().trim().min(1, "内容不能为空"),
})
type EditForm = z.infer<typeof knowledgeDocumentFormSchema> function createKnowledgeDocumentFormSchema(t: TFunction) {
const editFormResolver = zodResolver(knowledgeDocumentFormSchema as never) as Resolver< return z.object({
z.input<typeof knowledgeDocumentFormSchema>, title: z.string().trim().min(1, t("knowledge.documentTitleRequired")).max(255, t("knowledge.documentTitleMax")),
undefined, contentType: z.string().trim().min(1, t("knowledge.contentTypeRequired")),
z.output<typeof knowledgeDocumentFormSchema> content: z.string().trim().min(1, t("knowledge.contentRequired")),
> })
}
type EditForm = {
title: string
contentType: string
content: string
}
function buildForm(item: KnowledgeDocument | null): EditForm { function buildForm(item: KnowledgeDocument | null): EditForm {
if (!item) { if (!item) {
@@ -112,13 +116,15 @@ function DocumentFormDialogBody({
onOpenChange, onOpenChange,
onSubmit, onSubmit,
}: DocumentFormDialogBodyProps) { }: DocumentFormDialogBodyProps) {
const t = useI18n()
const formId = "knowledge-document-edit-form" const formId = "knowledge-document-edit-form"
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const form = useForm< const knowledgeDocumentFormSchema = useMemo(() => createKnowledgeDocumentFormSchema(t), [t])
z.input<typeof knowledgeDocumentFormSchema>, const editFormResolver = useMemo(
undefined, () => zodResolver(knowledgeDocumentFormSchema) as Resolver<EditForm>,
z.output<typeof knowledgeDocumentFormSchema> [knowledgeDocumentFormSchema],
>({ )
const form = useForm<EditForm>({
resolver: editFormResolver, resolver: editFormResolver,
defaultValues: emptyForm, defaultValues: emptyForm,
}) })
@@ -163,7 +169,7 @@ function DocumentFormDialogBody({
<ProjectDialog <ProjectDialog
open={true} open={true}
onOpenChange={onOpenChange} onOpenChange={onOpenChange}
title={itemId ? "编辑文档" : "新建文档"} title={itemId ? t("knowledge.editDocumentTitle") : t("knowledge.createDocumentTitle")}
size="xl" size="xl"
allowFullscreen allowFullscreen
footer={ footer={
@@ -174,26 +180,26 @@ function DocumentFormDialogBody({
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
disabled={saving} disabled={saving}
> >
{t("knowledge.cancel")}
</Button> </Button>
<Button type="submit" form={formId} disabled={saving || loading}> <Button type="submit" form={formId} disabled={saving || loading}>
{saving ? "保存中..." : itemId ? "保存" : "创建"} {saving ? t("knowledge.saving") : itemId ? t("knowledge.save") : t("knowledge.create")}
</Button> </Button>
</> </>
} }
> >
{loading ? ( {loading ? (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<div className="text-muted-foreground">...</div> <div className="text-muted-foreground">{t("knowledge.loading")}</div>
</div> </div>
) : ( ) : (
<form id={formId} onSubmit={handleSubmit(onFormSubmit)} className="space-y-4"> <form id={formId} onSubmit={handleSubmit(onFormSubmit)} className="space-y-4">
<Field data-invalid={!!errors.title}> <Field data-invalid={!!errors.title}>
<FieldLabel htmlFor="doc-title"></FieldLabel> <FieldLabel htmlFor="doc-title">{t("knowledge.documentTitle")}</FieldLabel>
<FieldContent> <FieldContent>
<Input <Input
id="doc-title" id="doc-title"
placeholder="文档标题" placeholder={t("knowledge.documentTitlePlaceholder")}
aria-invalid={!!errors.title} aria-invalid={!!errors.title}
{...register("title")} {...register("title")}
/> />
@@ -202,7 +208,7 @@ function DocumentFormDialogBody({
</Field> </Field>
<Field data-invalid={!!errors.content}> <Field data-invalid={!!errors.content}>
<FieldLabel htmlFor="doc-content"></FieldLabel> <FieldLabel htmlFor="doc-content">{t("knowledge.content")}</FieldLabel>
<FieldContent> <FieldContent>
<Controller <Controller
control={control} control={control}
@@ -223,7 +229,7 @@ function DocumentFormDialogBody({
shouldValidate: true, shouldValidate: true,
}) })
}} }}
placeholder="请输入文档内容" placeholder={t("knowledge.contentPlaceholder")}
disabled={saving} disabled={saving}
/> />
)} )}
@@ -8,7 +8,7 @@ import {
Trash2Icon, Trash2Icon,
WrenchIcon, WrenchIcon,
} from "lucide-react"; } from "lucide-react";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { ListPagination } from "@/components/list-pagination"; import { ListPagination } from "@/components/list-pagination";
@@ -27,14 +27,8 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { OptionCombobox } from "@/components/option-combobox";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@@ -51,11 +45,10 @@ import {
type KnowledgeDocumentListItem, type KnowledgeDocumentListItem,
type PageResult, type PageResult,
} from "@/lib/api/admin"; } from "@/lib/api/admin";
import { getEnumLabel, getEnumOptions } from "@/lib/enums"; import { useI18n } from "@/i18n/provider";
import { import {
KnowledgeDocumentIndexStatus, KnowledgeDocumentIndexStatus,
KnowledgeDocumentIndexStatusLabels, Status,
StatusLabels
} from "@/lib/generated/enums"; } from "@/lib/generated/enums";
import { cn, formatDateTime } from "@/lib/utils"; import { cn, formatDateTime } from "@/lib/utils";
import { DocumentEditDialog } from "./document-edit"; import { DocumentEditDialog } from "./document-edit";
@@ -73,15 +66,32 @@ export type DocumentListActionState = {
loading: boolean; loading: boolean;
}; };
const statusOptions = [ type TFunction = (key: string, values?: Record<string, string | number>) => string;
{ value: "all", label: "全部状态" },
...getEnumOptions(StatusLabels),
] as const;
const indexStatusOptions = [ function getStatusOptions(t: TFunction) {
{ value: "all", label: "全部索引状态" }, return [
...getEnumOptions(KnowledgeDocumentIndexStatusLabels), { value: "all", label: t("knowledge.allStatus") },
] as const; { value: String(Status.Ok), label: t("knowledge.statusOk") },
{ value: String(Status.Disabled), label: t("knowledge.statusDisabled") },
{ value: String(Status.Deleted), label: t("knowledge.statusDeleted") },
];
}
function getIndexStatusOptions(t: TFunction) {
return [
{ value: "all", label: t("knowledge.allIndexStatus") },
{ value: KnowledgeDocumentIndexStatus.Pending, label: t("knowledge.indexPending") },
{ value: KnowledgeDocumentIndexStatus.Indexed, label: t("knowledge.indexIndexed") },
{ value: KnowledgeDocumentIndexStatus.Failed, label: t("knowledge.indexFailed") },
];
}
function getIndexStatusLabel(status: string, t: TFunction) {
if (status === KnowledgeDocumentIndexStatus.Pending) return t("knowledge.indexPending");
if (status === KnowledgeDocumentIndexStatus.Indexed) return t("knowledge.indexIndexed");
if (status === KnowledgeDocumentIndexStatus.Failed) return t("knowledge.indexFailed");
return status || "-";
}
function getIndexStatusBadgeVariant(status: string) { function getIndexStatusBadgeVariant(status: string) {
switch (status) { switch (status) {
@@ -94,10 +104,10 @@ function getIndexStatusBadgeVariant(status: string) {
} }
} }
function renderIndexStatusBadge(item: KnowledgeDocumentListItem) { function renderIndexStatusBadge(item: KnowledgeDocumentListItem, t: TFunction) {
const badge = ( const badge = (
<Badge variant={getIndexStatusBadgeVariant(item.indexStatus)}> <Badge variant={getIndexStatusBadgeVariant(item.indexStatus)}>
{item.indexStatusName} {getIndexStatusLabel(item.indexStatus, t)}
</Badge> </Badge>
) )
@@ -125,6 +135,7 @@ function renderIndexStatusBadge(item: KnowledgeDocumentListItem) {
const VIEW_MODE_STORAGE_KEY = "knowledge-document-view-mode"; const VIEW_MODE_STORAGE_KEY = "knowledge-document-view-mode";
export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentListProps) { export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentListProps) {
const t = useI18n();
const [keywordInput, setKeywordInput] = useState(""); const [keywordInput, setKeywordInput] = useState("");
const [statusFilterInput, setStatusFilterInput] = useState("all"); const [statusFilterInput, setStatusFilterInput] = useState("all");
const [indexStatusFilterInput, setIndexStatusFilterInput] = useState("all"); const [indexStatusFilterInput, setIndexStatusFilterInput] = useState("all");
@@ -149,6 +160,8 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL
results: [], results: [],
page: { page: 1, limit: 20, total: 0 }, page: { page: 1, limit: 20, total: 0 },
}); });
const statusOptions = useMemo(() => getStatusOptions(t), [t]);
const indexStatusOptions = useMemo(() => getIndexStatusOptions(t), [t]);
const loadData = useCallback(async (options?: { const loadData = useCallback(async (options?: {
keyword?: string; keyword?: string;
@@ -181,11 +194,11 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL
}); });
setDocuments(data); setDocuments(data);
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "加载文档失败"); toast.error(error instanceof Error ? error.message : t("knowledge.loadDocumentsFailed"));
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [indexStatusFilter, keyword, statusFilter, knowledgeBaseId, limit, page]); }, [indexStatusFilter, keyword, statusFilter, knowledgeBaseId, limit, page, t]);
useEffect(() => { useEffect(() => {
void loadData(); void loadData();
@@ -277,16 +290,16 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL
id: editingItem.id, id: editingItem.id,
...payload, ...payload,
}); });
toast.success(`已更新文档:${editingItem.title}`); toast.success(t("knowledge.documentUpdated", { title: editingItem.title }));
} else { } else {
await createKnowledgeDocument(payload); await createKnowledgeDocument(payload);
toast.success(`已创建文档:${payload.title}`); toast.success(t("knowledge.documentCreated", { title: payload.title }));
} }
setDialogOpen(false); setDialogOpen(false);
setEditingItem(null); setEditingItem(null);
await loadData(); await loadData();
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "保存文档失败"); toast.error(error instanceof Error ? error.message : t("knowledge.documentSaveFailed"));
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -296,10 +309,10 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL
setActionLoadingMap((prev) => ({ ...prev, [item.id]: { ...prev[item.id], delete: true } })); setActionLoadingMap((prev) => ({ ...prev, [item.id]: { ...prev[item.id], delete: true } }));
try { try {
await deleteKnowledgeDocument(item.id); await deleteKnowledgeDocument(item.id);
toast.success(`已删除文档:${item.title}`); toast.success(t("knowledge.documentDeleted", { title: item.title }));
await loadData(); await loadData();
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "删除文档失败"); toast.error(error instanceof Error ? error.message : t("knowledge.documentDeleteFailed"));
} finally { } finally {
setActionLoadingMap((prev) => ({ ...prev, [item.id]: { ...prev[item.id], delete: false } })); setActionLoadingMap((prev) => ({ ...prev, [item.id]: { ...prev[item.id], delete: false } }));
} }
@@ -309,10 +322,10 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL
setActionLoadingMap((prev) => ({ ...prev, [item.id]: { ...prev[item.id], rebuildIndex: true } })); setActionLoadingMap((prev) => ({ ...prev, [item.id]: { ...prev[item.id], rebuildIndex: true } }));
try { try {
await buildKnowledgeDocumentIndex(item.id); await buildKnowledgeDocumentIndex(item.id);
toast.success(`已重建索引:${item.title}`); toast.success(t("knowledge.documentIndexRebuilt", { title: item.title }));
await loadData(); await loadData();
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "重建索引失败"); toast.error(error instanceof Error ? error.message : t("knowledge.documentIndexRebuildFailed"));
} finally { } finally {
setActionLoadingMap((prev) => ({ ...prev, [item.id]: { ...prev[item.id], rebuildIndex: false } })); setActionLoadingMap((prev) => ({ ...prev, [item.id]: { ...prev[item.id], rebuildIndex: false } }));
} }
@@ -322,7 +335,7 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL
return ( return (
<div className="flex h-full flex-col items-center justify-center text-muted-foreground"> <div className="flex h-full flex-col items-center justify-center text-muted-foreground">
<FileTextIcon className="mb-2 size-12 opacity-50" /> <FileTextIcon className="mb-2 size-12 opacity-50" />
<p></p> <p>{t("knowledge.selectKnowledgeBaseForDocuments")}</p>
</div> </div>
); );
} }
@@ -338,57 +351,26 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL
value={keywordInput} value={keywordInput}
onChange={(event) => setKeywordInput(event.target.value)} onChange={(event) => setKeywordInput(event.target.value)}
onKeyDown={handleFilterKeyDown} onKeyDown={handleFilterKeyDown}
placeholder="搜索文档标题" placeholder={t("knowledge.searchDocumentTitle")}
className="h-8 pl-8 text-xs" className="h-8 pl-8 text-xs"
/> />
</div> </div>
<Select <OptionCombobox
value={statusFilterInput} value={statusFilterInput}
onValueChange={handleStatusFilterChange} onChange={handleStatusFilterChange}
> options={statusOptions}
<SelectTrigger className="h-8 w-32 text-xs"> placeholder={t("knowledge.allStatus")}
<SelectValue> searchPlaceholder={t("knowledge.searchStatus")}
{statusFilterInput === "all" ? "全部状态" : getEnumLabel(StatusLabels, Number(statusFilterInput))} emptyText={t("knowledge.emptyStatus")}
</SelectValue> />
</SelectTrigger> <OptionCombobox
<SelectContent>
{statusOptions.map((item) => (
<SelectItem
key={item.value}
value={item.value}
className="text-xs"
>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={indexStatusFilterInput} value={indexStatusFilterInput}
onValueChange={handleIndexStatusFilterChange} onChange={handleIndexStatusFilterChange}
> options={indexStatusOptions}
<SelectTrigger className="h-8 w-36 text-xs"> placeholder={t("knowledge.allIndexStatus")}
<SelectValue> searchPlaceholder={t("knowledge.searchStatus")}
{indexStatusFilterInput === "all" emptyText={t("knowledge.emptyStatus")}
? "全部索引状态" />
: getEnumLabel(
KnowledgeDocumentIndexStatusLabels,
indexStatusFilterInput as KnowledgeDocumentIndexStatus
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{indexStatusOptions.map((item) => (
<SelectItem
key={item.value}
value={item.value}
className="text-xs"
>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div> </div>
</div> </div>
<div className="min-h-0 flex-1"> <div className="min-h-0 flex-1">
@@ -407,15 +389,15 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="text-sm font-medium">{item.title}</div> <div className="text-sm font-medium">{item.title}</div>
{renderIndexStatusBadge(item)} {renderIndexStatusBadge(item, t)}
</div> </div>
<div className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground"> <div className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
<span>{item.createUserName || "-"}</span> <span>{item.createUserName || "-"}</span>
<span>{formatDateTime(item.createdAt)}</span> <span>{formatDateTime(item.createdAt)}</span>
<span className={cn(item.indexStatus === KnowledgeDocumentIndexStatus.Failed && "text-destructive")}> <span className={cn(item.indexStatus === KnowledgeDocumentIndexStatus.Failed && "text-destructive")}>
{item.indexStatus === KnowledgeDocumentIndexStatus.Indexed {item.indexStatus === KnowledgeDocumentIndexStatus.Indexed
? `已索引 ${formatDateTime(item.indexedAt)}` ? t("knowledge.indexedAt", { time: formatDateTime(item.indexedAt) })
: item.indexStatusName} : getIndexStatusLabel(item.indexStatus, t)}
</span> </span>
</div> </div>
</div> </div>
@@ -429,25 +411,25 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL
className="size-6" className="size-6"
/> />
} }
aria-label={`更多操作 ${item.title}`} aria-label={t("knowledge.moreActions", { name: item.title })}
> >
<MoreHorizontalIcon className="size-3.5" /> <MoreHorizontalIcon className="size-3.5" />
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-32 min-w-32"> <DropdownMenuContent align="end" className="w-32 min-w-32">
<DropdownMenuItem onClick={() => openEditDialog(item)}> <DropdownMenuItem onClick={() => openEditDialog(item)}>
<PencilIcon className="mr-2 size-3.5" /> <PencilIcon className="mr-2 size-3.5" />
{t("knowledge.edit")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => void handleBuildIndex(item)}> <DropdownMenuItem onClick={() => void handleBuildIndex(item)}>
<WrenchIcon className="mr-2 size-3.5" /> <WrenchIcon className="mr-2 size-3.5" />
{actionLoadingMap[item.id]?.rebuildIndex ? "执行中..." : "重建索引"} {actionLoadingMap[item.id]?.rebuildIndex ? t("knowledge.running") : t("knowledge.rebuildIndex")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={() => void handleDelete(item)} onClick={() => void handleDelete(item)}
className="text-destructive focus:text-destructive" className="text-destructive focus:text-destructive"
> >
<Trash2Icon className="mr-2 size-3.5" /> <Trash2Icon className="mr-2 size-3.5" />
{actionLoadingMap[item.id]?.delete ? "删除中..." : "删除"} {actionLoadingMap[item.id]?.delete ? t("knowledge.deleting") : t("knowledge.delete")}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@@ -457,11 +439,11 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL
<ContextMenuContent className="w-40"> <ContextMenuContent className="w-40">
<ContextMenuItem onClick={() => openEditDialog(item)}> <ContextMenuItem onClick={() => openEditDialog(item)}>
<PencilIcon className="mr-2 size-3.5" /> <PencilIcon className="mr-2 size-3.5" />
{t("knowledge.edit")}
</ContextMenuItem> </ContextMenuItem>
<ContextMenuItem onClick={() => void handleBuildIndex(item)} disabled={actionLoadingMap[item.id]?.rebuildIndex}> <ContextMenuItem onClick={() => void handleBuildIndex(item)} disabled={actionLoadingMap[item.id]?.rebuildIndex}>
<WrenchIcon className="mr-2 size-3.5" /> <WrenchIcon className="mr-2 size-3.5" />
{actionLoadingMap[item.id]?.rebuildIndex ? "执行中..." : "重建索引"} {actionLoadingMap[item.id]?.rebuildIndex ? t("knowledge.running") : t("knowledge.rebuildIndex")}
</ContextMenuItem> </ContextMenuItem>
<ContextMenuItem <ContextMenuItem
onClick={() => void handleDelete(item)} onClick={() => void handleDelete(item)}
@@ -469,7 +451,7 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL
disabled={actionLoadingMap[item.id]?.delete} disabled={actionLoadingMap[item.id]?.delete}
> >
<Trash2Icon className="mr-2 size-3.5" /> <Trash2Icon className="mr-2 size-3.5" />
{actionLoadingMap[item.id]?.delete ? "删除中..." : "删除"} {actionLoadingMap[item.id]?.delete ? t("knowledge.deleting") : t("knowledge.delete")}
</ContextMenuItem> </ContextMenuItem>
</ContextMenuContent> </ContextMenuContent>
</ContextMenu> </ContextMenu>
@@ -483,12 +465,12 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="truncate text-sm font-medium">{item.title}</div> <div className="truncate text-sm font-medium">{item.title}</div>
{renderIndexStatusBadge(item)} {renderIndexStatusBadge(item, t)}
</div> </div>
<div className="mt-1 text-xs text-muted-foreground"> <div className="mt-1 text-xs text-muted-foreground">
{item.indexStatus === KnowledgeDocumentIndexStatus.Indexed {item.indexStatus === KnowledgeDocumentIndexStatus.Indexed
? `索引时间:${formatDateTime(item.indexedAt)}` ? t("knowledge.indexTime", { time: formatDateTime(item.indexedAt) })
: item.indexError || item.indexStatusName} : item.indexError || getIndexStatusLabel(item.indexStatus, t)}
</div> </div>
</div> </div>
<div className="shrink-0 text-xs text-muted-foreground"> <div className="shrink-0 text-xs text-muted-foreground">
@@ -503,25 +485,25 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL
className="size-6" className="size-6"
/> />
} }
aria-label={`更多操作 ${item.title}`} aria-label={t("knowledge.moreActions", { name: item.title })}
> >
<MoreHorizontalIcon className="size-3.5" /> <MoreHorizontalIcon className="size-3.5" />
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-32 min-w-32"> <DropdownMenuContent align="end" className="w-32 min-w-32">
<DropdownMenuItem onClick={() => openEditDialog(item)}> <DropdownMenuItem onClick={() => openEditDialog(item)}>
<PencilIcon className="mr-2 size-3.5" /> <PencilIcon className="mr-2 size-3.5" />
{t("knowledge.edit")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => void handleBuildIndex(item)}> <DropdownMenuItem onClick={() => void handleBuildIndex(item)}>
<WrenchIcon className="mr-2 size-3.5" /> <WrenchIcon className="mr-2 size-3.5" />
{actionLoadingMap[item.id]?.rebuildIndex ? "执行中..." : "重建索引"} {actionLoadingMap[item.id]?.rebuildIndex ? t("knowledge.running") : t("knowledge.rebuildIndex")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={() => void handleDelete(item)} onClick={() => void handleDelete(item)}
className="text-destructive focus:text-destructive" className="text-destructive focus:text-destructive"
> >
<Trash2Icon className="mr-2 size-3.5" /> <Trash2Icon className="mr-2 size-3.5" />
{actionLoadingMap[item.id]?.delete ? "删除中..." : "删除"} {actionLoadingMap[item.id]?.delete ? t("knowledge.deleting") : t("knowledge.delete")}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@@ -530,11 +512,11 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL
<ContextMenuContent className="w-40"> <ContextMenuContent className="w-40">
<ContextMenuItem onClick={() => openEditDialog(item)}> <ContextMenuItem onClick={() => openEditDialog(item)}>
<PencilIcon className="mr-2 size-3.5" /> <PencilIcon className="mr-2 size-3.5" />
{t("knowledge.edit")}
</ContextMenuItem> </ContextMenuItem>
<ContextMenuItem onClick={() => void handleBuildIndex(item)} disabled={actionLoadingMap[item.id]?.rebuildIndex}> <ContextMenuItem onClick={() => void handleBuildIndex(item)} disabled={actionLoadingMap[item.id]?.rebuildIndex}>
<WrenchIcon className="mr-2 size-3.5" /> <WrenchIcon className="mr-2 size-3.5" />
{actionLoadingMap[item.id]?.rebuildIndex ? "执行中..." : "重建索引"} {actionLoadingMap[item.id]?.rebuildIndex ? t("knowledge.running") : t("knowledge.rebuildIndex")}
</ContextMenuItem> </ContextMenuItem>
<ContextMenuItem <ContextMenuItem
onClick={() => void handleDelete(item)} onClick={() => void handleDelete(item)}
@@ -542,7 +524,7 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL
disabled={actionLoadingMap[item.id]?.delete} disabled={actionLoadingMap[item.id]?.delete}
> >
<Trash2Icon className="mr-2 size-3.5" /> <Trash2Icon className="mr-2 size-3.5" />
{actionLoadingMap[item.id]?.delete ? "删除中..." : "删除"} {actionLoadingMap[item.id]?.delete ? t("knowledge.deleting") : t("knowledge.delete")}
</ContextMenuItem> </ContextMenuItem>
</ContextMenuContent> </ContextMenuContent>
</ContextMenu> </ContextMenu>
@@ -550,7 +532,7 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL
))} ))}
{!loading && documents.results.length === 0 ? ( {!loading && documents.results.length === 0 ? (
<div className="py-8 text-center text-sm text-muted-foreground"> <div className="py-8 text-center text-sm text-muted-foreground">
{t("knowledge.emptyDocuments")}
</div> </div>
) : null} ) : null}
</div> </div>
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useForm, type Resolver } from "react-hook-form"; import { useForm, type Resolver } from "react-hook-form";
import { z } from "zod/v4"; import { z } from "zod/v4";
@@ -15,6 +15,7 @@ import {
type CreateKnowledgeFAQPayload, type CreateKnowledgeFAQPayload,
type KnowledgeFAQ, type KnowledgeFAQ,
} from "@/lib/api/admin"; } from "@/lib/api/admin";
import { useI18n } from "@/i18n/provider";
type FAQEditDialogProps = { type FAQEditDialogProps = {
open: boolean; open: boolean;
@@ -25,20 +26,23 @@ type FAQEditDialogProps = {
onSubmit: (payload: CreateKnowledgeFAQPayload) => Promise<void>; onSubmit: (payload: CreateKnowledgeFAQPayload) => Promise<void>;
}; };
const formSchema = z.object({ type TFunction = (key: string, values?: Record<string, string | number>) => string;
question: z.string().trim().min(1, "问题不能为空").max(500, "问题最多500个字符"),
answer: z.string().trim().min(1, "答案不能为空"), function createFormSchema(t: TFunction) {
return z.object({
question: z.string().trim().min(1, t("knowledge.faqQuestionRequired")).max(500, t("knowledge.faqQuestionMax")),
answer: z.string().trim().min(1, t("knowledge.faqAnswerRequired")),
similarQuestionsText: z.string(), similarQuestionsText: z.string(),
remark: z.string().trim().max(500, "备注最多500个字符"), remark: z.string().trim().max(500, t("knowledge.remarkMax")),
}); });
}
type EditForm = z.infer<typeof formSchema>; type EditForm = {
question: string;
const resolver = zodResolver(formSchema as never) as Resolver< answer: string;
z.input<typeof formSchema>, similarQuestionsText: string;
undefined, remark: string;
z.output<typeof formSchema> };
>;
const emptyForm: EditForm = { const emptyForm: EditForm = {
question: "", question: "",
@@ -113,13 +117,15 @@ function FAQEditDialogBody({
onOpenChange, onOpenChange,
onSubmit, onSubmit,
}: FAQEditDialogBodyProps) { }: FAQEditDialogBodyProps) {
const t = useI18n();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const formId = "knowledge-faq-edit-form"; const formId = "knowledge-faq-edit-form";
const form = useForm< const formSchema = useMemo(() => createFormSchema(t), [t]);
z.input<typeof formSchema>, const resolver = useMemo(
undefined, () => zodResolver(formSchema) as Resolver<EditForm>,
z.output<typeof formSchema> [formSchema],
>({ );
const form = useForm<EditForm>({
resolver, resolver,
defaultValues: emptyForm, defaultValues: emptyForm,
}); });
@@ -157,56 +163,56 @@ function FAQEditDialogBody({
<ProjectDialog <ProjectDialog
open={open} open={open}
onOpenChange={onOpenChange} onOpenChange={onOpenChange}
title={itemId ? "编辑FAQ" : "新建FAQ"} title={itemId ? t("knowledge.editFAQTitle") : t("knowledge.createFAQTitle")}
allowFullscreen allowFullscreen
size="xl" size="xl"
footer={ footer={
<> <>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={saving}> <Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
{t("knowledge.cancel")}
</Button> </Button>
<Button type="submit" form={formId} disabled={saving || loading}> <Button type="submit" form={formId} disabled={saving || loading}>
{saving ? "保存中..." : itemId ? "保存" : "创建"} {saving ? t("knowledge.saving") : itemId ? t("knowledge.save") : t("knowledge.create")}
</Button> </Button>
</> </>
} }
> >
{loading ? ( {loading ? (
<div className="flex items-center justify-center py-12 text-muted-foreground">...</div> <div className="flex items-center justify-center py-12 text-muted-foreground">{t("knowledge.loading")}</div>
) : ( ) : (
<form id={formId} onSubmit={handleSubmit(onFormSubmit)} className="space-y-4"> <form id={formId} onSubmit={handleSubmit(onFormSubmit)} className="space-y-4">
<Field data-invalid={!!errors.question}> <Field data-invalid={!!errors.question}>
<FieldLabel htmlFor="faq-question"></FieldLabel> <FieldLabel htmlFor="faq-question">{t("knowledge.standardQuestion")}</FieldLabel>
<FieldContent> <FieldContent>
<Input id="faq-question" placeholder="请输入标准问题" {...register("question")} /> <Input id="faq-question" placeholder={t("knowledge.questionPlaceholder")} {...register("question")} />
<FieldError errors={[errors.question]} /> <FieldError errors={[errors.question]} />
</FieldContent> </FieldContent>
</Field> </Field>
<Field data-invalid={!!errors.answer}> <Field data-invalid={!!errors.answer}>
<FieldLabel htmlFor="faq-answer"></FieldLabel> <FieldLabel htmlFor="faq-answer">{t("knowledge.answer")}</FieldLabel>
<FieldContent> <FieldContent>
<Textarea id="faq-answer" rows={8} placeholder="请输入FAQ答案" {...register("answer")} /> <Textarea id="faq-answer" rows={8} placeholder={t("knowledge.answerPlaceholder")} {...register("answer")} />
<FieldError errors={[errors.answer]} /> <FieldError errors={[errors.answer]} />
</FieldContent> </FieldContent>
</Field> </Field>
<Field> <Field>
<FieldLabel htmlFor="faq-similar-questions"></FieldLabel> <FieldLabel htmlFor="faq-similar-questions">{t("knowledge.similarQuestions")}</FieldLabel>
<FieldContent> <FieldContent>
<Textarea <Textarea
id="faq-similar-questions" id="faq-similar-questions"
rows={5} rows={5}
placeholder={"一行一个相似问题"} placeholder={t("knowledge.similarQuestionsPlaceholder")}
{...register("similarQuestionsText")} {...register("similarQuestionsText")}
/> />
</FieldContent> </FieldContent>
</Field> </Field>
<Field data-invalid={!!errors.remark}> <Field data-invalid={!!errors.remark}>
<FieldLabel htmlFor="faq-remark"></FieldLabel> <FieldLabel htmlFor="faq-remark">{t("knowledge.remark")}</FieldLabel>
<FieldContent> <FieldContent>
<Textarea id="faq-remark" rows={3} placeholder="备注" {...register("remark")} /> <Textarea id="faq-remark" rows={3} placeholder={t("knowledge.remarkPlaceholder")} {...register("remark")} />
<FieldError errors={[errors.remark]} /> <FieldError errors={[errors.remark]} />
</FieldContent> </FieldContent>
</Field> </Field>
@@ -215,4 +221,3 @@ function FAQEditDialogBody({
</ProjectDialog> </ProjectDialog>
); );
} }
@@ -16,6 +16,7 @@ import {
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 { createKnowledgeFAQ, type CreateKnowledgeFAQPayload } from "@/lib/api/admin";
import { useI18n } from "@/i18n/provider";
type FAQImportDialogProps = { type FAQImportDialogProps = {
open: boolean; open: boolean;
@@ -39,24 +40,20 @@ type ParseResult = {
warnings: string[]; warnings: string[];
}; };
const templateContent = [ type TFunction = (key: string, values?: Record<string, string | number>) => string;
"question,answer,similarQuestions,remark",
'"如何重置密码?","进入个人设置后点击重置密码。","忘记密码|密码重置在哪里","账号类FAQ"',
'"支持哪些接入渠道?","目前支持网站组件、企业微信等渠道接入。","有哪些渠道|支持什么渠道","渠道说明"',
].join("\n");
const acceptedHeaderMap: Record<string, keyof Omit<ParsedFAQRow, "rowNo">> = { const acceptedHeaderMap: Record<string, keyof Omit<ParsedFAQRow, "rowNo">> = {
question: "question", question: "question",
"标准问题": "question",
"问题": "question",
answer: "answer", answer: "answer",
"答案": "answer",
similarquestions: "similarQuestions", similarquestions: "similarQuestions",
"similarQuestions": "similarQuestions", "similarQuestions": "similarQuestions",
"相似问": "similarQuestions",
"相似问题": "similarQuestions",
remark: "remark", remark: "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) { function normalizeHeader(value: string) {
@@ -134,10 +131,10 @@ function parseSimilarQuestions(value: string) {
.filter(Boolean); .filter(Boolean);
} }
function parseFAQFileContent(input: string): ParseResult { function parseFAQFileContent(input: string, t: TFunction): ParseResult {
const table = parseDelimitedText(input); const table = parseDelimitedText(input);
if (table.length === 0) { if (table.length === 0) {
throw new Error("文件内容为空"); throw new Error(t("knowledge.fileEmpty"));
} }
const headerRow = table[0]; const headerRow = table[0];
@@ -150,7 +147,7 @@ function parseFAQFileContent(input: string): ParseResult {
} }
if (!headerMap.has("question") || !headerMap.has("answer")) { if (!headerMap.has("question") || !headerMap.has("answer")) {
throw new Error("导入模板缺少 question/answer 列"); throw new Error(t("knowledge.missingFAQColumns"));
} }
const rows: ParsedFAQRow[] = []; const rows: ParsedFAQRow[] = [];
@@ -168,7 +165,7 @@ function parseFAQFileContent(input: string): ParseResult {
continue; continue;
} }
if (!question || !answer) { if (!question || !answer) {
warnings.push(`${rowNo} 行缺少问题或答案,已跳过`); warnings.push(t("knowledge.skipRowMissingFAQ", { row: rowNo }));
continue; continue;
} }
@@ -185,6 +182,11 @@ function parseFAQFileContent(input: string): ParseResult {
} }
function downloadTemplate() { 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 blob = new Blob([templateContent], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const link = document.createElement("a"); const link = document.createElement("a");
@@ -212,6 +214,7 @@ export function FAQImportDialog({
onImportingChange, onImportingChange,
onImported, onImported,
}: FAQImportDialogProps) { }: FAQImportDialogProps) {
const t = useI18n();
const fileInputRef = useRef<HTMLInputElement | null>(null); const fileInputRef = useRef<HTMLInputElement | null>(null);
const [fileName, setFileName] = useState(""); const [fileName, setFileName] = useState("");
const [rows, setRows] = useState<ParsedFAQRow[]>([]); const [rows, setRows] = useState<ParsedFAQRow[]>([]);
@@ -236,18 +239,18 @@ export function FAQImportDialog({
try { try {
const content = await file.text(); const content = await file.text();
const parsed = parseFAQFileContent(content); const parsed = parseFAQFileContent(content, t);
setFileName(file.name); setFileName(file.name);
setRows(parsed.rows); setRows(parsed.rows);
setWarnings(parsed.warnings); setWarnings(parsed.warnings);
if (parsed.rows.length === 0) { if (parsed.rows.length === 0) {
toast.error("没有可导入的FAQ记录"); toast.error(t("knowledge.importNoRows"));
} else { } else {
toast.success(`已解析 ${parsed.rows.length} 条FAQ`); toast.success(t("knowledge.parsedFAQRows", { count: parsed.rows.length }));
} }
} catch (error) { } catch (error) {
resetState(); resetState();
toast.error(error instanceof Error ? error.message : "解析导入文件失败"); toast.error(error instanceof Error ? error.message : t("knowledge.parseImportFailed"));
} }
} }
@@ -267,7 +270,10 @@ export function FAQImportDialog({
successCount += 1; successCount += 1;
} catch (error) { } catch (error) {
failedRows.push( failedRows.push(
`${row.rowNo} 行:${error instanceof Error ? error.message : "导入失败"}` t("knowledge.importRowFailed", {
row: row.rowNo,
message: error instanceof Error ? error.message : t("knowledge.importFailed"),
})
); );
} }
} }
@@ -275,10 +281,10 @@ export function FAQImportDialog({
await onImported(); await onImported();
if (successCount > 0) { if (successCount > 0) {
toast.success(`成功导入 ${successCount} 条FAQ`); toast.success(t("knowledge.importSuccess", { count: successCount }));
} }
if (failedRows.length > 0) { if (failedRows.length > 0) {
toast.error(`${failedRows.length} 条FAQ导入失败`); toast.error(t("knowledge.importSomeFailed", { count: failedRows.length }));
setWarnings((current) => [...current, ...failedRows]); setWarnings((current) => [...current, ...failedRows]);
return; return;
} }
@@ -299,27 +305,31 @@ export function FAQImportDialog({
} }
onOpenChange(nextOpen); onOpenChange(nextOpen);
}} }}
title="导入FAQ" title={t("knowledge.importFAQTitle")}
description="上传 CSV 文件批量导入 FAQ。必填列为 question、answersimilarQuestions 使用 | 或换行分隔。" description={t("knowledge.importFAQDescription")}
size="lg" size="lg"
footer={ footer={
<> <>
<Button type="button" variant="outline" onClick={() => downloadTemplate()}> <Button type="button" variant="outline" onClick={() => downloadTemplate()}>
<DownloadIcon className="size-4" /> <DownloadIcon className="size-4" />
{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")}
</Button> </Button>
<Button type="button" onClick={() => void handleImport()} disabled={importing || rows.length === 0}> <Button type="button" onClick={() => void handleImport()} disabled={importing || rows.length === 0}>
{importing ? "导入中..." : `开始导入${rows.length > 0 ? ` (${rows.length})` : ""}`} {importing
? t("knowledge.importing")
: rows.length > 0
? t("knowledge.startImportWithCount", { count: rows.length })
: t("knowledge.startImport")}
</Button> </Button>
</> </>
} }
> >
<FieldGroup> <FieldGroup>
<Field> <Field>
<FieldLabel htmlFor="faq-import-file"></FieldLabel> <FieldLabel htmlFor="faq-import-file">{t("knowledge.importFile")}</FieldLabel>
<FieldContent> <FieldContent>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Input <Input
@@ -335,18 +345,18 @@ export function FAQImportDialog({
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
> >
<FileUpIcon className="size-4" /> <FileUpIcon className="size-4" />
{t("knowledge.chooseFile")}
</Button> </Button>
</div> </div>
<FieldDescription> <FieldDescription>
UTF-8 CSV {t("knowledge.importFileDescription")}
</FieldDescription> </FieldDescription>
</FieldContent> </FieldContent>
</Field> </Field>
{fileName ? ( {fileName ? (
<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">
{fileName} {t("knowledge.currentFile", { name: fileName })}
</div> </div>
) : null} ) : null}
@@ -354,7 +364,7 @@ export function FAQImportDialog({
<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")}
</div> </div>
<ul className="space-y-1"> <ul className="space-y-1">
{warnings.map((item, index) => ( {warnings.map((item, index) => (
@@ -366,7 +376,7 @@ export function FAQImportDialog({
<div className="rounded-md border"> <div className="rounded-md border">
<div className="border-b px-4 py-3 text-sm font-medium"> <div className="border-b px-4 py-3 text-sm font-medium">
{t("knowledge.importPreview")}
</div> </div>
{previewRows.length > 0 ? ( {previewRows.length > 0 ? (
<ScrollArea className="max-h-80"> <ScrollArea className="max-h-80">
@@ -374,7 +384,7 @@ export function FAQImportDialog({
{previewRows.map((row) => ( {previewRows.map((row) => (
<div key={row.rowNo} className="space-y-2 px-4 py-3 text-sm"> <div key={row.rowNo} className="space-y-2 px-4 py-3 text-sm">
<div> <div>
<span className="text-muted-foreground"> {row.rowNo} </span> <span className="text-muted-foreground">{t("knowledge.rowNumber", { row: row.rowNo })}</span>
</div> </div>
<div> <div>
<div className="font-medium">{row.question}</div> <div className="font-medium">{row.question}</div>
@@ -383,16 +393,18 @@ export function FAQImportDialog({
</div> </div>
</div> </div>
<div className="text-muted-foreground"> <div className="text-muted-foreground">
{row.similarQuestions.length > 0 ? row.similarQuestions.join(" / ") : "无"} {t("knowledge.similarQuestionShort", {
value: row.similarQuestions.length > 0 ? row.similarQuestions.join(" / ") : t("knowledge.none"),
})}
</div> </div>
<div className="text-muted-foreground">{row.remark || "无"}</div> <div className="text-muted-foreground">{t("knowledge.remark")}{row.remark || t("knowledge.none")}</div>
</div> </div>
))} ))}
</div> </div>
</ScrollArea> </ScrollArea>
) : ( ) : (
<div className="px-4 py-12 text-center text-sm text-muted-foreground"> <div className="px-4 py-12 text-center text-sm text-muted-foreground">
5 FAQ {t("knowledge.previewAfterUpload")}
</div> </div>
)} )}
</div> </div>
@@ -6,7 +6,7 @@ import {
Trash2Icon, Trash2Icon,
WrenchIcon, WrenchIcon,
} from "lucide-react"; } from "lucide-react";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { ListPagination } from "@/components/list-pagination"; import { ListPagination } from "@/components/list-pagination";
@@ -20,13 +20,7 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { import { OptionCombobox } from "@/components/option-combobox";
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { import {
TableBody, TableBody,
TableCell, TableCell,
@@ -50,11 +44,10 @@ import {
type KnowledgeFAQ, type KnowledgeFAQ,
type PageResult, type PageResult,
} from "@/lib/api/admin"; } from "@/lib/api/admin";
import { getEnumLabel, getEnumOptions } from "@/lib/enums";
import { import {
KnowledgeDocumentIndexStatus, KnowledgeDocumentIndexStatus,
KnowledgeDocumentIndexStatusLabels,
} from "@/lib/generated/enums"; } from "@/lib/generated/enums";
import { useI18n } from "@/i18n/provider";
import { formatDateTime } from "@/lib/utils"; import { formatDateTime } from "@/lib/utils";
import { FAQEditDialog } from "./faq-edit"; import { FAQEditDialog } from "./faq-edit";
import { FAQImportDialog } from "./faq-import-dialog"; import { FAQImportDialog } from "./faq-import-dialog";
@@ -72,10 +65,23 @@ export type FAQListActionState = {
importing: boolean; importing: boolean;
}; };
const indexStatusOptions = [ type TFunction = (key: string, values?: Record<string, string | number>) => string;
{ value: "all", label: "全部索引状态" },
...getEnumOptions(KnowledgeDocumentIndexStatusLabels), function getIndexStatusOptions(t: TFunction) {
] as const; return [
{ value: "all", label: t("knowledge.allIndexStatus") },
{ value: KnowledgeDocumentIndexStatus.Pending, label: t("knowledge.indexPending") },
{ value: KnowledgeDocumentIndexStatus.Indexed, label: t("knowledge.indexIndexed") },
{ value: KnowledgeDocumentIndexStatus.Failed, label: t("knowledge.indexFailed") },
];
}
function getIndexStatusLabel(status: string, t: TFunction) {
if (status === KnowledgeDocumentIndexStatus.Pending) return t("knowledge.indexPending");
if (status === KnowledgeDocumentIndexStatus.Indexed) return t("knowledge.indexIndexed");
if (status === KnowledgeDocumentIndexStatus.Failed) return t("knowledge.indexFailed");
return status || "-";
}
function getIndexStatusBadgeVariant(status: string) { function getIndexStatusBadgeVariant(status: string) {
switch (status) { switch (status) {
@@ -88,10 +94,10 @@ function getIndexStatusBadgeVariant(status: string) {
} }
} }
function renderIndexStatusBadge(item: KnowledgeFAQ) { function renderIndexStatusBadge(item: KnowledgeFAQ, t: TFunction) {
const badge = ( const badge = (
<Badge variant={getIndexStatusBadgeVariant(item.indexStatus)}> <Badge variant={getIndexStatusBadgeVariant(item.indexStatus)}>
{item.indexStatusName} {getIndexStatusLabel(item.indexStatus, t)}
</Badge> </Badge>
); );
@@ -120,6 +126,7 @@ export function FAQList({
knowledgeBaseId, knowledgeBaseId,
onActionStateChange, onActionStateChange,
}: FAQListProps) { }: FAQListProps) {
const t = useI18n();
const [keywordInput, setKeywordInput] = useState(""); const [keywordInput, setKeywordInput] = useState("");
const [indexStatusFilterInput, setIndexStatusFilterInput] = useState("all"); const [indexStatusFilterInput, setIndexStatusFilterInput] = useState("all");
const [keyword, setKeyword] = useState(""); const [keyword, setKeyword] = useState("");
@@ -139,6 +146,7 @@ export function FAQList({
results: [], results: [],
page: { page: 1, limit: 20, total: 0 }, page: { page: 1, limit: 20, total: 0 },
}); });
const indexStatusOptions = useMemo(() => getIndexStatusOptions(t), [t]);
const loadData = useCallback( const loadData = useCallback(
async (options?: { async (options?: {
@@ -172,12 +180,12 @@ export function FAQList({
}); });
setResult(data); setResult(data);
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "加载FAQ失败"); toast.error(error instanceof Error ? error.message : t("knowledge.loadFAQFailed"));
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, },
[indexStatusFilter, keyword, knowledgeBaseId, limit, page], [indexStatusFilter, keyword, knowledgeBaseId, limit, page, t],
); );
useEffect(() => { useEffect(() => {
@@ -221,9 +229,9 @@ export function FAQList({
setDialogOpen(false); setDialogOpen(false);
setEditingItem(null); setEditingItem(null);
await loadData(); await loadData();
toast.success("FAQ已保存"); toast.success(t("knowledge.faqSaved"));
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "保存FAQ失败"); toast.error(error instanceof Error ? error.message : t("knowledge.faqSaveFailed"));
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -236,10 +244,10 @@ export function FAQList({
})); }));
try { try {
await deleteKnowledgeFAQ(item.id); await deleteKnowledgeFAQ(item.id);
toast.success("FAQ已删除"); toast.success(t("knowledge.faqDeleted"));
await loadData(); await loadData();
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "删除FAQ失败"); toast.error(error instanceof Error ? error.message : t("knowledge.faqDeleteFailed"));
} finally { } finally {
setActionLoadingMap((prev) => ({ setActionLoadingMap((prev) => ({
...prev, ...prev,
@@ -255,10 +263,10 @@ export function FAQList({
})); }));
try { try {
await buildKnowledgeFAQIndex(item.id); await buildKnowledgeFAQIndex(item.id);
toast.success("FAQ索引已重建"); toast.success(t("knowledge.faqIndexRebuilt"));
await loadData(); await loadData();
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "重建FAQ索引失败"); toast.error(error instanceof Error ? error.message : t("knowledge.faqIndexRebuildFailed"));
} finally { } finally {
setActionLoadingMap((prev) => ({ setActionLoadingMap((prev) => ({
...prev, ...prev,
@@ -270,7 +278,7 @@ export function FAQList({
if (!knowledgeBaseId) { if (!knowledgeBaseId) {
return ( return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground"> <div className="flex h-full items-center justify-center text-sm text-muted-foreground">
FAQ知识库查看FAQ {t("knowledge.selectFAQBase")}
</div> </div>
); );
} }
@@ -289,38 +297,24 @@ export function FAQList({
applyFilters(); applyFilters();
} }
}} }}
placeholder="按问题搜索FAQ" placeholder={t("knowledge.searchFAQ")}
className="pl-9" className="pl-9"
/> />
</div> </div>
<Select <OptionCombobox
value={indexStatusFilterInput} value={indexStatusFilterInput}
onValueChange={(value) => setIndexStatusFilterInput(value ?? "all")} onChange={(value) => setIndexStatusFilterInput(value ?? "all")}
> options={indexStatusOptions}
<SelectTrigger className="w-40"> placeholder={t("knowledge.allIndexStatus")}
<SelectValue> searchPlaceholder={t("knowledge.searchStatus")}
{indexStatusFilterInput === "all" emptyText={t("knowledge.emptyStatus")}
? "全部索引状态" />
: getEnumLabel(
KnowledgeDocumentIndexStatusLabels,
indexStatusFilterInput as KnowledgeDocumentIndexStatus,
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{indexStatusOptions.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button <Button
variant="outline" variant="outline"
onClick={applyFilters} onClick={applyFilters}
disabled={loading} disabled={loading}
> >
{t("knowledge.query")}
</Button> </Button>
</div> </div>
@@ -329,11 +323,11 @@ export function FAQList({
<table className="w-full min-w-max caption-bottom text-sm"> <table className="w-full min-w-max caption-bottom text-sm">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead></TableHead> <TableHead>{t("knowledge.question")}</TableHead>
<TableHead></TableHead> <TableHead>{t("knowledge.indexStatus")}</TableHead>
<TableHead></TableHead> <TableHead>{t("knowledge.similarQuestions")}</TableHead>
<TableHead></TableHead> <TableHead>{t("knowledge.updatedAt")}</TableHead>
<TableHead className="w-20 text-right"></TableHead> <TableHead className="w-20 text-right">{t("knowledge.actions")}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -345,7 +339,7 @@ export function FAQList({
{item.answer} {item.answer}
</div> </div>
</TableCell> </TableCell>
<TableCell>{renderIndexStatusBadge(item)}</TableCell> <TableCell>{renderIndexStatusBadge(item, t)}</TableCell>
<TableCell> <TableCell>
{Array.isArray(item.similarQuestions) {Array.isArray(item.similarQuestions)
? item.similarQuestions.length ? item.similarQuestions.length
@@ -362,12 +356,12 @@ export function FAQList({
setDialogOpen(true); setDialogOpen(true);
}} }}
> >
{t("knowledge.edit")}
</Button> </Button>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
render={<Button variant="outline" size="icon-sm" />} render={<Button variant="outline" size="icon-sm" />}
aria-label={`更多操作 ${item.question}`} aria-label={t("knowledge.moreActions", { name: item.question })}
> >
<MoreHorizontalIcon className="size-4" /> <MoreHorizontalIcon className="size-4" />
</DropdownMenuTrigger> </DropdownMenuTrigger>
@@ -377,8 +371,8 @@ export function FAQList({
> >
<WrenchIcon className="mr-2 size-4" /> <WrenchIcon className="mr-2 size-4" />
{actionLoadingMap[item.id]?.rebuildIndex {actionLoadingMap[item.id]?.rebuildIndex
? "重建中..." ? t("knowledge.rebuilding")
: "重建索引"} : t("knowledge.rebuildIndex")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
className="text-destructive focus:text-destructive" className="text-destructive focus:text-destructive"
@@ -386,8 +380,8 @@ export function FAQList({
> >
<Trash2Icon className="mr-2 size-4" /> <Trash2Icon className="mr-2 size-4" />
{actionLoadingMap[item.id]?.delete {actionLoadingMap[item.id]?.delete
? "删除中..." ? t("knowledge.deleting")
: "删除"} : t("knowledge.delete")}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@@ -401,7 +395,7 @@ export function FAQList({
colSpan={5} colSpan={5}
className="py-12 text-center text-sm text-muted-foreground" className="py-12 text-center text-sm text-muted-foreground"
> >
FAQ {t("knowledge.emptyFAQ")}
</TableCell> </TableCell>
</TableRow> </TableRow>
) : null} ) : null}
@@ -1,8 +1,8 @@
"use client"; "use client";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Controller, Resolver, useForm } from "react-hook-form"; import { Controller, type Resolver, useForm } from "react-hook-form";
import { z } from "zod/v4"; import { z } from "zod/v4";
import { OptionCombobox } from "@/components/option-combobox"; import { OptionCombobox } from "@/components/option-combobox";
@@ -15,27 +15,17 @@ import {
FieldLabel, FieldLabel,
} from "@/components/ui/field"; } from "@/components/ui/field";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { import {
fetchKnowledgeBase, fetchKnowledgeBase,
type CreateKnowledgeBasePayload, type CreateKnowledgeBasePayload,
type KnowledgeBase, type KnowledgeBase,
} from "@/lib/api/admin"; } from "@/lib/api/admin";
import { getEnumLabel, getEnumOptions } from "@/lib/enums"; import { useI18n } from "@/i18n/provider";
import { import {
KnowledgeAnswerMode, KnowledgeAnswerMode,
KnowledgeAnswerModeLabels,
KnowledgeBaseType, KnowledgeBaseType,
KnowledgeBaseTypeLabels,
KnowledgeChunkProvider, KnowledgeChunkProvider,
KnowledgeChunkProviderLabels,
} from "@/lib/generated/enums"; } from "@/lib/generated/enums";
type KnowledgeBaseEditDialogProps = { type KnowledgeBaseEditDialogProps = {
@@ -61,29 +51,61 @@ const emptyForm: EditForm = {
remark: "", remark: "",
}; };
const knowledgeBaseFormSchema = z.object({ type TFunction = (key: string, values?: Record<string, string | number>) => string;
name: z.string().trim().min(1, "名称不能为空").max(100, "名称最多100个字符"),
description: z.string().trim().max(500, "描述最多500个字符"),
knowledgeType: z.string().trim().min(1, "请选择知识库类型"),
defaultTopK: z.string().trim().min(1, "请输入TopK值"),
defaultScoreThreshold: z.string().trim().min(1, "请输入分数阈值"),
defaultRerankLimit: z.string().trim().min(1, "请输入重排序限制"),
chunkProvider: z.string().trim().min(1, "请选择分块策略"),
chunkTargetTokens: z.string().trim().min(1, "请输入目标 token 数"),
chunkMaxTokens: z.string().trim().min(1, "请输入最大 token 数"),
chunkOverlapTokens: z.string().trim().min(1, "请输入重叠 token 数"),
answerMode: z.string().trim().min(1, "请选择回答模式"),
remark: z.string().trim().max(500, "备注最多500个字符"),
});
type EditForm = z.infer<typeof knowledgeBaseFormSchema>; function createKnowledgeBaseFormSchema(t: TFunction) {
const editFormResolver = zodResolver( return z.object({
knowledgeBaseFormSchema as never, name: z.string().trim().min(1, t("knowledge.nameRequired")).max(100, t("knowledge.nameMax")),
) as Resolver< description: z.string().trim().max(500, t("knowledge.descriptionMax")),
z.input<typeof knowledgeBaseFormSchema>, knowledgeType: z.string().trim().min(1, t("knowledge.typeRequired")),
undefined, defaultTopK: z.string().trim().min(1, t("knowledge.topKRequired")),
z.output<typeof knowledgeBaseFormSchema> defaultScoreThreshold: z.string().trim().min(1, t("knowledge.scoreRequired")),
>; defaultRerankLimit: z.string().trim().min(1, t("knowledge.rerankRequired")),
chunkProvider: z.string().trim().min(1, t("knowledge.chunkProviderRequired")),
chunkTargetTokens: z.string().trim().min(1, t("knowledge.targetTokensRequired")),
chunkMaxTokens: z.string().trim().min(1, t("knowledge.maxTokensRequired")),
chunkOverlapTokens: z.string().trim().min(1, t("knowledge.overlapTokensRequired")),
answerMode: z.string().trim().min(1, t("knowledge.answerModeRequired")),
remark: z.string().trim().max(500, t("knowledge.remarkMax")),
});
}
type EditForm = {
name: string;
description: string;
knowledgeType: string;
defaultTopK: string;
defaultScoreThreshold: string;
defaultRerankLimit: string;
chunkProvider: string;
chunkTargetTokens: string;
chunkMaxTokens: string;
chunkOverlapTokens: string;
answerMode: string;
remark: string;
};
function getKnowledgeTypeOptions(t: TFunction) {
return [
{ value: KnowledgeBaseType.Document, label: t("knowledge.typeDocument") },
{ value: KnowledgeBaseType.FAQ, label: t("knowledge.typeFAQ") },
];
}
function getChunkProviderOptions(t: TFunction) {
return [
{ value: KnowledgeChunkProvider.Fixed, label: t("knowledge.chunkFixed") },
{ value: KnowledgeChunkProvider.Structured, label: t("knowledge.chunkStructured") },
{ value: KnowledgeChunkProvider.Semantic, label: t("knowledge.chunkSemantic") },
];
}
function getAnswerModeOptions(t: TFunction) {
return [
{ value: String(KnowledgeAnswerMode.Strict), label: t("knowledge.answerStrict") },
{ value: String(KnowledgeAnswerMode.Assist), label: t("knowledge.answerAssist") },
];
}
function buildForm(item: KnowledgeBase | null): EditForm { function buildForm(item: KnowledgeBase | null): EditForm {
if (!item) { if (!item) {
@@ -161,13 +183,18 @@ function KnowledgeBaseFormDialogBody({
onOpenChange, onOpenChange,
onSubmit, onSubmit,
}: KnowledgeBaseFormDialogBodyProps) { }: KnowledgeBaseFormDialogBodyProps) {
const t = useI18n();
const formId = "knowledge-base-edit-form"; const formId = "knowledge-base-edit-form";
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const form = useForm< const knowledgeBaseFormSchema = useMemo(() => createKnowledgeBaseFormSchema(t), [t]);
z.input<typeof knowledgeBaseFormSchema>, const editFormResolver = useMemo(
undefined, () => zodResolver(knowledgeBaseFormSchema) as Resolver<EditForm>,
z.output<typeof knowledgeBaseFormSchema> [knowledgeBaseFormSchema],
>({ );
const knowledgeTypeOptions = useMemo(() => getKnowledgeTypeOptions(t), [t]);
const chunkProviderOptions = useMemo(() => getChunkProviderOptions(t), [t]);
const answerModeOptions = useMemo(() => getAnswerModeOptions(t), [t]);
const form = useForm<EditForm>({
resolver: editFormResolver, resolver: editFormResolver,
defaultValues: buildForm(null), defaultValues: buildForm(null),
}); });
@@ -228,7 +255,7 @@ function KnowledgeBaseFormDialogBody({
<ProjectDialog <ProjectDialog
open={open} open={open}
onOpenChange={onOpenChange} onOpenChange={onOpenChange}
title={itemId ? "编辑知识库" : "新建知识库"} title={itemId ? t("knowledge.editBaseTitle") : t("knowledge.createBaseTitle")}
size="lg" size="lg"
footer={ footer={
<> <>
@@ -238,17 +265,17 @@ function KnowledgeBaseFormDialogBody({
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
disabled={saving || loading} disabled={saving || loading}
> >
{t("knowledge.cancel")}
</Button> </Button>
<Button type="submit" form={formId} disabled={saving || loading}> <Button type="submit" form={formId} disabled={saving || loading}>
{saving ? "保存中..." : itemId ? "保存" : "创建"} {saving ? t("knowledge.saving") : itemId ? t("knowledge.save") : t("knowledge.create")}
</Button> </Button>
</> </>
} }
> >
{loading ? ( {loading ? (
<div className="flex items-center justify-center py-8"> <div className="flex items-center justify-center py-8">
<div className="text-sm text-muted-foreground">...</div> <div className="text-sm text-muted-foreground">{t("knowledge.loading")}</div>
</div> </div>
) : ( ) : (
<form <form
@@ -257,7 +284,7 @@ function KnowledgeBaseFormDialogBody({
className="space-y-4" className="space-y-4"
> >
<Field data-invalid={!!errors.knowledgeType}> <Field data-invalid={!!errors.knowledgeType}>
<FieldLabel htmlFor="kb-knowledge-type"></FieldLabel> <FieldLabel htmlFor="kb-knowledge-type">{t("knowledge.knowledgeType")}</FieldLabel>
<FieldContent> <FieldContent>
<Controller <Controller
control={control} control={control}
@@ -265,13 +292,10 @@ function KnowledgeBaseFormDialogBody({
render={({ field }) => ( render={({ field }) => (
<OptionCombobox <OptionCombobox
value={field.value} value={field.value}
options={getEnumOptions(KnowledgeBaseTypeLabels).map((option) => ({ options={knowledgeTypeOptions}
value: String(option.value), placeholder={t("knowledge.selectKnowledgeType")}
label: option.label, searchPlaceholder={t("knowledge.searchKnowledgeType")}
}))} emptyText={t("knowledge.emptyKnowledgeType")}
placeholder="选择知识库类型"
searchPlaceholder="搜索知识库类型"
emptyText="没有匹配的知识库类型"
onChange={field.onChange} onChange={field.onChange}
/> />
)} )}
@@ -281,11 +305,11 @@ function KnowledgeBaseFormDialogBody({
</Field> </Field>
<Field data-invalid={!!errors.name}> <Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="kb-name"></FieldLabel> <FieldLabel htmlFor="kb-name">{t("knowledge.name")}</FieldLabel>
<FieldContent> <FieldContent>
<Input <Input
id="kb-name" id="kb-name"
placeholder="知识库名称" placeholder={t("knowledge.namePlaceholder")}
aria-invalid={!!errors.name} aria-invalid={!!errors.name}
{...register("name")} {...register("name")}
/> />
@@ -294,11 +318,11 @@ function KnowledgeBaseFormDialogBody({
</Field> </Field>
<Field data-invalid={!!errors.description}> <Field data-invalid={!!errors.description}>
<FieldLabel htmlFor="kb-description"></FieldLabel> <FieldLabel htmlFor="kb-description">{t("knowledge.description")}</FieldLabel>
<FieldContent> <FieldContent>
<Textarea <Textarea
id="kb-description" id="kb-description"
placeholder="知识库描述" placeholder={t("knowledge.descriptionPlaceholder")}
rows={3} rows={3}
aria-invalid={!!errors.description} aria-invalid={!!errors.description}
{...register("description")} {...register("description")}
@@ -310,7 +334,7 @@ function KnowledgeBaseFormDialogBody({
{!isFAQKnowledgeBase ? ( {!isFAQKnowledgeBase ? (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
<Field data-invalid={!!errors.chunkProvider}> <Field data-invalid={!!errors.chunkProvider}>
<FieldLabel htmlFor="kb-chunk-provider"></FieldLabel> <FieldLabel htmlFor="kb-chunk-provider">{t("knowledge.chunkProvider")}</FieldLabel>
<FieldContent> <FieldContent>
<Controller <Controller
control={control} control={control}
@@ -318,15 +342,10 @@ function KnowledgeBaseFormDialogBody({
render={({ field }) => ( render={({ field }) => (
<OptionCombobox <OptionCombobox
value={field.value} value={field.value}
options={getEnumOptions(KnowledgeChunkProviderLabels) options={chunkProviderOptions}
.filter((option) => option.value !== KnowledgeChunkProvider.FAQ) placeholder={t("knowledge.selectChunkProvider")}
.map((option) => ({ searchPlaceholder={t("knowledge.searchChunkProvider")}
value: String(option.value), emptyText={t("knowledge.emptyChunkProvider")}
label: option.label,
}))}
placeholder="选择分块策略"
searchPlaceholder="搜索分块策略"
emptyText="没有匹配的分块策略"
onChange={field.onChange} onChange={field.onChange}
/> />
)} )}
@@ -336,7 +355,7 @@ function KnowledgeBaseFormDialogBody({
</Field> </Field>
<Field data-invalid={!!errors.chunkTargetTokens}> <Field data-invalid={!!errors.chunkTargetTokens}>
<FieldLabel htmlFor="kb-chunk-target-tokens"> <FieldLabel htmlFor="kb-chunk-target-tokens">
Token {t("knowledge.targetToken")}
</FieldLabel> </FieldLabel>
<FieldContent> <FieldContent>
<Input <Input
@@ -351,7 +370,7 @@ function KnowledgeBaseFormDialogBody({
</FieldContent> </FieldContent>
</Field> </Field>
<Field data-invalid={!!errors.chunkMaxTokens}> <Field data-invalid={!!errors.chunkMaxTokens}>
<FieldLabel htmlFor="kb-chunk-max-tokens"> Token</FieldLabel> <FieldLabel htmlFor="kb-chunk-max-tokens">{t("knowledge.maxToken")}</FieldLabel>
<FieldContent> <FieldContent>
<Input <Input
id="kb-chunk-max-tokens" id="kb-chunk-max-tokens"
@@ -366,7 +385,7 @@ function KnowledgeBaseFormDialogBody({
</Field> </Field>
<Field data-invalid={!!errors.chunkOverlapTokens}> <Field data-invalid={!!errors.chunkOverlapTokens}>
<FieldLabel htmlFor="kb-chunk-overlap-tokens"> <FieldLabel htmlFor="kb-chunk-overlap-tokens">
Token {t("knowledge.overlapToken")}
</FieldLabel> </FieldLabel>
<FieldContent> <FieldContent>
<Input <Input
@@ -385,7 +404,7 @@ function KnowledgeBaseFormDialogBody({
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
<Field data-invalid={!!errors.defaultTopK}> <Field data-invalid={!!errors.defaultTopK}>
<FieldLabel htmlFor="kb-default-top-k">TopK</FieldLabel> <FieldLabel htmlFor="kb-default-top-k">{t("knowledge.defaultTopK")}</FieldLabel>
<FieldContent> <FieldContent>
<Input <Input
id="kb-default-top-k" id="kb-default-top-k"
@@ -400,7 +419,7 @@ function KnowledgeBaseFormDialogBody({
</Field> </Field>
<Field data-invalid={!!errors.defaultScoreThreshold}> <Field data-invalid={!!errors.defaultScoreThreshold}>
<FieldLabel htmlFor="kb-default-score-threshold"> <FieldLabel htmlFor="kb-default-score-threshold">
{t("knowledge.defaultScoreThreshold")}
</FieldLabel> </FieldLabel>
<FieldContent> <FieldContent>
<Input <Input
@@ -417,7 +436,7 @@ function KnowledgeBaseFormDialogBody({
</Field> </Field>
<Field data-invalid={!!errors.defaultRerankLimit}> <Field data-invalid={!!errors.defaultRerankLimit}>
<FieldLabel htmlFor="kb-default-rerank-limit"> <FieldLabel htmlFor="kb-default-rerank-limit">
{t("knowledge.defaultRerankLimit")}
</FieldLabel> </FieldLabel>
<FieldContent> <FieldContent>
<Input <Input
@@ -432,37 +451,20 @@ function KnowledgeBaseFormDialogBody({
</FieldContent> </FieldContent>
</Field> </Field>
<Field data-invalid={!!errors.answerMode}> <Field data-invalid={!!errors.answerMode}>
<FieldLabel htmlFor="kb-answer-mode"></FieldLabel> <FieldLabel htmlFor="kb-answer-mode">{t("knowledge.answerMode")}</FieldLabel>
<FieldContent> <FieldContent>
<Controller <Controller
control={control} control={control}
name="answerMode" name="answerMode"
render={({ field }) => ( render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}> <OptionCombobox
<SelectTrigger value={field.value}
id="kb-answer-mode" options={answerModeOptions}
aria-invalid={!!errors.answerMode} placeholder={t("knowledge.selectAnswerMode")}
> searchPlaceholder={t("knowledge.searchAnswerMode")}
<SelectValue placeholder="选择回答模式"> emptyText={t("knowledge.emptyAnswerMode")}
{field.value onChange={field.onChange}
? getEnumLabel( />
KnowledgeAnswerModeLabels,
Number(field.value),
)
: undefined}
</SelectValue>
</SelectTrigger>
<SelectContent>
{getEnumOptions(KnowledgeAnswerModeLabels).map((option) => (
<SelectItem
key={option.value}
value={String(option.value)}
>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)} )}
/> />
<FieldError errors={[errors.answerMode]} /> <FieldError errors={[errors.answerMode]} />
@@ -471,11 +473,11 @@ function KnowledgeBaseFormDialogBody({
</div> </div>
<Field data-invalid={!!errors.remark}> <Field data-invalid={!!errors.remark}>
<FieldLabel htmlFor="kb-remark"></FieldLabel> <FieldLabel htmlFor="kb-remark">{t("knowledge.remark")}</FieldLabel>
<FieldContent> <FieldContent>
<Textarea <Textarea
id="kb-remark" id="kb-remark"
placeholder="备注信息" placeholder={t("knowledge.remarkPlaceholder")}
rows={2} rows={2}
aria-invalid={!!errors.remark} aria-invalid={!!errors.remark}
{...register("remark")} {...register("remark")}
@@ -29,7 +29,7 @@ import {
Trash2Icon, Trash2Icon,
} from "lucide-react"; } from "lucide-react";
import type { CSSProperties } from "react"; import type { CSSProperties } from "react";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -47,13 +47,7 @@ import {
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
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 { import { OptionCombobox } from "@/components/option-combobox";
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { import {
createKnowledgeBase, createKnowledgeBase,
deleteKnowledgeBase, deleteKnowledgeBase,
@@ -64,8 +58,8 @@ import {
type CreateKnowledgeBasePayload, type CreateKnowledgeBasePayload,
type KnowledgeBase, type KnowledgeBase,
} from "@/lib/api/admin"; } from "@/lib/api/admin";
import { getEnumLabel, getEnumOptions } from "@/lib/enums"; import { useI18n } from "@/i18n/provider";
import { KnowledgeBaseType, StatusLabels } from "@/lib/generated/enums"; import { KnowledgeBaseType, Status } from "@/lib/generated/enums";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { EditDialog } from "./knowledge-base-edit"; import { EditDialog } from "./knowledge-base-edit";
@@ -74,10 +68,16 @@ type KnowledgeBaseListProps = {
onSelectKnowledgeBase: (knowledgeBase: KnowledgeBase | null) => void; onSelectKnowledgeBase: (knowledgeBase: KnowledgeBase | null) => void;
}; };
const statusOptions = [ type TFunction = (key: string, values?: Record<string, string | number>) => string;
{ value: "all", label: "全部状态" },
...getEnumOptions(StatusLabels), function getStatusOptions(t: TFunction) {
] as const; return [
{ value: "all", label: t("knowledge.allStatus") },
{ value: String(Status.Ok), label: t("knowledge.statusOk") },
{ value: String(Status.Disabled), label: t("knowledge.statusDisabled") },
{ value: String(Status.Deleted), label: t("knowledge.statusDeleted") },
];
}
type SortableKnowledgeBaseCardProps = { type SortableKnowledgeBaseCardProps = {
item: KnowledgeBase; item: KnowledgeBase;
@@ -89,6 +89,7 @@ type SortableKnowledgeBaseCardProps = {
onRebuildIndex: () => void; onRebuildIndex: () => void;
deleteLoadingId: number | null; deleteLoadingId: number | null;
rebuildIndexLoadingId: number | null; rebuildIndexLoadingId: number | null;
t: TFunction;
}; };
function SortableKnowledgeBaseCard({ function SortableKnowledgeBaseCard({
@@ -101,6 +102,7 @@ function SortableKnowledgeBaseCard({
onRebuildIndex, onRebuildIndex,
deleteLoadingId, deleteLoadingId,
rebuildIndexLoadingId, rebuildIndexLoadingId,
t,
}: SortableKnowledgeBaseCardProps) { }: SortableKnowledgeBaseCardProps) {
const { const {
attributes, attributes,
@@ -157,7 +159,7 @@ function SortableKnowledgeBaseCard({
className="size-6 opacity-0 group-hover:opacity-100" className="size-6 opacity-0 group-hover:opacity-100"
/> />
} }
aria-label={`更多操作 ${item.name}`} aria-label={t("knowledge.moreActions", { name: item.name })}
> >
<MoreHorizontalIcon className="size-3.5" /> <MoreHorizontalIcon className="size-3.5" />
</DropdownMenuTrigger> </DropdownMenuTrigger>
@@ -169,7 +171,7 @@ function SortableKnowledgeBaseCard({
}} }}
> >
<PencilIcon className="mr-2 size-3.5" /> <PencilIcon className="mr-2 size-3.5" />
{t("knowledge.edit")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={(e) => { onClick={(e) => {
@@ -178,7 +180,7 @@ function SortableKnowledgeBaseCard({
}} }}
> >
<RefreshCwIcon className="mr-2 size-3.5" /> <RefreshCwIcon className="mr-2 size-3.5" />
{rebuildIndexLoadingId === item.id ? "重建中..." : "重建索引"} {rebuildIndexLoadingId === item.id ? t("knowledge.rebuilding") : t("knowledge.rebuildIndex")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={(e) => { onClick={(e) => {
@@ -188,7 +190,7 @@ function SortableKnowledgeBaseCard({
className="text-destructive focus:text-destructive" className="text-destructive focus:text-destructive"
> >
<Trash2Icon className="mr-2 size-3.5" /> <Trash2Icon className="mr-2 size-3.5" />
{deleteLoadingId === item.id ? "删除中..." : "删除"} {deleteLoadingId === item.id ? t("knowledge.deleting") : t("knowledge.delete")}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@@ -201,7 +203,7 @@ function SortableKnowledgeBaseCard({
}} }}
> >
<PencilIcon className="mr-2 size-3.5" /> <PencilIcon className="mr-2 size-3.5" />
{t("knowledge.edit")}
</ContextMenuItem> </ContextMenuItem>
<ContextMenuItem <ContextMenuItem
onClick={(e) => { onClick={(e) => {
@@ -210,7 +212,7 @@ function SortableKnowledgeBaseCard({
}} }}
> >
<RefreshCwIcon className="mr-2 size-3.5" /> <RefreshCwIcon className="mr-2 size-3.5" />
{rebuildIndexLoadingId === item.id ? "重建中..." : "重建索引"} {rebuildIndexLoadingId === item.id ? t("knowledge.rebuilding") : t("knowledge.rebuildIndex")}
</ContextMenuItem> </ContextMenuItem>
<ContextMenuItem <ContextMenuItem
onClick={(e) => { onClick={(e) => {
@@ -220,7 +222,7 @@ function SortableKnowledgeBaseCard({
variant="destructive" variant="destructive"
> >
<Trash2Icon className="mr-2 size-3.5" /> <Trash2Icon className="mr-2 size-3.5" />
{deleteLoadingId === item.id ? "删除中..." : "删除"} {deleteLoadingId === item.id ? t("knowledge.deleting") : t("knowledge.delete")}
</ContextMenuItem> </ContextMenuItem>
</ContextMenuContent> </ContextMenuContent>
</ContextMenu> </ContextMenu>
@@ -231,6 +233,7 @@ export function KnowledgeBaseList({
selectedKnowledgeBaseId, selectedKnowledgeBaseId,
onSelectKnowledgeBase, onSelectKnowledgeBase,
}: KnowledgeBaseListProps) { }: KnowledgeBaseListProps) {
const t = useI18n();
const [keywordInput, setKeywordInput] = useState(""); const [keywordInput, setKeywordInput] = useState("");
const [statusFilterInput, setStatusFilterInput] = useState("all"); const [statusFilterInput, setStatusFilterInput] = useState("all");
const [keyword, setKeyword] = useState(""); const [keyword, setKeyword] = useState("");
@@ -245,6 +248,7 @@ export function KnowledgeBaseList({
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [editingItemId, setEditingItemId] = useState<number | null>(null); const [editingItemId, setEditingItemId] = useState<number | null>(null);
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]); const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
const statusOptions = useMemo(() => getStatusOptions(t), [t]);
const sensors = useSensors( const sensors = useSensors(
useSensor(MouseSensor, { useSensor(MouseSensor, {
@@ -268,11 +272,11 @@ export function KnowledgeBaseList({
}); });
setKnowledgeBases(data.results); setKnowledgeBases(data.results);
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "加载知识库失败"); toast.error(error instanceof Error ? error.message : t("knowledge.loadBasesFailed"));
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [keyword, statusFilter]); }, [keyword, statusFilter, t]);
useEffect(() => { useEffect(() => {
void loadData(); void loadData();
@@ -340,16 +344,16 @@ export function KnowledgeBaseList({
const editingItem = knowledgeBases.find( const editingItem = knowledgeBases.find(
(item) => item.id === editingItemId, (item) => item.id === editingItemId,
); );
toast.success(`已更新知识库:${editingItem?.name || payload.name}`); toast.success(t("knowledge.baseUpdated", { name: editingItem?.name || payload.name }));
} else { } else {
await createKnowledgeBase(payload); await createKnowledgeBase(payload);
toast.success(`已创建知识库:${payload.name}`); toast.success(t("knowledge.baseCreated", { name: payload.name }));
} }
setDialogOpen(false); setDialogOpen(false);
setEditingItemId(null); setEditingItemId(null);
await loadData(); await loadData();
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "保存知识库失败"); toast.error(error instanceof Error ? error.message : t("knowledge.baseSaveFailed"));
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -359,13 +363,13 @@ export function KnowledgeBaseList({
setDeleteLoadingId(item.id); setDeleteLoadingId(item.id);
try { try {
await deleteKnowledgeBase(item.id); await deleteKnowledgeBase(item.id);
toast.success(`已删除知识库:${item.name}`); toast.success(t("knowledge.baseDeleted", { name: item.name }));
if (selectedKnowledgeBaseId === item.id) { if (selectedKnowledgeBaseId === item.id) {
onSelectKnowledgeBase(null); onSelectKnowledgeBase(null);
} }
await loadData(); await loadData();
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "删除知识库失败"); toast.error(error instanceof Error ? error.message : t("knowledge.baseDeleteFailed"));
} finally { } finally {
setDeleteLoadingId(null); setDeleteLoadingId(null);
} }
@@ -375,10 +379,10 @@ export function KnowledgeBaseList({
setRebuildIndexLoadingId(item.id); setRebuildIndexLoadingId(item.id);
try { try {
await rebuildKnowledgeBaseIndex(item.id); await rebuildKnowledgeBaseIndex(item.id);
toast.success(`已开始重建知识库索引:${item.name}`); toast.success(t("knowledge.rebuildStarted", { name: item.name }));
} catch (error) { } catch (error) {
toast.error( toast.error(
error instanceof Error ? error.message : "重建知识库索引失败", error instanceof Error ? error.message : t("knowledge.rebuildFailed"),
); );
} finally { } finally {
setRebuildIndexLoadingId(null); setRebuildIndexLoadingId(null);
@@ -404,12 +408,12 @@ export function KnowledgeBaseList({
try { try {
await updateKnowledgeBaseSort(nextResults.map((item) => item.id)); await updateKnowledgeBaseSort(nextResults.map((item) => item.id));
toast.success("知识库排序已更新"); toast.success(t("knowledge.sortUpdated"));
await loadData(); await loadData();
} catch (error) { } catch (error) {
setKnowledgeBases(previousResults); setKnowledgeBases(previousResults);
toast.error( toast.error(
error instanceof Error ? error.message : "更新知识库排序失败", error instanceof Error ? error.message : t("knowledge.sortUpdateFailed"),
); );
} finally { } finally {
setSorting(false); setSorting(false);
@@ -421,7 +425,7 @@ export function KnowledgeBaseList({
<div className="flex h-full flex-col border-r bg-muted/30"> <div className="flex h-full flex-col border-r bg-muted/30">
<div className="flex flex-col gap-2 border-b bg-background p-4"> <div className="flex flex-col gap-2 border-b bg-background p-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-sm font-semibold"></h2> <h2 className="text-sm font-semibold">{t("knowledge.title")}</h2>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button <Button
variant="ghost" variant="ghost"
@@ -451,33 +455,18 @@ export function KnowledgeBaseList({
value={keywordInput} value={keywordInput}
onChange={(event) => setKeywordInput(event.target.value)} onChange={(event) => setKeywordInput(event.target.value)}
onKeyDown={handleFilterKeyDown} onKeyDown={handleFilterKeyDown}
placeholder="搜索知识库" placeholder={t("knowledge.searchBase")}
className="h-8 pl-8 text-xs" className="h-8 pl-8 text-xs"
/> />
</div> </div>
<Select <OptionCombobox
value={statusFilterInput} value={statusFilterInput}
onValueChange={handleStatusFilterChange} onChange={handleStatusFilterChange}
> options={statusOptions}
<SelectTrigger className="h-8 w-28 text-xs"> placeholder={t("knowledge.allStatus")}
<SelectValue> searchPlaceholder={t("knowledge.searchBase")}
{statusFilterInput === "all" emptyText={t("knowledge.emptyBases")}
? "全部状态" />
: getEnumLabel(StatusLabels, Number(statusFilterInput))}
</SelectValue>
</SelectTrigger>
<SelectContent>
{statusOptions.map((item) => (
<SelectItem
key={item.value}
value={item.value}
className="text-xs"
>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div> </div>
</div> </div>
<ScrollArea className="flex-1"> <ScrollArea className="flex-1">
@@ -503,13 +492,14 @@ export function KnowledgeBaseList({
onRebuildIndex={() => void handleRebuildIndex(item)} onRebuildIndex={() => void handleRebuildIndex(item)}
deleteLoadingId={deleteLoadingId} deleteLoadingId={deleteLoadingId}
rebuildIndexLoadingId={rebuildIndexLoadingId} rebuildIndexLoadingId={rebuildIndexLoadingId}
t={t}
/> />
))} ))}
</SortableContext> </SortableContext>
</DndContext> </DndContext>
{!loading && knowledgeBases.length === 0 ? ( {!loading && knowledgeBases.length === 0 ? (
<div className="py-8 text-center text-sm text-muted-foreground"> <div className="py-8 text-center text-sm text-muted-foreground">
{t("knowledge.emptyBases")}
</div> </div>
) : null} ) : null}
</div> </div>
@@ -19,6 +19,13 @@ import {
} from "@/components/ui/drawer" } from "@/components/ui/drawer"
import { ScrollArea } from "@/components/ui/scroll-area" import { ScrollArea } from "@/components/ui/scroll-area"
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator"
import { useI18n } from "@/i18n/provider"
import {
getKnowledgeAnswerStatusLabel,
getKnowledgeChunkProviderLabel,
getKnowledgeRetrieveChannelLabel,
getKnowledgeRetrieveSceneLabel,
} from "@/lib/knowledge-i18n"
type RetrieveLogDetailDrawerProps = { type RetrieveLogDetailDrawerProps = {
open: boolean open: boolean
@@ -37,21 +44,23 @@ function safeParseJSON(value: string) {
} }
} }
function CitationList({ hits }: { hits: KnowledgeRetrieveHit[] }) { type TFunction = (key: string, values?: Record<string, string | number>) => string
function CitationList({ hits, t }: { hits: KnowledgeRetrieveHit[]; t: TFunction }) {
const citations = hits.filter((item) => item.isCitation) const citations = hits.filter((item) => item.isCitation)
if (citations.length === 0) { if (citations.length === 0) {
return <div className="text-sm text-muted-foreground"></div> return <div className="text-sm text-muted-foreground">{t("knowledge.noCitations")}</div>
} }
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{citations.map((item) => ( {citations.map((item) => (
<div key={item.id} className="rounded-lg border p-3"> <div key={item.id} className="rounded-lg border p-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-medium">{getHitSourceLabel(item)}</span> <span className="font-medium">{getHitSourceLabel(item, t)}</span>
<Badge variant="outline">Chunk #{item.chunkNo}</Badge> <Badge variant="outline">Chunk #{item.chunkNo}</Badge>
</div> </div>
<div className="mt-1 text-xs text-muted-foreground"> <div className="mt-1 text-xs text-muted-foreground">
{item.sectionPath || item.title || "未记录章节"} {item.sectionPath || item.title || t("knowledge.unrecordedSection")}
</div> </div>
<div className="mt-2 text-sm leading-6 whitespace-pre-wrap text-foreground/90"> <div className="mt-2 text-sm leading-6 whitespace-pre-wrap text-foreground/90">
{item.snippet || "-"} {item.snippet || "-"}
@@ -67,6 +76,7 @@ export function RetrieveLogDetailDrawer({
retrieveLogId, retrieveLogId,
onOpenChange, onOpenChange,
}: RetrieveLogDetailDrawerProps) { }: RetrieveLogDetailDrawerProps) {
const t = useI18n()
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [detail, setDetail] = useState<KnowledgeRetrieveLogDetail | null>(null) const [detail, setDetail] = useState<KnowledgeRetrieveLogDetail | null>(null)
@@ -80,11 +90,11 @@ export function RetrieveLogDetailDrawer({
const data = await fetchKnowledgeRetrieveLog(retrieveLogId) const data = await fetchKnowledgeRetrieveLog(retrieveLogId)
setDetail(data) setDetail(data)
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "加载检索日志详情失败") toast.error(error instanceof Error ? error.message : t("knowledge.loadRetrieveLogDetailFailed"))
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, [retrieveLogId]) }, [retrieveLogId, t])
useEffect(() => { useEffect(() => {
if (open && retrieveLogId) { if (open && retrieveLogId) {
@@ -102,105 +112,115 @@ export function RetrieveLogDetailDrawer({
<Drawer open={open} onOpenChange={onOpenChange} direction="right"> <Drawer open={open} onOpenChange={onOpenChange} direction="right">
<DrawerContent className="max-w-3xl"> <DrawerContent className="max-w-3xl">
<DrawerHeader> <DrawerHeader>
<DrawerTitle></DrawerTitle> <DrawerTitle>{t("knowledge.retrieveLogDetail")}</DrawerTitle>
<DrawerDescription> <DrawerDescription>
{detail?.log.question || (loading ? "加载中..." : "未找到检索日志")} {detail?.log.question || (loading ? t("knowledge.loading") : t("knowledge.retrieveLogNotFound"))}
</DrawerDescription> </DrawerDescription>
</DrawerHeader> </DrawerHeader>
<ScrollArea className="h-[calc(100vh-6rem)] px-4 pb-6"> <ScrollArea className="h-[calc(100vh-6rem)] px-4 pb-6">
{!detail ? ( {!detail ? (
<div className="py-6 text-sm text-muted-foreground"> <div className="py-6 text-sm text-muted-foreground">
{loading ? "正在加载详情..." : "暂无详情数据"} {loading ? t("knowledge.loadingDetail") : t("knowledge.emptyDetail")}
</div> </div>
) : ( ) : (
<div className="space-y-6 pb-6"> <div className="space-y-6 pb-6">
<section className="space-y-3"> <section className="space-y-3">
<h3 className="text-sm font-semibold"></h3> <h3 className="text-sm font-semibold">{t("knowledge.requestInfo")}</h3>
<div className="grid gap-3 rounded-lg border p-4 md:grid-cols-2"> <div className="grid gap-3 rounded-lg border p-4 md:grid-cols-2">
<div> <div>
<div className="text-xs text-muted-foreground"></div> <div className="text-xs text-muted-foreground">{t("knowledge.title")}</div>
<div className="mt-1 text-sm">{detail.log.knowledgeBaseName || `#${detail.log.knowledgeBaseId}`}</div> <div className="mt-1 text-sm">{detail.log.knowledgeBaseName || `#${detail.log.knowledgeBaseId}`}</div>
</div> </div>
<div> <div>
<div className="text-xs text-muted-foreground"></div> <div className="text-xs text-muted-foreground">{t("knowledge.createdAt")}</div>
<div className="mt-1 text-sm">{formatDateTime(detail.log.createdAt)}</div> <div className="mt-1 text-sm">{formatDateTime(detail.log.createdAt)}</div>
</div> </div>
<div> <div>
<div className="text-xs text-muted-foreground"> / </div> <div className="text-xs text-muted-foreground">{t("knowledge.channelScene")}</div>
<div className="mt-1 text-sm">{detail.log.channelName} / {detail.log.sceneName}</div> <div className="mt-1 text-sm">
{getKnowledgeRetrieveChannelLabel(detail.log.channel, detail.log.channelName, t)} /{" "}
{getKnowledgeRetrieveSceneLabel(detail.log.scene, detail.log.sceneName, t)}
</div>
</div> </div>
<div> <div>
<div className="text-xs text-muted-foreground">Request ID</div> <div className="text-xs text-muted-foreground">Request ID</div>
<div className="mt-1 break-all font-mono text-xs">{detail.log.requestId || "-"}</div> <div className="mt-1 break-all font-mono text-xs">{detail.log.requestId || "-"}</div>
</div> </div>
<div className="md:col-span-2"> <div className="md:col-span-2">
<div className="text-xs text-muted-foreground"></div> <div className="text-xs text-muted-foreground">{t("knowledge.originalQuestion")}</div>
<div className="mt-1 text-sm leading-6 whitespace-pre-wrap">{detail.log.question || "-"}</div> <div className="mt-1 text-sm leading-6 whitespace-pre-wrap">{detail.log.question || "-"}</div>
</div> </div>
<div className="md:col-span-2"> <div className="md:col-span-2">
<div className="text-xs text-muted-foreground"></div> <div className="text-xs text-muted-foreground">{t("knowledge.rewriteQuestion")}</div>
<div className="mt-1 text-sm leading-6 whitespace-pre-wrap">{detail.log.rewriteQuestion || "-"}</div> <div className="mt-1 text-sm leading-6 whitespace-pre-wrap">{detail.log.rewriteQuestion || "-"}</div>
</div> </div>
<div className="md:col-span-2"> <div className="md:col-span-2">
<div className="text-xs text-muted-foreground"></div> <div className="text-xs text-muted-foreground">{t("knowledge.answerContent")}</div>
<div className="mt-1 text-sm leading-6 whitespace-pre-wrap">{detail.log.answer || "-"}</div> <div className="mt-1 text-sm leading-6 whitespace-pre-wrap">{detail.log.answer || "-"}</div>
</div> </div>
</div> </div>
</section> </section>
<section className="space-y-3"> <section className="space-y-3">
<h3 className="text-sm font-semibold"></h3> <h3 className="text-sm font-semibold">{t("knowledge.retrieveStrategy")}</h3>
<div className="grid gap-3 rounded-lg border p-4 md:grid-cols-3"> <div className="grid gap-3 rounded-lg border p-4 md:grid-cols-3">
<Metric label="Chunk Provider" value={detail.log.chunkProvider || "-"} mono /> <Metric
label="Chunk Provider"
value={detail.log.chunkProvider ? getKnowledgeChunkProviderLabel(detail.log.chunkProvider, t) : "-"}
mono
/>
<Metric label="Target Tokens" value={detail.log.chunkTargetTokens} /> <Metric label="Target Tokens" value={detail.log.chunkTargetTokens} />
<Metric label="Max Tokens" value={detail.log.chunkMaxTokens} /> <Metric label="Max Tokens" value={detail.log.chunkMaxTokens} />
<Metric label="Overlap Tokens" value={detail.log.chunkOverlapTokens} /> <Metric label="Overlap Tokens" value={detail.log.chunkOverlapTokens} />
<Metric label="Rerank" value={detail.log.rerankEnabled ? "已启用" : "未启用"} /> <Metric label="Rerank" value={detail.log.rerankEnabled ? t("knowledge.rerankEnabled") : t("knowledge.rerankDisabled")} />
<Metric label="Rerank Limit" value={detail.log.rerankLimit} /> <Metric label="Rerank Limit" value={detail.log.rerankLimit} />
</div> </div>
</section> </section>
<section className="space-y-3"> <section className="space-y-3">
<h3 className="text-sm font-semibold"></h3> <h3 className="text-sm font-semibold">{t("knowledge.resultSummary")}</h3>
<div className="grid gap-3 rounded-lg border p-4 md:grid-cols-4"> <div className="grid gap-3 rounded-lg border p-4 md:grid-cols-4">
<Metric label="回答状态" value={detail.log.answerStatusName} /> <Metric
<Metric label="命中数" value={detail.log.hitCount} /> label={t("knowledge.answerStatus")}
<Metric label="引用数" value={detail.log.citationCount} /> value={getKnowledgeAnswerStatusLabel(detail.log.answerStatus, detail.log.answerStatusName, t)}
<Metric label="上下文 Chunk" value={detail.log.usedChunkCount} /> />
<Metric label={t("knowledge.hitCount")} value={detail.log.hitCount} />
<Metric label={t("knowledge.citations")} value={detail.log.citationCount} />
<Metric label={t("knowledge.contextChunks")} value={detail.log.usedChunkCount} />
<Metric label="Top Score" value={detail.log.topScore.toFixed(4)} mono /> <Metric label="Top Score" value={detail.log.topScore.toFixed(4)} mono />
<Metric label="检索耗时" value={`${detail.log.retrieveMs} ms`} /> <Metric label={t("knowledge.retrieveMs")} value={`${detail.log.retrieveMs} ms`} />
<Metric label="生成耗时" value={`${detail.log.generateMs} ms`} /> <Metric label={t("knowledge.generateMs")} value={`${detail.log.generateMs} ms`} />
<Metric label="总耗时" value={`${detail.log.latencyMs} ms`} /> <Metric label={t("knowledge.totalMs")} value={`${detail.log.latencyMs} ms`} />
<Metric label="Prompt Tokens" value={detail.log.promptTokens} /> <Metric label="Prompt Tokens" value={detail.log.promptTokens} />
<Metric label="Completion Tokens" value={detail.log.completionTokens} /> <Metric label="Completion Tokens" value={detail.log.completionTokens} />
<Metric label="模型" value={detail.log.modelName || "-"} mono /> <Metric label={t("knowledge.model")} value={detail.log.modelName || "-"} mono />
<Metric label="会话 ID" value={detail.log.sessionId || "-"} mono /> <Metric label={t("knowledge.sessionId")} value={detail.log.sessionId || "-"} mono />
</div> </div>
</section> </section>
<section className="space-y-3"> <section className="space-y-3">
<h3 className="text-sm font-semibold"></h3> <h3 className="text-sm font-semibold">{t("knowledge.citationSources")}</h3>
<CitationList hits={detail.hits} /> <CitationList hits={detail.hits} t={t} />
</section> </section>
<section className="space-y-3"> <section className="space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-sm font-semibold"></h3> <h3 className="text-sm font-semibold">{t("knowledge.hitDetails")}</h3>
<div className="text-xs text-muted-foreground">{detail.hits.length} </div> <div className="text-xs text-muted-foreground">{t("knowledge.itemsCount", { count: detail.hits.length })}</div>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
{detail.hits.map((item) => ( {detail.hits.map((item) => (
<div key={item.id} className="rounded-lg border p-3"> <div key={item.id} className="rounded-lg border p-3">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">#{item.rankNo}</Badge> <Badge variant="outline">#{item.rankNo}</Badge>
<span className="font-medium">{getHitSourceLabel(item)}</span> <span className="font-medium">{getHitSourceLabel(item, t)}</span>
<Badge variant={item.usedInAnswer ? "default" : "secondary"}> <Badge variant={item.usedInAnswer ? "default" : "secondary"}>
{item.usedInAnswer ? "已入上下文" : "未入上下文"} {item.usedInAnswer ? t("knowledge.usedInContext") : t("knowledge.notUsedInContext")}
</Badge> </Badge>
{item.isCitation ? <Badge></Badge> : null} {item.isCitation ? <Badge>{t("knowledge.citations")}</Badge> : null}
</div> </div>
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground"> <div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span>{item.sectionPath || item.title || "-"}</span> <span>{t("knowledge.section", { section: item.sectionPath || item.title || "-" })}</span>
<span>Chunk #{item.chunkNo}</span> <span>Chunk #{item.chunkNo}</span>
<span>Provider{item.provider || "-"}</span> <span>Provider{item.provider || "-"}</span>
<span>Score{item.score.toFixed(4)}</span> <span>Score{item.score.toFixed(4)}</span>
@@ -231,7 +251,7 @@ export function RetrieveLogDetailDrawer({
) )
} }
function getHitSourceLabel(item: KnowledgeRetrieveHit) { function getHitSourceLabel(item: KnowledgeRetrieveHit, t: TFunction) {
if (item.faqQuestion) { if (item.faqQuestion) {
return item.faqQuestion return item.faqQuestion
} }
@@ -241,7 +261,7 @@ function getHitSourceLabel(item: KnowledgeRetrieveHit) {
if (item.faqId > 0) { if (item.faqId > 0) {
return `FAQ #${item.faqId}` return `FAQ #${item.faqId}`
} }
return `文档 #${item.documentId}` return `${t("knowledge.document")} #${item.documentId}`
} }
function Metric({ function Metric({
@@ -10,13 +10,18 @@ import {
type PageResult, type PageResult,
} from "@/lib/api/admin" } from "@/lib/api/admin"
import { import {
KnowledgeAnswerStatusLabels, KnowledgeAnswerStatus,
KnowledgeChunkProviderLabels, KnowledgeRetrieveChannel,
KnowledgeRetrieveChannelLabels, KnowledgeRetrieveScene,
KnowledgeRetrieveSceneLabels,
} from "@/lib/generated/enums" } from "@/lib/generated/enums"
import { getEnumOptions } from "@/lib/enums" import {
getKnowledgeAnswerStatusLabel,
getKnowledgeChunkProviderLabel,
getKnowledgeRetrieveChannelLabel,
getKnowledgeRetrieveSceneLabel,
} from "@/lib/knowledge-i18n"
import { formatDateTime } from "@/lib/utils" import { formatDateTime } from "@/lib/utils"
import { useI18n } from "@/i18n/provider"
import { ListPagination } from "@/components/list-pagination" import { ListPagination } from "@/components/list-pagination"
import { OptionCombobox } from "@/components/option-combobox" import { OptionCombobox } from "@/components/option-combobox"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
@@ -36,43 +41,70 @@ type RetrieveLogListProps = {
knowledgeBaseId: number | null knowledgeBaseId: number | null
} }
const channelOptions = [ type TFunction = (key: string, values?: Record<string, string | number>) => string
{ value: "all", label: "全部渠道" },
...getEnumOptions(KnowledgeRetrieveChannelLabels).map((item) => ({
value: String(item.value),
label: item.label,
})),
]
const sceneOptions = [ function getChannelOptions(t: TFunction) {
{ value: "all", label: "全部场景" }, return [
...getEnumOptions(KnowledgeRetrieveSceneLabels).map((item) => ({ { value: "all", label: t("knowledge.allChannels") },
value: String(item.value), { value: KnowledgeRetrieveChannel.IM, label: t("knowledge.channelIM") },
label: item.label, { value: KnowledgeRetrieveChannel.AgentAssist, label: t("knowledge.channelAgentAssist") },
})), { value: KnowledgeRetrieveChannel.API, label: t("knowledge.channelAPI") },
{ value: KnowledgeRetrieveChannel.Debug, label: t("knowledge.channelDebug") },
] ]
}
const answerStatusOptions = [ function getSceneOptions(t: TFunction) {
{ value: "all", label: "全部回答状态" }, return [
...getEnumOptions(KnowledgeAnswerStatusLabels).map((item) => ({ { value: "all", label: t("knowledge.allScenes") },
value: String(item.value), { value: KnowledgeRetrieveScene.FirstResponse, label: t("knowledge.sceneFirstResponse") },
label: item.label, { value: KnowledgeRetrieveScene.Assist, label: t("knowledge.sceneAssist") },
})), { value: KnowledgeRetrieveScene.QA, label: t("knowledge.sceneQA") },
] ]
}
const providerOptions = [ function getAnswerStatusOptions(t: TFunction) {
{ value: "all", label: "全部切分策略" }, return [
...getEnumOptions(KnowledgeChunkProviderLabels).map((item) => ({ { value: "all", label: t("knowledge.allAnswerStatus") },
value: String(item.value), { value: String(KnowledgeAnswerStatus.Normal), label: t("knowledge.answerNormal") },
label: item.label, { value: String(KnowledgeAnswerStatus.NoAnswer), label: t("knowledge.answerNoAnswer") },
})), { value: String(KnowledgeAnswerStatus.Fallback), label: t("knowledge.answerFallback") },
{ value: String(KnowledgeAnswerStatus.Blocked), label: t("knowledge.answerBlocked") },
] ]
}
const rerankOptions = [ function getProviderOptions(t: TFunction) {
{ value: "all", label: "全部 Rerank" }, return [
{ value: "1", label: "已启用 Rerank" }, { value: "all", label: t("knowledge.allChunkProviders") },
{ value: "0", label: "未启用 Rerank" }, { value: "fixed", label: t("knowledge.chunkFixed") },
{ value: "structured", label: t("knowledge.chunkStructured") },
{ value: "faq", label: t("knowledge.chunkFAQ") },
{ value: "semantic", label: t("knowledge.chunkSemantic") },
] ]
}
function getRerankOptions(t: TFunction) {
return [
{ value: "all", label: t("knowledge.allRerank") },
{ value: "1", label: t("knowledge.rerankEnabled") },
{ value: "0", label: t("knowledge.rerankDisabled") },
]
}
function channelLabel(value: string, fallback: string, t: TFunction) {
return getKnowledgeRetrieveChannelLabel(value, fallback, t)
}
function sceneLabel(value: string, fallback: string, t: TFunction) {
return getKnowledgeRetrieveSceneLabel(value, fallback, t)
}
function answerStatusLabel(value: number, fallback: string, t: TFunction) {
return getKnowledgeAnswerStatusLabel(value, fallback, t)
}
function providerLabel(value: string, t: TFunction) {
return getKnowledgeChunkProviderLabel(value, t)
}
function getAnswerStatusVariant(status: number): "default" | "secondary" | "outline" | "destructive" { function getAnswerStatusVariant(status: number): "default" | "secondary" | "outline" | "destructive" {
switch (status) { switch (status) {
@@ -92,6 +124,7 @@ function getAnswerStatusVariant(status: number): "default" | "secondary" | "outl
export function RetrieveLogList({ export function RetrieveLogList({
knowledgeBaseId, knowledgeBaseId,
}: RetrieveLogListProps) { }: RetrieveLogListProps) {
const t = useI18n()
const [questionInput, setQuestionInput] = useState("") const [questionInput, setQuestionInput] = useState("")
const [question, setQuestion] = useState("") const [question, setQuestion] = useState("")
const [channel, setChannel] = useState("all") const [channel, setChannel] = useState("all")
@@ -108,6 +141,11 @@ export function RetrieveLogList({
results: [], results: [],
page: { page: 1, limit: 20, total: 0 }, page: { page: 1, limit: 20, total: 0 },
}) })
const channelOptions = useMemo(() => getChannelOptions(t), [t])
const sceneOptions = useMemo(() => getSceneOptions(t), [t])
const answerStatusOptions = useMemo(() => getAnswerStatusOptions(t), [t])
const providerOptions = useMemo(() => getProviderOptions(t), [t])
const rerankOptions = useMemo(() => getRerankOptions(t), [t])
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
if (!knowledgeBaseId) { if (!knowledgeBaseId) {
@@ -131,11 +169,11 @@ export function RetrieveLogList({
}) })
setResult(data) setResult(data)
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "加载检索日志失败") toast.error(error instanceof Error ? error.message : t("knowledge.loadRetrieveLogsFailed"))
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, [answerStatus, channel, chunkProvider, knowledgeBaseId, limit, page, question, rerankEnabled, scene]) }, [answerStatus, channel, chunkProvider, knowledgeBaseId, limit, page, question, rerankEnabled, scene, t])
useEffect(() => { useEffect(() => {
void loadData() void loadData()
@@ -149,13 +187,13 @@ export function RetrieveLogList({
const emptyStateText = useMemo(() => { const emptyStateText = useMemo(() => {
if (!knowledgeBaseId) { if (!knowledgeBaseId) {
return "请选择一个知识库查看检索日志" return t("knowledge.selectBaseForLogs")
} }
if (loading) { if (loading) {
return "正在加载检索日志..." return t("knowledge.loadingRetrieveLogs")
} }
return "当前知识库还没有检索日志" return t("knowledge.emptyRetrieveLogs")
}, [knowledgeBaseId, loading]) }, [knowledgeBaseId, loading, t])
function applyFilters() { function applyFilters() {
setQuestion(questionInput) setQuestion(questionInput)
@@ -194,16 +232,16 @@ export function RetrieveLogList({
value={questionInput} value={questionInput}
onChange={(event) => setQuestionInput(event.target.value)} onChange={(event) => setQuestionInput(event.target.value)}
onKeyDown={handleQuestionKeyDown} onKeyDown={handleQuestionKeyDown}
placeholder="按问题关键字筛选" placeholder={t("knowledge.filterQuestion")}
className="pl-9" className="pl-9"
/> />
</div> </div>
<OptionCombobox value={channel} options={channelOptions} placeholder="选择渠道" onChange={setChannel} /> <OptionCombobox value={channel} options={channelOptions} placeholder={t("knowledge.selectChannel")} onChange={setChannel} />
<OptionCombobox value={scene} options={sceneOptions} placeholder="选择场景" onChange={setScene} /> <OptionCombobox value={scene} options={sceneOptions} placeholder={t("knowledge.selectScene")} onChange={setScene} />
<OptionCombobox value={answerStatus} options={answerStatusOptions} placeholder="回答状态" onChange={setAnswerStatus} /> <OptionCombobox value={answerStatus} options={answerStatusOptions} placeholder={t("knowledge.answerStatus")} onChange={setAnswerStatus} />
<OptionCombobox value={chunkProvider} options={providerOptions} placeholder="切分策略" onChange={setChunkProvider} /> <OptionCombobox value={chunkProvider} options={providerOptions} placeholder={t("knowledge.chunkStrategy")} onChange={setChunkProvider} />
<OptionCombobox value={rerankEnabled} options={rerankOptions} placeholder="Rerank" onChange={setRerankEnabled} /> <OptionCombobox value={rerankEnabled} options={rerankOptions} placeholder="Rerank" onChange={setRerankEnabled} />
<Button onClick={applyFilters}></Button> <Button onClick={applyFilters}>{t("knowledge.filter")}</Button>
</div> </div>
</div> </div>
@@ -212,15 +250,15 @@ export function RetrieveLogList({
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="w-42"></TableHead> <TableHead className="w-42">{t("knowledge.time")}</TableHead>
<TableHead></TableHead> <TableHead>{t("knowledge.question")}</TableHead>
<TableHead className="w-28"></TableHead> <TableHead className="w-28">{t("knowledge.answerStatus")}</TableHead>
<TableHead className="w-24 text-right"></TableHead> <TableHead className="w-24 text-right">{t("knowledge.hitCount")}</TableHead>
<TableHead className="w-24 text-right">TopScore</TableHead> <TableHead className="w-24 text-right">TopScore</TableHead>
<TableHead className="w-28">Provider</TableHead> <TableHead className="w-28">Provider</TableHead>
<TableHead className="w-24">Rerank</TableHead> <TableHead className="w-24">Rerank</TableHead>
<TableHead className="w-24 text-right"></TableHead> <TableHead className="w-24 text-right">{t("knowledge.citations")}</TableHead>
<TableHead className="w-28 text-right"></TableHead> <TableHead className="w-28 text-right">{t("knowledge.duration")}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -242,21 +280,21 @@ export function RetrieveLogList({
<div className="space-y-1"> <div className="space-y-1">
<div className="line-clamp-2 font-medium">{item.question || "-"}</div> <div className="line-clamp-2 font-medium">{item.question || "-"}</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground"> <div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span>{item.channelName}</span> <span>{channelLabel(item.channel, item.channelName, t)}</span>
<span>{item.sceneName}</span> <span>{sceneLabel(item.scene, item.sceneName, t)}</span>
{item.knowledgeBaseName ? <span>{item.knowledgeBaseName}</span> : null} {item.knowledgeBaseName ? <span>{item.knowledgeBaseName}</span> : null}
</div> </div>
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge variant={getAnswerStatusVariant(item.answerStatus)}> <Badge variant={getAnswerStatusVariant(item.answerStatus)}>
{item.answerStatusName} {answerStatusLabel(item.answerStatus, item.answerStatusName, t)}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className="text-right">{item.hitCount}</TableCell> <TableCell className="text-right">{item.hitCount}</TableCell>
<TableCell className="text-right font-mono text-xs">{item.topScore.toFixed(4)}</TableCell> <TableCell className="text-right font-mono text-xs">{item.topScore.toFixed(4)}</TableCell>
<TableCell>{item.chunkProvider || "-"}</TableCell> <TableCell>{item.chunkProvider ? providerLabel(item.chunkProvider, t) : "-"}</TableCell>
<TableCell>{item.rerankEnabled ? ` (${item.rerankLimit})` : "否"}</TableCell> <TableCell>{item.rerankEnabled ? `${t("knowledge.yes")} (${item.rerankLimit})` : t("knowledge.no")}</TableCell>
<TableCell className="text-right">{item.citationCount}</TableCell> <TableCell className="text-right">{item.citationCount}</TableCell>
<TableCell className="text-right">{item.latencyMs} ms</TableCell> <TableCell className="text-right">{item.latencyMs} ms</TableCell>
</TableRow> </TableRow>
+15 -13
View File
@@ -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 { useI18n } from "@/i18n/provider"
import { import {
Bug, Bug,
DownloadIcon, DownloadIcon,
@@ -27,6 +28,7 @@ import { KnowledgeBaseList } from "./_components/knowledge-base-list"
import { RetrieveLogList } from "./_components/retrieve-log-list" import { RetrieveLogList } from "./_components/retrieve-log-list"
export default function DashboardKnowledgeDocumentsPage() { export default function DashboardKnowledgeDocumentsPage() {
const t = useI18n()
const [selectedKnowledgeBase, setSelectedKnowledgeBase] = useState<KnowledgeBase | null>(null) const [selectedKnowledgeBase, setSelectedKnowledgeBase] = useState<KnowledgeBase | null>(null)
const [sidebarCollapsed, setSidebarCollapsed] = useState(false) const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
const [debugPanelOpen, setDebugPanelOpen] = useState(false) const [debugPanelOpen, setDebugPanelOpen] = useState(false)
@@ -53,7 +55,7 @@ export default function DashboardKnowledgeDocumentsPage() {
size="icon" size="icon"
className="absolute top-4 left-1/2 z-10 size-7 -translate-x-1/2 rounded-full shadow-sm" className="absolute top-4 left-1/2 z-10 size-7 -translate-x-1/2 rounded-full shadow-sm"
onClick={() => setSidebarCollapsed((value) => !value)} onClick={() => setSidebarCollapsed((value) => !value)}
aria-label={sidebarCollapsed ? "展开知识库列表" : "折叠知识库列表"} aria-label={sidebarCollapsed ? t("knowledge.expandList") : t("knowledge.collapseList")}
> >
{sidebarCollapsed ? ( {sidebarCollapsed ? (
<PanelLeftOpenIcon className="size-3.5" /> <PanelLeftOpenIcon className="size-3.5" />
@@ -67,8 +69,8 @@ export default function DashboardKnowledgeDocumentsPage() {
<div className="border-b px-6 py-4"> <div className="border-b px-6 py-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<TabsList> <TabsList>
<TabsTrigger value="documents">{isFAQKnowledgeBase ? "FAQ" : "文档"}</TabsTrigger> <TabsTrigger value="documents">{isFAQKnowledgeBase ? t("knowledge.faq") : t("knowledge.document")}</TabsTrigger>
<TabsTrigger value="retrieveLogs"></TabsTrigger> <TabsTrigger value="retrieveLogs">{t("knowledge.retrieveLogs")}</TabsTrigger>
</TabsList> </TabsList>
{activeTab === "documents" && !isFAQKnowledgeBase && documentActionState ? ( {activeTab === "documents" && !isFAQKnowledgeBase && documentActionState ? (
<div className="ml-auto flex items-center gap-1"> <div className="ml-auto flex items-center gap-1">
@@ -78,7 +80,7 @@ export default function DashboardKnowledgeDocumentsPage() {
className="size-7" className="size-7"
onClick={documentActionState.onRefresh} onClick={documentActionState.onRefresh}
disabled={documentActionState.loading} disabled={documentActionState.loading}
aria-label="刷新文档" aria-label={t("knowledge.refreshDocuments")}
> >
<RefreshCwIcon className={documentActionState.loading ? "size-4 animate-spin" : "size-4"} /> <RefreshCwIcon className={documentActionState.loading ? "size-4 animate-spin" : "size-4"} />
</Button> </Button>
@@ -87,7 +89,7 @@ export default function DashboardKnowledgeDocumentsPage() {
size="icon" size="icon"
className="size-7" className="size-7"
onClick={() => documentActionState.onChangeViewMode("list")} onClick={() => documentActionState.onChangeViewMode("list")}
aria-label="列表布局" aria-label={t("knowledge.listLayout")}
> >
<LayoutListIcon className="size-4" /> <LayoutListIcon className="size-4" />
</Button> </Button>
@@ -96,7 +98,7 @@ export default function DashboardKnowledgeDocumentsPage() {
size="icon" size="icon"
className="size-7" className="size-7"
onClick={() => documentActionState.onChangeViewMode("grid")} onClick={() => documentActionState.onChangeViewMode("grid")}
aria-label="网格布局" aria-label={t("knowledge.gridLayout")}
> >
<LayoutGridIcon className="size-4" /> <LayoutGridIcon className="size-4" />
</Button> </Button>
@@ -105,7 +107,7 @@ export default function DashboardKnowledgeDocumentsPage() {
size="icon" size="icon"
className="size-7" className="size-7"
onClick={() => setDebugPanelOpen(true)} onClick={() => setDebugPanelOpen(true)}
aria-label="打开调试面板" aria-label={t("knowledge.openDebugPanel")}
> >
<Bug className="size-4" /> <Bug className="size-4" />
</Button> </Button>
@@ -114,7 +116,7 @@ export default function DashboardKnowledgeDocumentsPage() {
size="icon" size="icon"
className="size-7" className="size-7"
onClick={documentActionState.onCreate} onClick={documentActionState.onCreate}
aria-label="新增文档" aria-label={t("knowledge.newDocument")}
> >
<PlusIcon className="size-4" /> <PlusIcon className="size-4" />
</Button> </Button>
@@ -128,7 +130,7 @@ export default function DashboardKnowledgeDocumentsPage() {
className="size-7" className="size-7"
onClick={faqActionState.onRefresh} onClick={faqActionState.onRefresh}
disabled={faqActionState.loading} disabled={faqActionState.loading}
aria-label="刷新FAQ" aria-label={t("knowledge.refreshFAQ")}
> >
<RefreshCwIcon className={faqActionState.loading ? "size-4 animate-spin" : "size-4"} /> <RefreshCwIcon className={faqActionState.loading ? "size-4 animate-spin" : "size-4"} />
</Button> </Button>
@@ -138,7 +140,7 @@ export default function DashboardKnowledgeDocumentsPage() {
className="size-7" className="size-7"
onClick={faqActionState.onImport} onClick={faqActionState.onImport}
disabled={faqActionState.importing} disabled={faqActionState.importing}
aria-label="导入FAQ" aria-label={t("knowledge.importFAQ")}
> >
<DownloadIcon className="size-4" /> <DownloadIcon className="size-4" />
</Button> </Button>
@@ -147,7 +149,7 @@ export default function DashboardKnowledgeDocumentsPage() {
size="icon" size="icon"
className="size-7" className="size-7"
onClick={() => setDebugPanelOpen(true)} onClick={() => setDebugPanelOpen(true)}
aria-label="打开调试面板" aria-label={t("knowledge.openDebugPanel")}
> >
<Bug className="size-4" /> <Bug className="size-4" />
</Button> </Button>
@@ -156,7 +158,7 @@ export default function DashboardKnowledgeDocumentsPage() {
size="icon" size="icon"
className="size-7" className="size-7"
onClick={faqActionState.onCreate} onClick={faqActionState.onCreate}
aria-label="新增FAQ" aria-label={t("knowledge.newFAQ")}
> >
<PlusIcon className="size-4" /> <PlusIcon className="size-4" />
</Button> </Button>
@@ -187,7 +189,7 @@ export default function DashboardKnowledgeDocumentsPage() {
<Sheet open={debugPanelOpen} onOpenChange={setDebugPanelOpen}> <Sheet open={debugPanelOpen} onOpenChange={setDebugPanelOpen}>
<SheetContent side="right" className="min-w-170"> <SheetContent side="right" className="min-w-170">
<SheetHeader> <SheetHeader>
<SheetTitle>RAG </SheetTitle> <SheetTitle>{t("knowledge.ragDebug")}</SheetTitle>
</SheetHeader> </SheetHeader>
<DebugPanel knowledgeBaseId={selectedKnowledgeBase?.id ?? null} /> <DebugPanel knowledgeBaseId={selectedKnowledgeBase?.id ?? null} />
</SheetContent> </SheetContent>
+4 -2
View File
@@ -9,6 +9,7 @@ import { AppSidebar } from "@/components/app-sidebar"
import { useAuth } from "@/components/auth-provider" import { useAuth } from "@/components/auth-provider"
import { NotificationProvider } from "@/components/notification-provider" import { NotificationProvider } from "@/components/notification-provider"
import { SiteHeader } from "@/components/site-header" import { SiteHeader } from "@/components/site-header"
import { useI18n } from "@/i18n/provider"
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar" import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
export default function DashboardLayout({ export default function DashboardLayout({
@@ -16,6 +17,7 @@ export default function DashboardLayout({
}: { }: {
children: ReactNode children: ReactNode
}) { }) {
const t = useI18n()
const { ready, session } = useAuth() const { ready, session } = useAuth()
const pathname = usePathname() const pathname = usePathname()
const router = useRouter() const router = useRouter()
@@ -39,9 +41,9 @@ export default function DashboardLayout({
<Loader2Icon className="size-5 animate-spin" /> <Loader2Icon className="size-5 animate-spin" />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<p className="text-base font-medium"></p> <p className="text-base font-medium">{t("auth.checkingSession")}</p>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{t("auth.syncingProfile")}
</p> </p>
</div> </div>
</div> </div>
+10 -6
View File
@@ -5,6 +5,7 @@ import { Suspense, useEffect, useRef } from "react"
import { toast } from "sonner" import { toast } from "sonner"
import { exchangeOIDCTicket } from "@/lib/api/auth" import { exchangeOIDCTicket } from "@/lib/api/auth"
import { useI18n } from "@/i18n/provider"
export default function OIDCLoginCallbackPage() { export default function OIDCLoginCallbackPage() {
return ( return (
@@ -15,6 +16,7 @@ export default function OIDCLoginCallbackPage() {
} }
function OIDCLoginCallbackContent() { function OIDCLoginCallbackContent() {
const t = useI18n()
const router = useRouter() const router = useRouter()
const searchParams = useSearchParams() const searchParams = useSearchParams()
const ranRef = useRef(false) const ranRef = useRef(false)
@@ -30,32 +32,34 @@ function OIDCLoginCallbackContent() {
const nextPath = next && next.startsWith("/") ? next : "/dashboard" const nextPath = next && next.startsWith("/") ? next : "/dashboard"
if (!ticket) { if (!ticket) {
toast.error("OIDC 登录票据不存在") toast.error(t("auth.oidcMissingTicket"))
router.replace("/dashboard/login") router.replace("/dashboard/login")
return return
} }
void exchangeOIDCTicket(ticket) void exchangeOIDCTicket(ticket)
.then(() => { .then(() => {
toast.success("登录成功,正在进入系统") toast.success(t("auth.loginSuccess"))
router.replace(nextPath) router.replace(nextPath)
}) })
.catch((error) => { .catch((error) => {
toast.error(error instanceof Error ? error.message : "OIDC 登录失败") toast.error(error instanceof Error ? error.message : t("auth.oidcFailed"))
router.replace("/dashboard/login") router.replace("/dashboard/login")
}) })
}, [router, searchParams]) }, [router, searchParams, t])
return <OIDCLoginCallbackFallback /> return <OIDCLoginCallbackFallback />
} }
function OIDCLoginCallbackFallback() { function OIDCLoginCallbackFallback() {
const t = useI18n()
return ( return (
<div className="flex min-h-svh items-center justify-center bg-[linear-gradient(145deg,#fff7ed_0%,#ffffff_32%,#ecfeff_100%)] px-6"> <div className="flex min-h-svh items-center justify-center bg-[linear-gradient(145deg,#fff7ed_0%,#ffffff_32%,#ecfeff_100%)] px-6">
<div className="w-full max-w-md rounded-[28px] border border-white/70 bg-white/90 p-8 text-center shadow-[0_24px_80px_rgba(15,23,42,0.08)] backdrop-blur"> <div className="w-full max-w-md rounded-[28px] border border-white/70 bg-white/90 p-8 text-center shadow-[0_24px_80px_rgba(15,23,42,0.08)] backdrop-blur">
<h1 className="text-2xl font-semibold tracking-tight">OIDC </h1> <h1 className="text-2xl font-semibold tracking-tight">{t("auth.oidcSigningIn")}</h1>
<p className="mt-3 text-sm text-muted-foreground"> <p className="mt-3 text-sm text-muted-foreground">
{t("auth.checkingTicket")}
</p> </p>
</div> </div>
</div> </div>
+20 -18
View File
@@ -1,9 +1,20 @@
"use client"
import { LoginForm } from "@/components/login-form" import { LoginForm } from "@/components/login-form"
import { LocaleSwitcher } from "@/components/locale-switcher"
import { useI18n } from "@/i18n/provider"
import { ShieldCheckIcon } from "lucide-react" import { ShieldCheckIcon } from "lucide-react"
import Link from "next/link" import Link from "next/link"
import { Suspense } from "react" import { Suspense } from "react"
export default function LoginPage() { export default function LoginPage() {
const t = useI18n()
const heroPoints = [
t("auth.heroPoint1"),
t("auth.heroPoint2"),
t("auth.heroPoint3"),
]
return ( return (
<div className="grid min-h-svh bg-[linear-gradient(145deg,#fff7ed_0%,#ffffff_32%,#ecfeff_100%)] lg:grid-cols-[1.1fr_0.9fr]"> <div className="grid min-h-svh bg-[linear-gradient(145deg,#fff7ed_0%,#ffffff_32%,#ecfeff_100%)] lg:grid-cols-[1.1fr_0.9fr]">
<div className="relative hidden overflow-hidden border-r bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.18),transparent_30%),radial-gradient(circle_at_bottom_right,rgba(6,182,212,0.18),transparent_28%),linear-gradient(145deg,#111827_0%,#1f2937_35%,#0f172a_100%)] lg:block"> <div className="relative hidden overflow-hidden border-r bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.18),transparent_30%),radial-gradient(circle_at_bottom_right,rgba(6,182,212,0.18),transparent_28%),linear-gradient(145deg,#111827_0%,#1f2937_35%,#0f172a_100%)] lg:block">
@@ -12,51 +23,42 @@ export default function LoginPage() {
<div className="space-y-5"> <div className="space-y-5">
<div className="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/8 px-3 py-1 text-xs tracking-[0.2em] uppercase text-white/80"> <div className="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/8 px-3 py-1 text-xs tracking-[0.2em] uppercase text-white/80">
<ShieldCheckIcon className="size-3.5" /> <ShieldCheckIcon className="size-3.5" />
Secure Admin Space {t("auth.heroBadge")}
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
<h2 className="max-w-lg text-5xl font-semibold tracking-tight"> <h2 className="max-w-lg text-5xl font-semibold tracking-tight">
AI客服 {t("auth.heroTitle")}
</h2> </h2>
<p className="max-w-xl text-sm leading-6 text-white/72"> <p className="max-w-xl text-sm leading-6 text-white/72">
{t("auth.heroSubtitle")}
</p> </p>
</div> </div>
</div> </div>
<div className="grid gap-3"> <div className="grid gap-3">
{[ {heroPoints.map((text, index) => (
{
text: "让每一次咨询,都通向可追踪的结果。",
},
{
text: "从接待到转派到复盘,一条链路跑到底。",
},
{
text: "多渠道接入,智能分配,人机协同提效。",
},
].map((item, index) => (
<div <div
key={index} key={index}
className="rounded-2xl border border-white/10 bg-white/7 p-4 backdrop-blur" className="rounded-2xl border border-white/10 bg-white/7 p-4 backdrop-blur"
> >
<p className="text-sm leading-6 text-white/70">{item.text}</p> <p className="text-sm leading-6 text-white/70">{text}</p>
</div> </div>
))} ))}
</div> </div>
</div> </div>
</div> </div>
<div className="flex flex-col gap-4 p-6 md:p-10"> <div className="flex flex-col gap-4 p-6 md:p-10">
<div className="flex justify-center gap-2 md:justify-start"> <div className="flex items-center justify-between gap-2">
<Link href="/dashboard/login" className="flex items-center gap-2 font-medium"> <Link href="/dashboard/login" className="flex items-center gap-2 font-medium">
<img <img
src="/images/logo.svg" src="/images/logo.svg"
alt="CS Agent" alt={t("app.brand")}
width="32" width="32"
height="32" height="32"
className="size-7 shrink-0 object-contain" className="size-7 shrink-0 object-contain"
/> />
CS Agent {t("app.brand")}
</Link> </Link>
<LocaleSwitcher />
</div> </div>
<div className="flex flex-1 items-center justify-center"> <div className="flex flex-1 items-center justify-center">
<div className="w-full max-w-md rounded-[28px] border border-white/70 bg-white/90 p-8 shadow-[0_24px_80px_rgba(15,23,42,0.08)] backdrop-blur"> <div className="w-full max-w-md rounded-[28px] border border-white/70 bg-white/90 p-8 shadow-[0_24px_80px_rgba(15,23,42,0.08)] backdrop-blur">
@@ -5,6 +5,7 @@ import { Suspense, useEffect, useRef } from "react"
import { toast } from "sonner" import { toast } from "sonner"
import { exchangeWxWorkTicket } from "@/lib/api/auth" import { exchangeWxWorkTicket } from "@/lib/api/auth"
import { useI18n } from "@/i18n/provider"
export default function WxWorkLoginCallbackPage() { export default function WxWorkLoginCallbackPage() {
return ( return (
@@ -15,6 +16,7 @@ export default function WxWorkLoginCallbackPage() {
} }
function WxWorkLoginCallbackContent() { function WxWorkLoginCallbackContent() {
const t = useI18n()
const router = useRouter() const router = useRouter()
const searchParams = useSearchParams() const searchParams = useSearchParams()
const ranRef = useRef(false) const ranRef = useRef(false)
@@ -30,21 +32,21 @@ function WxWorkLoginCallbackContent() {
const nextPath = next && next.startsWith("/") ? next : "/dashboard" const nextPath = next && next.startsWith("/") ? next : "/dashboard"
if (!ticket) { if (!ticket) {
toast.error("企业微信登录票据不存在") toast.error(t("auth.wxworkMissingTicket"))
router.replace("/dashboard/login") router.replace("/dashboard/login")
return return
} }
void exchangeWxWorkTicket(ticket) void exchangeWxWorkTicket(ticket)
.then(() => { .then(() => {
toast.success("登录成功,正在进入系统") toast.success(t("auth.loginSuccess"))
router.replace(nextPath) router.replace(nextPath)
}) })
.catch((error) => { .catch((error) => {
toast.error(error instanceof Error ? error.message : "企业微信登录失败") toast.error(error instanceof Error ? error.message : t("auth.wxworkFailed"))
router.replace("/dashboard/login") router.replace("/dashboard/login")
}) })
}, [router, searchParams]) }, [router, searchParams, t])
return ( return (
<WxWorkLoginCallbackFallback /> <WxWorkLoginCallbackFallback />
@@ -52,12 +54,14 @@ function WxWorkLoginCallbackContent() {
} }
function WxWorkLoginCallbackFallback() { function WxWorkLoginCallbackFallback() {
const t = useI18n()
return ( return (
<div className="flex min-h-svh items-center justify-center bg-[linear-gradient(145deg,#fff7ed_0%,#ffffff_32%,#ecfeff_100%)] px-6"> <div className="flex min-h-svh items-center justify-center bg-[linear-gradient(145deg,#fff7ed_0%,#ffffff_32%,#ecfeff_100%)] px-6">
<div className="w-full max-w-md rounded-[28px] border border-white/70 bg-white/90 p-8 text-center shadow-[0_24px_80px_rgba(15,23,42,0.08)] backdrop-blur"> <div className="w-full max-w-md rounded-[28px] border border-white/70 bg-white/90 p-8 text-center shadow-[0_24px_80px_rgba(15,23,42,0.08)] backdrop-blur">
<h1 className="text-2xl font-semibold tracking-tight"></h1> <h1 className="text-2xl font-semibold tracking-tight">{t("auth.wxworkSigningIn")}</h1>
<p className="mt-3 text-sm text-muted-foreground"> <p className="mt-3 text-sm text-muted-foreground">
{t("auth.checkingTicket")}
</p> </p>
</div> </div>
</div> </div>
+33 -51
View File
@@ -33,10 +33,12 @@ import {
type MCPToolCallResult, type MCPToolCallResult,
type MCPToolInfo, type MCPToolInfo,
} from "@/lib/api/admin"; } from "@/lib/api/admin";
import { useI18n } from "@/i18n/provider";
const defaultServerCode = ""; const defaultServerCode = "";
export default function MCPDashboardPage() { export default function MCPDashboardPage() {
const t = useI18n();
const [serverCode, setServerCode] = useState(defaultServerCode); const [serverCode, setServerCode] = useState(defaultServerCode);
const [servers, setServers] = useState<MCPServerInfo[]>([]); const [servers, setServers] = useState<MCPServerInfo[]>([]);
const [connection, setConnection] = useState<MCPConnectionResult | null>( const [connection, setConnection] = useState<MCPConnectionResult | null>(
@@ -59,9 +61,9 @@ export default function MCPDashboardPage() {
value: server.code, value: server.code,
label: server.enabled label: server.enabled
? `${server.code} (${server.endpoint})` ? `${server.code} (${server.endpoint})`
: `${server.code} (disabled)`, : `${server.code} (${t("mcp.disabled")})`,
})), })),
[servers], [servers, t],
); );
useEffect(() => { useEffect(() => {
@@ -76,7 +78,7 @@ export default function MCPDashboardPage() {
} }
} catch (error) { } catch (error) {
toast.error( toast.error(
error instanceof Error ? error.message : "加载 MCP 服务失败", error instanceof Error ? error.message : t("mcp.loadServersFailed"),
); );
} finally { } finally {
setLoadingServers(false); setLoadingServers(false);
@@ -84,16 +86,16 @@ export default function MCPDashboardPage() {
} }
void loadServers(); void loadServers();
}, []); }, [t]);
async function handleTestConnection() { async function handleTestConnection() {
setTesting(true); setTesting(true);
try { try {
const result = await testMCPConnection(serverCode.trim()); const result = await testMCPConnection(serverCode.trim());
setConnection(result); setConnection(result);
toast.success("MCP 连接成功"); toast.success(t("mcp.connectSuccess"));
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "MCP 连接失败"); toast.error(error instanceof Error ? error.message : t("mcp.connectFailed"));
} finally { } finally {
setTesting(false); setTesting(false);
} }
@@ -104,9 +106,9 @@ export default function MCPDashboardPage() {
try { try {
const result = await listMCPTools(serverCode.trim()); const result = await listMCPTools(serverCode.trim());
setTools(result); setTools(result);
toast.success(`已加载 ${result.length} 个工具`); toast.success(t("mcp.toolsLoaded", { count: result.length }));
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "加载工具失败"); toast.error(error instanceof Error ? error.message : t("mcp.loadToolsFailed"));
} finally { } finally {
setLoadingTools(false); setLoadingTools(false);
} }
@@ -114,11 +116,11 @@ export default function MCPDashboardPage() {
async function handleCallTool() { async function handleCallTool() {
if (!activeTool) { if (!activeTool) {
toast.error("请先选择一个工具"); toast.error(t("mcp.selectToolFirst"));
return; return;
} }
if (argumentsError) { if (argumentsError) {
toast.error("Arguments JSON 格式不合法"); toast.error(t("mcp.argumentsInvalid"));
return; return;
} }
@@ -128,7 +130,7 @@ export default function MCPDashboardPage() {
? (JSON.parse(argumentsText) as Record<string, unknown>) ? (JSON.parse(argumentsText) as Record<string, unknown>)
: {}; : {};
} catch { } catch {
toast.error("arguments 必须是合法 JSON"); toast.error(t("mcp.argumentsMustBeJson"));
return; return;
} }
@@ -140,9 +142,9 @@ export default function MCPDashboardPage() {
arguments: parsedArguments, arguments: parsedArguments,
}); });
setToolResult(result); setToolResult(result);
toast.success(result.isError ? "工具返回错误结果" : "工具调用成功"); toast.success(result.isError ? t("mcp.toolReturnedError") : t("mcp.toolCallSuccess"));
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "工具调用失败"); toast.error(error instanceof Error ? error.message : t("mcp.toolCallFailed"));
} finally { } finally {
setCallingTool(false); setCallingTool(false);
} }
@@ -164,25 +166,6 @@ export default function MCPDashboardPage() {
return ( return (
<> <>
<DashboardPage className="gap-6"> <DashboardPage className="gap-6">
{/* <div className="flex flex-col gap-2">
<div className="flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() => {
setConnection(null)
setTools([])
setToolResult(null)
setActiveTool(null)
}}
>
<RefreshCwIcon className="mr-2 size-4" />
</Button>
</div>
</div>
</div> */}
<DashboardToolbar <DashboardToolbar
actions={ actions={
<> <>
@@ -195,7 +178,7 @@ export default function MCPDashboardPage() {
) : ( ) : (
<PlugZapIcon className="mr-2 size-4" /> <PlugZapIcon className="mr-2 size-4" />
)} )}
{t("mcp.testConnection")}
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
@@ -207,20 +190,20 @@ export default function MCPDashboardPage() {
) : ( ) : (
<WrenchIcon className="mr-2 size-4" /> <WrenchIcon className="mr-2 size-4" />
)} )}
{t("mcp.listTools")}
</Button> </Button>
</> </>
} }
> >
<div className="flex min-w-0 flex-1 items-center gap-3"> <div className="flex min-w-0 flex-1 items-center gap-3">
<span className="shrink-0 text-sm font-medium"></span> <span className="shrink-0 text-sm font-medium">{t("mcp.serverConfig")}</span>
<div className="min-w-[280px] max-w-[520px] flex-1"> <div className="min-w-[280px] max-w-[520px] flex-1">
<OptionCombobox <OptionCombobox
value={serverCode} value={serverCode}
options={serverOptions} options={serverOptions}
placeholder="选择一个 MCP Server" placeholder={t("mcp.selectServer")}
searchPlaceholder="搜索 serverCode" searchPlaceholder={t("mcp.searchServer")}
emptyText="没有可用的 MCP Server" emptyText={t("mcp.emptyServer")}
disabled={loadingServers} disabled={loadingServers}
onChange={(value) => { onChange={(value) => {
setServerCode(value); setServerCode(value);
@@ -235,7 +218,7 @@ export default function MCPDashboardPage() {
{connection ? ( {connection ? (
<div className="w-full rounded-lg border bg-muted/30 p-4 text-sm"> <div className="w-full rounded-lg border bg-muted/30 p-4 text-sm">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Badge></Badge> <Badge>{t("mcp.connected")}</Badge>
<span className="font-medium"> <span className="font-medium">
{connection.serverName || "-"} {connection.serverName || "-"}
</span> </span>
@@ -254,15 +237,15 @@ export default function MCPDashboardPage() {
{tools.length === 0 ? ( {tools.length === 0 ? (
<div className="rounded-lg border border-dashed p-6 text-sm text-muted-foreground"> <div className="rounded-lg border border-dashed p-6 text-sm text-muted-foreground">
{t("mcp.emptyToolsHint")}
</div> </div>
) : ( ) : (
<DashboardTableShell> <DashboardTableShell>
<div> <div>
<div className="grid grid-cols-[minmax(0,220px)_minmax(0,1fr)_88px] gap-4 border-b bg-muted/40 px-4 py-3 text-sm font-medium text-muted-foreground"> <div className="grid grid-cols-[minmax(0,220px)_minmax(0,1fr)_88px] gap-4 border-b bg-muted/40 px-4 py-3 text-sm font-medium text-muted-foreground">
<div></div> <div>{t("mcp.toolName")}</div>
<div></div> <div>{t("mcp.description")}</div>
<div className="text-right"></div> <div className="text-right">{t("mcp.actions")}</div>
</div> </div>
{tools.map((tool) => ( {tools.map((tool) => (
<div <div
@@ -280,7 +263,7 @@ export default function MCPDashboardPage() {
size="sm" size="sm"
onClick={() => openToolDrawer(tool)} onClick={() => openToolDrawer(tool)}
> >
{t("mcp.view")}
</Button> </Button>
</div> </div>
</div> </div>
@@ -293,10 +276,9 @@ export default function MCPDashboardPage() {
<Drawer open={drawerOpen} direction="right" onOpenChange={setDrawerOpen}> <Drawer open={drawerOpen} direction="right" onOpenChange={setDrawerOpen}>
<DrawerContent className="min-w-3xl"> <DrawerContent className="min-w-3xl">
<DrawerHeader> <DrawerHeader>
<DrawerTitle>{activeTool?.name || "工具详情"}</DrawerTitle> <DrawerTitle>{activeTool?.name || t("mcp.toolDetail")}</DrawerTitle>
<DrawerDescription> <DrawerDescription>
使 MCP Server {t("mcp.detailDescription")}
</DrawerDescription> </DrawerDescription>
</DrawerHeader> </DrawerHeader>
<div className="flex-1 space-y-4 overflow-y-auto px-4 pb-4"> <div className="flex-1 space-y-4 overflow-y-auto px-4 pb-4">
@@ -312,7 +294,7 @@ export default function MCPDashboardPage() {
) : null} ) : null}
</div> </div>
<p className="mt-3 text-sm text-muted-foreground"> <p className="mt-3 text-sm text-muted-foreground">
{activeTool.description || "暂无描述"} {activeTool.description || t("mcp.noDescription")}
</p> </p>
</div> </div>
@@ -354,7 +336,7 @@ export default function MCPDashboardPage() {
) : ( ) : (
<WrenchIcon className="mr-2 size-4" /> <WrenchIcon className="mr-2 size-4" />
)} )}
{t("mcp.testTool")}
</Button> </Button>
{toolResult ? ( {toolResult ? (
<div className="space-y-4 rounded-lg border p-4"> <div className="space-y-4 rounded-lg border p-4">
@@ -364,7 +346,7 @@ export default function MCPDashboardPage() {
toolResult.isError ? "destructive" : "default" toolResult.isError ? "destructive" : "default"
} }
> >
{toolResult.isError ? "返回错误" : "调用成功"} {toolResult.isError ? t("mcp.returnedError") : t("mcp.callSuccess")}
</Badge> </Badge>
<span className="text-sm font-medium"> <span className="text-sm font-medium">
{toolResult.toolName} {toolResult.toolName}
@@ -390,7 +372,7 @@ export default function MCPDashboardPage() {
</div> </div>
<DrawerFooter> <DrawerFooter>
<Button variant="outline" onClick={() => setDrawerOpen(false)}> <Button variant="outline" onClick={() => setDrawerOpen(false)}>
{t("mcp.close")}
</Button> </Button>
</DrawerFooter> </DrawerFooter>
</DrawerContent> </DrawerContent>
+21 -17
View File
@@ -1,6 +1,6 @@
"use client" "use client"
import { useCallback, useEffect, useState } from "react" import { useCallback, useEffect, useMemo, useState } from "react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { BellIcon, CheckCheckIcon, RefreshCwIcon } from "lucide-react" import { BellIcon, CheckCheckIcon, RefreshCwIcon } from "lucide-react"
import { toast } from "sonner" import { toast } from "sonner"
@@ -23,14 +23,10 @@ import {
} from "@/lib/api/notification" } from "@/lib/api/notification"
import type { PageResult } from "@/lib/api/admin" import type { PageResult } from "@/lib/api/admin"
import { cn, formatDateTime } from "@/lib/utils" import { cn, formatDateTime } from "@/lib/utils"
import { useI18n } from "@/i18n/provider"
const readStatusOptions: Array<{ value: NotificationReadStatus; label: string }> = [
{ value: "all", label: "全部" },
{ value: "unread", label: "未读" },
{ value: "read", label: "已读" },
]
export default function DashboardNotificationsPage() { export default function DashboardNotificationsPage() {
const t = useI18n()
const router = useRouter() const router = useRouter()
const { refreshUnreadCount } = useNotifications() const { refreshUnreadCount } = useNotifications()
const [readStatus, setReadStatus] = useState<NotificationReadStatus>("all") const [readStatus, setReadStatus] = useState<NotificationReadStatus>("all")
@@ -42,6 +38,14 @@ export default function DashboardNotificationsPage() {
results: [], results: [],
page: { page: 1, limit: 20, total: 0 }, page: { page: 1, limit: 20, total: 0 },
}) })
const readStatusOptions = useMemo<Array<{ value: NotificationReadStatus; label: string }>>(
() => [
{ value: "all", label: t("notification.all") },
{ value: "unread", label: t("notification.unread") },
{ value: "read", label: t("notification.read") },
],
[t]
)
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
setLoading(true) setLoading(true)
@@ -53,11 +57,11 @@ export default function DashboardNotificationsPage() {
}) })
setResult(data) setResult(data)
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "加载通知失败") toast.error(error instanceof Error ? error.message : t("notification.loadFailed"))
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, [limit, page, readStatus]) }, [limit, page, readStatus, t])
useEffect(() => { useEffect(() => {
void loadData() void loadData()
@@ -73,7 +77,7 @@ export default function DashboardNotificationsPage() {
router.push(item.actionUrl) router.push(item.actionUrl)
} }
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "打开通知失败") toast.error(error instanceof Error ? error.message : t("notification.openFailed"))
} }
} }
@@ -83,9 +87,9 @@ export default function DashboardNotificationsPage() {
await markAllNotificationsRead() await markAllNotificationsRead()
await refreshUnreadCount() await refreshUnreadCount()
await loadData() await loadData()
toast.success("已全部标记为已读") toast.success(t("notification.markAllReadSuccess"))
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "全部已读失败") toast.error(error instanceof Error ? error.message : t("notification.markAllReadFailed"))
} finally { } finally {
setActionLoading(false) setActionLoading(false)
} }
@@ -115,7 +119,7 @@ export default function DashboardNotificationsPage() {
<> <>
<Button variant="outline" onClick={() => void loadData()} disabled={loading}> <Button variant="outline" onClick={() => void loadData()} disabled={loading}>
<RefreshCwIcon className={cn(loading && "animate-spin")} /> <RefreshCwIcon className={cn(loading && "animate-spin")} />
{t("notification.refresh")}
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
@@ -123,7 +127,7 @@ export default function DashboardNotificationsPage() {
disabled={actionLoading || result.page.total === 0} disabled={actionLoading || result.page.total === 0}
> >
<CheckCheckIcon /> <CheckCheckIcon />
{t("notification.markAllRead")}
</Button> </Button>
</> </>
} }
@@ -164,8 +168,8 @@ export default function DashboardNotificationsPage() {
> >
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<BellIcon className="size-4 text-muted-foreground" /> <BellIcon className="size-4 text-muted-foreground" />
<span className="font-medium">{item.title || "通知"}</span> <span className="font-medium">{item.title || t("notification.fallbackTitle")}</span>
{unread ? <Badge></Badge> : <Badge variant="outline"></Badge>} {unread ? <Badge>{t("notification.unread")}</Badge> : <Badge variant="outline">{t("notification.read")}</Badge>}
<span className="ml-auto text-xs text-muted-foreground"> <span className="ml-auto text-xs text-muted-foreground">
{formatDateTime(item.createdAt)} {formatDateTime(item.createdAt)}
</span> </span>
@@ -179,7 +183,7 @@ export default function DashboardNotificationsPage() {
</div> </div>
) : ( ) : (
<div className="flex min-h-48 items-center justify-center text-sm text-muted-foreground"> <div className="flex min-h-48 items-center justify-center text-sm text-muted-foreground">
{loading ? "正在加载通知" : "暂无通知"} {loading ? t("notification.loading") : t("notification.empty")}
</div> </div>
)} )}
</DashboardTableShell> </DashboardTableShell>
+50 -26
View File
@@ -1,6 +1,6 @@
"use client" "use client"
import { useCallback, useEffect, useState } from "react" import { useCallback, useEffect, useMemo, useState } from "react"
import { KeyRoundIcon, RefreshCwIcon, RouteIcon, SearchIcon } from "lucide-react" import { KeyRoundIcon, RefreshCwIcon, RouteIcon, SearchIcon } from "lucide-react"
import { toast } from "sonner" import { toast } from "sonner"
@@ -28,16 +28,13 @@ import {
type AdminPermission, type AdminPermission,
type PageResult, type PageResult,
} from "@/lib/api/admin" } from "@/lib/api/admin"
import { Status, StatusLabels } from "@/lib/generated/enums" import { Status } from "@/lib/generated/enums"
import { useAppLocale, useI18n } from "@/i18n/provider"
const listStatusOptions = [ import { getPermissionDisplayName, getPermissionGroupName } from "@/lib/permission-i18n"
{ value: "all", label: "全部状态" },
{ value: String(Status.Ok), label: StatusLabels[Status.Ok] },
{ value: String(Status.Disabled), label: StatusLabels[Status.Disabled] },
{ value: String(Status.Deleted), label: StatusLabels[Status.Deleted] },
] as const
export default function DashboardPermissionsPage() { export default function DashboardPermissionsPage() {
const t = useI18n()
const { locale } = useAppLocale()
const [keywordInput, setKeywordInput] = useState("") const [keywordInput, setKeywordInput] = useState("")
const [groupNameInput, setGroupNameInput] = useState("") const [groupNameInput, setGroupNameInput] = useState("")
const [statusFilterInput, setStatusFilterInput] = useState("all") const [statusFilterInput, setStatusFilterInput] = useState("all")
@@ -51,6 +48,15 @@ export default function DashboardPermissionsPage() {
results: [], results: [],
page: { page: 1, limit: 20, total: 0 }, page: { page: 1, limit: 20, total: 0 },
}) })
const listStatusOptions = useMemo(
() => [
{ value: "all", label: t("status.all") },
{ value: String(Status.Ok), label: t("status.ok") },
{ value: String(Status.Disabled), label: t("status.disabled") },
{ value: String(Status.Deleted), label: t("status.deleted") },
],
[t]
)
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
setLoading(true) setLoading(true)
@@ -64,11 +70,11 @@ export default function DashboardPermissionsPage() {
}) })
setResult(data) setResult(data)
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "加载权限失败") toast.error(error instanceof Error ? error.message : t("permission.loadFailed"))
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, [groupName, keyword, limit, page, statusFilter]) }, [groupName, keyword, limit, page, statusFilter, t])
useEffect(() => { useEffect(() => {
void loadData() void loadData()
@@ -103,7 +109,7 @@ export default function DashboardPermissionsPage() {
actions={ actions={
<Button variant="outline" onClick={() => void loadData()} disabled={loading}> <Button variant="outline" onClick={() => void loadData()} disabled={loading}>
<RefreshCwIcon className={loading ? "animate-spin" : ""} /> <RefreshCwIcon className={loading ? "animate-spin" : ""} />
{t("permission.refresh")}
</Button> </Button>
} }
> >
@@ -113,7 +119,7 @@ export default function DashboardPermissionsPage() {
value={keywordInput} value={keywordInput}
onChange={(event) => setKeywordInput(event.target.value)} onChange={(event) => setKeywordInput(event.target.value)}
onKeyDown={handleFilterKeyDown} onKeyDown={handleFilterKeyDown}
placeholder="按权限名称/编码筛选" placeholder={t("permission.filterKeyword")}
className="pl-9" className="pl-9"
/> />
</div> </div>
@@ -121,20 +127,20 @@ export default function DashboardPermissionsPage() {
value={groupNameInput} value={groupNameInput}
onChange={(event) => setGroupNameInput(event.target.value)} onChange={(event) => setGroupNameInput(event.target.value)}
onKeyDown={handleFilterKeyDown} onKeyDown={handleFilterKeyDown}
placeholder="按分组筛选" placeholder={t("permission.filterGroup")}
className="w-full sm:w-44" className="w-full sm:w-44"
/> />
<div className="w-full sm:w-36"> <div className="w-full sm:w-36">
<OptionCombobox <OptionCombobox
value={statusFilterInput} value={statusFilterInput}
onChange={setStatusFilterInput} onChange={setStatusFilterInput}
placeholder="全部状态" placeholder={t("status.all")}
options={[...listStatusOptions]} options={[...listStatusOptions]}
/> />
</div> </div>
<Button variant="outline" onClick={applyFilters} disabled={loading}> <Button variant="outline" onClick={applyFilters} disabled={loading}>
<SearchIcon /> <SearchIcon />
{t("permission.query")}
</Button> </Button>
</DashboardToolbar> </DashboardToolbar>
<DashboardTableShell <DashboardTableShell
@@ -155,11 +161,11 @@ export default function DashboardPermissionsPage() {
<Table> <Table>
<TableHeader className="bg-muted/40"> <TableHeader className="bg-muted/40">
<TableRow> <TableRow>
<TableHead></TableHead> <TableHead>{t("permission.columnPermission")}</TableHead>
<TableHead></TableHead> <TableHead>{t("permission.columnCode")}</TableHead>
<TableHead></TableHead> <TableHead>{t("permission.columnGroup")}</TableHead>
<TableHead></TableHead> <TableHead>{t("permission.columnApi")}</TableHead>
<TableHead></TableHead> <TableHead>{t("permission.columnStatus")}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -171,7 +177,9 @@ export default function DashboardPermissionsPage() {
<KeyRoundIcon className="size-4" /> <KeyRoundIcon className="size-4" />
</div> </div>
<div> <div>
<div className="font-medium">{item.name}</div> <div className="font-medium">
{getPermissionDisplayName(item.code, item.name, locale)}
</div>
<div className="text-xs text-muted-foreground">{item.type}</div> <div className="text-xs text-muted-foreground">{item.type}</div>
</div> </div>
</div> </div>
@@ -179,7 +187,7 @@ export default function DashboardPermissionsPage() {
<TableCell> <TableCell>
<Badge variant="outline">{item.code}</Badge> <Badge variant="outline">{item.code}</Badge>
</TableCell> </TableCell>
<TableCell>{item.groupName}</TableCell> <TableCell>{getPermissionGroupName(item.groupName, locale)}</TableCell>
<TableCell> <TableCell>
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<Badge variant="secondary">{item.method || "ANY"}</Badge> <Badge variant="secondary">{item.method || "ANY"}</Badge>
@@ -195,7 +203,7 @@ export default function DashboardPermissionsPage() {
<Badge <Badge
variant={item.status === Status.Ok ? "secondary" : "outline"} variant={item.status === Status.Ok ? "secondary" : "outline"}
> >
{StatusLabels[item.status as Status] ?? String(item.status)} {getStatusLabel(item.status, t)}
</Badge> </Badge>
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -204,8 +212,8 @@ export default function DashboardPermissionsPage() {
<DashboardTableStateRow <DashboardTableStateRow
colSpan={5} colSpan={5}
loading={loading} loading={loading}
loadingText="正在加载权限数据..." loadingText={t("permission.loading")}
emptyText="没有匹配的权限数据" emptyText={t("permission.empty")}
/> />
) : null} ) : null}
</TableBody> </TableBody>
@@ -214,3 +222,19 @@ export default function DashboardPermissionsPage() {
</DashboardPage> </DashboardPage>
) )
} }
function getStatusLabel(
status: number,
t: (key: string, values?: Record<string, string | number>) => string
) {
if (status === Status.Ok) {
return t("status.ok")
}
if (status === Status.Disabled) {
return t("status.disabled")
}
if (status === Status.Deleted) {
return t("status.deleted")
}
return String(status)
}
@@ -1,10 +1,11 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { Controller, Resolver, useForm } from "react-hook-form"; import { Controller, Resolver, useForm } from "react-hook-form";
import { z } from "zod/v4"; import { z } from "zod/v4";
import { OptionCombobox } from "@/components/option-combobox";
import { ProjectDialog } from "@/components/project-dialog"; import { ProjectDialog } from "@/components/project-dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -14,21 +15,15 @@ import {
FieldLabel, FieldLabel,
} from "@/components/ui/field"; } from "@/components/ui/field";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { import {
type AdminQuickReply, type AdminQuickReply,
type CreateAdminQuickReplyPayload, type CreateAdminQuickReplyPayload,
fetchQuickReply, fetchQuickReply,
} from "@/lib/api/admin"; } from "@/lib/api/admin";
import { getEnumLabel, getEnumOptions } from "@/lib/enums"; import { getEnumOptions } from "@/lib/enums";
import { Status, StatusLabels } from "@/lib/generated/enums"; import { Status, StatusLabels } from "@/lib/generated/enums";
import { useI18n } from "@/i18n/provider";
type QuickReplyFormDialogProps = { type QuickReplyFormDialogProps = {
open: boolean; open: boolean;
@@ -46,33 +41,39 @@ const emptyForm: EditForm = {
sortNo: "0", sortNo: "0",
}; };
const formStatusOptions = getEnumOptions(StatusLabels).filter( type EditForm = {
(item) => Number(item.value) !== Status.Deleted, groupName: string;
); title: string;
content: string;
status: string;
sortNo: string;
};
const quickReplyFormSchema = z.object({ function createSchema(t: (key: string) => string) {
groupName: z.string().trim().min(1, "分组名称不能为空"), return z.object({
title: z.string().trim().min(1, "标题不能为空"), groupName: z.string().trim().min(1, t("quickReply.groupNameRequired")),
content: z.string().trim().min(1, "回复内容不能为空"), title: z.string().trim().min(1, t("quickReply.titleRequired")),
content: z.string().trim().min(1, t("quickReply.contentRequired")),
status: z.enum([String(Status.Ok), String(Status.Disabled)], { status: z.enum([String(Status.Ok), String(Status.Disabled)], {
message: "请选择状态", message: t("quickReply.statusRequired"),
}), }),
sortNo: z sortNo: z
.string() .string()
.trim() .trim()
.min(1, "排序不能为空") .min(1, t("quickReply.sortRequired"))
.regex(/^\d+$/, "排序值必须是大于等于 0 的整数"), .regex(/^\d+$/, t("quickReply.sortInvalid")),
}); });
}
type EditForm = z.infer<typeof quickReplyFormSchema>; function getLocalizedStatusLabel(value: string | number, t: (key: string) => string) {
const editFormResolver = zodResolver(quickReplyFormSchema as never) as Resolver< const status = Number(value) as Status;
z.input<typeof quickReplyFormSchema>, if (status === Status.Disabled) {
undefined, return t("status.disabled");
z.output<typeof quickReplyFormSchema> }
>; if (status === Status.Deleted) {
return t("status.deleted");
function getStatusLabel(value: string) { }
return getEnumLabel(StatusLabels, Number(value) as Status); return t("status.ok");
} }
function buildForm(item: AdminQuickReply | null): EditForm { function buildForm(item: AdminQuickReply | null): EditForm {
@@ -137,8 +138,29 @@ function QuickReplyFormDialogBody({
onOpenChange, onOpenChange,
onSubmit, onSubmit,
}: QuickReplyFormDialogBodyProps) { }: QuickReplyFormDialogBodyProps) {
const t = useI18n();
const formId = "quick-reply-edit-form"; const formId = "quick-reply-edit-form";
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const quickReplyFormSchema = useMemo(() => createSchema(t), [t]);
const editFormResolver = useMemo(
() =>
zodResolver(quickReplyFormSchema as never) as Resolver<
z.input<typeof quickReplyFormSchema>,
undefined,
z.output<typeof quickReplyFormSchema>
>,
[quickReplyFormSchema],
);
const formStatusOptions = useMemo(
() =>
getEnumOptions(StatusLabels)
.filter((item) => Number(item.value) !== Status.Deleted)
.map((item) => ({
value: String(item.value),
label: getLocalizedStatusLabel(item.value, t),
})),
[t],
);
const form = useForm< const form = useForm<
z.input<typeof quickReplyFormSchema>, z.input<typeof quickReplyFormSchema>,
undefined, undefined,
@@ -183,7 +205,7 @@ function QuickReplyFormDialogBody({
<ProjectDialog <ProjectDialog
open={open} open={open}
onOpenChange={onOpenChange} onOpenChange={onOpenChange}
title={itemId ? "编辑" : "新建"} title={itemId ? t("quickReply.editTitle") : t("quickReply.createTitle")}
size="md" size="md"
allowFullscreen allowFullscreen
footer={ footer={
@@ -194,26 +216,26 @@ function QuickReplyFormDialogBody({
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
disabled={saving} disabled={saving}
> >
{t("quickReply.cancel")}
</Button> </Button>
<Button type="submit" form={formId} disabled={saving || loading}> <Button type="submit" form={formId} disabled={saving || loading}>
{saving ? "保存中..." : itemId ? "保存" : "创建"} {saving ? t("quickReply.saving") : itemId ? t("quickReply.save") : t("quickReply.create")}
</Button> </Button>
</> </>
} }
> >
{loading ? ( {loading ? (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<div className="text-muted-foreground">...</div> <div className="text-muted-foreground">{t("quickReply.loadingDetail")}</div>
</div> </div>
) : ( ) : (
<form id={formId} onSubmit={handleSubmit(onFormSubmit)} className="space-y-4"> <form id={formId} onSubmit={handleSubmit(onFormSubmit)} className="space-y-4">
<Field data-invalid={!!errors.groupName}> <Field data-invalid={!!errors.groupName}>
<FieldLabel htmlFor="quick-reply-group-name"></FieldLabel> <FieldLabel htmlFor="quick-reply-group-name">{t("quickReply.groupName")}</FieldLabel>
<FieldContent> <FieldContent>
<Input <Input
id="quick-reply-group-name" id="quick-reply-group-name"
placeholder="例如:售前、售后、催单" placeholder={t("quickReply.groupNamePlaceholder")}
aria-invalid={!!errors.groupName} aria-invalid={!!errors.groupName}
{...register("groupName")} {...register("groupName")}
/> />
@@ -221,11 +243,11 @@ function QuickReplyFormDialogBody({
</FieldContent> </FieldContent>
</Field> </Field>
<Field data-invalid={!!errors.title}> <Field data-invalid={!!errors.title}>
<FieldLabel htmlFor="quick-reply-title"></FieldLabel> <FieldLabel htmlFor="quick-reply-title">{t("quickReply.title")}</FieldLabel>
<FieldContent> <FieldContent>
<Input <Input
id="quick-reply-title" id="quick-reply-title"
placeholder="请输入快捷回复标题" placeholder={t("quickReply.titlePlaceholder")}
aria-invalid={!!errors.title} aria-invalid={!!errors.title}
{...register("title")} {...register("title")}
/> />
@@ -233,11 +255,11 @@ function QuickReplyFormDialogBody({
</FieldContent> </FieldContent>
</Field> </Field>
<Field data-invalid={!!errors.content}> <Field data-invalid={!!errors.content}>
<FieldLabel htmlFor="quick-reply-content"></FieldLabel> <FieldLabel htmlFor="quick-reply-content">{t("quickReply.content")}</FieldLabel>
<FieldContent> <FieldContent>
<Textarea <Textarea
id="quick-reply-content" id="quick-reply-content"
placeholder="请输入回复内容" placeholder={t("quickReply.contentPlaceholder")}
rows={6} rows={6}
aria-invalid={!!errors.content} aria-invalid={!!errors.content}
{...register("content")} {...register("content")}
@@ -247,49 +269,32 @@ function QuickReplyFormDialogBody({
</Field> </Field>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field data-invalid={!!errors.status}> <Field data-invalid={!!errors.status}>
<FieldLabel htmlFor="quick-reply-status"></FieldLabel> <FieldLabel>{t("quickReply.columnStatus")}</FieldLabel>
<FieldContent> <FieldContent>
<Controller <Controller
control={control} control={control}
name="status" name="status"
render={({ field }) => ( render={({ field }) => (
<Select <OptionCombobox
value={field.value} value={field.value}
onValueChange={field.onChange} options={formStatusOptions}
modal={false} placeholder={t("quickReply.statusRequired")}
> onChange={field.onChange}
<SelectTrigger />
id="quick-reply-status"
className="w-full"
aria-invalid={!!errors.status}
>
<SelectValue>{getStatusLabel(field.value)}</SelectValue>
</SelectTrigger>
<SelectContent>
{formStatusOptions.map((option) => (
<SelectItem
key={String(option.value)}
value={String(option.value)}
>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)} )}
/> />
<FieldError errors={[errors.status]} /> <FieldError errors={[errors.status]} />
</FieldContent> </FieldContent>
</Field> </Field>
<Field data-invalid={!!errors.sortNo}> <Field data-invalid={!!errors.sortNo}>
<FieldLabel htmlFor="quick-reply-sort-no"></FieldLabel> <FieldLabel htmlFor="quick-reply-sort-no">{t("quickReply.columnSort")}</FieldLabel>
<FieldContent> <FieldContent>
<Input <Input
id="quick-reply-sort-no" id="quick-reply-sort-no"
type="number" type="number"
min={0} min={0}
step={1} step={1}
placeholder="数字越大越靠前" placeholder={t("quickReply.sortPlaceholder")}
aria-invalid={!!errors.sortNo} aria-invalid={!!errors.sortNo}
{...register("sortNo")} {...register("sortNo")}
/> />
+52 -40
View File
@@ -46,21 +46,23 @@ import {
type CreateAdminQuickReplyPayload, type CreateAdminQuickReplyPayload,
type PageResult, type PageResult,
} from "@/lib/api/admin" } from "@/lib/api/admin"
import { getEnumLabel, getEnumOptions } from "@/lib/enums" import { getEnumOptions } from "@/lib/enums"
import { Status, StatusLabels } from "@/lib/generated/enums" import { Status, StatusLabels } from "@/lib/generated/enums"
import { useI18n } from "@/i18n/provider"
import { EditDialog } from "./_components/edit" import { EditDialog } from "./_components/edit"
const listStatusOptions = [ function getStatusLabel(status: Status, t: (key: string) => string) {
{ value: "all", label: "全部状态" }, if (status === Status.Disabled) {
...getEnumOptions(StatusLabels) return t("status.disabled")
.filter((item) => Number(item.value) !== Status.Deleted) }
.map((item) => ({ if (status === Status.Deleted) {
value: String(item.value), return t("status.deleted")
label: item.label, }
})), return t("status.ok")
] as const }
export default function DashboardQuickRepliesPage() { export default function DashboardQuickRepliesPage() {
const t = useI18n()
const [keywordInput, setKeywordInput] = useState("") const [keywordInput, setKeywordInput] = useState("")
const [groupNameInput, setGroupNameInput] = useState("") const [groupNameInput, setGroupNameInput] = useState("")
const [statusFilterInput, setStatusFilterInput] = useState("all") const [statusFilterInput, setStatusFilterInput] = useState("all")
@@ -91,11 +93,21 @@ export default function DashboardQuickRepliesPage() {
}) })
setResult(data) setResult(data)
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "加载快捷回复失败") toast.error(error instanceof Error ? error.message : t("quickReply.loadFailed"))
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, [groupName, keyword, limit, page, statusFilter]) }, [groupName, keyword, limit, page, statusFilter, t])
const listStatusOptions = [
{ value: "all", label: t("status.all") },
...getEnumOptions(StatusLabels)
.filter((item) => Number(item.value) !== Status.Deleted)
.map((item) => ({
value: String(item.value),
label: getStatusLabel(item.value as Status, t),
})),
]
useEffect(() => { useEffect(() => {
void loadData() void loadData()
@@ -156,16 +168,16 @@ export default function DashboardQuickRepliesPage() {
id: editingItem.id, id: editingItem.id,
...payload, ...payload,
}) })
toast.success(`已更新快捷回复:${editingItem.title}`) toast.success(t("quickReply.updated", { title: editingItem.title }))
} else { } else {
await createQuickReply(payload) await createQuickReply(payload)
toast.success(`已创建快捷回复:${payload.title}`) toast.success(t("quickReply.created", { title: payload.title }))
} }
setDialogOpen(false) setDialogOpen(false)
setEditingItem(null) setEditingItem(null)
await loadData() await loadData()
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "保存快捷回复失败") toast.error(error instanceof Error ? error.message : t("quickReply.saveFailed"))
} finally { } finally {
setSaving(false) setSaving(false)
} }
@@ -185,11 +197,11 @@ export default function DashboardQuickRepliesPage() {
status: nextStatus, status: nextStatus,
}) })
toast.success( toast.success(
`${nextStatus === Status.Ok ? "启用" : "禁用"}${item.title}` t(nextStatus === Status.Ok ? "quickReply.enabled" : "quickReply.disabled", { title: item.title })
) )
await loadData() await loadData()
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "更新状态失败") toast.error(error instanceof Error ? error.message : t("quickReply.statusUpdateFailed"))
} finally { } finally {
setActionLoadingId(null) setActionLoadingId(null)
} }
@@ -199,10 +211,10 @@ export default function DashboardQuickRepliesPage() {
setActionLoadingId(item.id) setActionLoadingId(item.id)
try { try {
await deleteQuickReply(item.id) await deleteQuickReply(item.id)
toast.success(`已删除快捷回复:${item.title}`) toast.success(t("quickReply.deleted", { title: item.title }))
await loadData() await loadData()
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "删除快捷回复失败") toast.error(error instanceof Error ? error.message : t("quickReply.deleteFailed"))
} finally { } finally {
setActionLoadingId(null) setActionLoadingId(null)
} }
@@ -216,11 +228,11 @@ export default function DashboardQuickRepliesPage() {
<> <>
<Button variant="outline" onClick={() => void loadData()} disabled={loading}> <Button variant="outline" onClick={() => void loadData()} disabled={loading}>
<RefreshCwIcon className={loading ? "animate-spin" : undefined} /> <RefreshCwIcon className={loading ? "animate-spin" : undefined} />
{t("quickReply.refresh")}
</Button> </Button>
<Button onClick={openCreateDialog}> <Button onClick={openCreateDialog}>
<PlusIcon /> <PlusIcon />
{t("quickReply.new")}
</Button> </Button>
</> </>
} }
@@ -231,7 +243,7 @@ export default function DashboardQuickRepliesPage() {
value={keywordInput} value={keywordInput}
onChange={(event) => setKeywordInput(event.target.value)} onChange={(event) => setKeywordInput(event.target.value)}
onKeyDown={handleFilterKeyDown} onKeyDown={handleFilterKeyDown}
placeholder="按标题筛选" placeholder={t("quickReply.filterTitle")}
className="pl-9" className="pl-9"
/> />
</div> </div>
@@ -239,20 +251,20 @@ export default function DashboardQuickRepliesPage() {
value={groupNameInput} value={groupNameInput}
onChange={(event) => setGroupNameInput(event.target.value)} onChange={(event) => setGroupNameInput(event.target.value)}
onKeyDown={handleFilterKeyDown} onKeyDown={handleFilterKeyDown}
placeholder="按分组筛选" placeholder={t("quickReply.filterGroup")}
className="w-full sm:w-44" className="w-full sm:w-44"
/> />
<div className="w-full sm:w-36"> <div className="w-full sm:w-36">
<OptionCombobox <OptionCombobox
value={statusFilterInput} value={statusFilterInput}
onChange={setStatusFilterInput} onChange={setStatusFilterInput}
placeholder="全部状态" placeholder={t("status.all")}
options={[...listStatusOptions]} options={[...listStatusOptions]}
/> />
</div> </div>
<Button variant="outline" onClick={applyFilters} disabled={loading}> <Button variant="outline" onClick={applyFilters} disabled={loading}>
<SearchIcon /> <SearchIcon />
{t("quickReply.query")}
</Button> </Button>
</DashboardToolbar> </DashboardToolbar>
<DashboardTableShell <DashboardTableShell
@@ -273,12 +285,12 @@ export default function DashboardQuickRepliesPage() {
<Table> <Table>
<TableHeader className="bg-muted/40"> <TableHeader className="bg-muted/40">
<TableRow> <TableRow>
<TableHead></TableHead> <TableHead>{t("quickReply.columnQuickReply")}</TableHead>
<TableHead></TableHead> <TableHead>{t("quickReply.columnGroup")}</TableHead>
<TableHead></TableHead> <TableHead>{t("quickReply.columnStatus")}</TableHead>
<TableHead></TableHead> <TableHead>{t("quickReply.columnSort")}</TableHead>
<TableHead></TableHead> <TableHead>{t("quickReply.columnCreator")}</TableHead>
<TableHead className="w-[92px] text-right"></TableHead> <TableHead className="w-[92px] text-right">{t("quickReply.columnActions")}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -306,7 +318,7 @@ export default function DashboardQuickRepliesPage() {
item.status === Status.Ok ? "default" : "outline" item.status === Status.Ok ? "default" : "outline"
} }
> >
{getEnumLabel(StatusLabels, item.status as Status)} {getStatusLabel(item.status as Status, t)}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell>{item.sortNo}</TableCell> <TableCell>{item.sortNo}</TableCell>
@@ -318,12 +330,12 @@ export default function DashboardQuickRepliesPage() {
size="sm" size="sm"
onClick={() => openEditDialog(item)} onClick={() => openEditDialog(item)}
> >
{t("quickReply.edit")}
</Button> </Button>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
render={<Button variant="outline" size="icon-sm" />} render={<Button variant="outline" size="icon-sm" />}
aria-label={`更多操作 ${item.title}`} aria-label={t("quickReply.moreActions", { title: item.title })}
> >
<MoreHorizontalIcon /> <MoreHorizontalIcon />
</DropdownMenuTrigger> </DropdownMenuTrigger>
@@ -331,17 +343,17 @@ export default function DashboardQuickRepliesPage() {
<DropdownMenuItem onClick={() => void handleToggleStatus(item)}> <DropdownMenuItem onClick={() => void handleToggleStatus(item)}>
<RefreshCwIcon /> <RefreshCwIcon />
{actionLoadingId === item.id {actionLoadingId === item.id
? "处理中..." ? t("quickReply.processing")
: item.status === Status.Ok : item.status === Status.Ok
? "禁用" ? t("quickReply.disable")
: "启用"} : t("quickReply.enable")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={() => void handleDelete(item)} onClick={() => void handleDelete(item)}
className="text-destructive focus:text-destructive" className="text-destructive focus:text-destructive"
> >
<Trash2Icon /> <Trash2Icon />
{actionLoadingId === item.id ? "删除中..." : "删除"} {actionLoadingId === item.id ? t("quickReply.deleting") : t("quickReply.delete")}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@@ -353,8 +365,8 @@ export default function DashboardQuickRepliesPage() {
<DashboardTableStateRow <DashboardTableStateRow
colSpan={6} colSpan={6}
loading={loading} loading={loading}
loadingText="正在加载快捷回复..." loadingText={t("quickReply.loading")}
emptyText="没有匹配的快捷回复" emptyText={t("quickReply.empty")}
/> />
) : null} ) : null}
</TableBody> </TableBody>
@@ -25,6 +25,9 @@ import {
} from "@/components/ui/field"; } from "@/components/ui/field";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import type { AdminPermission, AdminRole } from "@/lib/api/admin"; import type { AdminPermission, AdminRole } from "@/lib/api/admin";
import { useAppLocale, useI18n } from "@/i18n/provider";
import { getPermissionDisplayName, getPermissionGroupName } from "@/lib/permission-i18n";
import { getRoleDisplayName } from "@/lib/role-i18n";
type AssignPermissionsDrawerProps = { type AssignPermissionsDrawerProps = {
open: boolean; open: boolean;
@@ -104,6 +107,8 @@ function AssignPermissionsDrawerBody({
onOpenChange, onOpenChange,
onSubmit, onSubmit,
}: AssignPermissionsDrawerBodyProps) { }: AssignPermissionsDrawerBodyProps) {
const t = useI18n();
const { locale } = useAppLocale();
const [keyword, setKeyword] = useState(""); const [keyword, setKeyword] = useState("");
const form = useForm< const form = useForm<
z.input<typeof assignPermissionsSchema>, z.input<typeof assignPermissionsSchema>,
@@ -128,7 +133,7 @@ function AssignPermissionsDrawerBody({
const output = keyword.trim().toLowerCase(); const output = keyword.trim().toLowerCase();
const filtered = output const filtered = output
? permissions.filter((permission) => ? permissions.filter((permission) =>
`${permission.name} ${permission.code} ${permission.groupName} ${permission.apiPath}` `${permission.name} ${getPermissionDisplayName(permission.code, permission.name, locale)} ${permission.code} ${permission.groupName} ${getPermissionGroupName(permission.groupName, locale)} ${permission.apiPath}`
.toLowerCase() .toLowerCase()
.includes(output), .includes(output),
) )
@@ -142,9 +147,9 @@ function AssignPermissionsDrawerBody({
groups.set(groupName, list); groups.set(groupName, list);
}); });
return Array.from(groups.entries()).sort(([left], [right]) => return Array.from(groups.entries()).sort(([left], [right]) =>
left.localeCompare(right, "zh-CN"), getPermissionGroupName(left, locale).localeCompare(getPermissionGroupName(right, locale)),
); );
}, [keyword, permissions]); }, [keyword, locale, permissions]);
async function onFormSubmit(values: AssignPermissionsForm) { async function onFormSubmit(values: AssignPermissionsForm) {
await onSubmit(values.permissionIds); await onSubmit(values.permissionIds);
@@ -153,9 +158,12 @@ function AssignPermissionsDrawerBody({
return ( return (
<DrawerContent className="min-w-3xl"> <DrawerContent className="min-w-3xl">
<DrawerHeader> <DrawerHeader>
<DrawerTitle></DrawerTitle> <DrawerTitle>{t("role.assignTitle")}</DrawerTitle>
<DrawerDescription> <DrawerDescription>
{item?.name || "-"} {item?.code ? `(${item.code})` : ""} {t("role.currentRole", {
name: item ? getRoleDisplayName(item.code, item.name, locale) : "-",
code: item?.code ? `(${item.code})` : "",
})}
</DrawerDescription> </DrawerDescription>
</DrawerHeader> </DrawerHeader>
<form <form
@@ -164,14 +172,14 @@ function AssignPermissionsDrawerBody({
> >
<div className="flex-1 flex flex-col min-h-0 space-y-4 px-4 pb-4"> <div className="flex-1 flex flex-col min-h-0 space-y-4 px-4 pb-4">
<Field data-invalid={!!errors.permissionIds} className="flex-1 flex flex-col min-h-0"> <Field data-invalid={!!errors.permissionIds} className="flex-1 flex flex-col min-h-0">
<FieldLabel></FieldLabel> <FieldLabel>{t("role.permissionList")}</FieldLabel>
<FieldContent className="flex-1 min-h-0 flex flex-col"> <FieldContent className="flex-1 min-h-0 flex flex-col">
<div className="relative mb-2"> <div className="relative mb-2">
<SearchIcon className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" /> <SearchIcon className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input <Input
value={keyword} value={keyword}
onChange={(event) => setKeyword(event.target.value)} onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索权限名称、编码、分组或接口" placeholder={t("role.searchPermission")}
className="pl-9" className="pl-9"
disabled={loading} disabled={loading}
/> />
@@ -186,15 +194,15 @@ function AssignPermissionsDrawerBody({
<div className="flex-1 min-h-0 space-y-4 overflow-y-auto rounded-xl border p-3"> <div className="flex-1 min-h-0 space-y-4 overflow-y-auto rounded-xl border p-3">
{loading ? ( {loading ? (
<div className="py-8 text-center text-sm text-muted-foreground"> <div className="py-8 text-center text-sm text-muted-foreground">
... {t("role.loadingPermissions")}
</div> </div>
) : groupedPermissions.length > 0 ? ( ) : groupedPermissions.length > 0 ? (
groupedPermissions.map(([groupName, list]) => ( groupedPermissions.map(([groupName, list]) => (
<section key={groupName} className="space-y-2"> <section key={groupName} className="space-y-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge variant="outline">{groupName}</Badge> <Badge variant="outline">{getPermissionGroupName(groupName, locale)}</Badge>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{list.length} {t("role.permissionCount", { count: list.length })}
</span> </span>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
@@ -225,7 +233,7 @@ function AssignPermissionsDrawerBody({
/> />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="font-medium"> <div className="font-medium">
{permission.name} {getPermissionDisplayName(permission.code, permission.name, locale)}
</div> </div>
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{permission.code} {permission.code}
@@ -243,8 +251,8 @@ function AssignPermissionsDrawerBody({
} }
> >
{permission.status === 1 {permission.status === 1
? "禁用" ? t("status.disabled")
: "启用"} : t("status.ok")}
</Badge> </Badge>
</label> </label>
); );
@@ -254,7 +262,7 @@ function AssignPermissionsDrawerBody({
)) ))
) : ( ) : (
<div className="py-8 text-center text-sm text-muted-foreground"> <div className="py-8 text-center text-sm text-muted-foreground">
{t("role.noPermissions")}
</div> </div>
)} )}
</div> </div>
@@ -267,7 +275,7 @@ function AssignPermissionsDrawerBody({
</div> </div>
<DrawerFooter className="border-t"> <DrawerFooter className="border-t">
<Button type="submit" disabled={saving || loading || !item}> <Button type="submit" disabled={saving || loading || !item}>
{saving ? "保存中..." : "保存权限"} {saving ? t("role.saving") : t("role.savePermissions")}
</Button> </Button>
<Button <Button
type="button" type="button"
@@ -275,7 +283,7 @@ function AssignPermissionsDrawerBody({
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
disabled={saving} disabled={saving}
> >
{t("role.cancel")}
</Button> </Button>
</DrawerFooter> </DrawerFooter>
</form> </form>
+36 -32
View File
@@ -1,5 +1,6 @@
"use client" "use client"
import { useMemo } from "react"
import { Resolver, useForm } from "react-hook-form" import { Resolver, useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod/v4" import { z } from "zod/v4"
@@ -22,6 +23,7 @@ import {
} from "@/components/ui/field" } from "@/components/ui/field"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
import { useI18n } from "@/i18n/provider"
type CreateRoleDrawerProps = { type CreateRoleDrawerProps = {
open: boolean open: boolean
@@ -30,23 +32,11 @@ type CreateRoleDrawerProps = {
onSubmit: (payload: CreateAdminRolePayload) => Promise<void> onSubmit: (payload: CreateAdminRolePayload) => Promise<void>
} }
const createFormSchema = z.object({ type CreateForm = {
name: z.string().trim().min(1, "角色名称不能为空"), name: string
code: z code: string
.string() remark: string
.trim() }
.min(1, "角色编码不能为空")
.regex(/^[A-Za-z][A-Za-z0-9:_-]*$/, "角色编码需以字母开头,仅支持字母、数字、冒号、下划线和短横线"),
remark: z.string().trim(),
})
type CreateForm = z.infer<typeof createFormSchema>
const createFormResolver = zodResolver(createFormSchema as never) as Resolver<
z.input<typeof createFormSchema>,
undefined,
z.output<typeof createFormSchema>
>
const emptyForm: CreateForm = { const emptyForm: CreateForm = {
name: "", name: "",
@@ -99,11 +89,25 @@ function CreateRoleDrawerBody({
onOpenChange, onOpenChange,
onSubmit, onSubmit,
}: CreateRoleDrawerBodyProps) { }: CreateRoleDrawerBodyProps) {
const form = useForm< const t = useI18n()
z.input<typeof createFormSchema>, const createFormSchema = useMemo(
undefined, () =>
z.output<typeof createFormSchema> z.object({
>({ name: z.string().trim().min(1, t("role.nameRequired")),
code: z
.string()
.trim()
.min(1, t("role.codeRequired"))
.regex(/^[A-Za-z][A-Za-z0-9:_-]*$/, t("role.codeInvalid")),
remark: z.string().trim(),
}),
[t]
)
const createFormResolver = useMemo(
() => zodResolver(createFormSchema as never) as Resolver<CreateForm>,
[createFormSchema]
)
const form = useForm<CreateForm>({
resolver: createFormResolver, resolver: createFormResolver,
defaultValues: buildEmptyForm(), defaultValues: buildEmptyForm(),
}) })
@@ -122,8 +126,8 @@ function CreateRoleDrawerBody({
return ( return (
<DrawerContent className="min-w-2xl"> <DrawerContent className="min-w-2xl">
<DrawerHeader> <DrawerHeader>
<DrawerTitle></DrawerTitle> <DrawerTitle>{t("role.createTitle")}</DrawerTitle>
<DrawerDescription></DrawerDescription> <DrawerDescription>{t("role.createDescription")}</DrawerDescription>
</DrawerHeader> </DrawerHeader>
<form <form
className="flex h-full flex-col" className="flex h-full flex-col"
@@ -131,11 +135,11 @@ function CreateRoleDrawerBody({
> >
<div className="space-y-4 overflow-y-auto px-4 pb-4"> <div className="space-y-4 overflow-y-auto px-4 pb-4">
<Field data-invalid={!!errors.name}> <Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="create-role-name"></FieldLabel> <FieldLabel htmlFor="create-role-name">{t("role.name")}</FieldLabel>
<FieldContent> <FieldContent>
<Input <Input
id="create-role-name" id="create-role-name"
placeholder="例如:客服主管" placeholder={t("role.namePlaceholder")}
autoComplete="off" autoComplete="off"
aria-invalid={!!errors.name} aria-invalid={!!errors.name}
{...register("name")} {...register("name")}
@@ -144,11 +148,11 @@ function CreateRoleDrawerBody({
</FieldContent> </FieldContent>
</Field> </Field>
<Field data-invalid={!!errors.code}> <Field data-invalid={!!errors.code}>
<FieldLabel htmlFor="create-role-code"></FieldLabel> <FieldLabel htmlFor="create-role-code">{t("role.code")}</FieldLabel>
<FieldContent> <FieldContent>
<Input <Input
id="create-role-code" id="create-role-code"
placeholder="例如:support_manager" placeholder={t("role.codePlaceholder")}
autoComplete="off" autoComplete="off"
aria-invalid={!!errors.code} aria-invalid={!!errors.code}
{...register("code")} {...register("code")}
@@ -157,11 +161,11 @@ function CreateRoleDrawerBody({
</FieldContent> </FieldContent>
</Field> </Field>
<Field data-invalid={!!errors.remark}> <Field data-invalid={!!errors.remark}>
<FieldLabel htmlFor="create-role-remark"></FieldLabel> <FieldLabel htmlFor="create-role-remark">{t("role.remark")}</FieldLabel>
<FieldContent> <FieldContent>
<Textarea <Textarea
id="create-role-remark" id="create-role-remark"
placeholder="可选" placeholder={t("role.optional")}
aria-invalid={!!errors.remark} aria-invalid={!!errors.remark}
{...register("remark")} {...register("remark")}
/> />
@@ -171,7 +175,7 @@ function CreateRoleDrawerBody({
</div> </div>
<DrawerFooter className="border-t"> <DrawerFooter className="border-t">
<Button type="submit" disabled={saving}> <Button type="submit" disabled={saving}>
{saving ? "创建中..." : "创建角色"} {saving ? t("role.creating") : t("role.create")}
</Button> </Button>
<Button <Button
type="button" type="button"
@@ -179,7 +183,7 @@ function CreateRoleDrawerBody({
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
disabled={saving} disabled={saving}
> >
{t("role.cancel")}
</Button> </Button>
</DrawerFooter> </DrawerFooter>
</form> </form>
+31 -25
View File
@@ -62,6 +62,8 @@ import {
TableRow, TableRow,
} from "@/components/ui/table" } from "@/components/ui/table"
import { Status } from "@/lib/generated/enums" import { Status } from "@/lib/generated/enums"
import { useAppLocale, useI18n } from "@/i18n/provider"
import { getRoleDisplayName } from "@/lib/role-i18n"
type SortableRoleRowProps = { type SortableRoleRowProps = {
item: AdminRole item: AdminRole
@@ -76,6 +78,9 @@ function SortableRoleRow({
actionLoading, actionLoading,
onAssignPermissions, onAssignPermissions,
}: SortableRoleRowProps) { }: SortableRoleRowProps) {
const t = useI18n()
const { locale } = useAppLocale()
const displayName = getRoleDisplayName(item.code, item.name, locale)
const { const {
attributes, attributes,
listeners, listeners,
@@ -109,7 +114,7 @@ function SortableRoleRow({
size="icon" size="icon"
className="size-8 cursor-grab active:cursor-grabbing" className="size-8 cursor-grab active:cursor-grabbing"
disabled={disabled} disabled={disabled}
aria-label={`拖拽排序 ${item.name}`} aria-label={t("role.dragSort", { name: displayName })}
{...attributes} {...attributes}
{...listeners} {...listeners}
> >
@@ -121,7 +126,7 @@ function SortableRoleRow({
<div className="flex size-10 items-center justify-center rounded-2xl bg-muted text-muted-foreground"> <div className="flex size-10 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
<ShieldCheckIcon className="size-4" /> <ShieldCheckIcon className="size-4" />
</div> </div>
<div className="font-medium">{item.name}</div> <div className="font-medium">{displayName}</div>
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
@@ -132,11 +137,11 @@ function SortableRoleRow({
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge variant={item.status === Status.Ok ? "secondary" : "outline"}> <Badge variant={item.status === Status.Ok ? "secondary" : "outline"}>
{item.status === Status.Ok ? "启用" : "禁用"} {item.status === Status.Ok ? t("status.ok") : t("status.disabled")}
</Badge> </Badge>
{item.isSystem ? ( {item.isSystem ? (
<Badge variant="outline" className="ml-2"> <Badge variant="outline" className="ml-2">
{t("role.system")}
</Badge> </Badge>
) : null} ) : null}
</TableCell> </TableCell>
@@ -149,7 +154,7 @@ function SortableRoleRow({
onClick={() => onAssignPermissions(item)} onClick={() => onAssignPermissions(item)}
disabled={disabled || actionLoading} disabled={disabled || actionLoading}
> >
{actionLoading ? "处理中..." : "分配权限"} {actionLoading ? t("role.processing") : t("role.assignPermissions")}
</Button> </Button>
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -157,6 +162,7 @@ function SortableRoleRow({
} }
export default function DashboardRolesPage() { export default function DashboardRolesPage() {
const t = useI18n()
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [sorting, setSorting] = useState(false) const [sorting, setSorting] = useState(false)
const [creatingOpen, setCreatingOpen] = useState(false) const [creatingOpen, setCreatingOpen] = useState(false)
@@ -191,7 +197,7 @@ export default function DashboardRolesPage() {
try { try {
setResult(await fetchRoles({ limit: 200 })) setResult(await fetchRoles({ limit: 200 }))
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "加载角色失败") toast.error(error instanceof Error ? error.message : t("role.loadFailed"))
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -212,11 +218,11 @@ export default function DashboardRolesPage() {
setSavingCreate(true) setSavingCreate(true)
try { try {
const role = await createRole(payload) const role = await createRole(payload)
toast.success(`已创建角色 ${role.name}`) toast.success(t("role.created", { name: role.name }))
setCreatingOpen(false) setCreatingOpen(false)
await loadRoles() await loadRoles()
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "创建角色失败") toast.error(error instanceof Error ? error.message : t("role.createFailed"))
} finally { } finally {
setSavingCreate(false) setSavingCreate(false)
} }
@@ -240,7 +246,7 @@ export default function DashboardRolesPage() {
) )
} catch (error) { } catch (error) {
setAssigningRole(null) setAssigningRole(null)
toast.error(error instanceof Error ? error.message : "加载权限分配数据失败") toast.error(error instanceof Error ? error.message : t("role.loadAssignFailed"))
} finally { } finally {
setAssignPermissionsLoading(false) setAssignPermissionsLoading(false)
setActionLoadingId(null) setActionLoadingId(null)
@@ -269,14 +275,14 @@ export default function DashboardRolesPage() {
try { try {
await updateRoleSort(nextResults.map((item) => item.id)) await updateRoleSort(nextResults.map((item) => item.id))
toast.success("角色排序已更新") toast.success(t("role.sortUpdated"))
await loadRoles() await loadRoles()
} catch (error) { } catch (error) {
setResult((current) => ({ setResult((current) => ({
...current, ...current,
results: previousResults, results: previousResults,
})) }))
toast.error(error instanceof Error ? error.message : "更新角色排序失败") toast.error(error instanceof Error ? error.message : t("role.sortUpdateFailed"))
} finally { } finally {
setSorting(false) setSorting(false)
} }
@@ -301,13 +307,13 @@ export default function DashboardRolesPage() {
setSavingPermissions(true) setSavingPermissions(true)
try { try {
await assignRolePermissions(assigningRole.id, permissionIds) await assignRolePermissions(assigningRole.id, permissionIds)
toast.success(`已更新角色 ${assigningRole.name} 的权限`) toast.success(t("role.permissionsUpdated", { name: assigningRole.name }))
setAssigningRole(null) setAssigningRole(null)
setAssignPermissionOptions([]) setAssignPermissionOptions([])
setAssignPermissionIds([]) setAssignPermissionIds([])
await loadRoles() await loadRoles()
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "保存权限分配失败") toast.error(error instanceof Error ? error.message : t("role.savePermissionsFailed"))
} finally { } finally {
setSavingPermissions(false) setSavingPermissions(false)
} }
@@ -328,7 +334,7 @@ export default function DashboardRolesPage() {
disabled={loading || sorting} disabled={loading || sorting}
> >
<PlusIcon className="size-4" /> <PlusIcon className="size-4" />
{t("role.add")}
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
@@ -336,22 +342,22 @@ export default function DashboardRolesPage() {
disabled={loading || sorting} disabled={loading || sorting}
> >
<RefreshCwIcon className={cn((loading || sorting) && "animate-spin")} /> <RefreshCwIcon className={cn((loading || sorting) && "animate-spin")} />
{t("role.refresh")}
</Button> </Button>
</> </>
} }
> >
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{t("role.dragHint")}
</div> </div>
</DashboardToolbar> </DashboardToolbar>
<DashboardTableShell <DashboardTableShell
pagination={ pagination={
<DashboardTableSummary> <DashboardTableSummary>
<span> <span>
{result.page.page} / {result.page.limit} {t("role.pageSummary", { page: result.page.page, limit: result.page.limit })}
</span> </span>
<span> {result.page.total} </span> <span>{t("pagination.total", { total: result.page.total })}</span>
</DashboardTableSummary> </DashboardTableSummary>
} }
> >
@@ -364,11 +370,11 @@ export default function DashboardRolesPage() {
<TableHeader className="bg-muted/40"> <TableHeader className="bg-muted/40">
<TableRow> <TableRow>
<TableHead className="w-14"></TableHead> <TableHead className="w-14"></TableHead>
<TableHead></TableHead> <TableHead>{t("role.columnRole")}</TableHead>
<TableHead></TableHead> <TableHead>{t("role.columnCode")}</TableHead>
<TableHead></TableHead> <TableHead>{t("role.columnStatus")}</TableHead>
<TableHead></TableHead> <TableHead>{t("role.columnSort")}</TableHead>
<TableHead className="text-right"></TableHead> <TableHead className="text-right">{t("role.columnActions")}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -392,8 +398,8 @@ export default function DashboardRolesPage() {
<DashboardTableStateRow <DashboardTableStateRow
colSpan={6} colSpan={6}
loading={loading} loading={loading}
loadingText="正在加载角色数据..." loadingText={t("role.loading")}
emptyText="暂无角色数据" emptyText={t("role.empty")}
/> />
) : null} ) : null}
</TableBody> </TableBody>
+10 -5
View File
@@ -1,15 +1,20 @@
"use client"
import { DashboardPlaceholder } from "@/components/dashboard-placeholder" import { DashboardPlaceholder } from "@/components/dashboard-placeholder"
import { useI18n } from "@/i18n/provider"
export default function DashboardSettingsPage() { export default function DashboardSettingsPage() {
const t = useI18n()
return ( return (
<DashboardPlaceholder <DashboardPlaceholder
eyebrow="Settings" eyebrow="Settings"
title="系统设置骨架" title={t("settings.title")}
description="系统设置页将管理认证参数、上传配置、基础信息与运行策略。" description={t("settings.description")}
nextSteps={[ nextSteps={[
"补充系统配置读取与保存接口。", t("settings.step1"),
"按安全、存储、登录策略拆分设置区块。", t("settings.step2"),
"增加敏感配置的脱敏显示和二次确认。", t("settings.step3"),
]} ]}
/> />
) )
@@ -2,7 +2,7 @@
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod"
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import { Resolver, useForm } from "react-hook-form" import { type Resolver, useForm } from "react-hook-form"
import { z } from "zod/v4" import { z } from "zod/v4"
import { LoaderCircleIcon, PlayIcon } from "lucide-react" import { LoaderCircleIcon, PlayIcon } from "lucide-react"
import { toast } from "sonner" import { toast } from "sonner"
@@ -29,6 +29,7 @@ import {
type SkillDebugRunPayload, type SkillDebugRunPayload,
type SkillDebugRunResult, type SkillDebugRunResult,
} from "@/lib/api/admin" } from "@/lib/api/admin"
import { useI18n } from "@/i18n/provider"
type DebugDialogProps = { type DebugDialogProps = {
open: boolean open: boolean
@@ -37,19 +38,21 @@ type DebugDialogProps = {
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
} }
const debugFormSchema = z.object({ type TFunction = (key: string, values?: Record<string, string | number>) => string
aiAgentId: z.string().trim().min(1, "请选择 AI Agent"),
function createDebugFormSchema(t: TFunction) {
return z.object({
aiAgentId: z.string().trim().min(1, t("skillDefinition.agentRequired")),
conversationId: z.string().trim(), conversationId: z.string().trim(),
userMessage: z.string().trim().min(1, "请输入用户消息"), userMessage: z.string().trim().min(1, t("skillDefinition.messageRequired")),
}) })
}
type DebugForm = z.infer<typeof debugFormSchema> type DebugForm = {
aiAgentId: string
const debugFormResolver = zodResolver(debugFormSchema as never) as Resolver< conversationId: string
z.input<typeof debugFormSchema>, userMessage: string
undefined, }
z.output<typeof debugFormSchema>
>
const emptyForm: DebugForm = { const emptyForm: DebugForm = {
aiAgentId: "", aiAgentId: "",
@@ -57,15 +60,17 @@ const emptyForm: DebugForm = {
userMessage: "", userMessage: "",
} }
const quickResumeActions = [ function getQuickResumeActions(t: TFunction) {
{ label: "确认", value: "确认" }, return [
{ label: "取消", value: "取消" }, { label: t("skillDefinition.confirm"), value: t("skillDefinition.confirm") },
{ label: t("skillDefinition.reject"), value: t("skillDefinition.reject") },
] ]
}
function ResultBlock({ function ResultBlock({
title, title,
value, value,
emptyText = "暂无数据", emptyText,
}: { }: {
title: string title: string
value?: string value?: string
@@ -116,6 +121,7 @@ function DebugDialogBody({
skillName, skillName,
onOpenChange, onOpenChange,
}: DebugDialogProps) { }: DebugDialogProps) {
const t = useI18n()
const formId = `skill-debug-form-${skillCode}` const formId = `skill-debug-form-${skillCode}`
const [running, setRunning] = useState(false) const [running, setRunning] = useState(false)
const [resuming, setResuming] = useState(false) const [resuming, setResuming] = useState(false)
@@ -123,11 +129,12 @@ function DebugDialogBody({
const [result, setResult] = useState<SkillDebugRunResult | null>(null) const [result, setResult] = useState<SkillDebugRunResult | null>(null)
const [resumeResult, setResumeResult] = useState<SkillDebugRunResult | null>(null) const [resumeResult, setResumeResult] = useState<SkillDebugRunResult | null>(null)
const [resumeMessage, setResumeMessage] = useState("") const [resumeMessage, setResumeMessage] = useState("")
const form = useForm< const debugFormSchema = useMemo(() => createDebugFormSchema(t), [t])
z.input<typeof debugFormSchema>, const debugFormResolver = useMemo(
undefined, () => zodResolver(debugFormSchema) as Resolver<DebugForm>,
z.output<typeof debugFormSchema> [debugFormSchema],
>({ )
const form = useForm<DebugForm>({
resolver: debugFormResolver, resolver: debugFormResolver,
defaultValues: emptyForm, defaultValues: emptyForm,
}) })
@@ -142,6 +149,7 @@ function DebugDialogBody({
} = form } = form
const selectedAgentId = watch("aiAgentId") const selectedAgentId = watch("aiAgentId")
const quickResumeActions = useMemo(() => getQuickResumeActions(t), [t])
useEffect(() => { useEffect(() => {
async function loadAIAgents() { async function loadAIAgents() {
@@ -205,7 +213,7 @@ function DebugDialogBody({
setResumeResult(null) setResumeResult(null)
setResumeMessage("") setResumeMessage("")
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "Skill 调试失败") toast.error(error instanceof Error ? error.message : t("skillDefinition.debugFailed"))
setResult(null) setResult(null)
} finally { } finally {
setRunning(false) setRunning(false)
@@ -218,7 +226,7 @@ function DebugDialogBody({
return return
} }
if (!nextMessage) { if (!nextMessage) {
toast.error("请输入恢复消息") toast.error(t("skillDefinition.resumeMessageRequired"))
return return
} }
const payload: SkillDebugResumePayload = { const payload: SkillDebugResumePayload = {
@@ -237,7 +245,7 @@ function DebugDialogBody({
setResumeResult(data) setResumeResult(data)
setResumeMessage(nextMessage) setResumeMessage(nextMessage)
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "恢复调试失败") toast.error(error instanceof Error ? error.message : t("skillDefinition.resumeFailed"))
setResumeResult(null) setResumeResult(null)
} finally { } finally {
setResuming(false) setResuming(false)
@@ -248,8 +256,8 @@ function DebugDialogBody({
<ProjectDialog <ProjectDialog
open={open} open={open}
onOpenChange={onOpenChange} onOpenChange={onOpenChange}
title={`调试 Skill · ${skillName || skillCode}`} title={t("skillDefinition.debugTitle", { name: skillName || skillCode })}
description="强制指定当前 Skill,直接查看 route、tools、graph、HITL 和回复结果。" description={t("skillDefinition.debugDescription")}
size="xl" size="xl"
allowFullscreen allowFullscreen
footer={ footer={
@@ -260,11 +268,11 @@ function DebugDialogBody({
disabled={running} disabled={running}
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
> >
{t("skillDefinition.close")}
</Button> </Button>
<Button type="submit" form={formId} disabled={running}> <Button type="submit" form={formId} disabled={running}>
{running ? <LoaderCircleIcon className="animate-spin" /> : <PlayIcon />} {running ? <LoaderCircleIcon className="animate-spin" /> : <PlayIcon />}
{running ? "调试中..." : "开始调试"} {running ? t("skillDefinition.debugging") : t("skillDefinition.startDebug")}
</Button> </Button>
</> </>
} }
@@ -272,7 +280,7 @@ function DebugDialogBody({
<div className="space-y-6"> <div className="space-y-6">
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle className="text-sm"></CardTitle> <CardTitle className="text-sm">{t("skillDefinition.debugInput")}</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<form id={formId} onSubmit={handleSubmit(onSubmit)} className="space-y-4"> <form id={formId} onSubmit={handleSubmit(onSubmit)} className="space-y-4">
@@ -283,9 +291,9 @@ function DebugDialogBody({
<OptionCombobox <OptionCombobox
value={selectedAgentId} value={selectedAgentId}
options={aiAgentOptions} options={aiAgentOptions}
placeholder="选择 AI Agent" placeholder={t("skillDefinition.selectAgent")}
searchPlaceholder="搜索 AI Agent" searchPlaceholder={t("skillDefinition.searchAgent")}
emptyText="未找到 AI Agent" emptyText={t("skillDefinition.emptyAgent")}
onChange={(value) => onChange={(value) =>
setValue("aiAgentId", value, { shouldValidate: true }) setValue("aiAgentId", value, { shouldValidate: true })
} }
@@ -302,7 +310,7 @@ function DebugDialogBody({
id="skill-debug-conversation-id" id="skill-debug-conversation-id"
type="number" type="number"
min={0} min={0}
placeholder="可选,填已有会话 ID 以复用上下文" placeholder={t("skillDefinition.conversationPlaceholder")}
aria-invalid={!!errors.conversationId} aria-invalid={!!errors.conversationId}
{...register("conversationId")} {...register("conversationId")}
/> />
@@ -318,22 +326,22 @@ function DebugDialogBody({
</FieldContent> </FieldContent>
</Field> </Field>
<Field> <Field>
<FieldLabel> Agent</FieldLabel> <FieldLabel>{t("skillDefinition.matchedAgent")}</FieldLabel>
<FieldContent> <FieldContent>
<Input <Input
value={selectedAgent?.name || "未选择"} value={selectedAgent?.name || t("skillDefinition.noAgentSelected")}
disabled disabled
/> />
</FieldContent> </FieldContent>
</Field> </Field>
</div> </div>
<Field data-invalid={!!errors.userMessage}> <Field data-invalid={!!errors.userMessage}>
<FieldLabel htmlFor="skill-debug-user-message"></FieldLabel> <FieldLabel htmlFor="skill-debug-user-message">{t("skillDefinition.userMessage")}</FieldLabel>
<FieldContent> <FieldContent>
<Textarea <Textarea
id="skill-debug-user-message" id="skill-debug-user-message"
rows={5} rows={5}
placeholder="输入一段用户消息,调试当前 Skill 的路由、工具和回复。" placeholder={t("skillDefinition.userMessagePlaceholder")}
aria-invalid={!!errors.userMessage} aria-invalid={!!errors.userMessage}
{...register("userMessage")} {...register("userMessage")}
/> />
@@ -347,7 +355,7 @@ function DebugDialogBody({
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2"> <div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle className="text-sm"></CardTitle> <CardTitle className="text-sm">{t("skillDefinition.debugSummary")}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-3 text-sm"> <CardContent className="space-y-3 text-sm">
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
@@ -359,38 +367,38 @@ function DebugDialogBody({
<Badge variant="secondary">{result.interruptType}</Badge> <Badge variant="secondary">{result.interruptType}</Badge>
) : null} ) : null}
{result?.interrupted ? ( {result?.interrupted ? (
<Badge></Badge> <Badge>{t("skillDefinition.interrupted")}</Badge>
) : ( ) : (
<Badge variant="outline"></Badge> <Badge variant="outline">{t("skillDefinition.notInterrupted")}</Badge>
)} )}
</div> </div>
<div className="rounded-lg bg-muted/50 p-3"> <div className="rounded-lg bg-muted/50 p-3">
<div className="text-xs text-muted-foreground">Skill </div> <div className="text-xs text-muted-foreground">{t("skillDefinition.skillName")}</div>
<div className="mt-1 font-medium">{result?.skillName || skillName}</div> <div className="mt-1 font-medium">{result?.skillName || skillName}</div>
</div> </div>
<div className="rounded-lg bg-muted/50 p-3"> <div className="rounded-lg bg-muted/50 p-3">
<div className="text-xs text-muted-foreground">Plan Reason</div> <div className="text-xs text-muted-foreground">Plan Reason</div>
<div className="mt-1 whitespace-pre-wrap break-words"> <div className="mt-1 whitespace-pre-wrap break-words">
{result?.planReason || "暂无"} {result?.planReason || t("skillDefinition.none")}
</div> </div>
</div> </div>
<div className="rounded-lg bg-muted/50 p-3"> <div className="rounded-lg bg-muted/50 p-3">
<div className="text-xs text-muted-foreground">Reply</div> <div className="text-xs text-muted-foreground">Reply</div>
<div className="mt-1 whitespace-pre-wrap break-words"> <div className="mt-1 whitespace-pre-wrap break-words">
{result?.replyText || "暂无"} {result?.replyText || t("skillDefinition.none")}
</div> </div>
</div> </div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="rounded-lg bg-muted/50 p-3"> <div className="rounded-lg bg-muted/50 p-3">
<div className="text-xs text-muted-foreground">Checkpoint</div> <div className="text-xs text-muted-foreground">Checkpoint</div>
<div className="mt-1 break-all"> <div className="mt-1 break-all">
{result?.checkPointId || "暂无"} {result?.checkPointId || t("skillDefinition.none")}
</div> </div>
</div> </div>
<div className="rounded-lg bg-muted/50 p-3"> <div className="rounded-lg bg-muted/50 p-3">
<div className="text-xs text-muted-foreground"></div> <div className="text-xs text-muted-foreground">{t("skillDefinition.errorMessage")}</div>
<div className="mt-1 whitespace-pre-wrap break-words"> <div className="mt-1 whitespace-pre-wrap break-words">
{result?.errorMessage || "暂无"} {result?.errorMessage || t("skillDefinition.none")}
</div> </div>
</div> </div>
</div> </div>
@@ -399,11 +407,11 @@ function DebugDialogBody({
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle className="text-sm"></CardTitle> <CardTitle className="text-sm">{t("skillDefinition.toolView")}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-3 text-sm"> <CardContent className="space-y-3 text-sm">
<div className="rounded-lg bg-muted/50 p-3"> <div className="rounded-lg bg-muted/50 p-3">
<div className="text-xs text-muted-foreground"></div> <div className="text-xs text-muted-foreground">{t("skillDefinition.skillToolWhitelist")}</div>
<div className="mt-2 flex flex-wrap gap-2"> <div className="mt-2 flex flex-wrap gap-2">
{(result?.toolWhitelist ?? []).length > 0 ? ( {(result?.toolWhitelist ?? []).length > 0 ? (
result?.toolWhitelist.map((toolCode) => ( result?.toolWhitelist.map((toolCode) => (
@@ -412,12 +420,12 @@ function DebugDialogBody({
</Badge> </Badge>
)) ))
) : ( ) : (
<span className="text-muted-foreground"></span> <span className="text-muted-foreground">{t("skillDefinition.none")}</span>
)} )}
</div> </div>
</div> </div>
<div className="rounded-lg bg-muted/50 p-3"> <div className="rounded-lg bg-muted/50 p-3">
<div className="text-xs text-muted-foreground"></div> <div className="text-xs text-muted-foreground">{t("skillDefinition.exposedTools")}</div>
<div className="mt-2 flex flex-wrap gap-2"> <div className="mt-2 flex flex-wrap gap-2">
{(result?.exposedToolCodes ?? []).length > 0 ? ( {(result?.exposedToolCodes ?? []).length > 0 ? (
result?.exposedToolCodes.map((toolCode) => ( result?.exposedToolCodes.map((toolCode) => (
@@ -426,12 +434,12 @@ function DebugDialogBody({
</Badge> </Badge>
)) ))
) : ( ) : (
<span className="text-muted-foreground"></span> <span className="text-muted-foreground">{t("skillDefinition.none")}</span>
)} )}
</div> </div>
</div> </div>
<div className="rounded-lg bg-muted/50 p-3"> <div className="rounded-lg bg-muted/50 p-3">
<div className="text-xs text-muted-foreground"></div> <div className="text-xs text-muted-foreground">{t("skillDefinition.invokedTools")}</div>
<div className="mt-2 flex flex-wrap gap-2"> <div className="mt-2 flex flex-wrap gap-2">
{(result?.invokedToolCodes ?? []).length > 0 ? ( {(result?.invokedToolCodes ?? []).length > 0 ? (
result?.invokedToolCodes.map((toolCode) => ( result?.invokedToolCodes.map((toolCode) => (
@@ -440,7 +448,7 @@ function DebugDialogBody({
</Badge> </Badge>
)) ))
) : ( ) : (
<span className="text-muted-foreground"></span> <span className="text-muted-foreground">{t("skillDefinition.none")}</span>
)} )}
</div> </div>
</div> </div>
@@ -449,29 +457,29 @@ function DebugDialogBody({
</div> </div>
<div className="grid grid-cols-1 gap-4"> <div className="grid grid-cols-1 gap-4">
<ResultBlock title="Skill Route Trace" value={result?.skillRouteTrace} /> <ResultBlock title="Skill Route Trace" value={result?.skillRouteTrace} emptyText={t("skillDefinition.emptyData")} />
<ResultBlock title="Tool Search Trace" value={result?.toolSearchTrace} /> <ResultBlock title="Tool Search Trace" value={result?.toolSearchTrace} emptyText={t("skillDefinition.emptyData")} />
<ResultBlock title="Graph Tool Trace" value={result?.graphToolTrace} /> <ResultBlock title="Graph Tool Trace" value={result?.graphToolTrace} emptyText={t("skillDefinition.emptyData")} />
<ResultBlock title="Trace Data" value={result?.traceData} /> <ResultBlock title="Trace Data" value={result?.traceData} emptyText={t("skillDefinition.emptyData")} />
</div> </div>
{result?.interrupted && result.checkPointId ? ( {result?.interrupted && result.checkPointId ? (
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle className="text-sm"></CardTitle> <CardTitle className="text-sm">{t("skillDefinition.resumeDebug")}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="rounded-lg bg-muted/50 p-3 text-sm"> <div className="rounded-lg bg-muted/50 p-3 text-sm">
<div className="text-xs text-muted-foreground"> Checkpoint</div> <div className="text-xs text-muted-foreground">{t("skillDefinition.currentCheckpoint")}</div>
<div className="mt-1 break-all">{result.checkPointId}</div> <div className="mt-1 break-all">{result.checkPointId}</div>
</div> </div>
<Field> <Field>
<FieldLabel htmlFor="skill-debug-resume-message"></FieldLabel> <FieldLabel htmlFor="skill-debug-resume-message">{t("skillDefinition.resumeMessage")}</FieldLabel>
<FieldContent> <FieldContent>
<Textarea <Textarea
id="skill-debug-resume-message" id="skill-debug-resume-message"
rows={3} rows={3}
placeholder="输入确认、取消或其他恢复消息" placeholder={t("skillDefinition.resumePlaceholder")}
value={resumeMessage} value={resumeMessage}
onChange={(event) => setResumeMessage(event.target.value)} onChange={(event) => setResumeMessage(event.target.value)}
/> />
@@ -499,7 +507,7 @@ function DebugDialogBody({
) : ( ) : (
<PlayIcon /> <PlayIcon />
)} )}
{resuming ? "恢复中..." : "恢复调试"} {resuming ? t("skillDefinition.resuming") : t("skillDefinition.resumeDebugAction")}
</Button> </Button>
</div> </div>
</CardContent> </CardContent>
@@ -510,7 +518,7 @@ function DebugDialogBody({
<div className="space-y-4"> <div className="space-y-4">
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle className="text-sm"></CardTitle> <CardTitle className="text-sm">{t("skillDefinition.resumeResult")}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-3 text-sm"> <CardContent className="space-y-3 text-sm">
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
@@ -522,34 +530,34 @@ function DebugDialogBody({
<Badge variant="secondary">{resumeResult.interruptType}</Badge> <Badge variant="secondary">{resumeResult.interruptType}</Badge>
) : null} ) : null}
{resumeResult.interrupted ? ( {resumeResult.interrupted ? (
<Badge></Badge> <Badge>{t("skillDefinition.stillWaiting")}</Badge>
) : ( ) : (
<Badge variant="outline"></Badge> <Badge variant="outline">{t("skillDefinition.resumeCompleted")}</Badge>
)} )}
</div> </div>
<div className="rounded-lg bg-muted/50 p-3"> <div className="rounded-lg bg-muted/50 p-3">
<div className="text-xs text-muted-foreground"></div> <div className="text-xs text-muted-foreground">{t("skillDefinition.resumeMessage")}</div>
<div className="mt-1 whitespace-pre-wrap break-words"> <div className="mt-1 whitespace-pre-wrap break-words">
{resumeMessage || "暂无"} {resumeMessage || t("skillDefinition.none")}
</div> </div>
</div> </div>
<div className="rounded-lg bg-muted/50 p-3"> <div className="rounded-lg bg-muted/50 p-3">
<div className="text-xs text-muted-foreground"></div> <div className="text-xs text-muted-foreground">{t("skillDefinition.resumeReply")}</div>
<div className="mt-1 whitespace-pre-wrap break-words"> <div className="mt-1 whitespace-pre-wrap break-words">
{resumeResult.replyText || "暂无"} {resumeResult.replyText || t("skillDefinition.none")}
</div> </div>
</div> </div>
<div className="rounded-lg bg-muted/50 p-3"> <div className="rounded-lg bg-muted/50 p-3">
<div className="text-xs text-muted-foreground"> Plan Reason</div> <div className="text-xs text-muted-foreground">Resume Plan Reason</div>
<div className="mt-1 whitespace-pre-wrap break-words"> <div className="mt-1 whitespace-pre-wrap break-words">
{resumeResult.planReason || "暂无"} {resumeResult.planReason || t("skillDefinition.none")}
</div> </div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<ResultBlock title="恢复 Tool Search Trace" value={resumeResult.toolSearchTrace} /> <ResultBlock title="Resume Tool Search Trace" value={resumeResult.toolSearchTrace} emptyText={t("skillDefinition.emptyData")} />
<ResultBlock title="恢复 Graph Tool Trace" value={resumeResult.graphToolTrace} /> <ResultBlock title="Resume Graph Tool Trace" value={resumeResult.graphToolTrace} emptyText={t("skillDefinition.emptyData")} />
<ResultBlock title="恢复 Trace Data" value={resumeResult.traceData} /> <ResultBlock title="Resume Trace Data" value={resumeResult.traceData} emptyText={t("skillDefinition.emptyData")} />
</div> </div>
) : null} ) : null}
</div> </div>
@@ -2,7 +2,7 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Resolver, useForm } from "react-hook-form"; import { type Resolver, useForm } from "react-hook-form";
import { z } from "zod/v4"; import { z } from "zod/v4";
import { OptionCombobox } from "@/components/option-combobox"; import { OptionCombobox } from "@/components/option-combobox";
@@ -23,7 +23,9 @@ import {
type MCPToolCatalogItem, type MCPToolCatalogItem,
type SkillDefinition, type SkillDefinition,
} from "@/lib/api/admin"; } from "@/lib/api/admin";
import { useI18n } from "@/i18n/provider";
type TFunction = (key: string, values?: Record<string, string | number>) => string;
type SkillEditDialogProps = { type SkillEditDialogProps = {
open: boolean; open: boolean;
@@ -42,25 +44,29 @@ const emptyForm: EditForm = {
remark: "", remark: "",
}; };
const skillFormSchema = z.object({ function createSkillFormSchema(t: TFunction) {
return z.object({
code: z code: z
.string() .string()
.trim() .trim()
.min(1, "Skill 编码不能为空") .min(1, t("skillDefinition.codeRequired"))
.regex(/^[a-zA-Z0-9_-]+$/, "Skill 编码仅支持字母、数字、下划线和中划线"), .regex(/^[a-zA-Z0-9_-]+$/, t("skillDefinition.codeInvalid")),
name: z.string().trim().min(1, "Skill 名称不能为空"), name: z.string().trim().min(1, t("skillDefinition.nameRequired")),
description: z.string().trim(), description: z.string().trim(),
instruction: z.string().trim().min(1, "技能说明不能为空"), instruction: z.string().trim().min(1, t("skillDefinition.instructionRequired")),
examplesText: z.string().trim(), examplesText: z.string().trim(),
remark: z.string().trim(), remark: z.string().trim(),
}); });
}
type EditForm = z.infer<typeof skillFormSchema>; type EditForm = {
const editFormResolver = zodResolver(skillFormSchema as never) as Resolver< code: string;
z.input<typeof skillFormSchema>, name: string;
undefined, description: string;
z.output<typeof skillFormSchema> instruction: string;
>; examplesText: string;
remark: string;
};
function buildForm(item: SkillDefinition | null): EditForm { function buildForm(item: SkillDefinition | null): EditForm {
if (!item) { if (!item) {
@@ -127,6 +133,7 @@ function SkillEditDialogBody({
onOpenChange, onOpenChange,
onSubmit, onSubmit,
}: SkillEditDialogBodyProps) { }: SkillEditDialogBodyProps) {
const t = useI18n();
const formId = "skill-definition-edit-form"; const formId = "skill-definition-edit-form";
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [toolCatalog, setToolCatalog] = useState<MCPToolCatalogItem[]>([]); const [toolCatalog, setToolCatalog] = useState<MCPToolCatalogItem[]>([]);
@@ -134,11 +141,12 @@ function SkillEditDialogBody({
string[] string[]
>([]); >([]);
const [toolCodeToAdd, setToolCodeToAdd] = useState(""); const [toolCodeToAdd, setToolCodeToAdd] = useState("");
const form = useForm< const skillFormSchema = useMemo(() => createSkillFormSchema(t), [t]);
z.input<typeof skillFormSchema>, const editFormResolver = useMemo(
undefined, () => zodResolver(skillFormSchema) as Resolver<EditForm>,
z.output<typeof skillFormSchema> [skillFormSchema],
>({ );
const form = useForm<EditForm>({
resolver: editFormResolver, resolver: editFormResolver,
defaultValues: emptyForm, defaultValues: emptyForm,
}); });
@@ -237,7 +245,7 @@ function SkillEditDialogBody({
<ProjectDialog <ProjectDialog
open={open} open={open}
onOpenChange={onOpenChange} onOpenChange={onOpenChange}
title={itemId ? "编辑" : "新建"} title={itemId ? t("skillDefinition.editTitle") : t("skillDefinition.createTitle")}
size="xl" size="xl"
allowFullscreen allowFullscreen
footer={ footer={
@@ -248,17 +256,17 @@ function SkillEditDialogBody({
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
disabled={saving} disabled={saving}
> >
{t("skillDefinition.cancel")}
</Button> </Button>
<Button type="submit" form={formId} disabled={saving || loading}> <Button type="submit" form={formId} disabled={saving || loading}>
{saving ? "保存中..." : itemId ? "保存" : "创建"} {saving ? t("skillDefinition.saving") : itemId ? t("skillDefinition.save") : t("skillDefinition.create")}
</Button> </Button>
</> </>
} }
> >
{loading ? ( {loading ? (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<div className="text-muted-foreground">...</div> <div className="text-muted-foreground">{t("skillDefinition.loading")}</div>
</div> </div>
) : ( ) : (
<form <form
@@ -268,11 +276,11 @@ function SkillEditDialogBody({
> >
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field data-invalid={!!errors.code}> <Field data-invalid={!!errors.code}>
<FieldLabel htmlFor="skill-code"></FieldLabel> <FieldLabel htmlFor="skill-code">{t("skillDefinition.code")}</FieldLabel>
<FieldContent> <FieldContent>
<Input <Input
id="skill-code" id="skill-code"
placeholder="例如:refund_skill" placeholder={t("skillDefinition.codePlaceholder")}
aria-invalid={!!errors.code} aria-invalid={!!errors.code}
{...register("code")} {...register("code")}
/> />
@@ -280,11 +288,11 @@ function SkillEditDialogBody({
</FieldContent> </FieldContent>
</Field> </Field>
<Field data-invalid={!!errors.name}> <Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="skill-name"></FieldLabel> <FieldLabel htmlFor="skill-name">{t("skillDefinition.name")}</FieldLabel>
<FieldContent> <FieldContent>
<Input <Input
id="skill-name" id="skill-name"
placeholder="例如:退款处理" placeholder={t("skillDefinition.namePlaceholder")}
aria-invalid={!!errors.name} aria-invalid={!!errors.name}
{...register("name")} {...register("name")}
/> />
@@ -294,12 +302,12 @@ function SkillEditDialogBody({
</div> </div>
<Field data-invalid={!!errors.description}> <Field data-invalid={!!errors.description}>
<FieldLabel htmlFor="skill-description"></FieldLabel> <FieldLabel htmlFor="skill-description">{t("skillDefinition.description")}</FieldLabel>
<FieldContent> <FieldContent>
<Textarea <Textarea
id="skill-description" id="skill-description"
rows={3} rows={3}
placeholder="描述这个 Skill 的用途、边界和适用场景" placeholder={t("skillDefinition.descriptionPlaceholder")}
aria-invalid={!!errors.description} aria-invalid={!!errors.description}
{...register("description")} {...register("description")}
/> />
@@ -308,12 +316,12 @@ function SkillEditDialogBody({
</Field> </Field>
<Field data-invalid={!!errors.instruction}> <Field data-invalid={!!errors.instruction}>
<FieldLabel htmlFor="skill-instruction"></FieldLabel> <FieldLabel htmlFor="skill-instruction">{t("skillDefinition.instruction")}</FieldLabel>
<FieldContent> <FieldContent>
<Textarea <Textarea
id="skill-instruction" id="skill-instruction"
rows={12} rows={12}
placeholder="请输入 Skill 文档内容,描述目标、步骤、工具使用规则和边界。" placeholder={t("skillDefinition.instructionPlaceholder")}
aria-invalid={!!errors.instruction} aria-invalid={!!errors.instruction}
{...register("instruction")} {...register("instruction")}
/> />
@@ -322,12 +330,12 @@ function SkillEditDialogBody({
</Field> </Field>
<Field data-invalid={!!errors.examplesText}> <Field data-invalid={!!errors.examplesText}>
<FieldLabel htmlFor="skill-examples"></FieldLabel> <FieldLabel htmlFor="skill-examples">{t("skillDefinition.examples")}</FieldLabel>
<FieldContent> <FieldContent>
<Textarea <Textarea
id="skill-examples" id="skill-examples"
rows={5} rows={5}
placeholder={"每行一个典型用户问法,例如:\n我要申请退款\n帮我查下订单"} placeholder={t("skillDefinition.examplesPlaceholder")}
aria-invalid={!!errors.examplesText} aria-invalid={!!errors.examplesText}
{...register("examplesText")} {...register("examplesText")}
/> />
@@ -336,16 +344,16 @@ function SkillEditDialogBody({
</Field> </Field>
<Field> <Field>
<FieldLabel></FieldLabel> <FieldLabel>{t("skillDefinition.toolWhitelist")}</FieldLabel>
<FieldContent className="space-y-3"> <FieldContent className="space-y-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="flex-1"> <div className="flex-1">
<OptionCombobox <OptionCombobox
value={toolCodeToAdd} value={toolCodeToAdd}
options={addableToolOptions} options={addableToolOptions}
placeholder="选择该 Skill 允许使用的工具" placeholder={t("skillDefinition.selectTool")}
searchPlaceholder="搜索 toolCode 或工具名" searchPlaceholder={t("skillDefinition.searchTool")}
emptyText="没有可添加的工具" emptyText={t("skillDefinition.emptyTool")}
onChange={handleAddToolWhitelist} onChange={handleAddToolWhitelist}
/> />
</div> </div>
@@ -355,13 +363,13 @@ function SkillEditDialogBody({
disabled={!toolCodeToAdd} disabled={!toolCodeToAdd}
onClick={() => handleAddToolWhitelist(toolCodeToAdd)} onClick={() => handleAddToolWhitelist(toolCodeToAdd)}
> >
{t("skillDefinition.add")}
</Button> </Button>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{selectedToolOptions.length === 0 ? ( {selectedToolOptions.length === 0 ? (
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
Skill Agent {t("skillDefinition.inheritAgentTools")}
</span> </span>
) : ( ) : (
selectedToolOptions.map((option) => ( selectedToolOptions.map((option) => (
@@ -382,12 +390,12 @@ function SkillEditDialogBody({
</Field> </Field>
<Field data-invalid={!!errors.remark}> <Field data-invalid={!!errors.remark}>
<FieldLabel htmlFor="skill-remark"></FieldLabel> <FieldLabel htmlFor="skill-remark">{t("skillDefinition.remark")}</FieldLabel>
<FieldContent> <FieldContent>
<Textarea <Textarea
id="skill-remark" id="skill-remark"
rows={3} rows={3}
placeholder="记录内部备注或维护说明" placeholder={t("skillDefinition.remarkPlaceholder")}
aria-invalid={!!errors.remark} aria-invalid={!!errors.remark}
{...register("remark")} {...register("remark")}
/> />
+64 -43
View File
@@ -1,6 +1,6 @@
"use client" "use client"
import { useCallback, useEffect, useState } from "react" import { useCallback, useEffect, useMemo, useState } from "react"
import { import {
BrainCircuitIcon, BrainCircuitIcon,
BugIcon, BugIcon,
@@ -51,19 +51,35 @@ import {
type PageResult, type PageResult,
type SkillDefinition, type SkillDefinition,
} from "@/lib/api/admin" } from "@/lib/api/admin"
import { Status, StatusLabels } from "@/lib/generated/enums" import { useI18n } from "@/i18n/provider"
import { getEnumLabel, getEnumOptions } from "@/lib/enums" import { Status } from "@/lib/generated/enums"
import { formatDateTime } from "@/lib/utils" import { formatDateTime } from "@/lib/utils"
import { EditDialog } from "./_components/edit" import { EditDialog } from "./_components/edit"
import { DebugDialog } from "./_components/debug-dialog" import { DebugDialog } from "./_components/debug-dialog"
const statusFilterOptions = [ type TFunction = (key: string, values?: Record<string, string | number>) => string
{ value: "all", label: "全部状态" },
...getEnumOptions(StatusLabels).map((option) => ({ function statusLabel(status: number, t: TFunction) {
value: String(option.value), if (status === Status.Ok) {
label: option.label, return t("skillDefinition.statusOk")
})), }
if (status === Status.Disabled) {
return t("skillDefinition.statusDisabled")
}
if (status === Status.Deleted) {
return t("skillDefinition.statusDeleted")
}
return String(status)
}
function getStatusFilterOptions(t: TFunction) {
return [
{ value: "all", label: t("skillDefinition.allStatus") },
{ value: String(Status.Ok), label: t("skillDefinition.statusOk") },
{ value: String(Status.Disabled), label: t("skillDefinition.statusDisabled") },
{ value: String(Status.Deleted), label: t("skillDefinition.statusDeleted") },
] ]
}
type SkillRowProps = { type SkillRowProps = {
item: SkillDefinition item: SkillDefinition
@@ -73,6 +89,7 @@ type SkillRowProps = {
handleToggleStatus: (item: SkillDefinition) => void handleToggleStatus: (item: SkillDefinition) => void
handleDelete: (item: SkillDefinition) => void handleDelete: (item: SkillDefinition) => void
handleRestore: (item: SkillDefinition) => void handleRestore: (item: SkillDefinition) => void
t: TFunction
} }
function SkillRow({ function SkillRow({
@@ -83,6 +100,7 @@ function SkillRow({
handleToggleStatus, handleToggleStatus,
handleDelete, handleDelete,
handleRestore, handleRestore,
t,
}: SkillRowProps) { }: SkillRowProps) {
const isDeleted = item.status === Status.Deleted const isDeleted = item.status === Status.Deleted
const statusBadgeVariant = isDeleted const statusBadgeVariant = isDeleted
@@ -102,12 +120,12 @@ function SkillRow({
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<div className="font-medium">{item.name}</div> <div className="font-medium">{item.name}</div>
<Badge variant="outline">{item.code}</Badge> <Badge variant="outline">{item.code}</Badge>
<Badge variant="secondary"> {item.toolWhitelist.length}</Badge> <Badge variant="secondary">{t("skillDefinition.whitelistCount", { count: item.toolWhitelist.length })}</Badge>
<Badge variant="secondary"> {item.examples.length}</Badge> <Badge variant="secondary">{t("skillDefinition.exampleCount", { count: item.examples.length })}</Badge>
</div> </div>
<div className="mt-2 space-y-2"> <div className="mt-2 space-y-2">
<div className="line-clamp-2 text-sm leading-6 text-muted-foreground"> <div className="line-clamp-2 text-sm leading-6 text-muted-foreground">
{item.description || "暂无描述"} {item.description || t("skillDefinition.noDescription")}
</div> </div>
</div> </div>
{item.toolWhitelist.length > 0 ? ( {item.toolWhitelist.length > 0 ? (
@@ -131,10 +149,10 @@ function SkillRow({
checked={item.status === Status.Ok} checked={item.status === Status.Ok}
disabled={actionLoadingId === item.id || isDeleted} disabled={actionLoadingId === item.id || isDeleted}
onCheckedChange={() => void handleToggleStatus(item)} onCheckedChange={() => void handleToggleStatus(item)}
aria-label={`${item.name} 状态切换`} aria-label={t("skillDefinition.toggleStatus", { name: item.name })}
/> />
<Badge variant={statusBadgeVariant}> <Badge variant={statusBadgeVariant}>
{getEnumLabel(StatusLabels, item.status as keyof typeof StatusLabels)} {statusLabel(item.status, t)}
</Badge> </Badge>
</div> </div>
</TableCell> </TableCell>
@@ -150,15 +168,15 @@ function SkillRow({
<ButtonGroup className="ml-auto"> <ButtonGroup className="ml-auto">
<Button variant="outline" size="sm" onClick={() => openDebugDialog(item)}> <Button variant="outline" size="sm" onClick={() => openDebugDialog(item)}>
<BugIcon /> <BugIcon />
{t("skillDefinition.debug")}
</Button> </Button>
<Button variant="outline" size="sm" onClick={() => openEditDialog(item)}> <Button variant="outline" size="sm" onClick={() => openEditDialog(item)}>
{t("skillDefinition.edit")}
</Button> </Button>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
render={<Button variant="outline" size="icon-sm" />} render={<Button variant="outline" size="icon-sm" />}
aria-label={`更多操作 ${item.name}`} aria-label={t("skillDefinition.moreActions", { name: item.name })}
> >
<MoreHorizontalIcon /> <MoreHorizontalIcon />
</DropdownMenuTrigger> </DropdownMenuTrigger>
@@ -169,7 +187,7 @@ function SkillRow({
onClick={() => void handleRestore(item)} onClick={() => void handleRestore(item)}
> >
<RotateCcwIcon /> <RotateCcwIcon />
{actionLoadingId === item.id ? "恢复中..." : "恢复"} {actionLoadingId === item.id ? t("skillDefinition.restoring") : t("skillDefinition.restore")}
</DropdownMenuItem> </DropdownMenuItem>
) : ( ) : (
<DropdownMenuItem <DropdownMenuItem
@@ -178,7 +196,7 @@ function SkillRow({
className="text-destructive focus:text-destructive" className="text-destructive focus:text-destructive"
> >
<Trash2Icon /> <Trash2Icon />
{actionLoadingId === item.id ? "删除中..." : "删除"} {actionLoadingId === item.id ? t("skillDefinition.deleting") : t("skillDefinition.delete")}
</DropdownMenuItem> </DropdownMenuItem>
)} )}
</DropdownMenuContent> </DropdownMenuContent>
@@ -190,6 +208,7 @@ function SkillRow({
} }
export default function DashboardSkillsPage() { export default function DashboardSkillsPage() {
const t = useI18n()
const [nameInput, setNameInput] = useState("") const [nameInput, setNameInput] = useState("")
const [codeInput, setCodeInput] = useState("") const [codeInput, setCodeInput] = useState("")
const [statusFilterInput, setStatusFilterInput] = useState("all") const [statusFilterInput, setStatusFilterInput] = useState("all")
@@ -209,6 +228,7 @@ export default function DashboardSkillsPage() {
results: [], results: [],
page: { page: 1, limit: 20, total: 0 }, page: { page: 1, limit: 20, total: 0 },
}) })
const statusFilterOptions = useMemo(() => getStatusFilterOptions(t), [t])
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
setLoading(true) setLoading(true)
@@ -222,11 +242,11 @@ export default function DashboardSkillsPage() {
}) })
setResult(data) setResult(data)
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "加载 Skills 失败") toast.error(error instanceof Error ? error.message : t("skillDefinition.loadFailed"))
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, [name, code, statusFilter, page, limit]) }, [name, code, statusFilter, page, limit, t])
useEffect(() => { useEffect(() => {
void loadData() void loadData()
@@ -298,16 +318,16 @@ export default function DashboardSkillsPage() {
id: editingItem.id, id: editingItem.id,
...payload, ...payload,
}) })
toast.success(`已更新 Skill${editingItem.name}`) toast.success(t("skillDefinition.updated", { name: editingItem.name }))
} else { } else {
await createSkillDefinition(payload) await createSkillDefinition(payload)
toast.success(`已创建 Skill${payload.name}`) toast.success(t("skillDefinition.created", { name: payload.name }))
} }
setDialogOpen(false) setDialogOpen(false)
setEditingItem(null) setEditingItem(null)
await loadData() await loadData()
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "保存 Skill 失败") toast.error(error instanceof Error ? error.message : t("skillDefinition.saveFailed"))
} finally { } finally {
setSaving(false) setSaving(false)
} }
@@ -323,10 +343,10 @@ export default function DashboardSkillsPage() {
setActionLoadingId(item.id) setActionLoadingId(item.id)
try { try {
await updateSkillDefinitionStatus(item.id, nextStatus) await updateSkillDefinitionStatus(item.id, nextStatus)
toast.success(`${nextStatus === Status.Ok ? "启用" : "停用"}${item.name}`) toast.success(t(nextStatus === Status.Ok ? "skillDefinition.enabled" : "skillDefinition.disabled", { name: item.name }))
await loadData() await loadData()
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "更新状态失败") toast.error(error instanceof Error ? error.message : t("skillDefinition.statusUpdateFailed"))
} finally { } finally {
setActionLoadingId(null) setActionLoadingId(null)
} }
@@ -340,10 +360,10 @@ export default function DashboardSkillsPage() {
setActionLoadingId(item.id) setActionLoadingId(item.id)
try { try {
await deleteSkillDefinition(item.id) await deleteSkillDefinition(item.id)
toast.success(`已删除 Skill${item.name}`) toast.success(t("skillDefinition.deleted", { name: item.name }))
await loadData() await loadData()
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "删除 Skill 失败") toast.error(error instanceof Error ? error.message : t("skillDefinition.deleteFailed"))
} finally { } finally {
setActionLoadingId(null) setActionLoadingId(null)
} }
@@ -357,10 +377,10 @@ export default function DashboardSkillsPage() {
setActionLoadingId(item.id) setActionLoadingId(item.id)
try { try {
await restoreSkillDefinition(item.id) await restoreSkillDefinition(item.id)
toast.success(`已恢复 Skill${item.name}`) toast.success(t("skillDefinition.restored", { name: item.name }))
await loadData() await loadData()
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "恢复 Skill 失败") toast.error(error instanceof Error ? error.message : t("skillDefinition.restoreFailed"))
} finally { } finally {
setActionLoadingId(null) setActionLoadingId(null)
} }
@@ -374,11 +394,11 @@ export default function DashboardSkillsPage() {
<> <>
<Button variant="outline" onClick={() => void loadData()} disabled={loading}> <Button variant="outline" onClick={() => void loadData()} disabled={loading}>
<RefreshCwIcon className={loading ? "animate-spin" : ""} /> <RefreshCwIcon className={loading ? "animate-spin" : ""} />
{t("skillDefinition.refresh")}
</Button> </Button>
<Button onClick={openCreateDialog}> <Button onClick={openCreateDialog}>
<PlusIcon /> <PlusIcon />
{t("skillDefinition.new")}
</Button> </Button>
</> </>
} }
@@ -389,7 +409,7 @@ export default function DashboardSkillsPage() {
value={nameInput} value={nameInput}
onChange={(event) => setNameInput(event.target.value)} onChange={(event) => setNameInput(event.target.value)}
onKeyDown={handleFilterKeyDown} onKeyDown={handleFilterKeyDown}
placeholder="按名称筛选" placeholder={t("skillDefinition.filterName")}
className="pl-9" className="pl-9"
/> />
</div> </div>
@@ -397,22 +417,22 @@ export default function DashboardSkillsPage() {
value={codeInput} value={codeInput}
onChange={(event) => setCodeInput(event.target.value)} onChange={(event) => setCodeInput(event.target.value)}
onKeyDown={handleFilterKeyDown} onKeyDown={handleFilterKeyDown}
placeholder="按编码筛选" placeholder={t("skillDefinition.filterCode")}
className="w-full sm:w-56" className="w-full sm:w-56"
/> />
<div className="w-full sm:w-36"> <div className="w-full sm:w-36">
<OptionCombobox <OptionCombobox
value={statusFilterInput} value={statusFilterInput}
options={statusFilterOptions} options={statusFilterOptions}
placeholder="全部状态" placeholder={t("skillDefinition.allStatus")}
searchPlaceholder="搜索状态" searchPlaceholder={t("skillDefinition.searchStatus")}
emptyText="未找到状态" emptyText={t("skillDefinition.emptyStatus")}
onChange={setStatusFilterInput} onChange={setStatusFilterInput}
/> />
</div> </div>
<Button variant="outline" onClick={applyFilters} disabled={loading}> <Button variant="outline" onClick={applyFilters} disabled={loading}>
<SearchIcon /> <SearchIcon />
{t("skillDefinition.query")}
</Button> </Button>
</DashboardToolbar> </DashboardToolbar>
@@ -435,9 +455,9 @@ export default function DashboardSkillsPage() {
<TableHeader className="bg-muted/40"> <TableHeader className="bg-muted/40">
<TableRow> <TableRow>
<TableHead>Skill</TableHead> <TableHead>Skill</TableHead>
<TableHead></TableHead> <TableHead>{t("skillDefinition.status")}</TableHead>
<TableHead></TableHead> <TableHead>{t("skillDefinition.updatedAt")}</TableHead>
<TableHead className="w-[168px] text-right"></TableHead> <TableHead className="w-[168px] text-right">{t("skillDefinition.actions")}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -445,8 +465,8 @@ export default function DashboardSkillsPage() {
<DashboardTableStateRow <DashboardTableStateRow
colSpan={4} colSpan={4}
loading={loading} loading={loading}
loadingText="正在加载 Skill..." loadingText={t("skillDefinition.loadingRows")}
emptyText="没有匹配的 Skill" emptyText={t("skillDefinition.emptyRows")}
/> />
) : null} ) : null}
{result.results.map((item) => ( {result.results.map((item) => (
@@ -459,6 +479,7 @@ export default function DashboardSkillsPage() {
handleToggleStatus={handleToggleStatus} handleToggleStatus={handleToggleStatus}
handleDelete={handleDelete} handleDelete={handleDelete}
handleRestore={handleRestore} handleRestore={handleRestore}
t={t}
/> />
))} ))}
</TableBody> </TableBody>
+52 -40
View File
@@ -1,10 +1,11 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { Controller, Resolver, useForm } from "react-hook-form"; import { Controller, Resolver, useForm } from "react-hook-form";
import { z } from "zod/v4"; import { z } from "zod/v4";
import { OptionCombobox } from "@/components/option-combobox";
import { ProjectDialog } from "@/components/project-dialog"; import { ProjectDialog } from "@/components/project-dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -22,6 +23,7 @@ import {
type Tag, type Tag,
type TagTree, type TagTree,
} from "@/lib/api/admin"; } from "@/lib/api/admin";
import { useI18n } from "@/i18n/provider";
type TagFormDialogProps = { type TagFormDialogProps = {
open: boolean; open: boolean;
@@ -37,18 +39,11 @@ const emptyForm: EditForm = {
remark: "", remark: "",
}; };
const tagFormSchema = z.object({ type EditForm = {
parentId: z.string(), parentId: string;
name: z.string().trim().min(1, "标签名称不能为空"), name: string;
remark: z.string(), remark: string;
}); };
type EditForm = z.infer<typeof tagFormSchema>;
const editFormResolver = zodResolver(tagFormSchema as never) as Resolver<
z.input<typeof tagFormSchema>,
undefined,
z.output<typeof tagFormSchema>
>;
function buildForm(item: Tag | null): EditForm { function buildForm(item: Tag | null): EditForm {
if (!item) { if (!item) {
@@ -142,16 +137,38 @@ function TagFormDialogBody({
onOpenChange, onOpenChange,
onSubmit, onSubmit,
}: TagFormDialogBodyProps) { }: TagFormDialogBodyProps) {
const t = useI18n();
const formId = "tag-edit-form"; const formId = "tag-edit-form";
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [parentTags, setParentTags] = useState< const [parentTags, setParentTags] = useState<
{ id: number; name: string; depth: number }[] { id: number; name: string; depth: number }[]
>([]); >([]);
const form = useForm<
z.input<typeof tagFormSchema>, const tagFormSchema = useMemo(
undefined, () =>
z.output<typeof tagFormSchema> z.object({
>({ parentId: z.string(),
name: z.string().trim().min(1, t("tag.nameRequired")),
remark: z.string(),
}),
[t],
);
const editFormResolver = useMemo(
() => zodResolver(tagFormSchema as never) as Resolver<EditForm>,
[tagFormSchema],
);
const parentOptions = useMemo(
() => [
{ value: "0", label: t("tag.rootParent") },
...parentTags.map((tag) => ({
value: String(tag.id),
label: `${" ".repeat(tag.depth)}${tag.name}`,
})),
],
[parentTags, t],
);
const form = useForm<EditForm>({
resolver: editFormResolver, resolver: editFormResolver,
defaultValues: emptyForm, defaultValues: emptyForm,
}); });
@@ -205,7 +222,7 @@ function TagFormDialogBody({
<ProjectDialog <ProjectDialog
open={open} open={open}
onOpenChange={onOpenChange} onOpenChange={onOpenChange}
title={itemId ? "编辑标签" : "新建标签"} title={itemId ? t("tag.editTitle") : t("tag.createTitle")}
size="md" size="md"
allowFullscreen allowFullscreen
footer={ footer={
@@ -216,17 +233,17 @@ function TagFormDialogBody({
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
disabled={saving} disabled={saving}
> >
{t("tag.cancel")}
</Button> </Button>
<Button type="submit" form={formId} disabled={saving || loading}> <Button type="submit" form={formId} disabled={saving || loading}>
{saving ? "保存中..." : itemId ? "保存" : "创建"} {saving ? t("tag.saving") : itemId ? t("tag.save") : t("tag.create")}
</Button> </Button>
</> </>
} }
> >
{loading ? ( {loading ? (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<div className="text-muted-foreground">...</div> <div className="text-muted-foreground">{t("tag.loadingDetail")}</div>
</div> </div>
) : ( ) : (
<form <form
@@ -235,37 +252,32 @@ function TagFormDialogBody({
className="space-y-4" className="space-y-4"
> >
<Field data-invalid={!!errors.parentId}> <Field data-invalid={!!errors.parentId}>
<FieldLabel htmlFor="tag-parent-id"></FieldLabel> <FieldLabel htmlFor="tag-parent-id">{t("tag.parent")}</FieldLabel>
<FieldContent> <FieldContent>
<Controller <Controller
control={control} control={control}
name="parentId" name="parentId"
render={({ field }) => ( render={({ field }) => (
<select <OptionCombobox
id="tag-parent-id"
value={field.value} value={field.value}
onChange={(e) => field.onChange(e.target.value)} options={parentOptions}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50" placeholder={t("tag.rootParent")}
> searchPlaceholder={t("tag.searchParent")}
<option value="0"></option> emptyText={t("tag.emptyParent")}
{parentTags.map((tag) => ( disabled={saving}
<option key={tag.id} value={String(tag.id)}> onChange={field.onChange}
{" ".repeat(tag.depth)} />
{tag.name}
</option>
))}
</select>
)} )}
/> />
<FieldError errors={[errors.parentId]} /> <FieldError errors={[errors.parentId]} />
</FieldContent> </FieldContent>
</Field> </Field>
<Field data-invalid={!!errors.name}> <Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="tag-name"></FieldLabel> <FieldLabel htmlFor="tag-name">{t("tag.name")}</FieldLabel>
<FieldContent> <FieldContent>
<Input <Input
id="tag-name" id="tag-name"
placeholder="请输入标签名称" placeholder={t("tag.namePlaceholder")}
aria-invalid={!!errors.name} aria-invalid={!!errors.name}
{...register("name")} {...register("name")}
/> />
@@ -273,11 +285,11 @@ function TagFormDialogBody({
</FieldContent> </FieldContent>
</Field> </Field>
<Field data-invalid={!!errors.remark}> <Field data-invalid={!!errors.remark}>
<FieldLabel htmlFor="tag-remark"></FieldLabel> <FieldLabel htmlFor="tag-remark">{t("tag.remark")}</FieldLabel>
<FieldContent> <FieldContent>
<Textarea <Textarea
id="tag-remark" id="tag-remark"
placeholder="请输入备注(可选)" placeholder={t("tag.remarkPlaceholder")}
rows={3} rows={3}
aria-invalid={!!errors.remark} aria-invalid={!!errors.remark}
{...register("remark")} {...register("remark")}
+53 -48
View File
@@ -50,6 +50,7 @@ import {
DashboardTableStateRow, DashboardTableStateRow,
DashboardToolbar, DashboardToolbar,
} from "@/components/dashboard-page" } from "@/components/dashboard-page"
import { OptionCombobox } from "@/components/option-combobox"
import { EditDialog } from "./_components/edit" import { EditDialog } from "./_components/edit"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
@@ -71,6 +72,7 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table" } from "@/components/ui/table"
import { useI18n } from "@/i18n/provider"
type TagNode = TagTree & { type TagNode = TagTree & {
children: TagNode[] children: TagNode[]
@@ -151,12 +153,6 @@ function filterTree(nodes: TagNode[], keyword: string, status?: number): TagNode
return result return result
} }
const listStatusOptions = [
{ value: "all", label: "全部状态" },
{ value: "0", label: "启用" },
{ value: "1", label: "禁用" },
] as const
type SortableRowProps = { type SortableRowProps = {
item: TagNode & { hasChildren: boolean } item: TagNode & { hasChildren: boolean }
disabled: boolean disabled: boolean
@@ -178,6 +174,7 @@ function SortableRow({
onDelete, onDelete,
actionLoadingId, actionLoadingId,
}: SortableRowProps) { }: SortableRowProps) {
const t = useI18n()
const { const {
attributes, attributes,
listeners, listeners,
@@ -211,7 +208,7 @@ function SortableRow({
size="icon" size="icon"
className="size-8 cursor-grab active:cursor-grabbing" className="size-8 cursor-grab active:cursor-grabbing"
disabled={disabled} disabled={disabled}
aria-label={`拖拽排序 ${item.name}`} aria-label={t("tag.dragSort", { name: item.name })}
{...attributes} {...attributes}
{...listeners} {...listeners}
> >
@@ -250,10 +247,10 @@ function SortableRow({
checked={item.status === 0} checked={item.status === 0}
disabled={actionLoadingId === item.id} disabled={actionLoadingId === item.id}
onCheckedChange={() => void onToggleStatus(item)} onCheckedChange={() => void onToggleStatus(item)}
aria-label={`${item.name} 状态切换`} aria-label={t("tag.toggleStatus", { name: item.name })}
/> />
<Badge variant={item.status === 0 ? "default" : "outline"}> <Badge variant={item.status === 0 ? "default" : "outline"}>
{item.status === 0 ? "启用" : "禁用"} {item.status === 0 ? t("status.ok") : t("status.disabled")}
</Badge> </Badge>
</div> </div>
</TableCell> </TableCell>
@@ -272,12 +269,12 @@ function SortableRow({
size="sm" size="sm"
onClick={() => onEdit(item)} onClick={() => onEdit(item)}
> >
{t("tag.edit")}
</Button> </Button>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
render={<Button variant="outline" size="icon-sm" />} render={<Button variant="outline" size="icon-sm" />}
aria-label={`更多操作 ${item.name}`} aria-label={t("tag.moreActions", { name: item.name })}
> >
<MoreHorizontalIcon /> <MoreHorizontalIcon />
</DropdownMenuTrigger> </DropdownMenuTrigger>
@@ -287,7 +284,7 @@ function SortableRow({
className="text-destructive focus:text-destructive" className="text-destructive focus:text-destructive"
> >
<Trash2Icon /> <Trash2Icon />
{actionLoadingId === item.id ? "删除中..." : "删除"} {actionLoadingId === item.id ? t("tag.deleting") : t("tag.delete")}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@@ -298,6 +295,7 @@ function SortableRow({
} }
export default function DashboardTagsPage() { export default function DashboardTagsPage() {
const t = useI18n()
const [keywordInput, setKeywordInput] = useState("") const [keywordInput, setKeywordInput] = useState("")
const [statusFilterInput, setStatusFilterInput] = useState("all") const [statusFilterInput, setStatusFilterInput] = useState("all")
const [keyword, setKeyword] = useState("") const [keyword, setKeyword] = useState("")
@@ -324,6 +322,15 @@ export default function DashboardTagsPage() {
}) })
) )
const listStatusOptions = useMemo(
() => [
{ value: "all", label: t("status.all") },
{ value: "0", label: t("status.ok") },
{ value: "1", label: t("status.disabled") },
],
[t]
)
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
setLoading(true) setLoading(true)
try { try {
@@ -336,11 +343,11 @@ export default function DashboardTagsPage() {
setAllTags(Array.isArray(listData.results) ? listData.results : []) setAllTags(Array.isArray(listData.results) ? listData.results : [])
setExpandedIds(collectParentIds(nextTree)) setExpandedIds(collectParentIds(nextTree))
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "加载标签失败") toast.error(error instanceof Error ? error.message : t("tag.loadFailed"))
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, []) }, [t])
useEffect(() => { useEffect(() => {
void loadData() void loadData()
@@ -415,16 +422,16 @@ export default function DashboardTagsPage() {
id: editingItem.id, id: editingItem.id,
...payload, ...payload,
}) })
toast.success(`已更新标签:${editingItem.name}`) toast.success(t("tag.updated", { name: editingItem.name }))
} else { } else {
await createTag(payload) await createTag(payload)
toast.success(`已创建标签:${payload.name}`) toast.success(t("tag.created", { name: payload.name }))
} }
setDialogOpen(false) setDialogOpen(false)
setEditingItem(null) setEditingItem(null)
await loadData() await loadData()
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "保存标签失败") toast.error(error instanceof Error ? error.message : t("tag.saveFailed"))
} finally { } finally {
setSaving(false) setSaving(false)
} }
@@ -435,10 +442,10 @@ export default function DashboardTagsPage() {
try { try {
const nextStatus = item.status === 0 ? 1 : 0 const nextStatus = item.status === 0 ? 1 : 0
await updateTagStatus(item.id, nextStatus) await updateTagStatus(item.id, nextStatus)
toast.success(`${nextStatus === 0 ? "启用" : "禁用"}${item.name}`) toast.success(t(nextStatus === 0 ? "tag.enabled" : "tag.disabled", { name: item.name }))
await loadData() await loadData()
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "更新状态失败") toast.error(error instanceof Error ? error.message : t("tag.statusUpdateFailed"))
} finally { } finally {
setActionLoadingId(null) setActionLoadingId(null)
} }
@@ -448,10 +455,10 @@ export default function DashboardTagsPage() {
setActionLoadingId(item.id) setActionLoadingId(item.id)
try { try {
await deleteTag(item.id) await deleteTag(item.id)
toast.success(`已删除标签:${item.name}`) toast.success(t("tag.deleted", { name: item.name }))
await loadData() await loadData()
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "删除标签失败") toast.error(error instanceof Error ? error.message : t("tag.deleteFailed"))
} finally { } finally {
setActionLoadingId(null) setActionLoadingId(null)
} }
@@ -498,7 +505,7 @@ export default function DashboardTagsPage() {
} }
if (activeItem.parentId !== overItem.parentId) { if (activeItem.parentId !== overItem.parentId) {
toast.error("只能在同一父标签下拖拽排序") toast.error(t("tag.sameParentOnly"))
return return
} }
@@ -521,15 +528,15 @@ export default function DashboardTagsPage() {
const movedIndex = siblingIds.indexOf(movedId) const movedIndex = siblingIds.indexOf(movedId)
const targetIndex = siblingIds.indexOf(targetId) const targetIndex = siblingIds.indexOf(targetId)
if (movedIndex < 0 || targetIndex < 0) { if (movedIndex < 0 || targetIndex < 0) {
throw new Error("找不到标签") throw new Error(t("tag.notFound"))
} }
const newSiblingIds = arrayMove(siblingIds, movedIndex, targetIndex) const newSiblingIds = arrayMove(siblingIds, movedIndex, targetIndex)
await updateTagSort(newSiblingIds) await updateTagSort(newSiblingIds)
toast.success("标签排序已更新") toast.success(t("tag.sortUpdated"))
await loadData() await loadData()
} catch (error) { } catch (error) {
setFlatList(previousList) setFlatList(previousList)
toast.error(error instanceof Error ? error.message : "更新标签排序失败") toast.error(error instanceof Error ? error.message : t("tag.sortUpdateFailed"))
} finally { } finally {
setSorting(false) setSorting(false)
} }
@@ -543,17 +550,17 @@ export default function DashboardTagsPage() {
<> <>
<Button variant="outline" onClick={() => void loadData()} disabled={loading}> <Button variant="outline" onClick={() => void loadData()} disabled={loading}>
<RefreshCwIcon className={loading ? "animate-spin" : ""} /> <RefreshCwIcon className={loading ? "animate-spin" : ""} />
{t("tag.refresh")}
</Button> </Button>
<Button variant="outline" onClick={expandAll} disabled={loading}> <Button variant="outline" onClick={expandAll} disabled={loading}>
{t("tag.expandAll")}
</Button> </Button>
<Button variant="outline" onClick={collapseAll} disabled={loading}> <Button variant="outline" onClick={collapseAll} disabled={loading}>
{t("tag.collapseAll")}
</Button> </Button>
<Button onClick={openCreateDialog}> <Button onClick={openCreateDialog}>
<PlusIcon /> <PlusIcon />
{t("tag.new")}
</Button> </Button>
</> </>
} }
@@ -564,24 +571,22 @@ export default function DashboardTagsPage() {
value={keywordInput} value={keywordInput}
onChange={(event) => setKeywordInput(event.target.value)} onChange={(event) => setKeywordInput(event.target.value)}
onKeyDown={handleFilterKeyDown} onKeyDown={handleFilterKeyDown}
placeholder="按名称筛选" placeholder={t("tag.filterName")}
className="pl-9" className="pl-9"
/> />
</div> </div>
<select <OptionCombobox
value={statusFilterInput} value={statusFilterInput}
onChange={(e) => handleStatusFilterChange(e.target.value)} options={listStatusOptions}
className="h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 xl:w-36" placeholder={t("status.all")}
> searchPlaceholder={t("tag.searchStatus")}
{listStatusOptions.map((item) => ( emptyText={t("tag.emptyStatus")}
<option key={item.value} value={item.value}> disabled={loading}
{item.label} onChange={handleStatusFilterChange}
</option> />
))}
</select>
<Button variant="outline" onClick={applyFilters} disabled={loading}> <Button variant="outline" onClick={applyFilters} disabled={loading}>
<SearchIcon /> <SearchIcon />
{t("tag.query")}
</Button> </Button>
</DashboardToolbar> </DashboardToolbar>
<DashboardTableShell> <DashboardTableShell>
@@ -594,11 +599,11 @@ export default function DashboardTagsPage() {
<TableHeader className="bg-muted/40"> <TableHeader className="bg-muted/40">
<TableRow> <TableRow>
<TableHead className="w-14" /> <TableHead className="w-14" />
<TableHead className="min-w-[260px]"></TableHead> <TableHead className="min-w-[260px]">{t("tag.columnName")}</TableHead>
<TableHead></TableHead> <TableHead>{t("tag.columnStatus")}</TableHead>
<TableHead></TableHead> <TableHead>{t("tag.columnRemark")}</TableHead>
<TableHead></TableHead> <TableHead>{t("tag.columnCreatedAt")}</TableHead>
<TableHead className="w-[92px] text-right"></TableHead> <TableHead className="w-[92px] text-right">{t("tag.columnActions")}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -624,8 +629,8 @@ export default function DashboardTagsPage() {
<DashboardTableStateRow <DashboardTableStateRow
colSpan={6} colSpan={6}
loading={loading} loading={loading}
loadingText="正在加载标签..." loadingText={t("tag.loading")}
emptyText="没有匹配的标签" emptyText={t("tag.empty")}
/> />
) : null} ) : null}
</TableBody> </TableBody>
@@ -2,6 +2,7 @@
import { toast } from "sonner" import { toast } from "sonner"
import { useI18n } from "@/i18n/provider"
import { createTicketFromConversation } from "@/lib/api/ticket" import { createTicketFromConversation } from "@/lib/api/ticket"
import { EditDialog } from "./edit" import { EditDialog } from "./edit"
@@ -26,6 +27,7 @@ export function CreateTicketFromConversationDialog({
onOpenChange, onOpenChange,
onSuccess, onSuccess,
}: CreateTicketFromConversationDialogProps) { }: CreateTicketFromConversationDialogProps) {
const t = useI18n()
const initialValues = conversation const initialValues = conversation
? { ? {
title: conversation.customerName || "", title: conversation.customerName || "",
@@ -43,11 +45,11 @@ export function CreateTicketFromConversationDialog({
fixedConversationId={conversation?.id} fixedConversationId={conversation?.id}
fixedCustomerId={conversation?.customerId} fixedCustomerId={conversation?.customerId}
initialValues={initialValues} initialValues={initialValues}
titleOverride="会话转工单" titleOverride={t("ticket.conversationToTicket")}
descriptionOverride="从当前会话上下文创建正式工单" descriptionOverride={t("ticket.conversationToTicketDescription")}
onSubmit={async (payload) => { onSubmit={async (payload) => {
if (!conversation?.id) { if (!conversation?.id) {
throw new Error("会话不存在") throw new Error(t("ticket.conversationMissing"))
} }
await createTicketFromConversation({ await createTicketFromConversation({
conversationId: conversation.id, conversationId: conversation.id,
@@ -56,7 +58,7 @@ export function CreateTicketFromConversationDialog({
currentAssigneeId: payload.currentAssigneeId, currentAssigneeId: payload.currentAssigneeId,
tagIds: payload.tagIds, tagIds: payload.tagIds,
}) })
toast.success("工单创建成功") toast.success(t("ticket.createSuccess"))
onSuccess?.() onSuccess?.()
}} }}
/> />
+42 -34
View File
@@ -42,6 +42,9 @@ import {
type TicketItem, type TicketItem,
type UpdateTicketPayload, type UpdateTicketPayload,
} from "@/lib/api/ticket" } from "@/lib/api/ticket"
import { useI18n } from "@/i18n/provider"
type TFunction = (key: string, values?: Record<string, string | number>) => string
type EditDialogProps = { type EditDialogProps = {
open: boolean open: boolean
@@ -56,20 +59,21 @@ type EditDialogProps = {
onSubmit: (payload: CreateTicketPayload | UpdateTicketPayload) => Promise<void> onSubmit: (payload: CreateTicketPayload | UpdateTicketPayload) => Promise<void>
} }
const schema = z.object({ function createSchema(t: TFunction) {
title: z.string().trim().min(1, "请输入工单标题"), return z.object({
description: z.string().refine((value) => !isRichTextEmpty(value), "请输入问题描述"), title: z.string().trim().min(1, t("ticket.titleRequired")),
description: z.string().refine((value) => !isRichTextEmpty(value), t("ticket.descriptionRequired")),
currentAssigneeId: z.coerce.number().int().min(0).optional(), currentAssigneeId: z.coerce.number().int().min(0).optional(),
tagIds: z.array(z.number().int().positive()).default([]), tagIds: z.array(z.number().int().positive()).default([]),
}) })
}
type EditForm = z.infer<typeof schema> type EditForm = {
title: string
const editFormResolver = zodResolver(schema as never) as Resolver< description: string
z.input<typeof schema>, currentAssigneeId?: number
undefined, tagIds: number[]
z.output<typeof schema> }
>
const emptyForm: EditForm = { const emptyForm: EditForm = {
title: "", title: "",
@@ -130,9 +134,10 @@ type TicketTagSelectorProps = {
value?: number[] value?: number[]
onChange: (value: number[]) => void onChange: (value: number[]) => void
availableTags: TagTree[] availableTags: TagTree[]
t: TFunction
} }
function TicketTagSelector({ value, onChange, availableTags }: TicketTagSelectorProps) { function TicketTagSelector({ value, onChange, availableTags, t }: TicketTagSelectorProps) {
const selectedValues = useMemo(() => value ?? [], [value]) const selectedValues = useMemo(() => value ?? [], [value])
const flatTags = useMemo(() => flattenTagTree(availableTags), [availableTags]) const flatTags = useMemo(() => flattenTagTree(availableTags), [availableTags])
const selectedTagIDs = useMemo(() => new Set(selectedValues), [selectedValues]) const selectedTagIDs = useMemo(() => new Set(selectedValues), [selectedValues])
@@ -158,14 +163,14 @@ function TicketTagSelector({ value, onChange, availableTags }: TicketTagSelector
} }
> >
<TagIcon className="size-4" /> <TagIcon className="size-4" />
{selectedTags.length > 0 ? `已选择 ${selectedTags.length} 个标签` : "请选择工单标签"} {selectedTags.length > 0 ? t("ticket.selectedTags", { count: selectedTags.length }) : t("ticket.selectTags")}
</PopoverTrigger> </PopoverTrigger>
<PopoverContent align="start" className="w-[320px] p-0"> <PopoverContent align="start" className="w-[320px] p-0">
<Command> <Command>
<CommandInput placeholder="搜索标签" /> <CommandInput placeholder={t("ticket.searchTags")} />
<CommandList> <CommandList>
<CommandEmpty></CommandEmpty> <CommandEmpty>{t("ticket.emptyTags")}</CommandEmpty>
<CommandGroup heading="标签"> <CommandGroup heading={t("ticket.tags")}>
{flatTags.map((tag) => { {flatTags.map((tag) => {
const checked = selectedTagIDs.has(tag.id) const checked = selectedTagIDs.has(tag.id)
return ( return (
@@ -245,15 +250,17 @@ function TicketEditDialogBody({
onOpenChange, onOpenChange,
onSubmit, onSubmit,
}: TicketEditDialogBodyProps) { }: TicketEditDialogBodyProps) {
const t = useI18n()
const formId = "ticket-edit-form" const formId = "ticket-edit-form"
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [tags, setTags] = useState<TagTree[]>([]) const [tags, setTags] = useState<TagTree[]>([])
const [agents, setAgents] = useState<AdminAgentProfile[]>([]) const [agents, setAgents] = useState<AdminAgentProfile[]>([])
const form = useForm< const schema = useMemo(() => createSchema(t), [t])
z.input<typeof schema>, const editFormResolver = useMemo(
undefined, () => zodResolver(schema) as Resolver<EditForm>,
z.output<typeof schema> [schema],
>({ )
const form = useForm<EditForm>({
resolver: editFormResolver, resolver: editFormResolver,
defaultValues: emptyForm, defaultValues: emptyForm,
}) })
@@ -296,14 +303,14 @@ function TicketEditDialogBody({
})() })()
}, [open]) }, [open])
const agentOptions = [{ value: "0", label: "不指定处理人" }].concat( const agentOptions = [{ value: "0", label: t("ticket.noAssignee") }].concat(
agents.map((agent) => ({ agents.map((agent) => ({
value: String(agent.userId), value: String(agent.userId),
label: label:
agent.displayName || agent.displayName ||
agent.nickname || agent.nickname ||
agent.username || agent.username ||
`客服#${agent.userId}`, t("ticket.agentFallback", { id: agent.userId }),
})), })),
) )
@@ -328,8 +335,8 @@ function TicketEditDialogBody({
<ProjectDialog <ProjectDialog
open={open} open={open}
onOpenChange={onOpenChange} onOpenChange={onOpenChange}
title={titleOverride || (itemId ? "编辑工单" : "新建工单")} title={titleOverride || (itemId ? t("ticket.editTitle") : t("ticket.createTitle"))}
description={descriptionOverride || "填写工单基础信息"} description={descriptionOverride || t("ticket.dialogDescription")}
size="lg" size="lg"
allowFullscreen allowFullscreen
footer={ footer={
@@ -340,27 +347,27 @@ function TicketEditDialogBody({
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
disabled={saving} disabled={saving}
> >
{t("ticket.cancel")}
</Button> </Button>
<Button type="submit" form={formId} disabled={saving || loading}> <Button type="submit" form={formId} disabled={saving || loading}>
{saving ? "保存中..." : itemId ? "保存" : "创建"} {saving ? t("ticket.saving") : itemId ? t("ticket.save") : t("ticket.create")}
</Button> </Button>
</> </>
} }
> >
{loading ? ( {loading ? (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<div className="text-muted-foreground">...</div> <div className="text-muted-foreground">{t("ticket.loading")}</div>
</div> </div>
) : ( ) : (
<form id={formId} onSubmit={handleSubmit(onFormSubmit)} className="space-y-4"> <form id={formId} onSubmit={handleSubmit(onFormSubmit)} className="space-y-4">
<FieldGroup> <FieldGroup>
<Field data-invalid={!!errors.title}> <Field data-invalid={!!errors.title}>
<FieldLabel htmlFor="ticket-title"></FieldLabel> <FieldLabel htmlFor="ticket-title">{t("ticket.title")}</FieldLabel>
<FieldContent> <FieldContent>
<Input <Input
id="ticket-title" id="ticket-title"
placeholder="请输入工单标题" placeholder={t("ticket.titlePlaceholder")}
aria-invalid={!!errors.title} aria-invalid={!!errors.title}
{...register("title")} {...register("title")}
/> />
@@ -369,7 +376,7 @@ function TicketEditDialogBody({
</Field> </Field>
<Field data-invalid={!!errors.description}> <Field data-invalid={!!errors.description}>
<FieldLabel></FieldLabel> <FieldLabel>{t("ticket.description")}</FieldLabel>
<FieldContent> <FieldContent>
<Controller <Controller
control={control} control={control}
@@ -378,7 +385,7 @@ function TicketEditDialogBody({
<ContentEditor <ContentEditor
value={{ mode: "html", raw: field.value ?? "" }} value={{ mode: "html", raw: field.value ?? "" }}
onChange={(next) => field.onChange(next.raw)} onChange={(next) => field.onChange(next.raw)}
placeholder="请输入问题描述" placeholder={t("ticket.descriptionRequired")}
disabled={saving || loading} disabled={saving || loading}
allowedModes={["html"]} allowedModes={["html"]}
height={260} height={260}
@@ -390,7 +397,7 @@ function TicketEditDialogBody({
</Field> </Field>
<Field> <Field>
<FieldLabel></FieldLabel> <FieldLabel>{t("ticket.assignee")}</FieldLabel>
<FieldContent> <FieldContent>
<Controller <Controller
control={control} control={control}
@@ -399,7 +406,7 @@ function TicketEditDialogBody({
<OptionCombobox <OptionCombobox
value={String(field.value ?? 0)} value={String(field.value ?? 0)}
onChange={(value) => field.onChange(Number(value))} onChange={(value) => field.onChange(Number(value))}
placeholder="请选择处理人" placeholder={t("ticket.selectAssignee")}
options={agentOptions} options={agentOptions}
/> />
)} )}
@@ -408,7 +415,7 @@ function TicketEditDialogBody({
</Field> </Field>
<Field> <Field>
<FieldLabel></FieldLabel> <FieldLabel>{t("ticket.ticketTags")}</FieldLabel>
<FieldContent> <FieldContent>
<Controller <Controller
control={control} control={control}
@@ -418,6 +425,7 @@ function TicketEditDialogBody({
value={field.value} value={field.value}
onChange={field.onChange} onChange={field.onChange}
availableTags={tags} availableTags={tags}
t={t}
/> />
)} )}
/> />
@@ -1,8 +1,8 @@
"use client" "use client"
import { useEffect, useRef, useState } from "react" import { useEffect, useMemo, useRef, useState } from "react"
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod"
import { Controller, Resolver, useForm } from "react-hook-form" import { Controller, type Resolver, useForm } from "react-hook-form"
import { toast } from "sonner" import { toast } from "sonner"
import { z } from "zod/v4" import { z } from "zod/v4"
@@ -21,20 +21,22 @@ import {
fetchAgentProfilesAll, fetchAgentProfilesAll,
type AdminAgentProfile, type AdminAgentProfile,
} from "@/lib/api/admin" } from "@/lib/api/admin"
import { useI18n } from "@/i18n/provider"
import { assignTicket } from "@/lib/api/ticket" import { assignTicket } from "@/lib/api/ticket"
const schema = z.object({ type TFunction = (key: string, values?: Record<string, string | number>) => string
toUserId: z.string().trim().min(1, "请选择处理人"),
function createSchema(t: TFunction) {
return z.object({
toUserId: z.string().trim().min(1, t("ticket.assigneeRequired")),
reason: z.string().trim(), reason: z.string().trim(),
}) })
}
type FormValues = z.infer<typeof schema> type FormValues = {
toUserId: string
const resolver = zodResolver(schema as never) as Resolver< reason: string
z.input<typeof schema>, }
undefined,
z.output<typeof schema>
>
const emptyForm: FormValues = { const emptyForm: FormValues = {
toUserId: "", toUserId: "",
@@ -77,16 +79,18 @@ function TicketAssignDialogBody({
onOpenChange, onOpenChange,
onSuccess, onSuccess,
}: Omit<TicketAssignDialogProps, "open">) { }: Omit<TicketAssignDialogProps, "open">) {
const t = useI18n()
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [agents, setAgents] = useState<AdminAgentProfile[]>([]) const [agents, setAgents] = useState<AdminAgentProfile[]>([])
const activeRef = useRef(false) const activeRef = useRef(false)
const form = useForm< const schema = useMemo(() => createSchema(t), [t])
z.input<typeof schema>, const resolver = useMemo(
undefined, () => zodResolver(schema) as Resolver<FormValues>,
z.output<typeof schema> [schema],
>({ )
const form = useForm<FormValues>({
resolver, resolver,
defaultValues: emptyForm, defaultValues: emptyForm,
}) })
@@ -126,18 +130,18 @@ function TicketAssignDialogBody({
if (!activeRef.current) { if (!activeRef.current) {
return return
} }
toast.error(error instanceof Error ? error.message : "加载处理人失败") toast.error(error instanceof Error ? error.message : t("ticket.loadAssigneesFailed"))
}) })
.finally(() => { .finally(() => {
if (activeRef.current) { if (activeRef.current) {
setLoading(false) setLoading(false)
} }
}) })
}, []) }, [t])
async function onFormSubmit(values: FormValues) { async function onFormSubmit(values: FormValues) {
if (!ticketId) { if (!ticketId) {
toast.error("请选择工单") toast.error(t("ticket.selectTicket"))
return return
} }
setSaving(true) setSaving(true)
@@ -150,7 +154,7 @@ function TicketAssignDialogBody({
if (!activeRef.current) { if (!activeRef.current) {
return return
} }
toast.success("处理人已更新") toast.success(t("ticket.assigneeUpdated"))
if (!activeRef.current) { if (!activeRef.current) {
return return
} }
@@ -160,7 +164,7 @@ function TicketAssignDialogBody({
if (!activeRef.current) { if (!activeRef.current) {
return return
} }
toast.error(error instanceof Error ? error.message : "指派工单失败") toast.error(error instanceof Error ? error.message : t("ticket.assignFailed"))
} finally { } finally {
if (activeRef.current) { if (activeRef.current) {
setSaving(false) setSaving(false)
@@ -171,12 +175,12 @@ function TicketAssignDialogBody({
return ( return (
<DialogContent className="max-w-lg gap-0 p-0 sm:max-w-lg"> <DialogContent className="max-w-lg gap-0 p-0 sm:max-w-lg">
<DialogHeader className="px-6 pt-6"> <DialogHeader className="px-6 pt-6">
<DialogTitle></DialogTitle> <DialogTitle>{t("ticket.assignTitle")}</DialogTitle>
</DialogHeader> </DialogHeader>
<form onSubmit={handleSubmit(onFormSubmit)}> <form onSubmit={handleSubmit(onFormSubmit)}>
<div className="space-y-4 p-6"> <div className="space-y-4 p-6">
<Field data-invalid={!!errors.toUserId}> <Field data-invalid={!!errors.toUserId}>
<FieldLabel></FieldLabel> <FieldLabel>{t("ticket.assignee")}</FieldLabel>
<FieldContent> <FieldContent>
<Controller <Controller
control={control} control={control}
@@ -185,14 +189,14 @@ function TicketAssignDialogBody({
<OptionCombobox <OptionCombobox
value={field.value} value={field.value}
onChange={field.onChange} onChange={field.onChange}
placeholder={loading ? "加载中..." : "选择处理人"} placeholder={loading ? t("ticket.loading") : t("ticket.selectHandler")}
options={agents.map((agent) => ({ options={agents.map((agent) => ({
value: String(agent.userId), value: String(agent.userId),
label: label:
agent.displayName || agent.displayName ||
agent.nickname || agent.nickname ||
agent.username || agent.username ||
`客服#${agent.userId}`, t("ticket.agentFallback", { id: agent.userId }),
}))} }))}
/> />
)} )}
@@ -201,19 +205,19 @@ function TicketAssignDialogBody({
</FieldContent> </FieldContent>
</Field> </Field>
<Field data-invalid={!!errors.reason}> <Field data-invalid={!!errors.reason}>
<FieldLabel></FieldLabel> <FieldLabel>{t("ticket.assignReason")}</FieldLabel>
<FieldContent> <FieldContent>
<Textarea rows={4} placeholder="填写指派说明" {...register("reason")} /> <Textarea rows={4} placeholder={t("ticket.assignReasonPlaceholder")} {...register("reason")} />
<FieldError errors={[errors.reason]} /> <FieldError errors={[errors.reason]} />
</FieldContent> </FieldContent>
</Field> </Field>
</div> </div>
<DialogFooter className="mx-0 mb-0 px-6 py-4"> <DialogFooter className="mx-0 mb-0 px-6 py-4">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}> <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
{t("ticket.cancel")}
</Button> </Button>
<Button type="submit" disabled={saving}> <Button type="submit" disabled={saving}>
{saving ? "提交中..." : "确认指派"} {saving ? t("ticket.submitting") : t("ticket.confirmAssign")}
</Button> </Button>
</DialogFooter> </DialogFooter>
</form> </form>
@@ -31,6 +31,7 @@ import {
type UpdateTicketPayload, type UpdateTicketPayload,
updateTicket, updateTicket,
} from "@/lib/api/ticket" } from "@/lib/api/ticket"
import { useI18n } from "@/i18n/provider"
import { cn, formatDateTime } from "@/lib/utils" import { cn, formatDateTime } from "@/lib/utils"
import { EditDialog } from "./edit" import { EditDialog } from "./edit"
import { TicketAssignDialog } from "./ticket-assign-dialog" import { TicketAssignDialog } from "./ticket-assign-dialog"
@@ -43,18 +44,22 @@ type TicketDetailDialogProps = {
onChanged: () => void onChanged: () => void
} }
const statusOptions: Array<{ value: TicketStatus; label: string }> = [ type TFunction = (key: string, values?: Record<string, string | number>) => string
{ value: "pending", label: "待处理" },
{ value: "in_progress", label: "处理中" },
{ value: "done", label: "已处理" },
]
function sourceLabel(source: string) { function getStatusOptions(t: TFunction): Array<{ value: TicketStatus; label: string }> {
return [
{ value: "pending", label: t("ticket.statusPending") },
{ value: "in_progress", label: t("ticket.statusInProgress") },
{ value: "done", label: t("ticket.statusDone") },
]
}
function sourceLabel(source: string, t: TFunction) {
switch (source) { switch (source) {
case "manual": case "manual":
return "手动创建" return t("ticket.manualCreated")
case "conversation": case "conversation":
return "会话生成" return t("ticket.conversationGenerated")
default: default:
return source || "-" return source || "-"
} }
@@ -77,6 +82,7 @@ export function TicketDetailDialog({
onOpenChange, onOpenChange,
onChanged, onChanged,
}: TicketDetailDialogProps) { }: TicketDetailDialogProps) {
const t = useI18n()
const [detail, setDetail] = useState<TicketDetail | null>(null) const [detail, setDetail] = useState<TicketDetail | null>(null)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [statusSaving, setStatusSaving] = useState<TicketStatus | null>(null) const [statusSaving, setStatusSaving] = useState<TicketStatus | null>(null)
@@ -120,13 +126,13 @@ export function TicketDetailDialog({
if (loadSeqRef.current !== seq || !isCurrentOperation(targetTicketId, dialogSeq)) { if (loadSeqRef.current !== seq || !isCurrentOperation(targetTicketId, dialogSeq)) {
return return
} }
toast.error(error instanceof Error ? error.message : "加载工单详情失败") toast.error(error instanceof Error ? error.message : t("ticket.loadDetailFailed"))
} finally { } finally {
if (loadSeqRef.current === seq) { if (loadSeqRef.current === seq) {
setLoading(false) setLoading(false)
} }
} }
}, [open, ticketId]) }, [open, t, ticketId])
useEffect(() => { useEffect(() => {
dialogSeqRef.current += 1 dialogSeqRef.current += 1
@@ -160,7 +166,7 @@ export function TicketDetailDialog({
if (!isCurrentOperation(activeTicketId, activeDialogSeq)) { if (!isCurrentOperation(activeTicketId, activeDialogSeq)) {
return return
} }
toast.success("工单状态已更新") toast.success(t("ticket.statusUpdated"))
await loadDetail(activeTicketId, activeDialogSeq) await loadDetail(activeTicketId, activeDialogSeq)
if (!isCurrentOperation(activeTicketId, activeDialogSeq)) { if (!isCurrentOperation(activeTicketId, activeDialogSeq)) {
return return
@@ -170,7 +176,7 @@ export function TicketDetailDialog({
if (!isCurrentOperation(activeTicketId, activeDialogSeq)) { if (!isCurrentOperation(activeTicketId, activeDialogSeq)) {
return return
} }
toast.error(error instanceof Error ? error.message : "更新工单状态失败") toast.error(error instanceof Error ? error.message : t("ticket.statusUpdateFailed"))
} finally { } finally {
if (isCurrentOperation(activeTicketId, activeDialogSeq)) { if (isCurrentOperation(activeTicketId, activeDialogSeq)) {
setStatusSaving(null) setStatusSaving(null)
@@ -186,7 +192,7 @@ export function TicketDetailDialog({
const activeDialogSeq = dialogSeqRef.current const activeDialogSeq = dialogSeqRef.current
const content = progressContent.trim() const content = progressContent.trim()
if (isRichTextEmpty(content)) { if (isRichTextEmpty(content)) {
toast.error("请填写处理进展") toast.error(t("ticket.progressRequired"))
return return
} }
setProgressSaving(true) setProgressSaving(true)
@@ -198,7 +204,7 @@ export function TicketDetailDialog({
if (!isCurrentOperation(activeTicketId, activeDialogSeq)) { if (!isCurrentOperation(activeTicketId, activeDialogSeq)) {
return return
} }
toast.success("处理进展已记录") toast.success(t("ticket.progressRecorded"))
setProgressContent("") setProgressContent("")
setProgressOpen(false) setProgressOpen(false)
await loadDetail(activeTicketId, activeDialogSeq) await loadDetail(activeTicketId, activeDialogSeq)
@@ -210,7 +216,7 @@ export function TicketDetailDialog({
if (!isCurrentOperation(activeTicketId, activeDialogSeq)) { if (!isCurrentOperation(activeTicketId, activeDialogSeq)) {
return return
} }
toast.error(error instanceof Error ? error.message : "记录处理进展失败") toast.error(error instanceof Error ? error.message : t("ticket.progressCreateFailed"))
} finally { } finally {
if (isCurrentOperation(activeTicketId, activeDialogSeq)) { if (isCurrentOperation(activeTicketId, activeDialogSeq)) {
setProgressSaving(false) setProgressSaving(false)
@@ -233,7 +239,7 @@ export function TicketDetailDialog({
async function handleUpdateTicket(payload: CreateTicketPayload | UpdateTicketPayload) { async function handleUpdateTicket(payload: CreateTicketPayload | UpdateTicketPayload) {
if (!("ticketId" in payload) || payload.ticketId <= 0) { if (!("ticketId" in payload) || payload.ticketId <= 0) {
toast.error("请选择工单") toast.error(t("ticket.selectTicket"))
return return
} }
const activeDialogSeq = dialogSeqRef.current const activeDialogSeq = dialogSeqRef.current
@@ -243,7 +249,7 @@ export function TicketDetailDialog({
if (!isCurrentOperation(payload.ticketId, activeDialogSeq)) { if (!isCurrentOperation(payload.ticketId, activeDialogSeq)) {
return return
} }
toast.success("工单已更新") toast.success(t("ticket.updated"))
setEditOpen(false) setEditOpen(false)
await loadDetail(payload.ticketId, activeDialogSeq) await loadDetail(payload.ticketId, activeDialogSeq)
if (!isCurrentOperation(payload.ticketId, activeDialogSeq)) { if (!isCurrentOperation(payload.ticketId, activeDialogSeq)) {
@@ -254,7 +260,7 @@ export function TicketDetailDialog({
if (!isCurrentOperation(payload.ticketId, activeDialogSeq)) { if (!isCurrentOperation(payload.ticketId, activeDialogSeq)) {
return return
} }
toast.error(error instanceof Error ? error.message : "更新工单失败") toast.error(error instanceof Error ? error.message : t("ticket.updateFailed"))
} finally { } finally {
if (isCurrentOperation(payload.ticketId, activeDialogSeq)) { if (isCurrentOperation(payload.ticketId, activeDialogSeq)) {
setEditSaving(false) setEditSaving(false)
@@ -265,7 +271,7 @@ export function TicketDetailDialog({
async function handleUpdateCustomer(payload: CustomerFormSavePayload) { async function handleUpdateCustomer(payload: CustomerFormSavePayload) {
const activeCustomerId = getTicketCustomerId(ticket) const activeCustomerId = getTicketCustomerId(ticket)
if (!ticket?.id || activeCustomerId <= 0) { if (!ticket?.id || activeCustomerId <= 0) {
toast.error("当前工单未关联客户") toast.error(t("ticket.noLinkedCustomer"))
return return
} }
if (customerEditSaving) { if (customerEditSaving) {
@@ -279,7 +285,7 @@ export function TicketDetailDialog({
if (!isCurrentOperation(activeTicketId, activeDialogSeq)) { if (!isCurrentOperation(activeTicketId, activeDialogSeq)) {
return return
} }
toast.success("已保存") toast.success(t("ticket.saved"))
setCustomerEditOpen(false) setCustomerEditOpen(false)
await loadDetail(activeTicketId, activeDialogSeq) await loadDetail(activeTicketId, activeDialogSeq)
if (!isCurrentOperation(activeTicketId, activeDialogSeq)) { if (!isCurrentOperation(activeTicketId, activeDialogSeq)) {
@@ -290,7 +296,7 @@ export function TicketDetailDialog({
if (!isCurrentOperation(activeTicketId, activeDialogSeq)) { if (!isCurrentOperation(activeTicketId, activeDialogSeq)) {
return return
} }
toast.error(error instanceof Error ? error.message : "保存失败") toast.error(error instanceof Error ? error.message : t("ticket.saveFailed"))
} finally { } finally {
if (isCurrentOperation(activeTicketId, activeDialogSeq)) { if (isCurrentOperation(activeTicketId, activeDialogSeq)) {
setCustomerEditSaving(false) setCustomerEditSaving(false)
@@ -313,6 +319,7 @@ export function TicketDetailDialog({
const ticket = detail?.ticket const ticket = detail?.ticket
const customerId = getTicketCustomerId(ticket) const customerId = getTicketCustomerId(ticket)
const statusOptions = getStatusOptions(t)
return ( return (
<> <>
@@ -321,7 +328,7 @@ export function TicketDetailDialog({
onOpenChange={onOpenChange} onOpenChange={onOpenChange}
title={ title={
<div className="flex min-w-0 items-center gap-2 pr-16 text-base"> <div className="flex min-w-0 items-center gap-2 pr-16 text-base">
<span className="truncate">{ticket?.title ?? "工单详情"}</span> <span className="truncate">{ticket?.title ?? t("ticket.detailTitle")}</span>
{ticket ? <TicketStatusBadge status={ticket.status} /> : null} {ticket ? <TicketStatusBadge status={ticket.status} /> : null}
</div> </div>
} }
@@ -329,8 +336,8 @@ export function TicketDetailDialog({
ticket ? ( ticket ? (
<span className="flex flex-wrap items-center gap-2 text-sm"> <span className="flex flex-wrap items-center gap-2 text-sm">
<span className="font-mono">{ticket.ticketNo}</span> <span className="font-mono">{ticket.ticketNo}</span>
<span>{sourceLabel(ticket.source)}</span> <span>{sourceLabel(ticket.source, t)}</span>
<span>{metadataValue(ticket.createdByName || ticket.createdBy)}</span> <span>{t("ticket.creator", { name: metadataValue(ticket.createdByName || ticket.createdBy) })}</span>
</span> </span>
) : undefined ) : undefined
} }
@@ -342,21 +349,21 @@ export function TicketDetailDialog({
{loading && !ticket ? ( {loading && !ticket ? (
<div className="flex h-130 items-center justify-center gap-2 text-sm text-muted-foreground"> <div className="flex h-130 items-center justify-center gap-2 text-sm text-muted-foreground">
<RefreshCcwIcon className="size-4 animate-spin" /> <RefreshCcwIcon className="size-4 animate-spin" />
... {t("ticket.loading")}
</div> </div>
) : ticket ? ( ) : ticket ? (
<div className="grid w-full h-full grid-cols-1 overflow-hidden lg:grid-cols-[minmax(0,1fr)_380px] border-t"> <div className="grid w-full h-full grid-cols-1 overflow-hidden lg:grid-cols-[minmax(0,1fr)_380px] border-t">
<div className="min-h-0 space-y-5 overflow-y-auto border-b p-6 lg:border-r lg:border-b-0"> <div className="min-h-0 space-y-5 overflow-y-auto border-b p-6 lg:border-r lg:border-b-0">
<section className="space-y-2"> <section className="space-y-2">
<div className="text-sm font-medium text-muted-foreground"></div> <div className="text-sm font-medium text-muted-foreground">{t("ticket.description")}</div>
<div className="rounded-md border bg-muted/30 px-3 py-2"> <div className="rounded-md border bg-muted/30 px-3 py-2">
<SafeRichHTML html={ticket.description} fallback="暂无描述" /> <SafeRichHTML html={ticket.description} fallback={t("ticket.noDescription")} />
</div> </div>
</section> </section>
<section className="grid gap-4 md:grid-cols-[minmax(0,1fr)_220px]"> <section className="grid gap-4 md:grid-cols-[minmax(0,1fr)_220px]">
<div className="space-y-2"> <div className="space-y-2">
<div className="text-sm font-medium text-muted-foreground"></div> <div className="text-sm font-medium text-muted-foreground">{t("ticket.columnStatus")}</div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{statusOptions.map((option) => ( {statusOptions.map((option) => (
<Button <Button
@@ -367,20 +374,20 @@ export function TicketDetailDialog({
disabled={!!statusSaving} disabled={!!statusSaving}
onClick={() => void handleStatusChange(option.value)} onClick={() => void handleStatusChange(option.value)}
> >
{statusSaving === option.value ? "更新中..." : option.label} {statusSaving === option.value ? t("ticket.updating") : option.label}
</Button> </Button>
))} ))}
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<div className="text-sm font-medium text-muted-foreground"></div> <div className="text-sm font-medium text-muted-foreground">{t("ticket.columnAssignee")}</div>
<div className="flex items-center justify-between gap-2 rounded-md border px-3 py-2"> <div className="flex items-center justify-between gap-2 rounded-md border px-3 py-2">
<div className="flex min-w-0 items-center gap-2 text-sm"> <div className="flex min-w-0 items-center gap-2 text-sm">
<UserRoundIcon className="size-4 shrink-0 text-muted-foreground" /> <UserRoundIcon className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate">{ticket.currentAssigneeName || "未分配"}</span> <span className="truncate">{ticket.currentAssigneeName || t("ticket.unassigned")}</span>
</div> </div>
<Button type="button" size="sm" variant="outline" onClick={() => setAssignOpen(true)}> <Button type="button" size="sm" variant="outline" onClick={() => setAssignOpen(true)}>
{t("ticket.assign")}
</Button> </Button>
</div> </div>
</div> </div>
@@ -388,9 +395,9 @@ export function TicketDetailDialog({
<section className="space-y-2"> <section className="space-y-2">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<div className="text-sm font-medium text-muted-foreground"></div> <div className="text-sm font-medium text-muted-foreground">{t("ticket.tags")}</div>
<Button type="button" size="sm" variant="outline" onClick={() => setEditOpen(true)}> <Button type="button" size="sm" variant="outline" onClick={() => setEditOpen(true)}>
{t("ticket.edit")}
</Button> </Button>
</div> </div>
{ticket.tags && ticket.tags.length > 0 ? ( {ticket.tags && ticket.tags.length > 0 ? (
@@ -402,13 +409,13 @@ export function TicketDetailDialog({
))} ))}
</div> </div>
) : ( ) : (
<div className="text-sm text-muted-foreground"></div> <div className="text-sm text-muted-foreground">{t("ticket.emptyTags")}</div>
)} )}
</section> </section>
<section className="space-y-3 rounded-md border p-3 text-sm"> <section className="space-y-3 rounded-md border p-3 text-sm">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<div className="font-medium text-muted-foreground"></div> <div className="font-medium text-muted-foreground">{t("ticket.customerInfo")}</div>
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
@@ -423,22 +430,22 @@ export function TicketDetailDialog({
}} }}
> >
<PencilIcon className="size-3.5" /> <PencilIcon className="size-3.5" />
{customerId > 0 ? "编辑" : "关联或创建"} {customerId > 0 ? t("ticket.edit") : t("ticket.linkOrCreate")}
</Button> </Button>
</div> </div>
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
<MetadataItem label="客户" value={ticket.customer?.name || ticket.customerId} /> <MetadataItem label={t("ticket.customer")} value={ticket.customer?.name || ticket.customerId} />
<MetadataItem label="联系方式" value={ticket.customer?.primaryMobile || ticket.customer?.primaryEmail} /> <MetadataItem label={t("ticket.contact")} value={ticket.customer?.primaryMobile || ticket.customer?.primaryEmail} />
</div> </div>
</section> </section>
<section className="space-y-3 rounded-md border p-3 text-sm"> <section className="space-y-3 rounded-md border p-3 text-sm">
<div className="font-medium text-muted-foreground"></div> <div className="font-medium text-muted-foreground">{t("ticket.ticketInfo")}</div>
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
<MetadataItem label="来源" value={sourceLabel(ticket.source)} /> <MetadataItem label={t("ticket.source")} value={sourceLabel(ticket.source, t)} />
<MetadataItem label="渠道" value={ticket.channel} /> <MetadataItem label={t("ticket.channel")} value={ticket.channel} />
<MetadataItem label="会话 ID" value={ticket.conversationId || undefined} /> <MetadataItem label={t("ticket.conversationId")} value={ticket.conversationId || undefined} />
<MetadataItem label="最后更新" value={ticket.updatedAt ? formatDateTime(ticket.updatedAt) : undefined} /> <MetadataItem label={t("ticket.columnUpdated")} value={ticket.updatedAt ? formatDateTime(ticket.updatedAt) : undefined} />
</div> </div>
</section> </section>
</div> </div>
@@ -447,11 +454,11 @@ export function TicketDetailDialog({
<div className="flex items-center justify-between gap-2 px-4 py-2"> <div className="flex items-center justify-between gap-2 px-4 py-2">
<div className="flex items-center gap-2 text-sm font-medium"> <div className="flex items-center gap-2 text-sm font-medium">
<MessageSquareTextIcon className="size-4 text-muted-foreground" /> <MessageSquareTextIcon className="size-4 text-muted-foreground" />
{t("ticket.progress")}
</div> </div>
<Button type="button" size="sm" onClick={() => setProgressOpen(true)}> <Button type="button" size="sm" onClick={() => setProgressOpen(true)}>
<PlusIcon className="size-3.5" /> <PlusIcon className="size-3.5" />
{t("ticket.addProgress")}
</Button> </Button>
</div> </div>
<Separator /> <Separator />
@@ -471,7 +478,7 @@ export function TicketDetailDialog({
</div> </div>
<div className="min-w-0 flex-1 pb-3"> <div className="min-w-0 flex-1 pb-3">
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground"> <div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<span>{progress.authorName || `用户#${progress.authorId}`}</span> <span>{progress.authorName || t("ticket.userFallback", { id: progress.authorId })}</span>
<span>{progress.createdAt ? formatDateTime(progress.createdAt) : "-"}</span> <span>{progress.createdAt ? formatDateTime(progress.createdAt) : "-"}</span>
</div> </div>
<SafeRichHTML html={progress.content} className="mt-1" /> <SafeRichHTML html={progress.content} className="mt-1" />
@@ -481,14 +488,14 @@ export function TicketDetailDialog({
</div> </div>
) : ( ) : (
<div className="rounded-md border border-dashed px-3 py-6 text-center text-sm text-muted-foreground"> <div className="rounded-md border border-dashed px-3 py-6 text-center text-sm text-muted-foreground">
{t("ticket.noProgress")}
</div> </div>
)} )}
</div> </div>
</aside> </aside>
</div> </div>
) : ( ) : (
<div className="flex h-[360px] items-center justify-center text-sm text-muted-foreground"></div> <div className="flex h-[360px] items-center justify-center text-sm text-muted-foreground">{t("ticket.chooseTicket")}</div>
)} )}
</ProjectDialog> </ProjectDialog>
@@ -533,13 +540,13 @@ export function TicketDetailDialog({
> >
<DialogContent className="max-w-2xl gap-0 p-0 sm:max-w-3xl"> <DialogContent className="max-w-2xl gap-0 p-0 sm:max-w-3xl">
<DialogHeader className="px-6 pt-6"> <DialogHeader className="px-6 pt-6">
<DialogTitle></DialogTitle> <DialogTitle>{t("ticket.addProgress")}</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="px-6 py-4"> <div className="px-6 py-4">
<ContentEditor <ContentEditor
value={{ mode: "html", raw: progressContent }} value={{ mode: "html", raw: progressContent }}
onChange={(next) => setProgressContent(next.raw)} onChange={(next) => setProgressContent(next.raw)}
placeholder="记录本次处理进展" placeholder={t("ticket.progressPlaceholder")}
disabled={progressSaving} disabled={progressSaving}
allowedModes={["html"]} allowedModes={["html"]}
height={260} height={260}
@@ -555,11 +562,11 @@ export function TicketDetailDialog({
setProgressContent("") setProgressContent("")
}} }}
> >
{t("ticket.cancel")}
</Button> </Button>
<Button type="button" disabled={progressSaving} onClick={() => void handleCreateProgress()}> <Button type="button" disabled={progressSaving} onClick={() => void handleCreateProgress()}>
<SendIcon className="size-3.5" /> <SendIcon className="size-3.5" />
{progressSaving ? "提交中..." : "提交"} {progressSaving ? t("ticket.submitting") : t("ticket.submit")}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -1,24 +1,26 @@
"use client" "use client"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { useI18n } from "@/i18n/provider"
import type { TicketStatus } from "@/lib/api/ticket" import type { TicketStatus } from "@/lib/api/ticket"
const statusMap = { const statusMap = {
pending: { label: "待处理", className: "border-amber-200 bg-amber-50 text-amber-700" }, pending: { labelKey: "ticket.statusPending", className: "border-amber-200 bg-amber-50 text-amber-700" },
in_progress: { label: "处理中", className: "border-blue-200 bg-blue-50 text-blue-700" }, in_progress: { labelKey: "ticket.statusInProgress", className: "border-blue-200 bg-blue-50 text-blue-700" },
done: { label: "已处理", className: "border-emerald-200 bg-emerald-50 text-emerald-700" }, done: { labelKey: "ticket.statusDone", className: "border-emerald-200 bg-emerald-50 text-emerald-700" },
} as const } as const
export function ticketStatusLabel(status: string) { export function ticketStatusLabel(status: string) {
return statusMap[status as TicketStatus]?.label ?? status return status
} }
export function TicketStatusBadge({ status }: { status: string }) { export function TicketStatusBadge({ status }: { status: string }) {
const t = useI18n()
const option = statusMap[status as TicketStatus] const option = statusMap[status as TicketStatus]
return ( return (
<Badge variant="outline" className={option?.className ?? "border-border bg-muted text-muted-foreground"}> <Badge variant="outline" className={option?.className ?? "border-border bg-muted text-muted-foreground"}>
{option?.label ?? status} {option ? t(option.labelKey) : status}
</Badge> </Badge>
) )
} }

Some files were not shown because too many files have changed in this diff Show More