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/simple v1.2.40
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/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/robfig/cron/v3 v3.0.1
github.com/silenceper/wechat/v2 v2.1.12
@@ -29,6 +31,7 @@ require (
github.com/yuin/goldmark v1.4.13
golang.org/x/crypto v0.49.0
golang.org/x/net v0.51.0
golang.org/x/text v0.35.0
golang.org/x/tools v0.42.0
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/mysql v1.5.7
@@ -79,7 +82,6 @@ require (
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/nikolalohinski/gonja v1.5.3 // 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/quic-go/qpack v0.6.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/sync v0.20.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
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // 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/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=
@@ -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/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
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/go.mod h1:RmjwxNiXAEqcq1HeK5SSMmqFJvKOfTfXhkJv6YBtPa4=
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.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
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/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
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/ginx"
"cs-agent/internal/pkg/httpx"
"cs-agent/internal/pkg/i18nx"
"cs-agent/internal/services"
webspa "cs-agent/web"
@@ -32,6 +33,7 @@ func NewServer() (*gin.Engine, error) {
app.Use(gin.Recovery())
app.Use(requestLogMiddleware())
app.Use(maxBodySizeMiddleware(cfg.Storage.MaxRequestBodySizeBytes()))
app.Use(i18nx.Middleware())
addRouter(app)
@@ -51,7 +53,7 @@ func NewServer() (*gin.Engine, error) {
},
NotFoundPrefixes: notFoundPrefixes,
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/pkg/dto/response"
"cs-agent/internal/pkg/enums"
"cs-agent/internal/pkg/i18nx"
"cs-agent/internal/pkg/utils"
"cs-agent/internal/services"
@@ -13,6 +14,10 @@ import (
)
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)
ret := response.ConversationResponse{
ID: item.ID,
@@ -28,7 +33,7 @@ func BuildConversation(item *models.Conversation) response.ConversationResponse
LastMessageID: item.LastMessageID,
LastMessageAt: utils.FormatTime(item.LastMessageAt),
LastActiveAt: utils.FormatTime(item.LastActiveAt),
LastMessageSummary: item.LastMessageSummary,
LastMessageSummary: localizeConversationSummary(locale, item.LastMessageSummary),
CustomerUnreadCount: item.CustomerUnreadCount,
AgentUnreadCount: item.AgentUnreadCount,
CustomerLastReadMessageID: readStateMessageID(customerReadState),
@@ -68,6 +73,26 @@ func BuildConversation(item *models.Conversation) response.ConversationResponse
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 {
list := services.ConversationParticipantService.Find(sqls.NewCnd().Eq("conversation_id", conversationID).Asc("id"))
if len(list) == 0 {
@@ -89,6 +114,10 @@ func BuildParticipantResponses(conversationID int64) []response.ConversationPart
}
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 {
return nil
}
@@ -97,17 +126,25 @@ func BuildMessages(list []models.Message) []response.MessageResponse {
agentProfiles := collectAgentProfilesByMessages(list)
ret := make([]response.MessageResponse, 0, len(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
}
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)
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 {
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)
ret := response.MessageResponse{
ID: item.ID,
@@ -116,7 +153,7 @@ func BuildMessageWithReadStates(item *models.Message, agentReadState, customerRe
SenderType: item.SenderType,
SenderID: item.SenderID,
MessageType: item.MessageType,
Content: content,
Content: localizeRenderableMessageContent(locale, content),
Payload: payload,
SeqNo: item.SeqNo,
SendStatus: item.SendStatus,
@@ -169,6 +206,16 @@ func BuildMessageWithReadStates(item *models.Message, agentReadState, customerRe
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 {
var agentUserIDs []int64
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 (
"cs-agent/internal/models"
"cs-agent/internal/pkg/dto/response"
"cs-agent/internal/pkg/i18nx"
"cs-agent/internal/pkg/utils"
"fmt"
"regexp"
"strings"
)
var (
ticketAssignedNotificationPattern = regexp.MustCompile(`^工单 (.+) 已指派给你$`)
conversationAssignedNotificationPattern = regexp.MustCompile(`^会话 #([0-9]+) 已分配给你$`)
)
func BuildNotification(item *models.Notification) *response.NotificationResponse {
return BuildNotificationWithLocale(item, i18nx.LocaleZhCN)
}
func BuildNotificationWithLocale(item *models.Notification, locale string) *response.NotificationResponse {
if item == nil {
return nil
}
title, content := localizeNotificationText(item, locale)
return &response.NotificationResponse{
ID: item.ID,
RecipientUserID: item.RecipientUserID,
Title: item.Title,
Content: item.Content,
Title: title,
Content: content,
NotificationType: item.NotificationType,
BizType: item.BizType,
BizID: item.BizID,
@@ -25,11 +39,90 @@ func BuildNotification(item *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))
for i := range list {
if item := BuildNotification(&list[i]); item != nil {
if item := BuildNotificationWithLocale(&list[i], locale); item != nil {
results = append(results, *item)
}
}
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"
"cs-agent/internal/models"
"cs-agent/internal/pkg/i18nx"
)
func TestBuildNotificationListReturnsEmptySlice(t *testing.T) {
@@ -16,3 +17,22 @@ func TestBuildNotificationListReturnsEmptySlice(t *testing.T) {
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/response"
"cs-agent/internal/pkg/httpx"
"cs-agent/internal/pkg/i18nx"
"cs-agent/internal/services"
"cs-agent/internal/pkg/httpx/params"
@@ -39,7 +40,7 @@ func ConversationGetBy(ctx *gin.Context) {
}
detail := response.ConversationDetailResponse{
ConversationResponse: builders.BuildConversation(item),
ConversationResponse: builders.BuildConversationWithLocale(item, i18nx.Locale(ctx)),
Participants: builders.BuildParticipantResponses(id),
}
httpx.WriteJSON(ctx, detail)
@@ -62,7 +63,7 @@ func ConversationPostCreate_or_match(ctx *gin.Context) {
httpx.WriteJSON(ctx, err)
return
}
httpx.WriteJSON(ctx, builders.BuildConversation(item))
httpx.WriteJSON(ctx, builders.BuildConversationWithLocale(item, i18nx.Locale(ctx)))
}
func ConversationPostClose(ctx *gin.Context) {
+3 -2
View File
@@ -5,6 +5,7 @@ import (
"cs-agent/internal/pkg/dto/request"
"cs-agent/internal/pkg/enums"
"cs-agent/internal/pkg/httpx"
"cs-agent/internal/pkg/i18nx"
"cs-agent/internal/services"
"strconv"
"strings"
@@ -51,7 +52,7 @@ func MessageAnyList(ctx *gin.Context) {
list, nextCursor, hasMore := services.MessageService.FindByConversationIDCursor(
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))
}
@@ -77,7 +78,7 @@ func MessagePostSend(ctx *gin.Context) {
httpx.WriteJSON(ctx, err)
return
}
httpx.WriteJSON(ctx, builders.BuildMessage(item))
httpx.WriteJSON(ctx, builders.BuildMessageWithLocale(item, i18nx.Locale(ctx)))
}
func MessagePostRead(ctx *gin.Context) {
@@ -10,6 +10,7 @@ import (
"cs-agent/internal/pkg/dto/request"
"cs-agent/internal/pkg/dto/response"
"cs-agent/internal/pkg/enums"
"cs-agent/internal/pkg/i18nx"
"cs-agent/internal/pkg/toolx"
"cs-agent/internal/pkg/utils"
"cs-agent/internal/services"
@@ -34,7 +35,7 @@ func AIAgentAnyList(ctx *gin.Context) {
list, paging := services.AIAgentService.FindPageByCnd(cnd)
results := make([]response.AIAgentResponse, 0, len(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})
}
@@ -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"))
results := make([]response.AIAgentResponse, 0, len(list))
for _, item := range list {
results = append(results, buildAIAgentResponse(&item))
results = append(results, buildAIAgentResponseWithLocale(&item, i18nx.Locale(ctx)))
}
httpx.WriteJSON(ctx, results)
}
@@ -66,7 +67,7 @@ func AIAgentGetBy(ctx *gin.Context) {
httpx.WriteJSON(ctx, web.JsonErrorMsg("AI Agent 不存在"))
return
}
httpx.WriteJSON(ctx, buildAIAgentResponse(item))
httpx.WriteJSON(ctx, buildAIAgentResponseWithLocale(item, i18nx.Locale(ctx)))
}
func AIAgentPostCreate(ctx *gin.Context) {
@@ -85,7 +86,7 @@ func AIAgentPostCreate(ctx *gin.Context) {
httpx.WriteJSON(ctx, err)
return
}
httpx.WriteJSON(ctx, buildAIAgentResponse(item))
httpx.WriteJSON(ctx, buildAIAgentResponseWithLocale(item, i18nx.Locale(ctx)))
}
func AIAgentPostUpdate(ctx *gin.Context) {
@@ -160,6 +161,10 @@ func AIAgentPostUpdate_status(ctx *gin.Context) {
}
func buildAIAgentResponse(item *models.AIAgent) response.AIAgentResponse {
return buildAIAgentResponseWithLocale(item, i18nx.LocaleZhCN)
}
func buildAIAgentResponseWithLocale(item *models.AIAgent, locale string) response.AIAgentResponse {
ret := response.AIAgentResponse{
ID: item.ID,
Name: item.Name,
@@ -242,13 +247,13 @@ func buildAIAgentResponse(item *models.AIAgent) response.AIAgentResponse {
}
title := strings.TrimSpace(tool.Title)
if title == "" {
if registeredTitle := toolx.GetRegisteredToolTitle(toolCode); registeredTitle != "" {
if registeredTitle := toolx.GetRegisteredToolTitleLocale(toolCode, locale); registeredTitle != "" {
title = registeredTitle
}
}
description := strings.TrimSpace(tool.Description)
if description == "" {
if registeredDescription := toolx.GetRegisteredToolDescription(toolCode); registeredDescription != "" {
if registeredDescription := toolx.GetRegisteredToolDescriptionLocale(toolCode, locale); registeredDescription != "" {
description = registeredDescription
}
}
@@ -7,6 +7,7 @@ import (
"cs-agent/internal/pkg/dto/response"
"cs-agent/internal/pkg/enums"
"cs-agent/internal/pkg/httpx"
"cs-agent/internal/pkg/i18nx"
"cs-agent/internal/services"
"strconv"
"strings"
@@ -65,7 +66,7 @@ func ConversationAnyList(ctx *gin.Context) {
list, paging := services.ConversationService.FindPageByCnd(cnd)
results := make([]response.ConversationResponse, 0, len(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})
}
@@ -94,7 +95,7 @@ func ConversationAnyConversations(ctx *gin.Context) {
results := make([]response.ConversationResponse, 0, len(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})
}
@@ -116,7 +117,7 @@ func ConversationGetBy(ctx *gin.Context) {
}
detail := response.ConversationDetailResponse{
ConversationResponse: builders.BuildConversation(item),
ConversationResponse: builders.BuildConversationWithLocale(item, i18nx.Locale(ctx)),
Participants: builders.BuildParticipantResponses(id),
}
httpx.WriteJSON(ctx, detail)
@@ -143,7 +144,7 @@ func ConversationAnyMessage_list(ctx *gin.Context) {
list, nextCursor, hasMore := services.MessageService.FindByConversationIDCursor(
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))
}
@@ -259,7 +260,7 @@ func ConversationPostSend_message(ctx *gin.Context) {
httpx.WriteJSON(ctx, err)
return
}
httpx.WriteJSON(ctx, builders.BuildMessage(item))
httpx.WriteJSON(ctx, builders.BuildMessageWithLocale(item, i18nx.Locale(ctx)))
}
func ConversationPostRecall_message(ctx *gin.Context) {
@@ -279,7 +280,7 @@ func ConversationPostRecall_message(ctx *gin.Context) {
httpx.WriteJSON(ctx, err)
return
}
httpx.WriteJSON(ctx, builders.BuildMessage(item))
httpx.WriteJSON(ctx, builders.BuildMessageWithLocale(item, i18nx.Locale(ctx)))
}
func ConversationPostRead(ctx *gin.Context) {
@@ -5,11 +5,12 @@ import (
"cs-agent/internal/services"
"cs-agent/internal/pkg/httpx/params"
"cs-agent/internal/pkg/i18nx"
"github.com/gin-gonic/gin"
)
func DashboardGetOverview(ctx *gin.Context) {
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/dto/request"
"cs-agent/internal/pkg/dto/response"
"cs-agent/internal/pkg/i18nx"
"cs-agent/internal/services"
"cs-agent/internal/pkg/httpx/params"
@@ -27,7 +28,7 @@ func MCPAnyCatalog(ctx *gin.Context) {
httpx.WriteJSON(ctx, err)
return
}
items, err := services.ToolCatalogService.ListMCPTools(context.Background())
items, err := services.ToolCatalogService.ListMCPToolsWithLocale(context.Background(), i18nx.Locale(ctx))
if err != nil {
httpx.WriteJSON(ctx, err)
return
@@ -12,6 +12,7 @@ import (
"cs-agent/internal/services"
"cs-agent/internal/pkg/httpx/params"
"cs-agent/internal/pkg/i18nx"
"github.com/gin-gonic/gin"
"github.com/mlogclub/simple/web"
@@ -39,7 +40,7 @@ func NotificationAnyList(ctx *gin.Context) {
list, paging := services.NotificationService.FindPageByCnd(cnd)
httpx.WriteJSON(ctx, &web.PageResult{
Results: builders.BuildNotificationList(list),
Results: builders.BuildNotificationListWithLocale(list, i18nx.Locale(ctx)),
Page: paging,
})
}
+4 -1
View File
@@ -1,6 +1,7 @@
package middleware
import (
"cs-agent/internal/pkg/i18nx"
"cs-agent/internal/services"
"github.com/gin-gonic/gin"
@@ -16,7 +17,9 @@ func AuthMiddleware(ctx *gin.Context) {
func authenticateRequest(ctx *gin.Context) bool {
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()
return false
}
+2 -1
View File
@@ -1,6 +1,7 @@
package httpx
import (
"cs-agent/internal/pkg/i18nx"
"net/http"
"strconv"
@@ -11,7 +12,7 @@ import (
func GetPathInt64(ctx *gin.Context, name string) (int64, bool) {
value, err := strconv.ParseInt(ctx.Param(name), 10, 64)
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 value, true
+11 -2
View File
@@ -1,6 +1,7 @@
package httpx
import (
"cs-agent/internal/pkg/i18nx"
"net/http"
"github.com/gin-gonic/gin"
@@ -28,11 +29,19 @@ func PageData(results any, paging *sqls.Paging) 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) {
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 {
+17
View File
@@ -1,6 +1,7 @@
package httpx
import (
"cs-agent/internal/pkg/i18nx"
"encoding/json"
"errors"
"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) {
gin.SetMode(gin.TestMode)
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"
"cs-agent/internal/pkg/enums"
"cs-agent/internal/pkg/i18nx"
)
type ToolSpec struct {
@@ -218,6 +219,20 @@ func GetRegisteredToolTitle(toolCode string) string {
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 {
spec, ok := GetRegisteredToolSpec(toolCode)
if !ok {
@@ -226,6 +241,62 @@ func GetRegisteredToolDescription(toolCode string) string {
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) {
spec, ok := GetRegisteredToolSpec(toolCode)
if !ok {
+30 -1
View File
@@ -1,6 +1,10 @@
package toolx
import "testing"
import (
"testing"
"cs-agent/internal/pkg/i18nx"
)
func TestResolveToolMetadata(t *testing.T) {
item := ResolveToolMetadata("builtin/create_ticket_with_confirmation", "")
@@ -33,3 +37,28 @@ func TestResolveToolMetadataFallsBackToName(t *testing.T) {
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/pkg/dto/response"
"cs-agent/internal/pkg/enums"
"cs-agent/internal/pkg/i18nx"
"cs-agent/internal/repositories"
"fmt"
"sort"
@@ -23,7 +24,8 @@ func newDashboardService() *dashboardService {
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()
normalizedRange, trendDays := normalizeDashboardRange(rangeValue)
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 {
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{
Range: normalizedRange,
@@ -91,7 +93,7 @@ func (s *dashboardService) GetOverview(rangeValue string) response.DashboardOver
AIServiceRate: calcAIServiceRate(activeConversations),
},
ConversationStats: response.DashboardSectionStatsResponse{
StatusDistribution: buildConversationStatusDistribution(db),
StatusDistribution: buildConversationStatusDistribution(db, locale),
Trend: buildConversationTrend(db, trendStart),
},
AgentStats: response.DashboardAgentStatsResponse{
@@ -110,7 +112,7 @@ func (s *dashboardService) GetOverview(rangeValue string) response.DashboardOver
TodayAIHandoffCount: aiHandoffCount,
},
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
}
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)
pendingTimeout := now.Add(-10 * 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{
ID: "pending-long-wait",
Level: "warning",
Title: "待接入会话堆积",
Description: "存在超过 10 分钟仍未接入的会话,建议优先处理分配。",
Title: dashboardText(locale, "alert.pendingLongWait.title"),
Description: dashboardText(locale, "alert.pendingLongWait.description"),
Count: pendingLongWaitCount,
Link: "/dashboard/conversations",
})
@@ -244,8 +246,8 @@ func (s *dashboardService) buildAlerts(now time.Time, db *gorm.DB, aiAgents []mo
alerts = append(alerts, response.DashboardAlertResponse{
ID: "stale-processing",
Level: "warning",
Title: "处理中会话长时间无响应",
Description: "部分处理中会话已超过 30 分钟没有最新消息,需要确认跟进状态。",
Title: dashboardText(locale, "alert.staleProcessing.title"),
Description: dashboardText(locale, "alert.staleProcessing.description"),
Count: staleProcessingCount,
Link: "/dashboard/conversations",
})
@@ -265,8 +267,8 @@ func (s *dashboardService) buildAlerts(now time.Time, db *gorm.DB, aiAgents []mo
alerts = append(alerts, response.DashboardAlertResponse{
ID: "team-no-schedule",
Level: "info",
Title: "客服组当前无生效排班",
Description: "部分启用中的客服组当前没有生效排班,可能影响自动分配。",
Title: dashboardText(locale, "alert.teamNoSchedule.title"),
Description: dashboardText(locale, "alert.teamNoSchedule.description"),
Count: scheduleMissingCount,
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{
ID: "ai-no-knowledge",
Level: "info",
Title: "AI Agent 未绑定知识库",
Description: "部分启用中的 AI Agent 尚未绑定知识库,回答质量可能不稳定。",
Title: dashboardText(locale, "alert.aiNoKnowledge.title"),
Description: dashboardText(locale, "alert.aiNoKnowledge.description"),
Count: aiAgentWithoutKnowledgeCount,
Link: "/dashboard/ai-agents",
})
@@ -299,12 +301,12 @@ func (s *dashboardService) buildAlerts(now time.Time, db *gorm.DB, aiAgents []mo
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))
for _, status := range enums.IMConversationStatusValues {
ret = append(ret, response.DashboardStatusDistributionItem{
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 {
return tx.Where("status = ?", status)
}),
@@ -364,13 +366,13 @@ func flattenTrendMap(series map[string]*response.DashboardTrendItem) []response.
return ret
}
func buildDashboardQuickLinks() []response.DashboardQuickLinkResponse {
func buildDashboardQuickLinks(locale string) []response.DashboardQuickLinkResponse {
return []response.DashboardQuickLinkResponse{
{Title: "会话管理", Description: "查看待接入与处理中会话", Link: "/dashboard/conversations"},
{Title: "客服档案", Description: "查看客服状态与分组配置", Link: "/dashboard/agents"},
{Title: "知识库", Description: "维护文档与查看检索日志", Link: "/dashboard/knowledge"},
{Title: "AI Agent", Description: "配置 AI 接待策略与知识绑定", Link: "/dashboard/ai-agents"},
{Title: "接入渠道", Description: "管理接入渠道与默认 Agent", Link: "/dashboard/channels"},
{Title: dashboardText(locale, "quick.conversations.title"), Description: dashboardText(locale, "quick.conversations.description"), Link: "/dashboard/conversations"},
{Title: dashboardText(locale, "quick.agents.title"), Description: dashboardText(locale, "quick.agents.description"), Link: "/dashboard/agents"},
{Title: dashboardText(locale, "quick.knowledge.title"), Description: dashboardText(locale, "quick.knowledge.description"), Link: "/dashboard/knowledge"},
{Title: dashboardText(locale, "quick.aiAgents.title"), Description: dashboardText(locale, "quick.aiAgents.description"), Link: "/dashboard/ai-agents"},
{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
}
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/enums"
"cs-agent/internal/pkg/errorsx"
"cs-agent/internal/pkg/i18nx"
"cs-agent/internal/pkg/toolx"
)
@@ -33,6 +34,10 @@ type MCPToolCatalogItem struct {
}
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()
ret := make([]MCPToolCatalogItem, 0, 3)
for _, spec := range toolx.ListAgentDirectToolSpecs() {
@@ -45,8 +50,8 @@ func (s *toolCatalogService) ListMCPTools(ctx context.Context) ([]MCPToolCatalog
ToolName: spec.Name,
SourceType: spec.SourceType,
AutoInjected: spec.AutoInjected,
Title: spec.Title,
Description: spec.Description,
Title: toolx.GetRegisteredToolTitleLocale(spec.Code, locale),
Description: toolx.GetRegisteredToolDescriptionLocale(spec.Code, locale),
})
}
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 { Badge } from "@/components/ui/badge"
import { useI18n } from "@/i18n/provider"
import {
Card,
CardContent,
@@ -28,19 +29,21 @@ function getAlertBadgeVariant(level: DashboardAlert["level"]) {
}
export function AlertList({ alerts }: AlertListProps) {
const t = useI18n()
return (
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription> AI </CardDescription>
<CardTitle>{t("dashboardHome.riskAlerts")}</CardTitle>
<CardDescription>{t("dashboardHome.riskAlertsDescription")}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{alerts.length === 0 ? (
<div className="rounded-2xl border border-dashed px-4 py-10 text-center">
<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">
AI
{t("dashboardHome.noRiskDescription")}
</div>
</div>
) : (
@@ -7,6 +7,7 @@ import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { Card, CardContent } from "@/components/ui/card"
import { useI18n } from "@/i18n/provider"
import {
fetchDashboardOverview,
type DashboardOverview,
@@ -17,12 +18,6 @@ import { TrendPanel } from "./trend-panel"
import { TeamLoadPanel } from "./team-load-panel"
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() {
return (
<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() {
const t = useI18n()
const [range, setRange] = useState<DashboardRange>("7d")
const [data, setData] = useState<DashboardOverview | null>(null)
const [loading, setLoading] = useState(true)
@@ -56,27 +52,33 @@ export function DashboardHome() {
const result = await fetchDashboardOverview(nextRange)
setData(result)
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载首页概览失败")
toast.error(error instanceof Error ? error.message : t("dashboardHome.loadFailed"))
} finally {
setLoading(false)
setRefreshing(false)
}
},
[]
[t]
)
useEffect(() => {
void 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 (
<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>
<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">
AI
{data ? `,更新于 ${data.generatedAt}` : ""}
{t("dashboardHome.description")}
{data ? t("dashboardHome.updatedAt", { time: data.generatedAt }) : ""}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
@@ -99,7 +101,7 @@ export function DashboardHome() {
disabled={loading || refreshing}
>
<RefreshCwIcon className={refreshing ? "mr-2 size-4 animate-spin" : "mr-2 size-4"} />
{t("dashboardHome.refresh")}
</Button>
</div>
</div>
@@ -111,8 +113,8 @@ export function DashboardHome() {
<SummaryCards summary={data.summary} />
<TrendPanel
title="会话趋势"
description="观察新增与关闭会话变化,快速判断接待压力是否持续上升"
title={t("dashboardHome.conversationTrend")}
description={t("dashboardHome.conversationTrendDescription")}
trend={data.conversationStats.trend}
distribution={data.conversationStats.statusDistribution}
/>
@@ -123,33 +125,33 @@ export function DashboardHome() {
<Card>
<CardContent className="grid gap-4 p-6 sm:grid-cols-2">
<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>
<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>
<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">
{data.aiStats.todayKnowledgeRetrieves}
</div>
</div>
<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">
{data.aiStats.todayKnowledgeRetrieveFailRate.toFixed(1)}%
</div>
</div>
<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">
{data.aiStats.todaySkillRunFailCount}
</div>
</div>
<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">
{data.aiStats.todayAiHandoffCount}
</div>
@@ -163,7 +165,7 @@ export function DashboardHome() {
) : (
<Card>
<CardContent className="flex min-h-60 items-center justify-center p-6 text-sm text-muted-foreground">
{t("dashboardHome.empty")}
</CardContent>
</Card>
)}
+17 -14
View File
@@ -17,6 +17,7 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { useI18n } from "@/i18n/provider"
type SummaryCardsProps = {
summary: DashboardOverview["summary"]
@@ -24,8 +25,8 @@ type SummaryCardsProps = {
type SummaryCardItem = {
key: keyof DashboardOverview["summary"]
title: string
description: string
titleKey: string
descriptionKey: string
link: string
icon: typeof BotMessageSquareIcon
format?: (value: number) => string
@@ -34,36 +35,36 @@ type SummaryCardItem = {
const cards: SummaryCardItem[] = [
{
key: "todayNewConversations",
title: "今日新增会话",
description: "今日进入系统的新增咨询量",
titleKey: "dashboardHome.summaryTodayNewConversations",
descriptionKey: "dashboardHome.summaryTodayNewConversationsDescription",
link: "/dashboard/conversations",
icon: BotMessageSquareIcon,
},
{
key: "processingConversations",
title: "当前处理中",
description: "正在由 AI 或人工接待的会话",
titleKey: "dashboardHome.summaryProcessingConversations",
descriptionKey: "dashboardHome.summaryProcessingConversationsDescription",
link: "/dashboard/conversations",
icon: WavesIcon,
},
{
key: "pendingDispatchConversations",
title: "待分配会话",
description: "仍在待接入池中等待分配",
titleKey: "dashboardHome.summaryPendingDispatchConversations",
descriptionKey: "dashboardHome.summaryPendingDispatchConversationsDescription",
link: "/dashboard/conversations",
icon: CircleDashedIcon,
},
{
key: "onlineAgents",
title: "在线客服",
description: "近 15 分钟内仍有活跃心跳的客服",
titleKey: "dashboardHome.summaryOnlineAgents",
descriptionKey: "dashboardHome.summaryOnlineAgentsDescription",
link: "/dashboard/agents",
icon: HeadsetIcon,
},
{
key: "aiServiceRate",
title: "AI 接待占比",
description: "当前活跃会话中 AI 参与服务比例",
titleKey: "dashboardHome.summaryAiServiceRate",
descriptionKey: "dashboardHome.summaryAiServiceRateDescription",
link: "/dashboard/ai-agents",
icon: SparklesIcon,
format: (value: number) => `${value.toFixed(1)}%`,
@@ -71,6 +72,8 @@ const cards: SummaryCardItem[] = [
]
export function SummaryCards({ summary }: SummaryCardsProps) {
const t = useI18n()
return (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-6">
{cards.map((item) => {
@@ -86,8 +89,8 @@ export function SummaryCards({ summary }: SummaryCardsProps) {
<Card className="h-full transition-colors hover:border-primary/40">
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-3">
<div className="space-y-1">
<CardTitle className="text-sm font-medium">{item.title}</CardTitle>
<CardDescription>{item.description}</CardDescription>
<CardTitle className="text-sm font-medium">{t(item.titleKey)}</CardTitle>
<CardDescription>{t(item.descriptionKey)}</CardDescription>
</div>
<div className="rounded-full bg-primary/10 p-2 text-primary">
<Icon className="size-4" />
@@ -1,6 +1,7 @@
"use client"
import { Badge } from "@/components/ui/badge"
import { useI18n } from "@/i18n/provider"
import {
Card,
CardContent,
@@ -25,18 +26,24 @@ function getLoadTone(loadRate: number) {
}
export function TeamLoadPanel({ agentStats }: TeamLoadPanelProps) {
const t = useI18n()
return (
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardTitle>{t("dashboardHome.teamLoad")}</CardTitle>
<CardDescription>
线 {agentStats.onlineAgents} {agentStats.busyAgents}线 {agentStats.offlineAgents}
{t("dashboardHome.teamLoadDescription", {
online: agentStats.onlineAgents,
busy: agentStats.busyAgents,
offline: agentStats.offlineAgents,
})}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{agentStats.teamLoads.length === 0 ? (
<div className="rounded-xl border border-dashed px-4 py-8 text-center text-sm text-muted-foreground">
{t("dashboardHome.noTeamData")}
</div>
) : (
agentStats.teamLoads.map((item) => (
@@ -46,20 +53,27 @@ export function TeamLoadPanel({ agentStats }: TeamLoadPanelProps) {
<div className="flex items-center gap-2">
<div className="font-medium">{item.teamName}</div>
{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 className="mt-1 text-sm text-muted-foreground">
{item.totalAgents}线 {item.onlineAgents} {item.busyAgents}线{" "}
{item.offlineAgents}
{t("dashboardHome.teamStats", {
total: item.totalAgents,
online: item.onlineAgents,
busy: item.busyAgents,
offline: item.offlineAgents,
})}
</div>
</div>
<div className="text-right">
<div className="text-2xl font-semibold">{item.loadRate.toFixed(1)}%</div>
<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>
@@ -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="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>
<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>
<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>
<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>
</div>
+48 -24
View File
@@ -16,6 +16,7 @@ import {
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart"
import { useI18n } from "@/i18n/provider"
type TrendPanelProps = {
title: string
@@ -24,33 +25,37 @@ type TrendPanelProps = {
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({
title,
description,
trend,
distribution,
}: 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 (
<div className="grid gap-4 xl:grid-cols-[1.5fr_0.9fr]">
<Card>
@@ -87,12 +92,12 @@ export function TrendPanel({
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
<CardTitle>{t("dashboardHome.statusDistribution")}</CardTitle>
<CardDescription>{t("dashboardHome.statusDistributionDescription")}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<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} />
<YAxis
type="category"
@@ -115,3 +120,22 @@ export function TrendPanel({
</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 { Button } from "@/components/ui/button"
import { fetchAgentRunLog, type AgentRunLog } from "@/lib/api/admin"
import { useI18n } from "@/i18n/provider"
import { formatDateTime } from "@/lib/utils"
type AgentRunLogDetailDialogProps = {
@@ -17,11 +18,14 @@ type AgentRunLogDetailDialogProps = {
onOpenChange: (open: boolean) => void
}
type TFunction = (key: string, values?: Record<string, string | number>) => string
export function AgentRunLogDetailDialog({
open,
logId,
onOpenChange,
}: AgentRunLogDetailDialogProps) {
const t = useI18n()
const [loading, setLoading] = useState(false)
const [activeLog, setActiveLog] = useState<AgentRunLog | null>(null)
@@ -42,7 +46,7 @@ export function AgentRunLogDetailDialog({
}
} catch (error) {
if (!cancelled) {
toast.error(error instanceof Error ? error.message : "加载日志详情失败")
toast.error(error instanceof Error ? error.message : t("agentRunLog.loadDetailFailed"))
onOpenChange(false)
}
} finally {
@@ -57,7 +61,7 @@ export function AgentRunLogDetailDialog({
return () => {
cancelled = true
}
}, [logId, onOpenChange, open])
}, [logId, onOpenChange, open, t])
useEffect(() => {
if (open) {
@@ -87,35 +91,35 @@ export function AgentRunLogDetailDialog({
title={
<span className="flex items-center gap-2">
<WorkflowIcon className="size-4" />
Agent
{t("agentRunLog.detailTitle")}
</span>
}
description="查看 planner 选择、最终动作、回复内容与错误信息。"
description={t("agentRunLog.detailDescription")}
size="xl"
allowFullscreen
defaultFullscreen
bodyClassName="min-h-0"
footer={
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t("agentRunLog.close")}
</Button>
}
>
{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 ? (
<>
<MetaStrip
items={[
{ label: "日志ID", value: String(activeLog.id) },
{ label: "会话ID", value: String(activeLog.conversationId || "-") },
{ label: "消息ID", value: String(activeLog.messageId || "-") },
{ label: t("agentRunLog.logId"), value: String(activeLog.id) },
{ label: t("agentRunLog.conversationId"), value: String(activeLog.conversationId || "-") },
{ label: t("agentRunLog.messageId"), value: String(activeLog.messageId || "-") },
{ label: "AI Agent", value: String(activeLog.aiAgentId || "-") },
]}
/>
<InfoBlock
title="规划阶段"
title={t("agentRunLog.planningStage")}
lines={[
`plannedAction: ${activeLog.plannedAction || "-"}`,
`plannedSkillCode: ${activeLog.plannedSkillCode || "-"}`,
@@ -131,15 +135,15 @@ export function AgentRunLogDetailDialog({
]}
/>
<InfoBlock
title="HITL 状态"
title={t("agentRunLog.hitlStatus")}
lines={[
`hitlStatus: ${activeLog.hitlStatus || "-"}`,
`hitlStatusName: ${activeLog.hitlStatusName || "-"}`,
`hitlSummary: ${activeLog.hitlSummary || "-"}`,
`hitlStatusName: ${getHitlStatusLabel(activeLog.hitlStatus, t) || "-"}`,
`hitlSummary: ${getHitlSummary(activeLog.hitlStatus, t) || "-"}`,
]}
/>
<InfoBlock
title="执行结果"
title={t("agentRunLog.executionResult")}
lines={[
`finalAction: ${activeLog.finalAction || "-"}`,
`finalStatus: ${activeLog.finalStatus || "-"}`,
@@ -151,40 +155,74 @@ export function AgentRunLogDetailDialog({
/>
<JsonBlock
title="动态工具选择"
title={t("agentRunLog.dynamicTools")}
jsonValue={activeToolSearchTrace}
fallbackValue={activeLog.toolSearchTrace}
/>
<JsonBlock
title="Graph Tool 调用"
title={t("agentRunLog.graphToolCall")}
jsonValue={activeGraphToolTrace}
fallbackValue={activeLog.graphToolTrace}
/>
<TextBlock
icon={<BotMessageSquareIcon className="size-4" />}
title="用户问题"
title={t("agentRunLog.userMessage")}
value={activeLog.userMessage}
renderAsHtml
/>
<TextBlock
icon={<WorkflowIcon className="size-4" />}
title="机器人回复"
title={t("agentRunLog.botReply")}
value={activeLog.replyText}
/>
<TextBlock title="错误信息" value={activeLog.errorMessage} tone="danger" />
<TextBlock title={t("agentRunLog.errorMessage")} value={activeLog.errorMessage} tone="danger" />
<JsonBlock
title="链路 Trace"
title={t("agentRunLog.trace")}
jsonValue={activeTraceData}
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>
)
}
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) {
if (!value.trim()) {
return null
+100 -70
View File
@@ -22,36 +22,62 @@ import {
type AgentRunLog,
type PageResult,
} from "@/lib/api/admin"
import { useI18n } from "@/i18n/provider"
import { formatDateTime } from "@/lib/utils"
const actionOptions = [
{ value: "all", label: "全部动作" },
{ value: "rag", label: "RAG" },
{ value: "skill", label: "Skill" },
{ value: "tool", label: "Tool" },
{ value: "graph", label: "Graph" },
{ value: "handoff", label: "转人工" },
{ value: "reply", label: "回复" },
{ value: "fallback", label: "兜底" },
]
type TFunction = (key: string, values?: Record<string, string | number>) => string
const finalStatusOptions = [
{ value: "all", label: "全部状态" },
{ value: "completed", label: "completed" },
{ value: "interrupted", label: "interrupted" },
{ value: "expired", label: "expired" },
{ value: "error", label: "error" },
{ value: "fallback", label: "fallback" },
]
function getActionOptions(t: TFunction) {
return [
{ value: "all", label: t("agentRunLog.allActions") },
{ value: "rag", label: "RAG" },
{ value: "skill", label: "Skill" },
{ value: "tool", label: "Tool" },
{ value: "graph", label: "Graph" },
{ value: "handoff", label: t("agentRunLog.handoff") },
{ value: "reply", label: t("agentRunLog.reply") },
{ value: "fallback", label: t("agentRunLog.fallback") },
]
}
const hitlStatusOptions = [
{ value: "all", label: "全部 HITL" },
{ value: "pending", label: "等待确认" },
{ value: "confirmed", label: "已确认" },
{ value: "cancelled", label: "已取消" },
{ value: "expired", label: "已过期" },
{ value: "triggered", label: "已触发" },
]
function getFinalStatusOptions(t: TFunction) {
return [
{ value: "all", label: t("agentRunLog.allStatus") },
{ value: "completed", label: "completed" },
{ value: "interrupted", label: "interrupted" },
{ value: "expired", label: "expired" },
{ value: "error", label: "error" },
{ value: "fallback", label: "fallback" },
]
}
function getHitlStatusOptions(t: TFunction) {
return [
{ value: "all", label: t("agentRunLog.allHitl") },
{ value: "pending", label: t("agentRunLog.hitlPending") },
{ value: "confirmed", label: t("agentRunLog.hitlConfirmed") },
{ value: "cancelled", label: t("agentRunLog.hitlCancelled") },
{ 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) {
switch (action) {
@@ -73,6 +99,7 @@ function actionBadgeVariant(action: string) {
}
export default function DashboardAgentRunLogsPage() {
const t = useI18n()
const [keywordInput, setKeywordInput] = useState("")
const [plannedActionInput, setPlannedActionInput] = useState("all")
const [finalActionInput, setFinalActionInput] = useState("all")
@@ -95,16 +122,19 @@ export default function DashboardAgentRunLogsPage() {
page: { page: 1, limit: 20, total: 0 },
})
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(
() => [
{ value: "all", label: "全部 Agent" },
{ value: "all", label: t("agentRunLog.allAgents") },
...aiAgents.map((item) => ({
value: String(item.id),
label: item.name,
})),
],
[aiAgents]
[aiAgents, t]
)
const loadData = useCallback(async () => {
@@ -122,11 +152,11 @@ export default function DashboardAgentRunLogsPage() {
})
setResult(data)
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载 Agent 运行日志失败")
toast.error(error instanceof Error ? error.message : t("agentRunLog.loadFailed"))
} finally {
setLoading(false)
}
}, [aiAgentId, finalAction, finalStatus, hitlStatus, keyword, limit, page, plannedAction])
}, [aiAgentId, finalAction, finalStatus, hitlStatus, keyword, limit, page, plannedAction, t])
useEffect(() => {
void loadData()
@@ -138,11 +168,11 @@ export default function DashboardAgentRunLogsPage() {
const data = await fetchAIAgentsAll()
setAiAgents(data)
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载 AI Agent 列表失败")
toast.error(error instanceof Error ? error.message : t("agentRunLog.loadAgentsFailed"))
}
}
void loadAIAgents()
}, [])
}, [t])
function applyFilters() {
setKeyword(keywordInput)
@@ -174,7 +204,7 @@ export default function DashboardAgentRunLogsPage() {
className="w-full xl:w-auto"
>
<RefreshCwIcon className={loading ? "animate-spin" : ""} />
{t("agentRunLog.refresh")}
</Button>
}
>
@@ -184,7 +214,7 @@ export default function DashboardAgentRunLogsPage() {
value={keywordInput}
onChange={(event) => setKeywordInput(event.target.value)}
onKeyDown={handleFilterKeyDown}
placeholder="按用户问题筛选"
placeholder={t("agentRunLog.filterUserMessage")}
className="pl-9"
/>
</div>
@@ -192,9 +222,9 @@ export default function DashboardAgentRunLogsPage() {
<OptionCombobox
value={plannedActionInput}
options={actionOptions}
placeholder="规划动作"
searchPlaceholder="搜索动作"
emptyText="未找到动作"
placeholder={t("agentRunLog.plannedAction")}
searchPlaceholder={t("agentRunLog.searchAction")}
emptyText={t("agentRunLog.emptyAction")}
onChange={(value) => setPlannedActionInput(value || "all")}
/>
</div>
@@ -202,9 +232,9 @@ export default function DashboardAgentRunLogsPage() {
<OptionCombobox
value={finalActionInput}
options={actionOptions}
placeholder="最终动作"
searchPlaceholder="搜索动作"
emptyText="未找到动作"
placeholder={t("agentRunLog.finalAction")}
searchPlaceholder={t("agentRunLog.searchAction")}
emptyText={t("agentRunLog.emptyAction")}
onChange={(value) => setFinalActionInput(value || "all")}
/>
</div>
@@ -212,9 +242,9 @@ export default function DashboardAgentRunLogsPage() {
<OptionCombobox
value={finalStatusInput}
options={finalStatusOptions}
placeholder="最终状态"
searchPlaceholder="搜索状态"
emptyText="未找到状态"
placeholder={t("agentRunLog.finalStatus")}
searchPlaceholder={t("agentRunLog.searchStatus")}
emptyText={t("agentRunLog.emptyStatus")}
onChange={(value) => setFinalStatusInput(value || "all")}
/>
</div>
@@ -222,9 +252,9 @@ export default function DashboardAgentRunLogsPage() {
<OptionCombobox
value={hitlStatusInput}
options={hitlStatusOptions}
placeholder="HITL 状态"
searchPlaceholder="搜索 HITL 状态"
emptyText="未找到状态"
placeholder={t("agentRunLog.hitlStatus")}
searchPlaceholder={t("agentRunLog.searchHitl")}
emptyText={t("agentRunLog.emptyStatus")}
onChange={(value) => setHitlStatusInput(value || "all")}
/>
</div>
@@ -232,15 +262,15 @@ export default function DashboardAgentRunLogsPage() {
<OptionCombobox
value={aiAgentIdInput}
options={aiAgentOptions}
placeholder="选择 Agent"
searchPlaceholder="搜索 Agent"
emptyText="未找到 Agent"
placeholder={t("agentRunLog.selectAgent")}
searchPlaceholder={t("agentRunLog.searchAgent")}
emptyText={t("agentRunLog.emptyAgent")}
onChange={(value) => setAiAgentIdInput(value || "all")}
/>
</div>
<Button variant="outline" onClick={applyFilters} disabled={loading} className="w-full xl:w-auto">
<SearchIcon />
{t("agentRunLog.query")}
</Button>
</DashboardToolbar>
@@ -261,18 +291,18 @@ export default function DashboardAgentRunLogsPage() {
>
{!loading && result.results.length === 0 ? (
<div className="py-14 text-center text-sm text-muted-foreground">
Agent
{t("agentRunLog.emptyRows")}
</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></div>
<div></div>
<div></div>
<div>Skill / Tool</div>
<div></div>
<div className="text-right"></div>
<div className="text-right"></div>
<div>{t("agentRunLog.time")}</div>
<div>{t("agentRunLog.userMessage")}</div>
<div>{t("agentRunLog.plannedAction")}</div>
<div>{t("agentRunLog.skillTool")}</div>
<div>{t("agentRunLog.finalStatus")}</div>
<div className="text-right">{t("agentRunLog.duration")}</div>
<div className="text-right">{t("agentRunLog.actions")}</div>
</div>
<div className="divide-y">
@@ -286,7 +316,7 @@ export default function DashboardAgentRunLogsPage() {
</div>
<div className="min-w-0">
<UserMessagePreview value={item.userMessage} />
<UserMessagePreview value={item.userMessage} t={t} />
{item.errorMessage ? (
<div className="truncate text-xs text-destructive">{item.errorMessage}</div>
) : null}
@@ -310,13 +340,13 @@ export default function DashboardAgentRunLogsPage() {
</div>
) : item.handoffReason ? (
<div className="truncate text-xs text-muted-foreground">
{item.handoffReason}
{t("agentRunLog.handoffReason", { reason: item.handoffReason })}
</div>
) : item.recommendedAction ? (
<div className="truncate text-xs text-muted-foreground">
{item.recommendedAction}
{t("agentRunLog.routingRecommendation", { action: item.recommendedAction })}
{item.riskLevel ? ` / ${item.riskLevel} risk` : ""}
{item.ticketDraftReady ? " / 草稿已就绪" : ""}
{item.ticketDraftReady ? ` / ${t("agentRunLog.draftReady")}` : ""}
</div>
) : null}
</div>
@@ -331,8 +361,8 @@ export default function DashboardAgentRunLogsPage() {
{item.finalAction || "-"}
</Badge>
<div className="truncate text-xs text-muted-foreground">
{item.hitlStatusName
? `${item.hitlStatusName} / ${item.finalStatus || "-"}`
{getHitlStatusLabel(item.hitlStatus, t)
? `${getHitlStatusLabel(item.hitlStatus, t)} / ${item.finalStatus || "-"}`
: item.finalStatus || "-"}
</div>
</div>
@@ -351,7 +381,7 @@ export default function DashboardAgentRunLogsPage() {
setDetailOpen(true)
}}
>
{t("agentRunLog.detail")}
</Button>
</div>
</article>
@@ -375,8 +405,8 @@ export default function DashboardAgentRunLogsPage() {
)
}
function UserMessagePreview({ value }: { value?: string }) {
const preview = useMemo(() => summarizeUserMessage(value), [value])
function UserMessagePreview({ value, t }: { value?: string; t: TFunction }) {
const preview = useMemo(() => summarizeUserMessage(value, t), [value, t])
return (
<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()
if (!normalized) {
return "-"
@@ -396,9 +426,9 @@ function summarizeUserMessage(value?: string) {
}
if (containsHTML(normalized)) {
if (/<img[\s>]/i.test(normalized)) {
return "[图片]"
return t("agentRunLog.imageMessage")
}
return "[富文本消息]"
return t("agentRunLog.richMessage")
}
return normalized
}
@@ -34,6 +34,7 @@ import {
type AdminAgentTeamScheduleBatchPreview,
type BatchAdminAgentTeamSchedulePayload,
} from "@/lib/api/admin"
import { useI18n } from "@/i18n/provider"
import { cn } from "@/lib/utils"
type BatchScheduleDialogProps = {
@@ -42,15 +43,24 @@ type BatchScheduleDialogProps = {
onSuccess: (created: number) => void | Promise<void>
}
const weekdayOptions = [
{ value: 1, label: "周一" },
{ value: 2, label: "周二" },
{ value: 3, label: "周三" },
{ value: 4, label: "周四" },
{ value: 5, label: "周五" },
{ value: 6, label: "周六" },
{ value: 7, label: "周日" },
]
type TFunction = (key: string, values?: Record<string, string | number>) => string
const weekdayKeys = [
"weekdayMon",
"weekdayTue",
"weekdayWed",
"weekdayThu",
"weekdayFri",
"weekdaySat",
"weekdaySun",
] as const
function getWeekdayOptions(t: TFunction) {
return weekdayKeys.map((key, index) => ({
value: index + 1,
label: t(`agentTeamSchedule.${key}`),
}))
}
function todayDateValue() {
const today = new Date()
@@ -88,32 +98,33 @@ function buildPayload(form: BatchFormState): BatchAdminAgentTeamSchedulePayload
}
}
function getWeekdayLabel(value: number) {
return weekdayOptions.find((option) => option.value === value)?.label ?? `${value}`
function getWeekdayLabel(value: number, t: TFunction) {
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()
if (form.selectedTeamIds.length === 0) {
return "请选择至少一个客服组"
return t("agentTeamSchedule.selectAtLeastOneTeam")
}
if (!form.startDate || !form.endDate) {
return "请选择日期范围"
return t("agentTeamSchedule.selectDateRange")
}
if (form.startDate < today) {
return "开始日期不能早于今天"
return t("agentTeamSchedule.startBeforeToday")
}
if (form.endDate < form.startDate) {
return "结束日期不能早于开始日期"
return t("agentTeamSchedule.endBeforeStart")
}
if (form.weekdays.length === 0) {
return "请选择至少一个星期"
return t("agentTeamSchedule.selectAtLeastOneWeekday")
}
if (!form.startTime || !form.endTime) {
return "请选择开始和结束时间"
return t("agentTeamSchedule.selectStartEndTime")
}
if (form.endTime <= form.startTime) {
return "结束时间必须晚于开始时间"
return t("agentTeamSchedule.endAfterStart")
}
return ""
}
@@ -123,6 +134,7 @@ export function BatchScheduleDialog({
onOpenChange,
onSuccess,
}: BatchScheduleDialogProps) {
const t = useI18n()
const [teams, setTeams] = useState<AdminAgentTeam[]>([])
const [form, setForm] = useState(defaultFormState)
const [step, setStep] = useState<DialogStep>("form")
@@ -134,6 +146,7 @@ export function BatchScheduleDialog({
const openRef = useRef(open)
const previewRequestIdRef = useRef(0)
const busy = loadingTeams || previewing || submitting
const weekdayOptions = useMemo(() => getWeekdayOptions(t), [t])
const teamOptions = useMemo(
() =>
@@ -181,7 +194,7 @@ export function BatchScheduleDialog({
}
} catch (error) {
if (!ignore) {
toast.error(error instanceof Error ? error.message : "加载客服组选项失败")
toast.error(error instanceof Error ? error.message : t("agentTeamSchedule.loadTeamsFailed"))
}
} finally {
if (!ignore) {
@@ -194,7 +207,7 @@ export function BatchScheduleDialog({
return () => {
ignore = true
}
}, [open])
}, [open, t])
function updateForm(values: Partial<BatchFormState>) {
previewRequestIdRef.current += 1
@@ -234,7 +247,7 @@ export function BatchScheduleDialog({
}
async function handlePreview() {
const validationMessage = validateForm(form)
const validationMessage = validateForm(form, t)
if (validationMessage) {
toast.error(validationMessage)
return
@@ -254,7 +267,7 @@ export function BatchScheduleDialog({
setStep("preview")
} catch (error) {
if (openRef.current && previewRequestIdRef.current === requestId) {
toast.error(error instanceof Error ? error.message : "预览批量排班失败")
toast.error(error instanceof Error ? error.message : t("agentTeamSchedule.previewFailed"))
}
} finally {
if (openRef.current && previewRequestIdRef.current === requestId) {
@@ -265,11 +278,11 @@ export function BatchScheduleDialog({
async function handleSubmit() {
if (!preview || !previewPayload) {
toast.error("请先预览批量排班")
toast.error(t("agentTeamSchedule.previewFirst"))
return
}
if (preview.conflict) {
toast.error("存在冲突排班,不能提交")
toast.error(t("agentTeamSchedule.conflictCannotSubmit"))
return
}
@@ -277,17 +290,17 @@ export function BatchScheduleDialog({
setSubmitting(true)
try {
const data = await generateAgentTeamScheduleBatch(payload)
toast.success(`已创建 ${data.created} 条客服组排班`)
toast.success(t("agentTeamSchedule.batchCreated", { count: data.created }))
setPreview(null)
setPreviewPayload(null)
onOpenChange(false)
try {
await onSuccess(data.created)
} 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) {
toast.error(error instanceof Error ? error.message : "生成批量排班失败")
toast.error(error instanceof Error ? error.message : t("agentTeamSchedule.generateFailed"))
} finally {
setSubmitting(false)
}
@@ -297,9 +310,9 @@ export function BatchScheduleDialog({
<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">
<DialogHeader className="shrink-0 px-6 pt-6">
<DialogTitle></DialogTitle>
<DialogTitle>{t("agentTeamSchedule.batch")}</DialogTitle>
<DialogDescription>
{t("agentTeamSchedule.batchDescription")}
</DialogDescription>
</DialogHeader>
@@ -307,15 +320,15 @@ export function BatchScheduleDialog({
{step === "form" ? (
<div className="space-y-5">
<div className="space-y-2">
<Label></Label>
<Label>{t("agentTeamSchedule.team")}</Label>
<div className="flex gap-2">
<div className="min-w-0 flex-1">
<OptionCombobox
value=""
options={teamOptions}
placeholder={loadingTeams ? "加载客服组中..." : "添加客服组"}
searchPlaceholder="搜索客服组"
emptyText={teams.length === 0 ? "暂无客服组" : "已选择全部客服组"}
placeholder={loadingTeams ? t("agentTeamSchedule.loadingTeams") : t("agentTeamSchedule.addTeam")}
searchPlaceholder={t("agentTeamSchedule.searchTeam")}
emptyText={teams.length === 0 ? t("agentTeamSchedule.noTeams") : t("agentTeamSchedule.allTeamsSelected")}
disabled={loadingTeams}
onChange={handleTeamSelect}
/>
@@ -332,7 +345,7 @@ export function BatchScheduleDialog({
size="icon-sm"
className="size-5 rounded-sm"
onClick={() => removeTeam(team.id)}
aria-label={`移除${team.name}`}
aria-label={t("agentTeamSchedule.removeTeam", { name: team.name })}
>
<XIcon className="size-3" />
</Button>
@@ -340,13 +353,13 @@ export function BatchScheduleDialog({
))}
</div>
) : (
<div className="text-sm text-muted-foreground"></div>
<div className="text-sm text-muted-foreground">{t("agentTeamSchedule.noSelectedTeams")}</div>
)}
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="batch-schedule-start-date"></Label>
<Label htmlFor="batch-schedule-start-date">{t("agentTeamSchedule.startDate")}</Label>
<Input
id="batch-schedule-start-date"
type="date"
@@ -356,7 +369,7 @@ export function BatchScheduleDialog({
/>
</div>
<div className="space-y-2">
<Label htmlFor="batch-schedule-end-date"></Label>
<Label htmlFor="batch-schedule-end-date">{t("agentTeamSchedule.endDate")}</Label>
<Input
id="batch-schedule-end-date"
type="date"
@@ -368,7 +381,7 @@ export function BatchScheduleDialog({
</div>
<div className="space-y-2">
<Label></Label>
<Label>{t("agentTeamSchedule.weekday")}</Label>
<div className="flex flex-wrap gap-2">
{weekdayOptions.map((option) => {
const selected = selectedWeekdays.has(option.value)
@@ -391,7 +404,7 @@ export function BatchScheduleDialog({
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="batch-schedule-start-time"></Label>
<Label htmlFor="batch-schedule-start-time">{t("agentTeamSchedule.startTime")}</Label>
<Input
id="batch-schedule-start-time"
type="time"
@@ -400,7 +413,7 @@ export function BatchScheduleDialog({
/>
</div>
<div className="space-y-2">
<Label htmlFor="batch-schedule-end-time"></Label>
<Label htmlFor="batch-schedule-end-time">{t("agentTeamSchedule.endTime")}</Label>
<Input
id="batch-schedule-end-time"
type="time"
@@ -411,11 +424,11 @@ export function BatchScheduleDialog({
</div>
<div className="space-y-2">
<Label htmlFor="batch-schedule-remark"></Label>
<Label htmlFor="batch-schedule-remark">{t("agentTeamSchedule.remark")}</Label>
<Textarea
id="batch-schedule-remark"
rows={4}
placeholder="请输入备注"
placeholder={t("agentTeamSchedule.remarkPlaceholder")}
value={form.remark}
onChange={(event) => updateForm({ remark: event.target.value })}
/>
@@ -425,13 +438,14 @@ export function BatchScheduleDialog({
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<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>
{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 className="overflow-x-auto rounded-lg border">
@@ -439,12 +453,12 @@ export function BatchScheduleDialog({
<Table>
<TableHeader className="bg-muted/40">
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>{t("agentTeamSchedule.team")}</TableHead>
<TableHead>{t("agentTeamSchedule.date")}</TableHead>
<TableHead>{t("agentTeamSchedule.weekday")}</TableHead>
<TableHead>{t("agentTeamSchedule.time")}</TableHead>
<TableHead>{t("agentTeamSchedule.remark")}</TableHead>
<TableHead>{t("agentTeamSchedule.status")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -456,11 +470,11 @@ export function BatchScheduleDialog({
)}
>
<TableCell>
<div className="font-medium">{item.teamName || `客服组#${item.teamId}`}</div>
<div className="text-xs text-muted-foreground">ID{item.teamId}</div>
<div className="font-medium">{item.teamName || t("agentTeamSchedule.teamFallback", { id: item.teamId })}</div>
<div className="text-xs text-muted-foreground">{t("agentTeamSchedule.teamId", { id: item.teamId })}</div>
</TableCell>
<TableCell>{item.date}</TableCell>
<TableCell>{getWeekdayLabel(item.weekday)}</TableCell>
<TableCell>{getWeekdayLabel(item.weekday, t)}</TableCell>
<TableCell>
{item.startAt.slice(11, 16)} - {item.endAt.slice(11, 16)}
</TableCell>
@@ -468,10 +482,10 @@ export function BatchScheduleDialog({
<TableCell>
{item.conflict ? (
<span className="font-medium">
{item.conflictReason || "排班冲突"}
{item.conflictReason || t("agentTeamSchedule.conflictFallback")}
</span>
) : (
<span className="text-muted-foreground"></span>
<span className="text-muted-foreground">{t("agentTeamSchedule.canGenerate")}</span>
)}
</TableCell>
</TableRow>
@@ -479,7 +493,7 @@ export function BatchScheduleDialog({
{preview && preview.items.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="py-10 text-center text-muted-foreground">
{t("agentTeamSchedule.emptyPreview")}
</TableCell>
</TableRow>
) : null}
@@ -500,7 +514,7 @@ export function BatchScheduleDialog({
disabled={submitting}
>
<ArrowLeftIcon />
{t("agentTeamSchedule.backToEdit")}
</Button>
) : null}
<Button
@@ -509,12 +523,12 @@ export function BatchScheduleDialog({
onClick={() => handleOpenChange(false)}
disabled={busy}
>
{t("agentTeamSchedule.cancel")}
</Button>
{step === "form" ? (
<Button type="button" onClick={() => void handlePreview()} disabled={busy}>
{previewing ? <Loader2Icon className="animate-spin" /> : <CheckIcon />}
{t("agentTeamSchedule.preview")}
</Button>
) : (
<Button
@@ -523,7 +537,7 @@ export function BatchScheduleDialog({
disabled={submitting || hasConflict || !preview || !previewPayload || preview.items.length === 0}
>
{submitting ? <Loader2Icon className="animate-spin" /> : <CheckIcon />}
{t("agentTeamSchedule.generate")}
</Button>
)}
</DialogFooter>
@@ -58,7 +58,7 @@ export function formatDateTimeValue(date: 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) {
@@ -13,7 +13,17 @@ import { cn, formatDateTime } from "@/lib/utils"
import { isSameLocalDay } from "./calendar-date-range"
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 minuteMs = 60 * 1000
const minDurationMs = 15 * minuteMs
@@ -31,6 +41,7 @@ type ScheduleCalendarProps = {
onEdit: (item: AdminAgentTeamSchedule) => void
onMove: (payload: UpdateAdminAgentTeamSchedulePayload) => Promise<void>
onResize: (payload: UpdateAdminAgentTeamSchedulePayload) => Promise<void>
t: TFunction
}
type DragState =
@@ -226,6 +237,7 @@ export function ScheduleCalendar({
onEdit,
onMove,
onResize,
t,
}: ScheduleCalendarProps) {
const days = buildCalendarDays(calendarStart, calendarEnd)
const defaultTeamID = teams[0]?.id ?? 0
@@ -250,7 +262,7 @@ export function ScheduleCalendar({
return {
itemId: state.item.id,
date: null,
label: "拖到日历日期格内",
label: t("agentTeamSchedule.dropInsideCalendar"),
invalid: true,
x: pointerEvent.clientX,
y: pointerEvent.clientY,
@@ -265,7 +277,7 @@ export function ScheduleCalendar({
return {
itemId: state.item.id,
date,
label: "不能修改历史日期",
label: t("agentTeamSchedule.historyReadonly"),
invalid: true,
x: pointerEvent.clientX,
y: pointerEvent.clientY,
@@ -274,11 +286,11 @@ export function ScheduleCalendar({
const point = { x: pointerEvent.clientX, y: pointerEvent.clientY }
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))
return buildPreviewFromPayload(state.item.id, date, payload, point, "不能跨天或少于 15 分钟")
return buildPreviewFromPayload(state.item.id, date, payload, point, t("agentTeamSchedule.resizeInvalid"))
}
function cleanupPointerInteraction(
@@ -365,7 +377,7 @@ export function ScheduleCalendar({
if (teams.length === 0 && !loading) {
return (
<div className="flex min-h-64 items-center justify-center rounded-lg border bg-background text-sm text-muted-foreground">
{t("agentTeamSchedule.noTeamsCalendar")}
</div>
)
}
@@ -426,7 +438,7 @@ export function ScheduleCalendar({
<div className="flex shrink-0 items-center gap-1">
{today ? (
<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>
) : null}
{historical ? null : <CalendarPlusIcon className="size-3.5 text-muted-foreground" />}
@@ -434,7 +446,7 @@ export function ScheduleCalendar({
</div>
<div className="space-y-1">
{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 active = interactionPreview?.itemId === item.id
const timeLayout = dayTimeLayout.items.get(item.id)
@@ -512,7 +524,7 @@ export function ScheduleCalendar({
)
})}
{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}
</div>
</div>
@@ -528,7 +540,7 @@ export function ScheduleCalendar({
return (
<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>{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>
{renderDayCell(day, dayIndex, {
@@ -562,9 +574,9 @@ export function ScheduleCalendar({
return (
<div className="min-w-[960px] overflow-hidden rounded-lg border bg-background">
<div className="grid grid-cols-7 border-b bg-muted/40">
{weekDayNames.map((name) => (
<div key={name} className="flex h-10 items-center justify-center border-l first:border-l-0 text-sm font-medium">
{name}
{weekDayKeys.map((key) => (
<div key={key} className="flex h-10 items-center justify-center border-l first:border-l-0 text-sm font-medium">
{t(`agentTeamSchedule.${key}`)}
</div>
))}
</div>
@@ -1,7 +1,7 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useCallback, useEffect, useState } from "react"
import { Controller, Resolver, useForm } from "react-hook-form"
import { useCallback, useEffect, useMemo, useState } from "react"
import { Controller, type Resolver, useForm } from "react-hook-form"
import { toast } from "sonner"
import { z } from "zod/v4"
@@ -27,8 +27,11 @@ import {
type AdminAgentTeamSchedule,
type CreateAdminAgentTeamSchedulePayload,
fetchAgentTeamSchedule,
fetchAgentTeamsAll
fetchAgentTeamsAll,
} from "@/lib/api/admin"
import { useI18n } from "@/i18n/provider"
type TFunction = (key: string, values?: Record<string, string | number>) => string
type ScheduleEditDialogProps = {
open: boolean
@@ -47,10 +50,18 @@ const emptyForm: EditForm = {
remark: "",
}
const editFormSchema = z.object({
teamId: z.string().trim().regex(/^\d+$/, "请选择客服组"),
startAt: z.string().trim().min(1, "开始时间不能为空"),
endAt: z.string().trim().min(1, "结束时间不能为空"),
type EditForm = {
teamId: string
startAt: string
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(),
}).superRefine((value, ctx) => {
const startAt = parseDateTimeLocal(value.startAt)
@@ -62,7 +73,7 @@ const editFormSchema = z.object({
ctx.addIssue({
code: "custom",
path: ["endAt"],
message: "结束时间必须晚于开始时间",
message: t("agentTeamSchedule.endAfterStart"),
})
return
}
@@ -70,24 +81,18 @@ const editFormSchema = z.object({
ctx.addIssue({
code: "custom",
path: ["endAt"],
message: "单条排班记录不能跨天",
message: t("agentTeamSchedule.singleDayOnly"),
})
}
if (startAt < startOfLocalDay(new Date())) {
ctx.addIssue({
code: "custom",
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) {
if (!value) {
@@ -180,6 +185,7 @@ function ScheduleEditDialogBody({
onSubmit,
onDelete,
}: ScheduleEditDialogBodyProps) {
const t = useI18n()
const [teams, setTeams] = useState<AdminAgentTeam[]>([])
const [loading, setLoading] = useState(false)
const loadOptions = useCallback(async () => {
@@ -187,14 +193,15 @@ function ScheduleEditDialogBody({
const teamsData = await fetchAgentTeamsAll()
setTeams(teamsData)
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载选项失败")
toast.error(error instanceof Error ? error.message : t("agentTeamSchedule.loadOptionsFailed"))
}
}, [])
const form = useForm<
z.input<typeof editFormSchema>,
undefined,
z.output<typeof editFormSchema>
>({
}, [t])
const editFormSchema = useMemo(() => createEditFormSchema(t), [t])
const editFormResolver = useMemo(
() => zodResolver(editFormSchema) as Resolver<EditForm>,
[editFormSchema],
)
const form = useForm<EditForm>({
resolver: editFormResolver,
defaultValues: emptyForm,
})
@@ -218,13 +225,13 @@ function ScheduleEditDialogBody({
const data = await fetchAgentTeamSchedule(itemId)
reset(buildForm(data))
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载客服组排班详情失败")
toast.error(error instanceof Error ? error.message : t("agentTeamSchedule.loadDetailFailed"))
} finally {
setLoading(false)
}
}
void loadDetail()
}, [defaultValues, itemId, reset])
}, [defaultValues, itemId, reset, t])
useEffect(() => {
void loadOptions()
@@ -237,18 +244,18 @@ function ScheduleEditDialogBody({
return (
<DialogContent className="max-w-xl gap-0 p-0 sm:max-w-xl">
<DialogHeader className="px-6 pt-6">
<DialogTitle>{itemId ? "编辑客服组排班" : "新建客服组排班"}</DialogTitle>
<DialogTitle>{itemId ? t("agentTeamSchedule.editTitle") : t("agentTeamSchedule.createTitle")}</DialogTitle>
</DialogHeader>
{loading ? (
<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>
) : (
<form onSubmit={handleSubmit(onFormSubmit)}>
<div className="space-y-4 p-6">
<div className="grid grid-cols-1 gap-4">
<Field data-invalid={!!errors.teamId}>
<FieldLabel></FieldLabel>
<FieldLabel>{t("agentTeamSchedule.team")}</FieldLabel>
<FieldContent>
<Controller
control={control}
@@ -260,8 +267,9 @@ function ScheduleEditDialogBody({
value: String(team.id),
label: team.name,
}))}
placeholder="请选择客服组"
searchPlaceholder="搜索客服组"
placeholder={t("agentTeamSchedule.teamRequired")}
searchPlaceholder={t("agentTeamSchedule.searchTeam")}
emptyText={t("agentTeamSchedule.emptyTeam")}
onChange={field.onChange}
/>
)}
@@ -272,14 +280,14 @@ function ScheduleEditDialogBody({
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<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>
<Input id="agent-team-schedule-start-at" type="datetime-local" min={minDateTime} {...register("startAt")} />
<FieldError errors={[errors.startAt]} />
</FieldContent>
</Field>
<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>
<Input id="agent-team-schedule-end-at" type="datetime-local" min={minDateTime} {...register("endAt")} />
<FieldError errors={[errors.endAt]} />
@@ -287,23 +295,23 @@ function ScheduleEditDialogBody({
</Field>
</div>
<Field>
<FieldLabel htmlFor="agent-team-schedule-remark"></FieldLabel>
<FieldLabel htmlFor="agent-team-schedule-remark">{t("agentTeamSchedule.remark")}</FieldLabel>
<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>
</Field>
</div>
<DialogFooter className="mx-0 mb-0 px-6 py-4">
{itemId && onDelete ? (
<Button type="button" variant="destructive" onClick={() => void onDelete(itemId)} disabled={saving}>
{t("agentTeamSchedule.delete")}
</Button>
) : null}
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
{t("agentTeamSchedule.cancel")}
</Button>
<Button type="submit" disabled={saving || loading}>
{saving ? "保存中..." : "保存"}
{saving ? t("agentTeamSchedule.saving") : t("agentTeamSchedule.save")}
</Button>
</DialogFooter>
</form>
+51 -44
View File
@@ -49,6 +49,7 @@ import {
type PageResult,
type UpdateAdminAgentTeamSchedulePayload,
} from "@/lib/api/admin"
import { useI18n } from "@/i18n/provider"
import { formatDateTime } from "@/lib/utils"
import { BatchScheduleDialog } from "./_components/batch-schedule-dialog"
import { ScheduleCalendar } from "./_components/calendar"
@@ -56,7 +57,6 @@ import {
addDays,
addMonths,
formatDateTimeValue,
formatMonthTitle,
formatWeekTitle,
startOfDay,
startOfMonth,
@@ -79,6 +79,7 @@ function isHistoricalSchedule(item: AdminAgentTeamSchedule) {
}
export default function DashboardAgentTeamSchedulesPage() {
const t = useI18n()
const [viewMode, setViewMode] = useState<ViewMode>("month")
const [teamFilterInput, setTeamFilterInput] = useState("all")
const [teamFilter, setTeamFilter] = useState("all")
@@ -120,11 +121,11 @@ export default function DashboardAgentTeamSchedulesPage() {
})
setResult(data)
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载客服组排班失败")
toast.error(error instanceof Error ? error.message : t("agentTeamSchedule.loadFailed"))
} finally {
setLoading(false)
}
}, [limit, page, teamFilter])
}, [limit, page, t, teamFilter])
const loadCalendarData = useCallback(async () => {
setCalendarLoading(true)
@@ -138,20 +139,20 @@ export default function DashboardAgentTeamSchedulesPage() {
})
setCalendarItems(data)
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载客服组排班日历失败")
toast.error(error instanceof Error ? error.message : t("agentTeamSchedule.loadCalendarFailed"))
} finally {
setCalendarLoading(false)
}
}, [monthStart, teamFilter, viewMode, weekStart])
}, [monthStart, t, teamFilter, viewMode, weekStart])
const loadTeams = useCallback(async () => {
try {
const data = await fetchAgentTeamsAll()
setTeams(data)
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载客服组选项失败")
toast.error(error instanceof Error ? error.message : t("agentTeamSchedule.loadTeamsFailed"))
}
}, [])
}, [t])
const refreshActiveView = useCallback(async () => {
await Promise.all([
@@ -194,7 +195,7 @@ export default function DashboardAgentTeamSchedulesPage() {
function openEditDialog(item: AdminAgentTeamSchedule) {
if (isHistoricalSchedule(item)) {
toast.error("不能修改历史日期的排班")
toast.error(t("agentTeamSchedule.historyReadonly"))
return
}
setDialogDefaults(null)
@@ -221,17 +222,17 @@ export default function DashboardAgentTeamSchedulesPage() {
try {
if (editingItem) {
await updateAgentTeamSchedule({ id: editingItem.id, ...payload })
toast.success("已更新客服组排班")
toast.success(t("agentTeamSchedule.updated"))
} else {
await createAgentTeamSchedule(payload)
toast.success("已创建客服组排班")
toast.success(t("agentTeamSchedule.created"))
}
setDialogOpen(false)
setEditingItem(null)
setDialogDefaults(null)
await refreshActiveView()
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存客服组排班失败")
toast.error(error instanceof Error ? error.message : t("agentTeamSchedule.saveFailed"))
} finally {
setSaving(false)
}
@@ -245,13 +246,13 @@ export default function DashboardAgentTeamSchedulesPage() {
setActionLoadingId(id)
try {
await deleteAgentTeamSchedule(id)
toast.success("已删除客服组排班")
toast.success(t("agentTeamSchedule.deleted"))
setDialogOpen(false)
setEditingItem(null)
setDialogDefaults(null)
await refreshActiveView()
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除客服组排班失败")
toast.error(error instanceof Error ? error.message : t("agentTeamSchedule.deleteFailed"))
} finally {
setActionLoadingId(null)
}
@@ -264,16 +265,16 @@ export default function DashboardAgentTeamSchedulesPage() {
async function handleCalendarUpdate(payload: UpdateAdminAgentTeamSchedulePayload) {
const startAt = parseLocalDateTime(payload.startAt)
if (startAt && startAt < startOfDay(new Date())) {
toast.error("不能修改历史日期的排班")
toast.error(t("agentTeamSchedule.historyReadonly"))
return
}
setActionLoadingId(payload.id)
try {
await updateAgentTeamSchedule(payload)
toast.success("已更新客服组排班")
toast.success(t("agentTeamSchedule.updated"))
await loadCalendarData()
} catch (error) {
toast.error(error instanceof Error ? error.message : "更新客服组排班失败")
toast.error(error instanceof Error ? error.message : t("agentTeamSchedule.updateFailed"))
await loadCalendarData()
} finally {
setActionLoadingId(null)
@@ -298,7 +299,7 @@ export default function DashboardAgentTeamSchedulesPage() {
onClick={() => setViewMode("month")}
>
<CalendarDaysIcon />
{t("agentTeamSchedule.month")}
</Button>
<Button
variant={viewMode === "week" ? "default" : "outline"}
@@ -306,7 +307,7 @@ export default function DashboardAgentTeamSchedulesPage() {
onClick={() => setViewMode("week")}
>
<CalendarRangeIcon />
{t("agentTeamSchedule.week")}
</Button>
<Button
variant={viewMode === "list" ? "default" : "outline"}
@@ -314,37 +315,42 @@ export default function DashboardAgentTeamSchedulesPage() {
onClick={() => setViewMode("list")}
>
<ListIcon />
{t("agentTeamSchedule.list")}
</Button>
</ButtonGroup>
{viewMode === "month" ? (
<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 />
</Button>
<Button variant="outline" size="sm" onClick={() => setMonthStart(startOfMonth(new Date()))}>
{t("agentTeamSchedule.thisMonth")}
</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 />
</Button>
</ButtonGroup>
) : null}
{viewMode === "week" ? (
<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 />
</Button>
<Button variant="outline" size="sm" onClick={() => setWeekStart(startOfWeek(new Date()))}>
{t("agentTeamSchedule.thisWeek")}
</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 />
</Button>
</ButtonGroup>
) : null}
{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}
{viewMode === "week" ? (
<div className="text-sm text-muted-foreground">{formatWeekTitle(weekStart)}</div>
@@ -352,7 +358,7 @@ export default function DashboardAgentTeamSchedulesPage() {
{viewMode !== "list" ? (
<Button variant="outline" size="sm" onClick={goToToday}>
<CalendarSearchIcon />
{t("agentTeamSchedule.today")}
</Button>
) : null}
</div>
@@ -362,18 +368,18 @@ export default function DashboardAgentTeamSchedulesPage() {
<OptionCombobox
value={teamFilterInput}
options={[
{ value: "all", label: "全部客服组" },
{ value: "all", label: t("agentTeamSchedule.allTeams") },
...teams.map((team) => ({ value: String(team.id), label: team.name })),
]}
placeholder="筛选客服组"
searchPlaceholder="搜索客服组"
emptyText="未找到客服组"
placeholder={t("agentTeamSchedule.filterTeam")}
searchPlaceholder={t("agentTeamSchedule.searchTeam")}
emptyText={t("agentTeamSchedule.emptyTeam")}
onChange={(value) => setTeamFilterInput(value)}
/>
</div>
<Button variant="outline" onClick={applyFilters} disabled={refreshing}>
<SearchIcon />
{t("agentTeamSchedule.query")}
</Button>
<Button
variant="outline"
@@ -381,15 +387,15 @@ export default function DashboardAgentTeamSchedulesPage() {
disabled={refreshing}
>
<RefreshCwIcon className={refreshing ? "animate-spin" : ""} />
{t("agentTeamSchedule.refresh")}
</Button>
<Button variant="outline" onClick={() => setBatchDialogOpen(true)}>
<LayersIcon />
{t("agentTeamSchedule.batch")}
</Button>
<Button onClick={() => openCreateDialog()}>
<PlusIcon />
{t("agentTeamSchedule.new")}
</Button>
</div>
</div>
@@ -409,6 +415,7 @@ export default function DashboardAgentTeamSchedulesPage() {
onEdit={openEditDialog}
onMove={handleCalendarUpdate}
onResize={handleCalendarUpdate}
t={t}
/>
</div>
) : (
@@ -417,9 +424,9 @@ export default function DashboardAgentTeamSchedulesPage() {
<Table>
<TableHeader className="bg-muted/40">
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-[92px] text-right"></TableHead>
<TableHead>{t("agentTeamSchedule.team")}</TableHead>
<TableHead>{t("agentTeamSchedule.timeRange")}</TableHead>
<TableHead className="w-[92px] text-right">{t("agentTeamSchedule.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -431,8 +438,8 @@ export default function DashboardAgentTeamSchedulesPage() {
<CalendarClockIcon className="size-4" />
</div>
<div className="min-w-0">
<div className="font-medium">{item.teamName || `客服组#${item.teamId}`}</div>
<div className="text-xs text-muted-foreground">ID{item.teamId}</div>
<div className="font-medium">{item.teamName || t("agentTeamSchedule.teamFallback", { id: item.teamId })}</div>
<div className="text-xs text-muted-foreground">{t("agentTeamSchedule.teamId", { id: item.teamId })}</div>
</div>
</div>
</TableCell>
@@ -443,12 +450,12 @@ export default function DashboardAgentTeamSchedulesPage() {
<TableCell className="text-right">
<ButtonGroup className="ml-auto">
<Button variant="outline" size="sm" onClick={() => openEditDialog(item)} disabled={isHistoricalSchedule(item)}>
{t("agentTeamSchedule.edit")}
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={<Button variant="outline" size="icon-sm" />}
aria-label={`更多操作 ${item.startAt}`}
aria-label={t("agentTeamSchedule.moreActions", { name: item.startAt })}
>
<MoreHorizontalIcon />
</DropdownMenuTrigger>
@@ -458,7 +465,7 @@ export default function DashboardAgentTeamSchedulesPage() {
className="text-destructive focus:text-destructive"
>
<Trash2Icon />
{actionLoadingId === item.id ? "删除中..." : "删除"}
{actionLoadingId === item.id ? t("agentTeamSchedule.deleting") : t("agentTeamSchedule.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -469,7 +476,7 @@ export default function DashboardAgentTeamSchedulesPage() {
{!loading && result.results.length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="py-12 text-center text-muted-foreground">
{t("agentTeamSchedule.emptyRows")}
</TableCell>
</TableRow>
) : null}
+78 -85
View File
@@ -2,12 +2,13 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { CheckIcon, ChevronsUpDownIcon } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { Controller, Resolver, useForm } from "react-hook-form";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Controller, type Resolver, useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod/v4";
import { ImageInput } from "@/components/image-input";
import { OptionCombobox } from "@/components/option-combobox";
import { ProjectDialog } from "@/components/project-dialog";
import { Button } from "@/components/ui/button";
import {
@@ -30,13 +31,6 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import {
@@ -44,13 +38,12 @@ import {
fetchUsersAll,
type AdminAgentProfile,
type AdminUser,
type CreateAdminAgentProfilePayload
type CreateAdminAgentProfilePayload,
} from "@/lib/api/admin";
import {
ServiceStatus,
ServiceStatusLabels,
} from "@/lib/generated/enums";
import { getEnumLabel, getEnumOptions } from "@/lib/enums";
import { useI18n } from "@/i18n/provider";
import { ServiceStatus } from "@/lib/generated/enums";
type TFunction = (key: string, values?: Record<string, string | number>) => string;
type AgentEditDialogProps = {
open: boolean;
@@ -61,7 +54,6 @@ type AgentEditDialogProps = {
onSubmit: (payload: CreateAdminAgentProfilePayload) => Promise<void>;
};
const serviceStatusOptions = getEnumOptions(ServiceStatusLabels);
const emptyForm: EditForm = {
userId: "",
teamId: "",
@@ -76,40 +68,49 @@ const emptyForm: EditForm = {
remark: "",
};
const editFormSchema = z.object({
userId: z.string().trim().min(1, "请选择关联用户"),
teamId: z.string().trim().min(1, "请选择所属客服组"),
agentCode: z.string().trim().min(1, "客服工号不能为空"),
displayName: z.string().trim().min(1, "展示名不能为空"),
type EditForm = {
userId: string;
teamId: string;
agentCode: string;
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(),
serviceStatus: z.enum(["0", "1"], {
message: "请选择客服状态",
message: t("agentProfile.statusRequired"),
}),
maxConcurrentCount: z
.string()
.trim()
.regex(/^\d+$/, "最大并发必须是大于等于 0 的整数"),
.regex(/^\d+$/, t("agentProfile.maxConcurrentInvalid")),
priorityLevel: z
.string()
.trim()
.regex(/^-?\d+$/, "优先级必须是整数"),
.regex(/^-?\d+$/, t("agentProfile.priorityInvalid")),
autoAssignEnabled: z.boolean(),
receiveOfflineMessage: z.boolean(),
remark: z.string().trim(),
});
});
}
type EditForm = z.infer<typeof editFormSchema>;
const editFormResolver = zodResolver(editFormSchema as never) as Resolver<
z.input<typeof editFormSchema>,
undefined,
z.output<typeof editFormSchema>
>;
function getStatusLabel(value: string) {
return getEnumLabel(
ServiceStatusLabels,
Number(value) as ServiceStatus,
);
function getServiceStatusOptions(t: TFunction) {
return [
{ value: String(ServiceStatus.Idle), label: t("agentProfile.statusIdle") },
{ value: String(ServiceStatus.Busy), label: t("agentProfile.statusBusy") },
];
}
function buildForm(item: AdminAgentProfile | null): EditForm {
@@ -203,6 +204,7 @@ function AgentEditDialogBody({
onOpenChange,
onSubmit,
}: AgentEditDialogBodyProps) {
const t = useI18n();
const [users, setUsers] = useState<AdminUser[]>([]);
const [userSelectOpen, setUserSelectOpen] = useState(false);
const [loading, setLoading] = useState(false);
@@ -210,6 +212,7 @@ function AgentEditDialogBody({
value: String(user.id),
label: `${user.nickname || user.username} (${user.username})`,
}));
const serviceStatusOptions = useMemo(() => getServiceStatusOptions(t), [t]);
const loadOptions = useCallback(async () => {
try {
const [usersData] = await Promise.all([
@@ -217,14 +220,15 @@ function AgentEditDialogBody({
]);
setUsers(usersData);
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载选项失败");
toast.error(error instanceof Error ? error.message : t("agentProfile.loadOptionsFailed"));
}
}, []);
const form = useForm<
z.input<typeof editFormSchema>,
undefined,
z.output<typeof editFormSchema>
>({
}, [t]);
const editFormSchema = useMemo(() => createEditFormSchema(t), [t]);
const editFormResolver = useMemo(
() => zodResolver(editFormSchema) as Resolver<EditForm>,
[editFormSchema],
);
const form = useForm<EditForm>({
resolver: editFormResolver,
defaultValues: buildFormWithDefaultTeam(null, defaultTeamId),
});
@@ -247,13 +251,13 @@ function AgentEditDialogBody({
const data = await fetchAgentProfile(itemId);
reset(buildForm(data));
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载客服档案详情失败");
toast.error(error instanceof Error ? error.message : t("agentProfile.loadDetailFailed"));
} finally {
setLoading(false);
}
}
void loadDetail();
}, [itemId, defaultTeamId, reset]);
}, [itemId, defaultTeamId, reset, t]);
useEffect(() => {
if (open) {
@@ -271,7 +275,7 @@ function AgentEditDialogBody({
<ProjectDialog
open={open}
onOpenChange={onOpenChange}
title={itemId ? "编辑客服档案" : "新建客服档案"}
title={itemId ? t("agentProfile.editTitle") : t("agentProfile.createTitle")}
size="lg"
footer={
<>
@@ -281,17 +285,17 @@ function AgentEditDialogBody({
onClick={() => onOpenChange(false)}
disabled={saving}
>
{t("agentProfile.cancel")}
</Button>
<Button type="submit" form={formId} disabled={saving || loading}>
{saving ? "保存中..." : "保存"}
{saving ? t("agentProfile.saving") : t("agentProfile.save")}
</Button>
</>
}
>
{loading ? (
<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>
) : (
<form
@@ -301,7 +305,7 @@ function AgentEditDialogBody({
>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field data-invalid={!!errors.userId}>
<FieldLabel></FieldLabel>
<FieldLabel>{t("agentProfile.linkedUser")}</FieldLabel>
<FieldContent>
<Controller
control={control}
@@ -324,7 +328,7 @@ function AgentEditDialogBody({
<span className="truncate">
{userOptions.find(
(option) => option.value === field.value,
)?.label ?? "请选择用户"}
)?.label ?? t("agentProfile.selectUser")}
</span>
<ChevronsUpDownIcon className="ml-2 size-4 shrink-0 opacity-50" />
</PopoverTrigger>
@@ -333,9 +337,9 @@ function AgentEditDialogBody({
align="start"
>
<Command>
<CommandInput placeholder="搜索用户..." />
<CommandInput placeholder={t("agentProfile.searchUser")} />
<CommandList>
<CommandEmpty></CommandEmpty>
<CommandEmpty>{t("agentProfile.emptyUser")}</CommandEmpty>
<CommandGroup>
{userOptions.map((option) => (
<CommandItem
@@ -367,11 +371,11 @@ function AgentEditDialogBody({
</FieldContent>
</Field>
<Field data-invalid={!!errors.displayName}>
<FieldLabel htmlFor="agent-display-name"></FieldLabel>
<FieldLabel htmlFor="agent-display-name">{t("agentProfile.displayName")}</FieldLabel>
<FieldContent>
<Input
id="agent-display-name"
placeholder="请输入展示名"
placeholder={t("agentProfile.displayNamePlaceholder")}
{...register("displayName")}
/>
<FieldError errors={[errors.displayName]} />
@@ -381,11 +385,11 @@ function AgentEditDialogBody({
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field data-invalid={!!errors.agentCode}>
<FieldLabel htmlFor="agent-code"></FieldLabel>
<FieldLabel htmlFor="agent-code">{t("agentProfile.agentCodeLabel")}</FieldLabel>
<FieldContent>
<Input
id="agent-code"
placeholder="例如:A1001"
placeholder={t("agentProfile.agentCodePlaceholder")}
{...register("agentCode")}
/>
<FieldError errors={[errors.agentCode]} />
@@ -393,7 +397,7 @@ function AgentEditDialogBody({
</Field>
<Field className="min-h-32">
<FieldLabel></FieldLabel>
<FieldLabel>{t("agentProfile.avatar")}</FieldLabel>
<FieldContent>
<Controller
control={control}
@@ -404,7 +408,7 @@ function AgentEditDialogBody({
onChange={field.onChange}
disabled={saving}
prefix="avatar"
placeholder="上传头像"
placeholder={t("agentProfile.avatarUpload")}
className="size-16 rounded-full"
/>
)}
@@ -415,31 +419,20 @@ function AgentEditDialogBody({
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field data-invalid={!!errors.serviceStatus}>
<FieldLabel></FieldLabel>
<FieldLabel>{t("agentProfile.serviceStatus")}</FieldLabel>
<FieldContent>
<Controller
control={control}
name="serviceStatus"
render={({ field }) => (
<Select
<OptionCombobox
options={serviceStatusOptions}
value={field.value}
onValueChange={field.onChange}
modal={false}
>
<SelectTrigger className="w-full">
<SelectValue>{getStatusLabel(field.value)}</SelectValue>
</SelectTrigger>
<SelectContent>
{serviceStatusOptions.map((option) => (
<SelectItem
key={option.value}
value={String(option.value)}
>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
onChange={field.onChange}
placeholder={t("agentProfile.selectStatus")}
searchPlaceholder={t("agentProfile.searchStatus")}
emptyText={t("agentProfile.emptyStatus")}
/>
)}
/>
<FieldError errors={[errors.serviceStatus]} />
@@ -450,7 +443,7 @@ function AgentEditDialogBody({
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field data-invalid={!!errors.maxConcurrentCount}>
<FieldLabel htmlFor="agent-max-concurrent-count">
{t("agentProfile.maxConcurrent")}
</FieldLabel>
<FieldContent>
<Input
@@ -463,7 +456,7 @@ function AgentEditDialogBody({
</FieldContent>
</Field>
<Field data-invalid={!!errors.priorityLevel}>
<FieldLabel htmlFor="agent-priority-level"></FieldLabel>
<FieldLabel htmlFor="agent-priority-level">{t("agentProfile.priority")}</FieldLabel>
<FieldContent>
<Input
id="agent-priority-level"
@@ -478,7 +471,7 @@ function AgentEditDialogBody({
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field>
<FieldLabel></FieldLabel>
<FieldLabel>{t("agentProfile.autoAssignEnabled")}</FieldLabel>
<FieldContent>
<Controller
control={control}
@@ -493,7 +486,7 @@ function AgentEditDialogBody({
</FieldContent>
</Field>
<Field>
<FieldLabel>线</FieldLabel>
<FieldLabel>{t("agentProfile.receiveOfflineMessage")}</FieldLabel>
<FieldContent>
<Controller
control={control}
@@ -510,12 +503,12 @@ function AgentEditDialogBody({
</div>
<Field>
<FieldLabel htmlFor="agent-remark"></FieldLabel>
<FieldLabel htmlFor="agent-remark">{t("agentProfile.remark")}</FieldLabel>
<FieldContent>
<Textarea
id="agent-remark"
rows={4}
placeholder="请输入备注"
placeholder={t("agentProfile.remarkPlaceholder")}
{...register("remark")}
/>
</FieldContent>
@@ -1,9 +1,9 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
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 { z } from "zod/v4";
@@ -14,6 +14,7 @@ import {
fetchUsersAll,
type AdminUser,
} from "@/lib/api/admin";
import { OptionCombobox } from "@/components/option-combobox";
import { Button } from "@/components/ui/button";
import {
Command,
@@ -42,16 +43,11 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Status, StatusLabels } from "@/lib/generated/enums";
import { getEnumOptions } from "@/lib/enums";
import { useI18n } from "@/i18n/provider";
import { Status } from "@/lib/generated/enums";
type TFunction = (key: string, values?: Record<string, string | number>) => string;
type TeamEditDialogProps = {
open: boolean;
@@ -61,13 +57,6 @@ type TeamEditDialogProps = {
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 = {
name: "",
leaderUserId: "0",
@@ -76,22 +65,32 @@ const emptyForm: EditForm = {
remark: "",
};
const editFormSchema = z.object({
name: z.string().trim().min(1, "客服组名称不能为空"),
leaderUserId: z.string().trim().regex(/^\d+$/, "组长用户不合法"),
type EditForm = {
name: string;
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)], {
message: "请选择状态",
message: t("agentProfile.teamStatusRequired"),
}),
description: z.string().trim(),
remark: z.string().trim(),
});
});
}
type EditForm = z.infer<typeof editFormSchema>;
const editFormResolver = zodResolver(editFormSchema as never) as Resolver<
z.input<typeof editFormSchema>,
undefined,
z.output<typeof editFormSchema>
>;
function getStatusOptions(t: TFunction) {
return [
{ value: String(Status.Ok), label: t("agentProfile.enabled") },
{ value: String(Status.Disabled), label: t("agentProfile.disabled") },
];
}
function buildForm(item: AdminAgentTeam | null): EditForm {
if (!item) {
@@ -146,6 +145,7 @@ function TeamEditDialogBody({
onOpenChange,
onSubmit,
}: TeamEditDialogBodyProps) {
const t = useI18n();
const [users, setUsers] = useState<AdminUser[]>([]);
const [userSelectOpen, setUserSelectOpen] = useState(false);
const [loading, setLoading] = useState(false);
@@ -153,19 +153,21 @@ function TeamEditDialogBody({
value: String(user.id),
label: `${user.nickname || user.username} (${user.username})`,
}));
const statusOptions = useMemo(() => getStatusOptions(t), [t]);
const loadUsers = useCallback(async () => {
try {
const data = await fetchUsersAll();
setUsers(data);
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载用户选项失败");
toast.error(error instanceof Error ? error.message : t("agentProfile.loadUsersFailed"));
}
}, []);
const form = useForm<
z.input<typeof editFormSchema>,
undefined,
z.output<typeof editFormSchema>
>({
}, [t]);
const editFormSchema = useMemo(() => createEditFormSchema(t), [t]);
const editFormResolver = useMemo(
() => zodResolver(editFormSchema) as Resolver<EditForm>,
[editFormSchema],
);
const form = useForm<EditForm>({
resolver: editFormResolver,
defaultValues: emptyForm,
});
@@ -188,13 +190,13 @@ function TeamEditDialogBody({
const data = await fetchAgentTeam(itemId);
reset(buildForm(data));
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载客服组详情失败");
toast.error(error instanceof Error ? error.message : t("agentProfile.loadTeamDetailFailed"));
} finally {
setLoading(false);
}
}
void loadDetail();
}, [itemId, reset]);
}, [itemId, reset, t]);
useEffect(() => {
void loadUsers();
@@ -207,28 +209,28 @@ function TeamEditDialogBody({
return (
<DialogContent className="max-w-xl gap-0 p-0 sm:max-w-xl">
<DialogHeader className="px-6 pt-6">
<DialogTitle>{itemId ? "编辑" : "新建"}</DialogTitle>
<DialogTitle>{itemId ? t("agentProfile.teamEditTitle") : t("agentProfile.teamCreateTitle")}</DialogTitle>
</DialogHeader>
{loading ? (
<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>
) : (
<form onSubmit={handleSubmit(onFormSubmit)}>
<div className="space-y-4 p-6">
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="agent-team-name"></FieldLabel>
<FieldLabel htmlFor="agent-team-name">{t("agentProfile.teamName")}</FieldLabel>
<FieldContent>
<Input
id="agent-team-name"
placeholder="请输入客服组名称"
placeholder={t("agentProfile.teamNamePlaceholder")}
{...register("name")}
/>
<FieldError errors={[errors.name]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.leaderUserId}>
<FieldLabel></FieldLabel>
<FieldLabel>{t("agentProfile.leader")}</FieldLabel>
<FieldContent>
<Controller
control={control}
@@ -247,19 +249,19 @@ function TeamEditDialogBody({
>
<span className="truncate">
{field.value === "0"
? "暂不设置"
: userOptions.find((option) => option.value === field.value)?.label ?? "请选择组长"}
? t("agentProfile.noLeader")
: userOptions.find((option) => option.value === field.value)?.label ?? t("agentProfile.selectLeader")}
</span>
<ChevronsUpDownIcon className="ml-2 size-4 shrink-0 opacity-50" />
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popper-anchor-width)] p-0" align="start">
<Command>
<CommandInput placeholder="搜索用户..." />
<CommandInput placeholder={t("agentProfile.searchUser")} />
<CommandList>
<CommandEmpty></CommandEmpty>
<CommandEmpty>{t("agentProfile.emptyUser")}</CommandEmpty>
<CommandGroup>
<CommandItem
value="暂不设置"
value={t("agentProfile.noLeader")}
onSelect={() => {
field.onChange("0");
setUserSelectOpen(false);
@@ -268,7 +270,7 @@ function TeamEditDialogBody({
<CheckIcon
className={`mr-2 size-4 ${field.value === "0" ? "opacity-100" : "opacity-0"}`}
/>
{t("agentProfile.noLeader")}
</CommandItem>
{userOptions.map((option) => (
<CommandItem
@@ -298,54 +300,42 @@ function TeamEditDialogBody({
</FieldContent>
</Field>
<Field data-invalid={!!errors.status}>
<FieldLabel></FieldLabel>
<FieldLabel>{t("agentProfile.status")}</FieldLabel>
<FieldContent>
<Controller
control={control}
name="status"
render={({ field }) => (
<Select
<OptionCombobox
options={statusOptions}
value={field.value}
onValueChange={field.onChange}
modal={false}
>
<SelectTrigger className="w-full">
<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>
onChange={field.onChange}
placeholder={t("agentProfile.selectStatus")}
searchPlaceholder={t("agentProfile.searchStatus")}
emptyText={t("agentProfile.emptyStatus")}
/>
)}
/>
<FieldError errors={[errors.status]} />
</FieldContent>
</Field>
<Field>
<FieldLabel htmlFor="agent-team-description"></FieldLabel>
<FieldLabel htmlFor="agent-team-description">{t("agentProfile.description")}</FieldLabel>
<FieldContent>
<Input
id="agent-team-description"
placeholder="例如:负责售前咨询与线索转化"
placeholder={t("agentProfile.descriptionPlaceholder")}
{...register("description")}
/>
</FieldContent>
</Field>
<Field>
<FieldLabel htmlFor="agent-team-remark"></FieldLabel>
<FieldLabel htmlFor="agent-team-remark">{t("agentProfile.remark")}</FieldLabel>
<FieldContent>
<Textarea
id="agent-team-remark"
rows={4}
placeholder="请输入备注"
placeholder={t("agentProfile.remarkPlaceholder")}
{...register("remark")}
/>
</FieldContent>
@@ -358,10 +348,10 @@ function TeamEditDialogBody({
onClick={() => onOpenChange(false)}
disabled={saving}
>
{t("agentProfile.cancel")}
</Button>
<Button type="submit" disabled={saving || loading}>
{saving ? "保存中..." : "保存"}
{saving ? t("agentProfile.saving") : t("agentProfile.save")}
</Button>
</DialogFooter>
</form>
@@ -31,8 +31,8 @@ import {
type AdminAgentTeam,
type CreateAdminAgentTeamPayload,
} from "@/lib/api/admin";
import { Status, StatusLabels } from "@/lib/generated/enums";
import { getEnumLabel } from "@/lib/enums";
import { Status } from "@/lib/generated/enums";
import { useI18n } from "@/i18n/provider";
import { cn } from "@/lib/utils";
type AgentTeamSidebarProps = {
@@ -41,17 +41,21 @@ type AgentTeamSidebarProps = {
onTeamsChange?: (teams: AdminAgentTeam[]) => void;
};
const statusTabs = [
{ value: "all", label: "全部" },
{ value: String(Status.Ok), label: StatusLabels[Status.Ok] },
{ value: String(Status.Disabled), label: StatusLabels[Status.Disabled] },
] as const;
function getStatusTabs(t: (key: string, values?: Record<string, string | number>) => string) {
return [
{ value: "all", label: t("agentProfile.all") },
{ value: String(Status.Ok), label: t("agentProfile.enabled") },
{ value: String(Status.Disabled), label: t("agentProfile.disabled") },
] as const;
}
export function AgentTeamSidebar({
selectedTeamId,
onSelectTeam,
onTeamsChange,
}: AgentTeamSidebarProps) {
const t = useI18n();
const statusTabs = getStatusTabs(t);
const [keyword, setKeyword] = useState("");
const [statusFilter, setStatusFilter] =
useState<(typeof statusTabs)[number]["value"]>("all");
@@ -69,11 +73,11 @@ export function AgentTeamSidebar({
setTeams(data);
onTeamsChange?.(data);
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载客服组失败");
toast.error(error instanceof Error ? error.message : t("agentProfile.loadTeamsFailed"));
} finally {
setLoading(false);
}
}, [onTeamsChange]);
}, [onTeamsChange, t]);
useEffect(() => {
void loadData();
@@ -135,16 +139,16 @@ export function AgentTeamSidebar({
try {
if (editingItem) {
await updateAgentTeam({ id: editingItem.id, ...payload });
toast.success(`已更新客服组:${editingItem.name}`);
toast.success(t("agentProfile.teamUpdated", { name: editingItem.name }));
} else {
await createAgentTeam(payload);
toast.success(`已创建客服组:${payload.name}`);
toast.success(t("agentProfile.teamCreated", { name: payload.name }));
}
setDialogOpen(false);
setEditingItem(null);
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存客服组失败");
toast.error(error instanceof Error ? error.message : t("agentProfile.teamSaveFailed"));
} finally {
setSaving(false);
}
@@ -154,10 +158,10 @@ export function AgentTeamSidebar({
setActionLoadingId(item.id);
try {
await deleteAgentTeam(item.id);
toast.success(`已删除客服组:${item.name}`);
toast.success(t("agentProfile.teamDeleted", { name: item.name }));
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除客服组失败");
toast.error(error instanceof Error ? error.message : t("agentProfile.teamDeleteFailed"));
} finally {
setActionLoadingId(null);
}
@@ -169,7 +173,7 @@ export function AgentTeamSidebar({
<div className="border-b px-3 py-3">
<div className="flex items-center justify-between gap-2">
<div>
<div className="text-sm font-medium"></div>
<div className="text-sm font-medium">{t("agentProfile.teamTitle")}</div>
</div>
</div>
<div className="relative mt-3">
@@ -177,7 +181,7 @@ export function AgentTeamSidebar({
<Input
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索客服组"
placeholder={t("agentProfile.searchTeams")}
className="pl-9"
/>
</div>
@@ -235,7 +239,7 @@ export function AgentTeamSidebar({
item.status === Status.Ok ? "secondary" : "outline"
}
>
{getEnumLabel(StatusLabels, item.status as Status)}
{item.status === Status.Ok ? t("agentProfile.enabled") : t("agentProfile.disabled")}
</Badge>
</button>
<DropdownMenu>
@@ -247,21 +251,21 @@ export function AgentTeamSidebar({
className="opacity-0 group-hover:opacity-100"
/>
}
aria-label={`更多操作 ${item.name}`}
aria-label={t("agentProfile.moreActions", { name: item.name })}
>
<MoreHorizontalIcon />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40 min-w-40">
<DropdownMenuItem onClick={() => openEditDialog(item)}>
<Pencil />
{t("agentProfile.edit")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => void handleDelete(item)}
className="text-destructive focus:text-destructive"
>
<Trash2Icon />
{actionLoadingId === item.id ? "删除中..." : "删除"}
{actionLoadingId === item.id ? t("agentProfile.deleting") : t("agentProfile.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -269,7 +273,7 @@ export function AgentTeamSidebar({
))}
{!loading && filteredTeams.length === 0 ? (
<div className="px-2 py-10 text-center text-sm text-muted-foreground">
{t("agentProfile.noTeams")}
</div>
) : null}
</div>
+63 -74
View File
@@ -21,6 +21,7 @@ import { ListPagination } from "@/components/list-pagination";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ButtonGroup } from "@/components/ui/button-group";
import { OptionCombobox } from "@/components/option-combobox";
import {
DropdownMenu,
DropdownMenuContent,
@@ -28,13 +29,6 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
@@ -53,25 +47,29 @@ import {
type CreateAdminAgentProfilePayload,
type PageResult,
} from "@/lib/api/admin";
import { getEnumLabel, getEnumOptions } from "@/lib/enums";
import {
ServiceStatus,
ServiceStatusLabels,
} from "@/lib/generated/enums";
import { useI18n } from "@/i18n/provider";
import { ServiceStatus } from "@/lib/generated/enums";
import { formatDateTime } from "@/lib/utils";
import { EditDialog } from "./_components/edit";
import { AgentTeamSidebar } from "./_components/team-sidebar";
const serviceStatusOptions = [
{ value: "all", label: "全部状态" },
...getEnumOptions(ServiceStatusLabels),
];
type TFunction = (key: string, values?: Record<string, string | number>) => string;
function getStatusLabel(value: number) {
return getEnumLabel(ServiceStatusLabels, value as ServiceStatus);
function getServiceStatusOptions(t: TFunction) {
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() {
const t = useI18n();
const serviceStatusOptions = getServiceStatusOptions(t);
const [selectedTeam, setSelectedTeam] = useState<AdminAgentTeam | null>(null);
const [teams, setTeams] = useState<AdminAgentTeam[]>([]);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
@@ -108,11 +106,11 @@ export default function DashboardAgentsPage() {
});
setResult(data);
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载客服档案失败");
toast.error(error instanceof Error ? error.message : t("agentProfile.loadFailed"));
} finally {
setLoading(false);
}
}, [agentCode, displayName, limit, page, selectedTeam?.id, statusFilter]);
}, [agentCode, displayName, limit, page, selectedTeam?.id, statusFilter, t]);
useEffect(() => {
void loadData();
@@ -193,16 +191,16 @@ export default function DashboardAgentsPage() {
try {
if (editingItem) {
await updateAgentProfile({ id: editingItem.id, ...payload });
toast.success(`已更新客服档案:${editingItem.displayName}`);
toast.success(t("agentProfile.updated", { name: editingItem.displayName }));
} else {
await createAgentProfile(payload);
toast.success(`已创建客服档案:${payload.displayName}`);
toast.success(t("agentProfile.created", { name: payload.displayName }));
}
setDialogOpen(false);
setEditingItem(null);
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存客服档案失败");
toast.error(error instanceof Error ? error.message : t("agentProfile.saveFailed"));
} finally {
setSaving(false);
}
@@ -212,10 +210,10 @@ export default function DashboardAgentsPage() {
setActionLoadingId(item.id);
try {
await deleteAgentProfile(item.id);
toast.success(`已删除客服档案:${item.displayName}`);
toast.success(t("agentProfile.deleted", { name: item.displayName }));
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除客服档案失败");
toast.error(error instanceof Error ? error.message : t("agentProfile.deleteFailed"));
} finally {
setActionLoadingId(null);
}
@@ -241,7 +239,7 @@ export default function DashboardAgentsPage() {
size="icon"
className="absolute top-4 left-1/2 z-10 size-7 -translate-x-1/2 rounded-full shadow-sm"
onClick={() => setSidebarCollapsed((value) => !value)}
aria-label={sidebarCollapsed ? "展开客服组列表" : "折叠客服组列表"}
aria-label={sidebarCollapsed ? t("agentProfile.expandTeams") : t("agentProfile.collapseTeams")}
>
{sidebarCollapsed ? (
<PanelLeftOpenIcon className="size-3.5" />
@@ -256,7 +254,7 @@ export default function DashboardAgentsPage() {
actions={
<Button onClick={openCreateDialog}>
<PlusIcon />
{t("agentProfile.new")}
</Button>
}
>
@@ -268,7 +266,7 @@ export default function DashboardAgentsPage() {
setDisplayNameInput(event.target.value)
}
onKeyDown={handleFilterKeyDown}
placeholder="按展示名筛选"
placeholder={t("agentProfile.filterDisplayName")}
className="pl-9"
/>
</div>
@@ -276,37 +274,26 @@ export default function DashboardAgentsPage() {
value={agentCodeInput}
onChange={(event) => setAgentCodeInput(event.target.value)}
onKeyDown={handleFilterKeyDown}
placeholder="按客服工号筛选"
placeholder={t("agentProfile.filterAgentCode")}
className="w-full sm:w-44"
/>
<Select
value={statusFilterInput}
onValueChange={(value) =>
setStatusFilterInput(value ?? "all")
}
>
<SelectTrigger className="w-full sm:w-36">
<SelectValue>
{serviceStatusOptions.find(
(item) => item.value === statusFilterInput,
)?.label ?? "全部状态"}
</SelectValue>
</SelectTrigger>
<SelectContent>
{serviceStatusOptions.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="w-full sm:w-36">
<OptionCombobox
value={statusFilterInput}
options={serviceStatusOptions}
placeholder={t("agentProfile.allStatuses")}
searchPlaceholder={t("agentProfile.searchStatus")}
emptyText={t("agentProfile.emptyStatus")}
onChange={(value) => setStatusFilterInput(value ?? "all")}
/>
</div>
<Button
variant="outline"
onClick={applyFilters}
disabled={loading}
>
<SearchIcon />
{t("agentProfile.query")}
</Button>
</DashboardToolbar>
<DashboardTableShell
@@ -328,12 +315,12 @@ export default function DashboardAgentsPage() {
<Table>
<TableHeader className="bg-muted/40">
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>{t("agentProfile.columnAgent")}</TableHead>
<TableHead>{t("agentProfile.columnRules")}</TableHead>
<TableHead>{t("agentProfile.columnDispatch")}</TableHead>
<TableHead>{t("agentProfile.columnRecent")}</TableHead>
<TableHead className="w-[92px] text-right">
{t("agentProfile.columnActions")}
</TableHead>
</TableRow>
</TableHeader>
@@ -360,21 +347,23 @@ export default function DashboardAgentsPage() {
<div className="text-xs text-muted-foreground">
{item.nickname ||
item.username ||
`用户#${item.userId}`}
t("agentProfile.userFallback", { id: item.userId })}
</div>
<div className="mt-1 text-xs text-muted-foreground">
{item.agentCode}
{t("agentProfile.agentCode", { code: item.agentCode })}
</div>
</div>
</div>
</TableCell>
<TableCell>
<Badge variant="outline">
{getStatusLabel(item.serviceStatus)}
{getStatusLabel(item.serviceStatus, t)}
</Badge>
<div className="mt-2 text-sm text-muted-foreground">
{item.maxConcurrentCount} / {" "}
{item.priorityLevel}
{t("agentProfile.capacityPriority", {
capacity: item.maxConcurrentCount,
priority: item.priorityLevel,
})}
</div>
</TableCell>
<TableCell>
@@ -385,8 +374,8 @@ export default function DashboardAgentsPage() {
}
>
{item.autoAssignEnabled
? "自动分配"
: "不自动分配"}
? t("agentProfile.autoAssign")
: t("agentProfile.noAutoAssign")}
</Badge>
<Badge
variant={
@@ -396,17 +385,17 @@ export default function DashboardAgentsPage() {
}
>
{item.receiveOfflineMessage
? "离线接收"
: "离线不接收"}
? t("agentProfile.offlineReceive")
: t("agentProfile.noOfflineReceive")}
</Badge>
</div>
</TableCell>
<TableCell>
<div className="text-sm">
线{formatDateTime(item.lastOnlineAt)}
{t("agentProfile.onlineAt", { time: formatDateTime(item.lastOnlineAt) })}
</div>
<div className="text-sm text-muted-foreground">
{formatDateTime(item.lastStatusAt)}
{t("agentProfile.statusAt", { time: formatDateTime(item.lastStatusAt) })}
</div>
</TableCell>
<TableCell className="text-right">
@@ -416,14 +405,14 @@ export default function DashboardAgentsPage() {
size="sm"
onClick={() => openEditDialog(item)}
>
{t("agentProfile.edit")}
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="outline" size="icon-sm" />
}
aria-label={`更多操作 ${item.displayName}`}
aria-label={t("agentProfile.moreActions", { name: item.displayName })}
>
<MoreHorizontalIcon />
</DropdownMenuTrigger>
@@ -437,8 +426,8 @@ export default function DashboardAgentsPage() {
>
<Trash2Icon />
{actionLoadingId === item.id
? "删除中..."
: "删除"}
? t("agentProfile.deleting")
: t("agentProfile.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -450,11 +439,11 @@ export default function DashboardAgentsPage() {
<DashboardTableStateRow
colSpan={5}
loading={loading}
loadingText="正在加载客服档案..."
loadingText={t("agentProfile.loadingRows")}
emptyText={
selectedTeam
? "当前客服组下没有匹配的客服档案"
: "没有匹配的客服档案"
? t("agentProfile.emptyTeamRows")
: t("agentProfile.emptyRows")
}
/>
) : null}
+152 -139
View File
@@ -54,15 +54,12 @@ import {
type MCPToolSourceType,
type SkillDefinition,
} from "@/lib/api/admin";
import { getEnumOptions } from "@/lib/enums";
import { useI18n } from "@/i18n/provider";
import {
AIAgentFallbackMode,
AIAgentFallbackModeLabels,
AIAgentHandoffMode,
AIAgentHandoffModeLabels,
AIModelType,
IMConversationServiceMode,
IMConversationServiceModeLabels,
Status,
} from "@/lib/generated/enums";
@@ -90,49 +87,43 @@ type EditDialogProps = {
onSubmit: (payload: CreateAIAgentPayload) => Promise<void>;
};
const schema = z.object({
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 TFunction = (key: string, values?: Record<string, string | number>) => string;
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<
z.input<typeof schema>,
undefined,
z.output<typeof schema>
>;
function getServiceModeOptions(t: TFunction) {
return [
{ value: String(IMConversationServiceMode.AIOnly), label: t("aiAgent.serviceAiOnly") },
{ value: String(IMConversationServiceMode.HumanOnly), label: t("aiAgent.serviceHumanOnly") },
{ value: String(IMConversationServiceMode.AIFirst), label: t("aiAgent.serviceAiFirst") },
];
}
const serviceModeOptions = getEnumOptions(IMConversationServiceModeLabels).map(
(option) => ({
value: String(option.value),
label: option.label,
}),
);
function getHandoffModeOptions(t: TFunction) {
return [
{ value: String(AIAgentHandoffMode.WaitPool), label: t("aiAgent.handoffWaitPool") },
{ value: String(AIAgentHandoffMode.DefaultTeamPool), label: t("aiAgent.handoffDefaultTeamPool") },
{ value: String(AIAgentHandoffMode.AIHoldAndNotify), label: t("aiAgent.handoffAiHoldAndNotify") },
];
}
const handoffModeOptions = getEnumOptions(AIAgentHandoffModeLabels).map(
(option) => ({
value: String(option.value),
label: option.label,
}),
);
const fallbackModeOptions = getEnumOptions(AIAgentFallbackModeLabels).map(
(option) => ({
value: String(option.value),
label: option.label,
}),
);
function getFallbackModeOptions(t: TFunction) {
return [
{ value: String(AIAgentFallbackMode.NoAnswer), label: t("aiAgent.fallbackNoAnswer") },
{ value: String(AIAgentFallbackMode.SuggestRetry), label: t("aiAgent.fallbackSuggestRetry") },
];
}
function buildForm(item: AIAgent | null): EditForm {
if (!item) {
@@ -220,12 +211,34 @@ function EditDialogBody({
onSubmit,
}: EditDialogProps) {
const formId = "ai-agent-edit-form";
const t = useI18n();
const [loading, setLoading] = useState(false);
const form = useForm<
z.input<typeof schema>,
undefined,
z.output<typeof schema>
>({
const schema = useMemo(
() =>
z.object({
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,
defaultValues: buildForm(null),
});
@@ -296,14 +309,14 @@ function EditDialogBody({
setGraphToolToAdd("");
} catch (error) {
toast.error(
error instanceof Error ? error.message : "加载 AI Agent 详情失败",
error instanceof Error ? error.message : t("aiAgent.loadDetailFailed"),
);
} finally {
setLoading(false);
}
}
void loadDetail();
}, [itemId, reset]);
}, [itemId, reset, t]);
useEffect(() => {
async function loadAIConfigs() {
@@ -314,12 +327,12 @@ function EditDialogBody({
setAIConfigs(data);
} catch (error) {
toast.error(
error instanceof Error ? error.message : "加载 AI 配置失败",
error instanceof Error ? error.message : t("aiAgent.loadAiConfigFailed"),
);
}
}
void loadAIConfigs();
}, []);
}, [t]);
useEffect(() => {
async function loadAgentTeams() {
@@ -327,11 +340,11 @@ function EditDialogBody({
const data = await fetchAgentTeamsAll();
setAgentTeams(data);
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载客服组失败");
toast.error(error instanceof Error ? error.message : t("aiAgent.loadTeamsFailed"));
}
}
void loadAgentTeams();
}, []);
}, [t]);
useEffect(() => {
async function loadKnowledgeBases() {
@@ -341,11 +354,11 @@ function EditDialogBody({
});
setKnowledgeBases(data);
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载知识库失败");
toast.error(error instanceof Error ? error.message : t("aiAgent.loadKnowledgeFailed"));
}
}
void loadKnowledgeBases();
}, []);
}, [t]);
useEffect(() => {
async function loadSkills() {
@@ -355,11 +368,11 @@ function EditDialogBody({
});
setSkills(data);
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载 Skills 失败");
toast.error(error instanceof Error ? error.message : t("aiAgent.loadSkillsFailed"));
}
}
void loadSkills();
}, []);
}, [t]);
useEffect(() => {
async function loadDirectToolOptions() {
@@ -376,7 +389,7 @@ function EditDialogBody({
autoInjected: tool.autoInjected,
groupLabel:
tool.sourceType === "builtin"
? "内置工具"
? t("aiAgent.builtinTools")
: tool.serverCode,
meta: {
toolCode: tool.toolCode,
@@ -398,12 +411,12 @@ function EditDialogBody({
);
} catch (error) {
toast.error(
error instanceof Error ? error.message : "加载 Direct Tools 失败",
error instanceof Error ? error.message : t("aiAgent.loadDirectToolsFailed"),
);
}
}
void loadDirectToolOptions();
}, []);
}, [t]);
const aiConfigOptions = useMemo(
() =>
@@ -508,13 +521,13 @@ function EditDialogBody({
const directToolsGrouped = useMemo(() => {
const groups = new Map<string, DirectToolItem[]>();
for (const tool of directTools) {
const groupLabel = tool.serverCode || "未分组";
const groupLabel = tool.serverCode || t("aiAgent.ungrouped");
const current = groups.get(groupLabel) ?? [];
current.push(tool);
groups.set(groupLabel, current);
}
return Array.from(groups.entries());
}, [directTools]);
}, [directTools, t]);
const addableGraphToolOptions = useMemo(
() =>
@@ -545,7 +558,7 @@ function EditDialogBody({
const handoffMode = watch("handoffMode");
const selectedHandoffModeLabel =
handoffModeOptions.find((item) => item.value === handoffMode)?.label ??
"未选择";
t("aiAgent.notSelected");
async function onFormSubmit(values: EditForm) {
await onSubmit(
@@ -653,7 +666,7 @@ function EditDialogBody({
<ProjectDialog
open={open}
onOpenChange={onOpenChange}
title={itemId ? "编辑 AI Agent" : "新建 AI Agent"}
title={itemId ? t("aiAgent.editTitle") : t("aiAgent.createTitle")}
size="xl"
defaultFullscreen
footer={
@@ -663,17 +676,17 @@ function EditDialogBody({
variant="outline"
onClick={() => onOpenChange(false)}
>
{t("aiAgent.cancel")}
</Button>
<Button type="submit" form={formId} disabled={saving || loading}>
{saving ? "保存中..." : "保存"}
{saving ? t("aiAgent.saving") : t("aiAgent.save")}
</Button>
</>
}
>
{loading ? (
<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>
) : (
<form
@@ -682,11 +695,11 @@ function EditDialogBody({
className="space-y-6"
>
<SectionCard
title="基础信息"
title={t("aiAgent.sectionBasic")}
>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-3">
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="ai-agent-name"></FieldLabel>
<FieldLabel htmlFor="ai-agent-name">{t("aiAgent.name")}</FieldLabel>
<FieldContent>
<Input id="ai-agent-name" {...register("name")} />
<FieldError errors={[errors.name]} />
@@ -694,7 +707,7 @@ function EditDialogBody({
</Field>
<Field data-invalid={!!errors.aiConfigId}>
<FieldLabel>AI </FieldLabel>
<FieldLabel>{t("aiAgent.aiConfig")}</FieldLabel>
<FieldContent>
<Controller
control={control}
@@ -703,9 +716,9 @@ function EditDialogBody({
<OptionCombobox
value={field.value}
options={aiConfigOptions}
placeholder="请选择 AI 配置"
searchPlaceholder="搜索 AI 配置"
emptyText="未找到 AI 配置"
placeholder={t("aiAgent.selectAiConfig")}
searchPlaceholder={t("aiAgent.searchAiConfig")}
emptyText={t("aiAgent.emptyAiConfig")}
onChange={field.onChange}
/>
)}
@@ -715,7 +728,7 @@ function EditDialogBody({
</Field>
<Field data-invalid={!!errors.serviceMode}>
<FieldLabel></FieldLabel>
<FieldLabel>{t("aiAgent.columnServiceMode")}</FieldLabel>
<FieldContent>
<Controller
control={control}
@@ -724,9 +737,9 @@ function EditDialogBody({
<OptionCombobox
value={field.value}
options={serviceModeOptions}
placeholder="请选择服务模式"
searchPlaceholder="搜索服务模式"
emptyText="未找到服务模式"
placeholder={t("aiAgent.selectServiceMode")}
searchPlaceholder={t("aiAgent.searchServiceMode")}
emptyText={t("aiAgent.emptyServiceMode")}
onChange={field.onChange}
/>
)}
@@ -737,14 +750,14 @@ function EditDialogBody({
</div>
<Field data-invalid={!!errors.description}>
<FieldLabel htmlFor="ai-agent-description"></FieldLabel>
<FieldLabel htmlFor="ai-agent-description">{t("aiAgent.description")}</FieldLabel>
<FieldContent>
<Textarea id="ai-agent-description" {...register("description")} />
<FieldError errors={[errors.description]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.welcomeMessage}>
<FieldLabel htmlFor="ai-agent-welcome-message"></FieldLabel>
<FieldLabel htmlFor="ai-agent-welcome-message">{t("aiAgent.welcomeMessage")}</FieldLabel>
<FieldContent>
<Textarea
id="ai-agent-welcome-message"
@@ -757,7 +770,7 @@ function EditDialogBody({
<Field data-invalid={!!errors.systemPrompt}>
<FieldLabel htmlFor="ai-agent-system-prompt">
{t("aiAgent.systemPrompt")}
</FieldLabel>
<FieldContent>
<Textarea
@@ -771,12 +784,12 @@ function EditDialogBody({
</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="space-y-4">
<Field data-invalid={!!errors.handoffMode}>
<FieldLabel></FieldLabel>
<FieldLabel>{t("aiAgent.handoffMode")}</FieldLabel>
<FieldContent>
<Controller
control={control}
@@ -785,9 +798,9 @@ function EditDialogBody({
<OptionCombobox
value={field.value}
options={handoffModeOptions}
placeholder="请选择转人工模式"
searchPlaceholder="搜索转人工模式"
emptyText="未找到转人工模式"
placeholder={t("aiAgent.selectHandoffMode")}
searchPlaceholder={t("aiAgent.searchHandoffMode")}
emptyText={t("aiAgent.emptyHandoffMode")}
onChange={field.onChange}
/>
)}
@@ -801,7 +814,7 @@ function EditDialogBody({
htmlFor="ai-agent-reply-timeout-seconds"
className="flex items-center gap-1"
>
{t("aiAgent.replyTimeoutSeconds")}
<Popover>
<PopoverTrigger
render={
@@ -815,7 +828,7 @@ function EditDialogBody({
/>
<PopoverContent side="top" align="start" className="max-w-xs">
<PopoverDescription>
AI 0 使 180
{t("aiAgent.replyTimeoutHelp")}
</PopoverDescription>
</PopoverContent>
</Popover>
@@ -834,28 +847,28 @@ function EditDialogBody({
</div>
<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">
{selectedHandoffModeLabel}
{t("aiAgent.currentHandoffMode", { mode: selectedHandoffModeLabel })}
{handoffMode ===
String(AIAgentHandoffMode.DefaultTeamPool)
? "。该模式要求至少配置一个客服组。"
: "。仅在涉及转人工时生效。"}
? t("aiAgent.handoffNeedsTeam")
: t("aiAgent.handoffOnlyWhenNeeded")}
</div>
<Field>
<FieldContent className="space-y-3">
<OptionCombobox
value={teamToAdd}
options={addableTeamOptions}
placeholder="请选择客服组"
searchPlaceholder="搜索客服组"
emptyText="未找到客服组"
placeholder={t("aiAgent.selectTeam")}
searchPlaceholder={t("aiAgent.searchTeam")}
emptyText={t("aiAgent.emptyTeam")}
onChange={handleAddTeam}
/>
<div className="flex flex-wrap gap-2">
{selectedTeamOptions.length === 0 ? (
<span className="text-sm text-muted-foreground">
{t("aiAgent.noTeams")}
</span>
) : (
selectedTeamOptions.map((option) => (
@@ -873,7 +886,7 @@ function EditDialogBody({
onClick={() =>
handleRemoveTeam(Number(option.value))
}
aria-label={`移除客服组 ${option.label}`}
aria-label={t("aiAgent.removeTeam", { name: option.label })}
>
<Trash2Icon className="size-3" />
</Button>
@@ -888,19 +901,19 @@ function EditDialogBody({
</SectionCard>
<SectionCard
title="能力配置"
title={t("aiAgent.sectionCapabilities")}
>
<Tabs defaultValue="knowledge" className="gap-4">
<TabsList className="w-fit">
<TabsTrigger value="knowledge"></TabsTrigger>
<TabsTrigger value="skills">Skills</TabsTrigger>
<TabsTrigger value="direct-tools">Direct Tools</TabsTrigger>
<TabsTrigger value="graph-tools">Graph Tools</TabsTrigger>
<TabsTrigger value="knowledge">{t("aiAgent.knowledgeTab")}</TabsTrigger>
<TabsTrigger value="skills">{t("aiAgent.skillsTab")}</TabsTrigger>
<TabsTrigger value="direct-tools">{t("aiAgent.directToolsTab")}</TabsTrigger>
<TabsTrigger value="graph-tools">{t("aiAgent.graphToolsTab")}</TabsTrigger>
</TabsList>
<TabsContent value="knowledge" className="space-y-4">
<div className="text-xs text-muted-foreground">
{t("aiAgent.knowledgeHint")}
</div>
<Field data-invalid={selectedKnowledgeIds.length === 0}>
<FieldContent className="space-y-3">
@@ -909,9 +922,9 @@ function EditDialogBody({
<OptionCombobox
value={knowledgeToAdd}
options={addableKnowledgeOptions}
placeholder="选择并添加知识库"
searchPlaceholder="搜索知识库"
emptyText="没有可添加的知识库"
placeholder={t("aiAgent.selectKnowledge")}
searchPlaceholder={t("aiAgent.searchKnowledge")}
emptyText={t("aiAgent.emptyKnowledge")}
onChange={handleAddKnowledge}
/>
</div>
@@ -922,12 +935,12 @@ function EditDialogBody({
onClick={() => handleAddKnowledge(knowledgeToAdd)}
>
<PlusIcon />
{t("aiAgent.add")}
</Button>
</div>
{selectedKnowledgeIds.length === 0 ? (
<div className="rounded-md border border-dashed px-3 py-4 text-sm text-muted-foreground">
{t("aiAgent.knowledgeRequired")}
</div>
) : (
<div className="space-y-2 rounded-md border p-3">
@@ -975,14 +988,14 @@ function EditDialogBody({
</div>
)}
{selectedKnowledgeIds.length === 0 ? (
<FieldError errors={[{ message: "请至少选择一个知识库" }]} />
<FieldError errors={[{ message: t("aiAgent.knowledgeRequired") }]} />
) : null}
</FieldContent>
</Field>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-1">
<Field data-invalid={!!errors.fallbackMode}>
<FieldLabel></FieldLabel>
<FieldLabel>{t("aiAgent.fallbackMode")}</FieldLabel>
<FieldContent className="space-y-3">
<Controller
control={control}
@@ -991,9 +1004,9 @@ function EditDialogBody({
<OptionCombobox
value={field.value}
options={fallbackModeOptions}
placeholder="请选择兜底策略"
searchPlaceholder="搜索兜底策略"
emptyText="未找到兜底策略"
placeholder={t("aiAgent.selectFallbackMode")}
searchPlaceholder={t("aiAgent.searchFallbackMode")}
emptyText={t("aiAgent.emptyFallbackMode")}
onChange={field.onChange}
/>
)}
@@ -1004,11 +1017,11 @@ function EditDialogBody({
<Field data-invalid={!!errors.fallbackMessage}>
<FieldLabel htmlFor="ai-agent-fallback-message">
{t("aiAgent.fallbackMessage")}
</FieldLabel>
<FieldContent>
<div className="mb-1 text-xs text-muted-foreground">
{t("aiAgent.fallbackMessageHint")}
</div>
<Textarea
id="ai-agent-fallback-message"
@@ -1023,7 +1036,7 @@ function EditDialogBody({
<TabsContent value="skills" className="space-y-4">
<div className="text-xs text-muted-foreground">
{t("aiAgent.skillsHint")}
</div>
<Field>
<FieldContent className="space-y-3">
@@ -1032,9 +1045,9 @@ function EditDialogBody({
<OptionCombobox
value={skillToAdd}
options={addableSkillOptions}
placeholder="选择并添加 Skill"
searchPlaceholder="搜索 Skill"
emptyText="没有可添加的 Skill"
placeholder={t("aiAgent.selectSkill")}
searchPlaceholder={t("aiAgent.searchSkill")}
emptyText={t("aiAgent.emptySkill")}
onChange={handleAddSkill}
/>
</div>
@@ -1045,13 +1058,13 @@ function EditDialogBody({
onClick={() => handleAddSkill(skillToAdd)}
>
<PlusIcon />
{t("aiAgent.add")}
</Button>
</div>
<div className="flex flex-wrap gap-2">
{selectedSkillOptions.length === 0 ? (
<span className="text-sm text-muted-foreground">
Skill
{t("aiAgent.noSkillsHint")}
</span>
) : (
selectedSkillOptions.map((option) => (
@@ -1069,7 +1082,7 @@ function EditDialogBody({
onClick={() =>
handleRemoveSkill(Number(option.value))
}
aria-label={`移除 Skill ${option.label}`}
aria-label={t("aiAgent.removeSkill", { name: option.label })}
>
<Trash2Icon className="size-3" />
</Button>
@@ -1083,7 +1096,7 @@ function EditDialogBody({
<TabsContent value="direct-tools" className="space-y-4">
<div className="text-xs text-muted-foreground">
MCP
{t("aiAgent.directToolsHint")}
</div>
<Field>
<FieldContent className="space-y-3">
@@ -1092,9 +1105,9 @@ function EditDialogBody({
<OptionCombobox
value={directToolGroupToAdd}
options={directToolGroupOptions}
placeholder="选择工具分组"
searchPlaceholder="搜索工具分组"
emptyText="没有可用的工具分组"
placeholder={t("aiAgent.selectToolGroup")}
searchPlaceholder={t("aiAgent.searchToolGroup")}
emptyText={t("aiAgent.emptyToolGroup")}
onChange={(value) => {
setDirectToolGroupToAdd(value);
setDirectToolToAdd("");
@@ -1105,9 +1118,9 @@ function EditDialogBody({
<OptionCombobox
value={directToolToAdd}
options={addableDirectToolOptions}
placeholder="选择 Direct Tool"
searchPlaceholder="搜索 Direct Tool"
emptyText="没有可添加的 Direct Tool"
placeholder={t("aiAgent.selectDirectTool")}
searchPlaceholder={t("aiAgent.searchDirectTool")}
emptyText={t("aiAgent.emptyDirectTool")}
onChange={handleAddDirectTool}
/>
</div>
@@ -1118,13 +1131,13 @@ function EditDialogBody({
onClick={() => handleAddDirectTool(directToolToAdd)}
>
<PlusIcon />
{t("aiAgent.add")}
</Button>
</div>
<div className="space-y-3">
{directTools.length === 0 ? (
<span className="text-sm text-muted-foreground">
Direct Tool Agent Skill
{t("aiAgent.noDirectToolsHint")}
</span>
) : (
directToolsGrouped.map(([groupLabel, tools]) => (
@@ -1156,7 +1169,7 @@ function EditDialogBody({
onClick={() =>
handleRemoveDirectTool(value)
}
aria-label={`移除 Direct Tool ${value}`}
aria-label={t("aiAgent.removeDirectTool", { name: value })}
>
<Trash2Icon className="size-3" />
</Button>
@@ -1174,7 +1187,7 @@ function EditDialogBody({
<TabsContent value="graph-tools" className="space-y-4">
<div className="text-xs text-muted-foreground">
Direct Tools
{t("aiAgent.graphToolsHint")}
</div>
<Field>
<FieldContent className="space-y-3">
@@ -1183,9 +1196,9 @@ function EditDialogBody({
<OptionCombobox
value={graphToolToAdd}
options={addableGraphToolOptions}
placeholder="选择 Graph Tool"
searchPlaceholder="搜索 Graph Tool"
emptyText="没有可添加的 Graph Tool"
placeholder={t("aiAgent.selectGraphTool")}
searchPlaceholder={t("aiAgent.searchGraphTool")}
emptyText={t("aiAgent.emptyGraphTool")}
onChange={handleAddGraphTool}
/>
</div>
@@ -1196,13 +1209,13 @@ function EditDialogBody({
onClick={() => handleAddGraphTool(graphToolToAdd)}
>
<PlusIcon />
{t("aiAgent.add")}
</Button>
</div>
<div className="flex flex-wrap gap-2">
{graphTools.length === 0 ? (
<span className="text-sm text-muted-foreground">
Graph Tool Agent /
{t("aiAgent.noGraphToolsHint")}
</span>
) : (
graphTools.map((toolCode) => {
@@ -1225,7 +1238,7 @@ function EditDialogBody({
size="icon"
className="size-5"
onClick={() => handleRemoveGraphTool(toolCode)}
aria-label={`移除 Graph Tool ${toolCode}`}
aria-label={t("aiAgent.removeGraphTool", { name: toolCode })}
>
<Trash2Icon className="size-3" />
</Button>
+80 -56
View File
@@ -73,34 +73,48 @@ import {
type CreateAIAgentPayload,
type PageResult,
} from "@/lib/api/admin";
import {
IMConversationServiceModeLabels,
Status,
StatusLabels,
} from "@/lib/generated/enums";
import { getEnumLabel, getEnumOptions } from "@/lib/enums";
import { IMConversationServiceMode, Status } from "@/lib/generated/enums";
import { useI18n } from "@/i18n/provider";
import { cn } from "@/lib/utils";
import { EditDialog } from "./_components/edit";
import { ButtonGroup } from "@/components/ui/button-group";
const statusOptions = [
{ value: "all", label: "全部状态" },
...getEnumOptions(StatusLabels).map((option) => ({
value: String(option.value),
label: option.label,
})),
];
type TFunction = (key: string, values?: Record<string, string | number>) => string;
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 (
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 = {
item: AIAgent;
disabled: boolean;
actionLoadingId: number | null;
t: TFunction;
openEditDialog: (item: AIAgent) => void;
handleToggleStatus: (item: AIAgent) => void;
handleDelete: (item: AIAgent) => void;
@@ -110,6 +124,7 @@ function SortableAIAgentRow({
item,
disabled,
actionLoadingId,
t,
openEditDialog,
handleToggleStatus,
handleDelete,
@@ -154,7 +169,7 @@ function SortableAIAgentRow({
size="icon"
className="size-8 cursor-grab active:cursor-grabbing"
disabled={disabled}
aria-label={`拖拽排序 ${item.name}`}
aria-label={t("aiAgent.dragSort", { name: item.name })}
{...attributes}
{...listeners}
>
@@ -175,15 +190,14 @@ function SortableAIAgentRow({
{item.aiConfigName || "-"}
</TableCell>
<TableCell>
{getEnumLabel(
IMConversationServiceModeLabels,
item.serviceMode as keyof typeof IMConversationServiceModeLabels,
)}
{getServiceModeLabel(item.serviceMode, t)}
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{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) => (
<Badge key={knowledgeIds[index] ?? `${item.id}-${index}`} variant="secondary">
@@ -196,7 +210,7 @@ function SortableAIAgentRow({
<TableCell>
<div className="flex flex-wrap gap-1">
{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) => (
<Badge key={skill.id} variant="outline">
@@ -214,7 +228,9 @@ function SortableAIAgentRow({
</div>
<div className="flex flex-wrap gap-1">
{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) => (
<Badge key={serverCode} variant="outline">
@@ -231,12 +247,12 @@ function SortableAIAgentRow({
checked={item.status === Status.Ok}
disabled={actionLoadingId === item.id}
onCheckedChange={() => void handleToggleStatus(item)}
aria-label={`${item.name} 状态切换`}
aria-label={t("aiAgent.toggleStatus", { name: item.name })}
/>
<Badge
variant={item.status === Status.Ok ? "default" : "secondary"}
>
{getStatusLabel(String(item.status))}
{getStatusLabel(String(item.status), t)}
</Badge>
</div>
</TableCell>
@@ -247,14 +263,14 @@ function SortableAIAgentRow({
size="sm"
onClick={() => openEditDialog(item)}
>
{t("aiAgent.edit")}
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="outline" size="icon-sm" className="ml-auto" />
}
aria-label={`更多操作 ${item.name}`}
aria-label={t("aiAgent.moreActions", { name: item.name })}
>
<MoreHorizontalIcon />
</DropdownMenuTrigger>
@@ -264,7 +280,7 @@ function SortableAIAgentRow({
onClick={() => void handleToggleStatus(item)}
>
<PowerIcon className="size-4" />
{item.status === Status.Ok ? "停用" : "启用"}
{item.status === Status.Ok ? t("aiAgent.stop") : t("aiAgent.enabled")}
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive"
@@ -272,7 +288,7 @@ function SortableAIAgentRow({
onClick={() => void handleDelete(item)}
>
<Trash2Icon className="size-4" />
{t("aiAgent.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -283,6 +299,8 @@ function SortableAIAgentRow({
}
export default function DashboardAIAgentsPage() {
const t = useI18n();
const statusOptions = getStatusOptions(t);
const [nameInput, setNameInput] = useState("");
const [statusInput, setStatusInput] = useState("all");
const [name, setName] = useState("");
@@ -324,12 +342,12 @@ export default function DashboardAIAgentsPage() {
setResult(data);
} catch (error) {
toast.error(
error instanceof Error ? error.message : "加载 AI Agent 失败",
error instanceof Error ? error.message : t("aiAgent.loadFailed"),
);
} finally {
setLoading(false);
}
}, [limit, name, page, status]);
}, [limit, name, page, status, t]);
useEffect(() => {
void loadData();
@@ -367,17 +385,17 @@ export default function DashboardAIAgentsPage() {
try {
if (editingItemId) {
await updateAIAgent({ id: editingItemId, ...payload });
toast.success(`已更新 AI Agent${payload.name}`);
toast.success(t("aiAgent.updated", { name: payload.name }));
} else {
const created = await createAIAgent(payload);
toast.success(`已创建 AI Agent${created.name}`);
toast.success(t("aiAgent.created", { name: created.name }));
}
setDialogOpen(false);
setEditingItemId(null);
await loadData();
} catch (error) {
toast.error(
error instanceof Error ? error.message : "保存 AI Agent 失败",
error instanceof Error ? error.message : t("aiAgent.saveFailed"),
);
} finally {
setSaving(false);
@@ -391,12 +409,15 @@ export default function DashboardAIAgentsPage() {
item.status === Status.Ok ? Status.Disabled : Status.Ok;
await updateAIAgentStatus(item.id, nextStatus);
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();
} catch (error) {
toast.error(
error instanceof Error ? error.message : "更新 AI Agent 状态失败",
error instanceof Error ? error.message : t("aiAgent.statusUpdateFailed"),
);
} finally {
setActionLoadingId(null);
@@ -407,11 +428,11 @@ export default function DashboardAIAgentsPage() {
setActionLoadingId(item.id);
try {
await deleteAIAgent(item.id);
toast.success(`已删除 AI Agent${item.name}`);
toast.success(t("aiAgent.deleted", { name: item.name }));
await loadData();
} catch (error) {
toast.error(
error instanceof Error ? error.message : "删除 AI Agent 失败",
error instanceof Error ? error.message : t("aiAgent.deleteFailed"),
);
} finally {
setActionLoadingId(null);
@@ -440,14 +461,14 @@ export default function DashboardAIAgentsPage() {
try {
await updateAIAgentSort(nextResults.map((item) => item.id));
toast.success("AI Agent 排序已更新");
toast.success(t("aiAgent.sortUpdated"));
await loadData();
} catch (error) {
setResult((current) => ({
...current,
results: previousResults,
}));
toast.error(error instanceof Error ? error.message : "更新排序失败");
toast.error(error instanceof Error ? error.message : t("aiAgent.sortUpdateFailed"));
} finally {
setSorting(false);
}
@@ -465,11 +486,11 @@ export default function DashboardAIAgentsPage() {
disabled={loading}
>
<RefreshCwIcon className={loading ? "animate-spin" : ""} />
{t("aiAgent.refresh")}
</Button>
<Button onClick={openCreateDialog}>
<PlusIcon />
AI Agent
{t("aiAgent.new")}
</Button>
</>
}
@@ -478,22 +499,22 @@ export default function DashboardAIAgentsPage() {
value={nameInput}
onChange={(event) => setNameInput(event.target.value)}
onKeyDown={handleFilterKeyDown}
placeholder="按名称筛选"
placeholder={t("aiAgent.filterName")}
className="w-full sm:w-56"
/>
<div className="w-full sm:w-52">
<OptionCombobox
value={statusInput}
options={statusOptions}
placeholder="全部状态"
searchPlaceholder="搜索状态"
emptyText="未找到状态"
placeholder={t("aiAgent.allStatuses")}
searchPlaceholder={t("aiAgent.searchStatus")}
emptyText={t("aiAgent.emptyStatus")}
onChange={setStatusInput}
/>
</div>
<Button variant="outline" onClick={applyFilters} disabled={loading}>
<SearchIcon />
{t("aiAgent.query")}
</Button>
</DashboardToolbar>
@@ -521,13 +542,15 @@ export default function DashboardAIAgentsPage() {
<TableRow>
<TableHead className="w-14"></TableHead>
<TableHead>Agent</TableHead>
<TableHead>AI配置</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>Skills</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-[88px] text-right"></TableHead>
<TableHead>{t("aiAgent.columnAiConfig")}</TableHead>
<TableHead>{t("aiAgent.columnServiceMode")}</TableHead>
<TableHead>{t("aiAgent.columnKnowledge")}</TableHead>
<TableHead>{t("aiAgent.columnSkills")}</TableHead>
<TableHead>{t("aiAgent.columnCapabilities")}</TableHead>
<TableHead>{t("aiAgent.columnStatus")}</TableHead>
<TableHead className="w-[88px] text-right">
{t("aiAgent.columnActions")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -535,8 +558,8 @@ export default function DashboardAIAgentsPage() {
<DashboardTableStateRow
colSpan={9}
loading={loading}
loadingText="正在加载 AI Agent..."
emptyText="暂无 AI Agent"
loadingText={t("aiAgent.loadingRows")}
emptyText={t("aiAgent.emptyRows")}
/>
) : null}
<SortableContext
@@ -549,6 +572,7 @@ export default function DashboardAIAgentsPage() {
item={item}
disabled={sorting}
actionLoadingId={actionLoadingId}
t={t}
openEditDialog={openEditDialog}
handleToggleStatus={handleToggleStatus}
handleDelete={handleDelete}
@@ -1,6 +1,6 @@
"use client"
import { useEffect, useState } from "react"
import { useEffect, useMemo, useState } from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import { Controller, Resolver, useForm } from "react-hook-form"
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 {
AIModelType,
AIModelTypeLabels,
AIProvider,
AIProviderLabels,
} from "@/lib/generated/enums"
import { getEnumOptions } from "@/lib/enums"
import { useI18n } from "@/i18n/provider"
import { OptionCombobox } from "./option-combobox"
type AIConfigEditDialogProps = {
@@ -33,15 +31,19 @@ type AIConfigEditDialogProps = {
onSubmit: (payload: CreateAIConfigPayload) => Promise<void>
}
const providerOptions = getEnumOptions(AIProviderLabels).map((option) => ({
value: String(option.value),
label: option.label,
}))
type TFunction = (key: string, values?: Record<string, string | number>) => string
const modelTypeOptions = getEnumOptions(AIModelTypeLabels).map((option) => ({
value: String(option.value),
label: option.label,
}))
function getProviderOptions(t: TFunction) {
return [{ value: String(AIProvider.OpenAI), label: t("aiConfig.providerOpenAI") }]
}
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 = {
name: "",
@@ -60,29 +62,22 @@ const emptyForm: EditForm = {
remark: "",
}
const aiConfigFormSchema = z.object({
name: z.string().trim().min(1, "配置名称不能为空"),
provider: z.string().trim().min(1, "供应商不能为空"),
baseUrl: z.string().trim().min(1, "基础地址不能为空"),
apiKey: z.string().trim(),
modelType: z.string().trim().min(1, "模型类型不能为空"),
modelName: z.string().trim().min(1, "模型名称不能为空"),
dimension: z.string().trim().regex(/^\d+$/, "向量维度必须是大于等于 0 的整数"),
maxContextTokens: z.string().trim().regex(/^\d+$/, "最大上下文 Token 必须是大于等于 0 的整数"),
maxOutputTokens: z.string().trim().regex(/^\d+$/, "最大输出 Token 必须是大于等于 0 的整数"),
timeoutMs: z.string().trim().regex(/^\d+$/, "超时时间必须是大于等于 0 的整数"),
maxRetryCount: z.string().trim().regex(/^\d+$/, "最大重试次数必须是大于等于 0 的整数"),
rpmLimit: z.string().trim().regex(/^\d+$/, "RPM 限制必须是大于等于 0 的整数"),
tpmLimit: z.string().trim().regex(/^\d+$/, "TPM 限制必须是大于等于 0 的整数"),
remark: z.string().trim(),
})
type EditForm = z.infer<typeof aiConfigFormSchema>
const editFormResolver = zodResolver(aiConfigFormSchema as never) as Resolver<
z.input<typeof aiConfigFormSchema>,
undefined,
z.output<typeof aiConfigFormSchema>
>
type EditForm = {
name: string
provider: string
baseUrl: string
apiKey: string
modelType: string
modelName: string
dimension: string
maxContextTokens: string
maxOutputTokens: string
timeoutMs: string
maxRetryCount: string
rpmLimit: string
tpmLimit: string
remark: string
}
function buildForm(item: AIConfig | null): EditForm {
if (!item) {
@@ -159,12 +154,35 @@ function AIConfigEditDialogBody({
onSubmit,
}: AIConfigEditDialogBodyProps) {
const formId = "ai-config-edit-form"
const t = useI18n()
const [loading, setLoading] = useState(false)
const form = useForm<
z.input<typeof aiConfigFormSchema>,
undefined,
z.output<typeof aiConfigFormSchema>
>({
const providerOptions = useMemo(() => getProviderOptions(t), [t])
const modelTypeOptions = useMemo(() => getModelTypeOptions(t), [t])
const aiConfigFormSchema = useMemo(
() =>
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,
defaultValues: emptyForm,
})
@@ -206,7 +224,7 @@ function AIConfigEditDialogBody({
<ProjectDialog
open={open}
onOpenChange={onOpenChange}
title={itemId ? "编辑 AI 配置" : "新建 AI 配置"}
title={itemId ? t("aiConfig.editTitle") : t("aiConfig.createTitle")}
size="xl"
footer={
<>
@@ -216,26 +234,26 @@ function AIConfigEditDialogBody({
onClick={() => onOpenChange(false)}
disabled={saving}
>
{t("aiConfig.cancel")}
</Button>
<Button type="submit" form={formId} disabled={saving || loading}>
{saving ? "保存中..." : itemId ? "保存" : "创建"}
{saving ? t("aiConfig.saving") : itemId ? t("aiConfig.save") : t("aiConfig.create")}
</Button>
</>
}
>
{loading ? (
<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>
) : (
<form id={formId} onSubmit={handleSubmit(onFormSubmit)} className="space-y-4">
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="ai-config-name"></FieldLabel>
<FieldLabel htmlFor="ai-config-name">{t("aiConfig.name")}</FieldLabel>
<FieldContent>
<Input
id="ai-config-name"
placeholder="例如:OpenAI 主回答模型"
placeholder={t("aiConfig.namePlaceholder")}
aria-invalid={!!errors.name}
{...register("name")}
/>
@@ -245,7 +263,7 @@ function AIConfigEditDialogBody({
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field data-invalid={!!errors.provider}>
<FieldLabel></FieldLabel>
<FieldLabel>{t("aiConfig.provider")}</FieldLabel>
<FieldContent>
<Controller
control={control}
@@ -254,9 +272,9 @@ function AIConfigEditDialogBody({
<OptionCombobox
value={field.value}
options={providerOptions}
placeholder="请选择供应商"
searchPlaceholder="搜索供应商"
emptyText="未找到供应商"
placeholder={t("aiConfig.selectProvider")}
searchPlaceholder={t("aiConfig.searchProvider")}
emptyText={t("aiConfig.emptyProvider")}
onChange={field.onChange}
/>
)}
@@ -266,7 +284,7 @@ function AIConfigEditDialogBody({
</Field>
<Field data-invalid={!!errors.modelType}>
<FieldLabel></FieldLabel>
<FieldLabel>{t("aiConfig.modelType")}</FieldLabel>
<FieldContent>
<Controller
control={control}
@@ -275,9 +293,9 @@ function AIConfigEditDialogBody({
<OptionCombobox
value={field.value}
options={modelTypeOptions}
placeholder="请选择模型类型"
searchPlaceholder="搜索模型类型"
emptyText="未找到模型类型"
placeholder={t("aiConfig.selectModelType")}
searchPlaceholder={t("aiConfig.searchModelType")}
emptyText={t("aiConfig.emptyModelType")}
onChange={field.onChange}
/>
)}
@@ -288,11 +306,11 @@ function AIConfigEditDialogBody({
</div>
<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>
<Input
id="ai-config-base-url"
placeholder="例如:https://api.openai.com/v1"
placeholder={t("aiConfig.baseUrlPlaceholder")}
aria-invalid={!!errors.baseUrl}
{...register("baseUrl")}
/>
@@ -306,7 +324,7 @@ function AIConfigEditDialogBody({
<Input
id="ai-config-api-key"
type="password"
placeholder="请输入 API Key"
placeholder={t("aiConfig.apiKeyPlaceholder")}
aria-invalid={!!errors.apiKey}
{...register("apiKey")}
/>
@@ -316,11 +334,11 @@ function AIConfigEditDialogBody({
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field data-invalid={!!errors.modelName}>
<FieldLabel htmlFor="ai-config-model-name"></FieldLabel>
<FieldLabel htmlFor="ai-config-model-name">{t("aiConfig.modelName")}</FieldLabel>
<FieldContent>
<Input
id="ai-config-model-name"
placeholder="例如:gpt-4o-mini"
placeholder={t("aiConfig.modelNamePlaceholder")}
aria-invalid={!!errors.modelName}
{...register("modelName")}
/>
@@ -329,7 +347,7 @@ function AIConfigEditDialogBody({
</Field>
<Field data-invalid={!!errors.dimension}>
<FieldLabel htmlFor="ai-config-dimension"></FieldLabel>
<FieldLabel htmlFor="ai-config-dimension">{t("aiConfig.dimensionLabel")}</FieldLabel>
<FieldContent>
<Input
id="ai-config-dimension"
@@ -347,7 +365,7 @@ function AIConfigEditDialogBody({
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field data-invalid={!!errors.maxContextTokens}>
<FieldLabel htmlFor="ai-config-max-context"> Token</FieldLabel>
<FieldLabel htmlFor="ai-config-max-context">{t("aiConfig.maxContextTokens")}</FieldLabel>
<FieldContent>
<Input
id="ai-config-max-context"
@@ -362,7 +380,7 @@ function AIConfigEditDialogBody({
</Field>
<Field data-invalid={!!errors.maxOutputTokens}>
<FieldLabel htmlFor="ai-config-max-output"> Token</FieldLabel>
<FieldLabel htmlFor="ai-config-max-output">{t("aiConfig.maxOutputTokens")}</FieldLabel>
<FieldContent>
<Input
id="ai-config-max-output"
@@ -379,7 +397,7 @@ function AIConfigEditDialogBody({
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field data-invalid={!!errors.timeoutMs}>
<FieldLabel htmlFor="ai-config-timeout"> (ms)</FieldLabel>
<FieldLabel htmlFor="ai-config-timeout">{t("aiConfig.timeoutMs")}</FieldLabel>
<FieldContent>
<Input
id="ai-config-timeout"
@@ -394,7 +412,7 @@ function AIConfigEditDialogBody({
</Field>
<Field data-invalid={!!errors.maxRetryCount}>
<FieldLabel htmlFor="ai-config-retry"></FieldLabel>
<FieldLabel htmlFor="ai-config-retry">{t("aiConfig.maxRetryCount")}</FieldLabel>
<FieldContent>
<Input
id="ai-config-retry"
@@ -411,7 +429,7 @@ function AIConfigEditDialogBody({
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<Field data-invalid={!!errors.rpmLimit}>
<FieldLabel htmlFor="ai-config-rpm">RPM </FieldLabel>
<FieldLabel htmlFor="ai-config-rpm">{t("aiConfig.rpmLimit")}</FieldLabel>
<FieldContent>
<Input
id="ai-config-rpm"
@@ -426,7 +444,7 @@ function AIConfigEditDialogBody({
</Field>
<Field data-invalid={!!errors.tpmLimit}>
<FieldLabel htmlFor="ai-config-tpm">TPM </FieldLabel>
<FieldLabel htmlFor="ai-config-tpm">{t("aiConfig.tpmLimit")}</FieldLabel>
<FieldContent>
<Input
id="ai-config-tpm"
@@ -442,11 +460,11 @@ function AIConfigEditDialogBody({
</div>
<Field data-invalid={!!errors.remark}>
<FieldLabel htmlFor="ai-config-remark"></FieldLabel>
<FieldLabel htmlFor="ai-config-remark">{t("aiConfig.remark")}</FieldLabel>
<FieldContent>
<Textarea
id="ai-config-remark"
placeholder="记录用途、费用、限制说明等"
placeholder={t("aiConfig.remarkPlaceholder")}
rows={3}
aria-invalid={!!errors.remark}
{...register("remark")}
@@ -16,6 +16,7 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { useI18n } from "@/i18n/provider"
import { cn } from "@/lib/utils"
export type ComboboxOption = {
@@ -37,11 +38,12 @@ export function OptionCombobox({
value,
options,
placeholder,
searchPlaceholder = "请输入关键字搜索",
emptyText = "没有可选项",
searchPlaceholder,
emptyText,
disabled = false,
onChange,
}: OptionComboboxProps) {
const t = useI18n()
const selectedLabel =
options.find((option) => option.value === value)?.label ?? placeholder
@@ -62,9 +64,9 @@ export function OptionCombobox({
</PopoverTrigger>
<PopoverContent className="w-(--radix-popover-trigger-width) p-0" align="start">
<Command>
<CommandInput placeholder={searchPlaceholder} />
<CommandInput placeholder={searchPlaceholder ?? t("common.searchKeyword")} />
<CommandList>
<CommandEmpty>{emptyText}</CommandEmpty>
<CommandEmpty>{emptyText ?? t("common.emptyOptions")}</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
+105 -89
View File
@@ -26,7 +26,7 @@ import {
SearchIcon,
Trash2Icon
} 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 {
@@ -76,40 +76,52 @@ import {
} from "@/lib/api/admin";
import {
AIModelType,
AIModelTypeLabels,
AIProvider,
AIProviderLabels,
Status,
StatusLabels
} from "@/lib/generated/enums";
import { getEnumLabel, getEnumOptions } from "@/lib/enums";
import { useI18n } from "@/i18n/provider";
import { cn } from "@/lib/utils";
import { EditDialog } from "./_components/edit";
import { OptionCombobox } from "./_components/option-combobox";
const listStatusOptions = [
{ value: "all", label: "全部状态" },
...getEnumOptions(StatusLabels).map((option) => ({
value: String(option.value),
label: option.label,
})),
];
type TFunction = (key: string, values?: Record<string, string | number>) => string;
const providerFilterOptions = [
{ value: "all", label: "全部供应商" },
...getEnumOptions(AIProviderLabels).map((option) => ({
value: String(option.value),
label: option.label,
})),
];
function getStatusOptions(t: TFunction) {
return [
{ value: "all", label: t("aiConfig.allStatuses") },
{ value: String(Status.Ok), label: t("aiConfig.enabled") },
{ value: String(Status.Disabled), label: t("aiConfig.disabled") },
{ value: String(Status.Deleted), label: t("aiConfig.deletedStatus") },
];
}
const modelTypeFilterOptions = [
{ value: "all", label: "全部类型" },
...getEnumOptions(AIModelTypeLabels).map((option) => ({
value: String(option.value),
label: option.label,
})),
];
function getProviderOptions(t: TFunction, includeAll = true) {
const options = [
{ value: String(AIProvider.OpenAI), label: t("aiConfig.providerOpenAI") },
];
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) {
const text = value.trim();
@@ -126,6 +138,7 @@ type SortableAIConfigRowProps = {
item: AIConfig;
disabled: boolean;
actionLoadingId: number | null;
t: TFunction;
openEditDialog: (item: AIConfig) => void;
handleToggleStatus: (item: AIConfig) => void;
handleDelete: (item: AIConfig) => void;
@@ -135,6 +148,7 @@ function SortableAIConfigRow({
item,
disabled,
actionLoadingId,
t,
openEditDialog,
handleToggleStatus,
handleDelete,
@@ -172,7 +186,7 @@ function SortableAIConfigRow({
size="icon"
className="size-8 cursor-grab active:cursor-grabbing"
disabled={disabled}
aria-label={`拖拽排序 ${item.name}`}
aria-label={t("aiConfig.dragSort", { name: item.name })}
{...attributes}
{...listeners}
>
@@ -184,24 +198,18 @@ function SortableAIConfigRow({
</TableCell>
<TableCell>
<Badge variant="outline">
{getEnumLabel(
AIProviderLabels,
item.provider as AIProvider,
)}
{getProviderLabel(item.provider as AIProvider, t)}
</Badge>
</TableCell>
<TableCell>
<div className="space-y-1">
<Badge variant="secondary">
{getEnumLabel(
AIModelTypeLabels,
item.modelType as AIModelType,
)}
{getModelTypeLabel(item.modelType as AIModelType, t)}
</Badge>
<div className="text-sm">{item.modelName}</div>
{item.dimension > 0 && (
<div className="text-xs text-muted-foreground">
{item.dimension}
{t("aiConfig.dimension", { count: item.dimension })}
</div>
)}
</div>
@@ -210,16 +218,19 @@ function SortableAIConfigRow({
<div className="space-y-1 text-sm">
<div className="line-clamp-1">{item.baseUrl}</div>
<div className="text-xs text-muted-foreground">
Key: {maskAPIKey(item.apiKey)}
{t("aiConfig.apiKey", { key: maskAPIKey(item.apiKey) })}
</div>
</div>
</TableCell>
<TableCell>
<div className="space-y-1 text-xs text-muted-foreground">
<div> {item.maxContextTokens || 0}</div>
<div> {item.maxOutputTokens || 0}</div>
<div>{t("aiConfig.contextTokens", { count: item.maxContextTokens || 0 })}</div>
<div>{t("aiConfig.outputTokens", { count: item.maxOutputTokens || 0 })}</div>
<div>
{item.timeoutMs}ms / {item.maxRetryCount}
{t("aiConfig.timeoutRetry", {
timeout: item.timeoutMs,
retries: item.maxRetryCount,
})}
</div>
<div>
RPM {item.rpmLimit || 0} / TPM {item.tpmLimit || 0}
@@ -232,17 +243,14 @@ function SortableAIConfigRow({
checked={item.status === Status.Ok}
disabled={actionLoadingId === item.id}
onCheckedChange={() => void handleToggleStatus(item)}
aria-label={`${item.name} 状态切换`}
aria-label={t("aiConfig.toggleStatus", { name: item.name })}
/>
<Badge
variant={
item.status === Status.Ok ? "default" : "outline"
}
>
{getEnumLabel(
StatusLabels,
item.status as keyof typeof StatusLabels,
)}
{getStatusLabel(item.status as Status, t)}
</Badge>
</div>
</TableCell>
@@ -253,12 +261,12 @@ function SortableAIConfigRow({
size="sm"
onClick={() => openEditDialog(item)}
>
{t("aiConfig.edit")}
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={<Button variant="outline" size="icon-sm" />}
aria-label={`更多操作 ${item.name}`}
aria-label={t("aiConfig.moreActions", { name: item.name })}
>
<MoreHorizontalIcon />
</DropdownMenuTrigger>
@@ -273,10 +281,10 @@ function SortableAIConfigRow({
>
<Trash2Icon />
{item.status === Status.Ok
? "启用中不可删"
? t("aiConfig.deleteDisabledActive")
: actionLoadingId === item.id
? "删除中..."
: "删除"}
? t("aiConfig.deleting")
: t("aiConfig.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -287,6 +295,10 @@ function SortableAIConfigRow({
}
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 [statusFilterInput, setStatusFilterInput] = useState("all");
const [providerFilterInput, setProviderFilterInput] = useState("all");
@@ -335,11 +347,11 @@ export default function DashboardAIConfigsPage() {
});
setResult(data);
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载 AI 配置失败");
toast.error(error instanceof Error ? error.message : t("aiConfig.loadFailed"));
} finally {
setLoading(false);
}
}, [keyword, statusFilter, providerFilter, modelTypeFilter, page, limit]);
}, [keyword, statusFilter, providerFilter, modelTypeFilter, page, limit, t]);
useEffect(() => {
void loadData();
@@ -405,16 +417,16 @@ export default function DashboardAIConfigsPage() {
try {
if (editingItem) {
await updateAIConfig({ id: editingItem.id, ...payload });
toast.success(`已更新 AI 配置:${editingItem.name}`);
toast.success(t("aiConfig.updated", { name: editingItem.name }));
} else {
await createAIConfig(payload);
toast.success(`已创建 AI 配置:${payload.name}`);
toast.success(t("aiConfig.created", { name: payload.name }));
}
setDialogOpen(false);
setEditingItem(null);
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存 AI 配置失败");
toast.error(error instanceof Error ? error.message : t("aiConfig.saveFailed"));
} finally {
setSaving(false);
}
@@ -429,11 +441,14 @@ export default function DashboardAIConfigsPage() {
: Status.Ok;
await updateAIConfigStatus(item.id, nextStatus);
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();
} catch (error) {
toast.error(error instanceof Error ? error.message : "更新状态失败");
toast.error(error instanceof Error ? error.message : t("aiConfig.statusUpdateFailed"));
} finally {
setActionLoadingId(null);
}
@@ -441,7 +456,7 @@ export default function DashboardAIConfigsPage() {
async function handleDelete(item: AIConfig) {
if (item.status === Status.Ok) {
toast.error("启用中的 AI 配置不允许删除");
toast.error(t("aiConfig.activeDeleteBlocked"));
return;
}
setDeletingItem(item);
@@ -456,12 +471,12 @@ export default function DashboardAIConfigsPage() {
setActionLoadingId(item.id);
try {
await deleteAIConfig(item.id);
toast.success(`已删除 AI 配置:${item.name}`);
toast.success(t("aiConfig.deleted", { name: item.name }));
setDeleteDialogOpen(false);
setDeletingItem(null);
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除 AI 配置失败");
toast.error(error instanceof Error ? error.message : t("aiConfig.deleteFailed"));
} finally {
setActionLoadingId(null);
}
@@ -489,14 +504,14 @@ export default function DashboardAIConfigsPage() {
try {
await updateAIConfigSort(nextResults.map((item) => item.id));
toast.success("AI 配置排序已更新");
toast.success(t("aiConfig.sortUpdated"));
await loadData();
} catch (error) {
setResult((current) => ({
...current,
results: previousResults,
}));
toast.error(error instanceof Error ? error.message : "更新排序失败");
toast.error(error instanceof Error ? error.message : t("aiConfig.sortUpdateFailed"));
} finally {
setSorting(false);
}
@@ -514,11 +529,11 @@ export default function DashboardAIConfigsPage() {
disabled={loading}
>
<RefreshCwIcon className={loading ? "animate-spin" : ""} />
{t("aiConfig.refresh")}
</Button>
<Button onClick={openCreateDialog}>
<PlusIcon />
{t("aiConfig.new")}
</Button>
</>
}
@@ -529,7 +544,7 @@ export default function DashboardAIConfigsPage() {
value={keywordInput}
onChange={(event) => setKeywordInput(event.target.value)}
onKeyDown={handleFilterKeyDown}
placeholder="按配置名称筛选"
placeholder={t("aiConfig.filterName")}
className="pl-9"
/>
</div>
@@ -537,9 +552,9 @@ export default function DashboardAIConfigsPage() {
<OptionCombobox
value={modelTypeFilterInput}
options={modelTypeFilterOptions}
placeholder="全部类型"
searchPlaceholder="搜索模型类型"
emptyText="未找到模型类型"
placeholder={t("aiConfig.allTypes")}
searchPlaceholder={t("aiConfig.searchModelType")}
emptyText={t("aiConfig.emptyModelType")}
onChange={setModelTypeFilterInput}
/>
</div>
@@ -547,9 +562,9 @@ export default function DashboardAIConfigsPage() {
<OptionCombobox
value={providerFilterInput}
options={providerFilterOptions}
placeholder="全部供应商"
searchPlaceholder="搜索供应商"
emptyText="未找到供应商"
placeholder={t("aiConfig.allProviders")}
searchPlaceholder={t("aiConfig.searchProvider")}
emptyText={t("aiConfig.emptyProvider")}
onChange={setProviderFilterInput}
/>
</div>
@@ -557,15 +572,15 @@ export default function DashboardAIConfigsPage() {
<OptionCombobox
value={statusFilterInput}
options={listStatusOptions}
placeholder="全部状态"
searchPlaceholder="搜索状态"
emptyText="未找到状态"
placeholder={t("aiConfig.allStatuses")}
searchPlaceholder={t("aiConfig.searchStatus")}
emptyText={t("aiConfig.emptyStatus")}
onChange={setStatusFilterInput}
/>
</div>
<Button variant="outline" onClick={applyFilters} disabled={loading}>
<SearchIcon />
{t("aiConfig.query")}
</Button>
</DashboardToolbar>
@@ -589,13 +604,13 @@ export default function DashboardAIConfigsPage() {
<TableHeader>
<TableRow>
<TableHead className="w-14"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead>{t("aiConfig.columnConfig")}</TableHead>
<TableHead>{t("aiConfig.columnProvider")}</TableHead>
<TableHead>{t("aiConfig.columnModel")}</TableHead>
<TableHead>{t("aiConfig.columnAccess")}</TableHead>
<TableHead>{t("aiConfig.columnLimits")}</TableHead>
<TableHead>{t("aiConfig.columnStatus")}</TableHead>
<TableHead className="text-right">{t("aiConfig.columnActions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -603,8 +618,8 @@ export default function DashboardAIConfigsPage() {
<DashboardTableStateRow
colSpan={8}
loading={loading}
loadingText="正在加载 AI 配置..."
emptyText="暂无 AI 配置数据"
loadingText={t("aiConfig.loadingRows")}
emptyText={t("aiConfig.emptyRows")}
/>
) : (
<SortableContext
@@ -617,6 +632,7 @@ export default function DashboardAIConfigsPage() {
item={item}
disabled={sorting}
actionLoadingId={actionLoadingId}
t={t}
openEditDialog={openEditDialog}
handleToggleStatus={handleToggleStatus}
handleDelete={handleDelete}
@@ -652,11 +668,11 @@ export default function DashboardAIConfigsPage() {
>
<DialogContent className="max-w-md" showCloseButton={false}>
<DialogHeader>
<DialogTitle> AI </DialogTitle>
<DialogTitle>{t("aiConfig.confirmDeleteTitle")}</DialogTitle>
<DialogDescription>
{deletingItem
? `确认删除“${deletingItem.name}”吗?此操作不可撤销。`
: "此操作不可撤销。"}
? t("aiConfig.confirmDeleteDescription", { name: deletingItem.name })
: t("aiConfig.deleteIrreversible")}
</DialogDescription>
</DialogHeader>
<DialogFooter>
@@ -669,7 +685,7 @@ export default function DashboardAIConfigsPage() {
setDeletingItem(null);
}}
>
{t("aiConfig.cancel")}
</Button>
<Button
type="button"
@@ -677,7 +693,7 @@ export default function DashboardAIConfigsPage() {
disabled={!!actionLoadingId}
onClick={() => void handleConfirmDelete()}
>
{actionLoadingId ? "删除中..." : "确认删除"}
{actionLoadingId ? t("aiConfig.deleting") : t("aiConfig.confirmDelete")}
</Button>
</DialogFooter>
</DialogContent>
+175 -143
View File
@@ -28,6 +28,7 @@ import {
fetchWxWorkKFAccounts,
resetChannelUserTokenSecret,
} from "@/lib/api/admin"
import { useI18n } from "@/i18n/provider"
type ChannelFormDialogProps = {
open: boolean
@@ -37,16 +38,7 @@ type ChannelFormDialogProps = {
onSubmit: (payload: CreateAdminChannelPayload) => Promise<void>
}
const channelTypeOptions = [
{ 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 Translate = (key: string, values?: Record<string, string | number>) => string
type WebChannelConfig = {
title?: string
@@ -64,59 +56,72 @@ type WechatMPChannelConfig = {
userTokenSecret?: string
}
const defaultWebChannelConfig: Required<WebChannelConfig> = {
title: "在线客服",
subtitle: "欢迎咨询",
themeColor: "#2563eb",
position: "right",
width: "380px",
userTokenSecret: "",
function getDefaultWebChannelConfig(t: Translate): Required<WebChannelConfig> {
return {
title: t("channel.defaultTitleWeb"),
subtitle: t("channel.defaultSubtitle"),
themeColor: "#2563eb",
position: "right",
width: "380px",
userTokenSecret: "",
}
}
const schema = z
.object({
channelType: z.enum(["web", "wechat_mp", "wxwork_kf"], "请选择渠道类型"),
aiAgentId: z.string().trim().regex(/^\d+$/, "请选择 AI Agent"),
name: z.string().trim().min(1, "渠道名称不能为空"),
openKfId: z.string().trim(),
widgetTitle: z.string().trim(),
widgetSubtitle: z.string().trim(),
widgetThemeColor: z.string().trim(),
widgetPosition: z.enum(["left", "right"]),
widgetWidth: z.string().trim(),
userTokenSecret: z.string().trim(),
remark: z.string().trim(),
})
.superRefine((values, ctx) => {
if (values.channelType === "wxwork_kf" && !values.openKfId.trim()) {
ctx.addIssue({
code: "custom",
path: ["openKfId"],
message: "请选择企业微信客服账号",
})
}
})
function createSchema(t: Translate) {
return z
.object({
channelType: z.enum(["web", "wechat_mp", "wxwork_kf"], t("channel.typeRequired")),
aiAgentId: z.string().trim().regex(/^\d+$/, t("channel.agentRequired")),
name: z.string().trim().min(1, t("channel.nameRequired")),
openKfId: z.string().trim(),
widgetTitle: z.string().trim(),
widgetSubtitle: z.string().trim(),
widgetThemeColor: z.string().trim(),
widgetPosition: z.enum(["left", "right"]),
widgetWidth: z.string().trim(),
userTokenSecret: z.string().trim(),
remark: z.string().trim(),
})
.superRefine((values, ctx) => {
if (values.channelType === "wxwork_kf" && !values.openKfId.trim()) {
ctx.addIssue({
code: "custom",
path: ["openKfId"],
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<
z.input<typeof schema>,
undefined,
z.output<typeof schema>
>
const emptyForm: EditForm = {
channelType: "web",
aiAgentId: "",
name: "",
openKfId: "",
widgetTitle: defaultWebChannelConfig.title,
widgetSubtitle: defaultWebChannelConfig.subtitle,
widgetThemeColor: defaultWebChannelConfig.themeColor,
widgetPosition: defaultWebChannelConfig.position,
widgetWidth: defaultWebChannelConfig.width,
userTokenSecret: "",
remark: "",
function createEmptyForm(t: Translate): EditForm {
const defaultWebChannelConfig = getDefaultWebChannelConfig(t)
return {
channelType: "web",
aiAgentId: "",
name: "",
openKfId: "",
widgetTitle: defaultWebChannelConfig.title,
widgetSubtitle: defaultWebChannelConfig.subtitle,
widgetThemeColor: defaultWebChannelConfig.themeColor,
widgetPosition: defaultWebChannelConfig.position,
widgetWidth: defaultWebChannelConfig.width,
userTokenSecret: "",
remark: "",
}
}
function parseOpenKfId(configJson: string): string {
@@ -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()) {
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 = {
title: "公众号客服",
title: t("channel.defaultTitleWechat"),
subtitle: defaultWebChannelConfig.subtitle,
themeColor: defaultWebChannelConfig.themeColor,
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) {
return emptyForm
return createEmptyForm(t)
}
const isWechatMP = item.channelType === "wechat_mp"
const webConfig = parseWebChannelConfig(item.configJson)
const webConfig = parseWebChannelConfig(item.configJson, t)
const wechatConfig = isWechatMP
? parseWechatMPChannelConfig(item.configJson)
? parseWechatMPChannelConfig(item.configJson, t)
: null
return {
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 defaultWebChannelConfig = getDefaultWebChannelConfig(t)
const webLikeConfig = {
title:
form.widgetTitle.trim() ||
(channelType === "wechat_mp" ? "公众号客服" : defaultWebChannelConfig.title),
(channelType === "wechat_mp" ? t("channel.defaultTitleWechat") : defaultWebChannelConfig.title),
subtitle: form.widgetSubtitle.trim(),
themeColor:
form.widgetThemeColor.trim() || defaultWebChannelConfig.themeColor,
@@ -267,7 +275,19 @@ function ChannelFormBody({
onOpenChange,
onSubmit,
}: ChannelFormBodyProps) {
const t = useI18n()
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 [aiAgents, setAIAgents] = useState<AIAgent[]>([])
const [wxWorkKFAccounts, setWxWorkKFAccounts] = useState<WxWorkKFAccount[]>([])
@@ -320,7 +340,7 @@ function ChannelFormBody({
const data = await fetchChannel(itemId)
setChannelDetail(data)
setCurrentStatus(data.status)
reset(buildForm(data))
reset(buildForm(data, t))
} catch (error) {
console.error("Failed to load channel:", error)
} finally {
@@ -328,7 +348,7 @@ function ChannelFormBody({
}
}
void loadDetail()
}, [itemId, reset])
}, [emptyForm, itemId, reset, t])
useEffect(() => {
if (
@@ -348,7 +368,7 @@ function ChannelFormBody({
} catch (error) {
console.error("Failed to load WeCom KF accounts:", error)
setWxWorkKFAccountsError(
error instanceof Error ? error.message : "企业微信客服账号加载失败"
error instanceof Error ? error.message : t("channel.loadWxworkAccountsFailed")
)
} finally {
setWxWorkKFAccountsLoading(false)
@@ -360,6 +380,7 @@ function ChannelFormBody({
wxWorkKFAccounts.length,
wxWorkKFAccountsError,
wxWorkKFAccountsLoading,
t,
])
const aiAgentOptions = aiAgents.map((item) => ({
@@ -370,6 +391,15 @@ function ChannelFormBody({
value: 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 (
channelType === "wxwork_kf" &&
openKfId &&
@@ -382,14 +412,14 @@ function ChannelFormBody({
}
async function onFormSubmit(values: EditForm) {
await onSubmit(buildPayload(values, currentStatus))
await onSubmit(buildPayload(values, currentStatus, t))
}
async function handleResetUserTokenSecret() {
if (!itemId) {
return
}
if (!window.confirm("重置后旧 userToken 将在过期后失效,确认重置?")) {
if (!window.confirm(t("channel.resetSecretConfirm"))) {
return
}
try {
@@ -405,9 +435,9 @@ function ChannelFormBody({
configJson: JSON.stringify(parsed),
})
}
toast.success("已重置用户 JWT Secret")
toast.success(t("channel.resetSecretSuccess"))
} 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 {
await navigator.clipboard.writeText(userTokenSecret)
toast.success("已复制用户 JWT Secret")
toast.success(t("channel.copySecretSuccess"))
} catch {
toast.error("复制失败")
toast.error(t("channel.copyFailed"))
}
}
@@ -427,29 +457,29 @@ function ChannelFormBody({
<ProjectDialog
open={true}
onOpenChange={onOpenChange}
title={itemId ? "编辑渠道" : "新建渠道"}
title={itemId ? t("channel.editTitle") : t("channel.createTitle")}
size="lg"
allowFullscreen
footer={
<>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
{t("channel.cancel")}
</Button>
<Button type="submit" form={formId} disabled={saving || loading}>
{saving ? "保存中..." : "保存"}
{saving ? t("channel.saving") : t("channel.save")}
</Button>
</>
}
>
{loading ? (
<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>
) : (
<form id={formId} onSubmit={handleSubmit(onFormSubmit)} className="space-y-5">
<div className="grid grid-cols-1 gap-4">
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="channel-name"></FieldLabel>
<FieldLabel htmlFor="channel-name">{t("channel.name")}</FieldLabel>
<FieldContent>
<Input id="channel-name" {...register("name")} />
<FieldError errors={[errors.name]} />
@@ -457,7 +487,7 @@ function ChannelFormBody({
</Field>
<Field data-invalid={!!errors.aiAgentId}>
<FieldLabel> Agent</FieldLabel>
<FieldLabel>{t("channel.columnAgent")}</FieldLabel>
<FieldContent>
<Controller
control={control}
@@ -466,9 +496,9 @@ function ChannelFormBody({
<OptionCombobox
value={field.value}
options={aiAgentOptions}
placeholder="请选择 AI Agent"
searchPlaceholder="搜索 AI Agent"
emptyText="未找到 AI Agent"
placeholder={t("channel.agentRequired")}
searchPlaceholder={t("channel.searchAiAgent")}
emptyText={t("channel.emptyAiAgent")}
onChange={field.onChange}
/>
)}
@@ -478,7 +508,7 @@ function ChannelFormBody({
</Field>
<Field data-invalid={!!errors.channelType}>
<FieldLabel></FieldLabel>
<FieldLabel>{t("channel.channelType")}</FieldLabel>
<FieldContent>
<Controller
control={control}
@@ -487,9 +517,9 @@ function ChannelFormBody({
<OptionCombobox
value={field.value}
options={[...channelTypeOptions]}
placeholder="请选择接入渠道"
searchPlaceholder="搜索接入渠道"
emptyText="未找到接入渠道"
placeholder={t("channel.selectChannelType")}
searchPlaceholder={t("channel.searchChannelType")}
emptyText={t("channel.emptyChannelType")}
onChange={field.onChange}
/>
)}
@@ -501,19 +531,19 @@ function ChannelFormBody({
<div className="space-y-4 rounded-md border p-4">
<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">
{channelType === "wxwork_kf"
? "配置企业微信客服账号,用于匹配回调消息和对外发送消息。"
? t("channel.configWxworkDescription")
: channelType === "wechat_mp"
? "配置公众号菜单直达聊天页的展示参数。"
: "配置 Web 站点客服窗口的展示参数。"}
? t("channel.configWechatDescription")
: t("channel.configWebDescription")}
</div>
</div>
{channelType === "wxwork_kf" ? (
<Field data-invalid={!!errors.openKfId}>
<FieldLabel></FieldLabel>
<FieldLabel>{t("channel.wxworkAccount")}</FieldLabel>
<FieldContent>
<Controller
control={control}
@@ -523,11 +553,11 @@ function ChannelFormBody({
value={field.value}
options={wxWorkKFAccountOptions}
placeholder={
wxWorkKFAccountsLoading ? "正在加载客服账号" : "请选择客服账号"
wxWorkKFAccountsLoading ? t("channel.loadingWxworkAccount") : t("channel.selectWxworkAccount")
}
searchPlaceholder="搜索客服账号"
searchPlaceholder={t("channel.searchWxworkAccount")}
emptyText={
wxWorkKFAccountsError || "未找到企业微信客服账号"
wxWorkKFAccountsError || t("channel.emptyWxworkAccount")
}
disabled={wxWorkKFAccountsLoading}
onChange={field.onChange}
@@ -543,7 +573,7 @@ function ChannelFormBody({
<>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field data-invalid={!!errors.widgetTitle}>
<FieldLabel htmlFor="channel-widget-title"></FieldLabel>
<FieldLabel htmlFor="channel-widget-title">{t("channel.widgetTitle")}</FieldLabel>
<FieldContent>
<Input id="channel-widget-title" {...register("widgetTitle")} />
<FieldError errors={[errors.widgetTitle]} />
@@ -551,7 +581,7 @@ function ChannelFormBody({
</Field>
<Field data-invalid={!!errors.widgetSubtitle}>
<FieldLabel htmlFor="channel-widget-subtitle"></FieldLabel>
<FieldLabel htmlFor="channel-widget-subtitle">{t("channel.widgetSubtitle")}</FieldLabel>
<FieldContent>
<Input
id="channel-widget-subtitle"
@@ -562,7 +592,7 @@ function ChannelFormBody({
</Field>
<Field data-invalid={!!errors.widgetThemeColor}>
<FieldLabel htmlFor="channel-widget-theme-color"></FieldLabel>
<FieldLabel htmlFor="channel-widget-theme-color">{t("channel.themeColor")}</FieldLabel>
<FieldContent>
<Input
id="channel-widget-theme-color"
@@ -576,7 +606,7 @@ function ChannelFormBody({
{channelType === "web" ? (
<>
<Field data-invalid={!!errors.widgetPosition}>
<FieldLabel></FieldLabel>
<FieldLabel>{t("channel.mountPosition")}</FieldLabel>
<FieldContent>
<Controller
control={control}
@@ -585,9 +615,9 @@ function ChannelFormBody({
<OptionCombobox
value={field.value}
options={[...widgetPositionOptions]}
placeholder="请选择挂载位置"
searchPlaceholder="搜索挂载位置"
emptyText="未找到挂载位置"
placeholder={t("channel.selectMountPosition")}
searchPlaceholder={t("channel.searchMountPosition")}
emptyText={t("channel.emptyMountPosition")}
onChange={field.onChange}
/>
)}
@@ -597,7 +627,7 @@ function ChannelFormBody({
</Field>
<Field data-invalid={!!errors.widgetWidth}>
<FieldLabel htmlFor="channel-widget-width"></FieldLabel>
<FieldLabel htmlFor="channel-widget-width">{t("channel.widgetWidth")}</FieldLabel>
<FieldContent>
<Input
id="channel-widget-width"
@@ -612,14 +642,14 @@ function ChannelFormBody({
</div>
<div className="space-y-3 rounded-md border p-3">
<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">
使 secret userToken
{t("channel.userJwtSecretDescription")}
</div>
</div>
{!itemId ? (
<div className="rounded-md bg-muted px-3 py-2 text-sm text-muted-foreground">
JWT Secret
{t("channel.secretAfterSave")}
</div>
) : (
<Field data-invalid={!!errors.userTokenSecret}>
@@ -640,14 +670,14 @@ function ChannelFormBody({
disabled={!userTokenSecret}
>
<CopyIcon className="size-4" />
{t("channel.copy")}
</Button>
<Button
type="button"
variant="outline"
onClick={() => void handleResetUserTokenSecret()}
>
{t("channel.reset")}
</Button>
</div>
</div>
@@ -666,7 +696,7 @@ function ChannelFormBody({
</div>
<Field data-invalid={!!errors.remark}>
<FieldLabel htmlFor="channel-remark"></FieldLabel>
<FieldLabel htmlFor="channel-remark">{t("channel.remark")}</FieldLabel>
<FieldContent>
<Textarea id="channel-remark" rows={3} {...register("remark")} />
<FieldError errors={[errors.remark]} />
@@ -679,6 +709,7 @@ function ChannelFormBody({
}
function WebAccessGuide({ channelId }: { channelId: string }) {
const t = useI18n()
const [origin, setOrigin] = useState("")
useEffect(() => {
@@ -723,29 +754,29 @@ function WebAccessGuide({ channelId }: { channelId: string }) {
await navigator.clipboard.writeText(text)
toast.success(successMessage)
} catch {
toast.error("复制失败")
toast.error(t("channel.copyFailed"))
}
}
return (
<div className="space-y-4 border-t pt-4">
<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">
{channelId
? "复制链接或嵌入代码即可接入当前 Web 渠道。"
: "保存渠道后生成接入链接。"}
? t("channel.webAccessReady")
: t("channel.webAccessPending")}
</div>
</div>
{!channelId ? (
<div className="rounded-md bg-muted px-3 py-2 text-sm text-muted-foreground">
channelId
{t("channel.newChannelPending")}
</div>
) : (
<div className="space-y-4">
<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">
<Input readOnly value={accessUrl} className="font-mono text-xs" />
<div className="flex gap-2">
@@ -753,8 +784,8 @@ function WebAccessGuide({ channelId }: { channelId: string }) {
type="button"
variant="outline"
size="icon"
title="复制链接"
onClick={() => copyText(accessUrl, "已复制接入链接")}
title={t("channel.copyLink")}
onClick={() => copyText(accessUrl, t("channel.copiedAccessLink"))}
>
<CopyIcon className="size-4" />
</Button>
@@ -762,7 +793,7 @@ function WebAccessGuide({ channelId }: { channelId: string }) {
type="button"
variant="outline"
size="icon"
title="打开链接"
title={t("channel.openLink")}
onClick={() => window.open(accessUrl, "_blank", "noopener,noreferrer")}
>
<ExternalLinkIcon className="size-4" />
@@ -774,16 +805,16 @@ function WebAccessGuide({ channelId }: { channelId: string }) {
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<div className="text-xs font-medium text-muted-foreground">
{t("channel.embeddedSnippet")}
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => copyText(snippet, "已复制接入代码")}
onClick={() => copyText(snippet, t("channel.copiedSnippet"))}
>
<CopyIcon className="size-4" />
{t("channel.copyCode")}
</Button>
</div>
<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 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>1. </div>
<div>2. HTML body </div>
<div>3. </div>
<div>4. 使访</div>
<div className="font-medium text-foreground">{t("channel.accessGuide")}</div>
<div>{t("channel.webGuide1")}</div>
<div>{t("channel.webGuide2")}</div>
<div>{t("channel.webGuide3")}</div>
<div>{t("channel.webGuide4")}</div>
<div className="pt-1">
<Button
type="button"
@@ -805,7 +836,7 @@ function WebAccessGuide({ channelId }: { channelId: string }) {
onClick={() => window.open(testUrl, "_blank", "noopener,noreferrer")}
>
<ExternalLinkIcon className="size-4" />
{t("channel.openTestPage")}
</Button>
</div>
</div>
@@ -816,6 +847,7 @@ function WebAccessGuide({ channelId }: { channelId: string }) {
}
function WechatMPAccessGuide({ channelId }: { channelId: string }) {
const t = useI18n()
const [origin, setOrigin] = useState("")
useEffect(() => {
@@ -837,32 +869,32 @@ function WechatMPAccessGuide({ channelId }: { channelId: string }) {
}
try {
await navigator.clipboard.writeText(text)
toast.success("已复制公众号菜单链接")
toast.success(t("channel.copiedWechatMenuUrl"))
} catch {
toast.error("复制失败")
toast.error(t("channel.copyFailed"))
}
}
return (
<div className="space-y-4 border-t pt-4">
<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">
{channelId
? "将该链接配置到微信公众号自定义菜单,用户点击菜单后直接进入客服聊天页。"
: "保存渠道后生成公众号菜单链接。"}
? t("channel.wechatAccessReady")
: t("channel.wechatAccessPending")}
</div>
</div>
{!channelId ? (
<div className="rounded-md bg-muted px-3 py-2 text-sm text-muted-foreground">
channelId
{t("channel.newChannelPending")}
</div>
) : (
<div className="space-y-4">
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground">
{t("channel.wechatMenuUrl")}
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<Input readOnly value={menuUrl} className="font-mono text-xs" />
@@ -871,7 +903,7 @@ function WechatMPAccessGuide({ channelId }: { channelId: string }) {
type="button"
variant="outline"
size="icon"
title="复制链接"
title={t("channel.copyLink")}
onClick={() => copyText(menuUrl)}
>
<CopyIcon className="size-4" />
@@ -880,7 +912,7 @@ function WechatMPAccessGuide({ channelId }: { channelId: string }) {
type="button"
variant="outline"
size="icon"
title="打开链接"
title={t("channel.openLink")}
onClick={() => window.open(menuUrl, "_blank", "noopener,noreferrer")}
>
<ExternalLinkIcon className="size-4" />
@@ -890,10 +922,10 @@ function WechatMPAccessGuide({ channelId }: { channelId: string }) {
</div>
<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>1. </div>
<div>2. </div>
<div>3. </div>
<div className="font-medium text-foreground">{t("channel.accessGuide")}</div>
<div>{t("channel.webGuide1")}</div>
<div>{t("channel.wechatGuide2")}</div>
<div>{t("channel.wechatGuide3")}</div>
</div>
</div>
)}
+66 -54
View File
@@ -50,33 +50,29 @@ import {
TableRow,
} from "@/components/ui/table"
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 { Switch } from "@/components/ui/switch"
import { useI18n } from "@/i18n/provider"
const statusOptions = [
{ 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) {
function getChannelTypeLabel(channelType: string, t: (key: string) => string) {
if (channelType === "wechat_mp") {
return "微信公众号"
return t("channel.typeWechatMp")
}
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 }) {
@@ -90,6 +86,7 @@ function ChannelIcon({ channelType }: { channelType: string }) {
}
export default function DashboardChannelsPage() {
const t = useI18n()
const [nameInput, setNameInput] = useState("")
const [channelIdInput, setChannelIdInput] = useState("")
const [channelTypeInput, setChannelTypeInput] = useState("all")
@@ -123,11 +120,26 @@ export default function DashboardChannelsPage() {
})
setResult(data)
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载接入渠道失败")
toast.error(error instanceof Error ? error.message : t("channel.loadFailed"))
} finally {
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(() => {
void loadData()
@@ -167,16 +179,16 @@ export default function DashboardChannelsPage() {
try {
if (editingItem) {
await updateChannel({ id: editingItem.id, ...payload })
toast.success(`已更新接入渠道:${payload.name}`)
toast.success(t("channel.updated", { name: payload.name }))
} else {
const created = await createChannel(payload)
toast.success(`已创建接入渠道:${created.name}`)
toast.success(t("channel.created", { name: created.name }))
}
setDialogOpen(false)
setEditingItem(null)
await loadData()
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存接入渠道失败")
toast.error(error instanceof Error ? error.message : t("channel.saveFailed"))
} finally {
setSaving(false)
}
@@ -187,10 +199,10 @@ export default function DashboardChannelsPage() {
try {
const nextStatus = item.status === Status.Ok ? Status.Disabled : Status.Ok
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()
} catch (error) {
toast.error(error instanceof Error ? error.message : "更新渠道状态失败")
toast.error(error instanceof Error ? error.message : t("channel.statusUpdateFailed"))
} finally {
setActionLoadingId(null)
}
@@ -200,10 +212,10 @@ export default function DashboardChannelsPage() {
setActionLoadingId(item.id)
try {
await deleteChannel(item.id)
toast.success(`已删除接入渠道:${item.name}`)
toast.success(t("channel.deleted", { name: item.name }))
await loadData()
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除接入渠道失败")
toast.error(error instanceof Error ? error.message : t("channel.deleteFailed"))
} finally {
setActionLoadingId(null)
}
@@ -217,11 +229,11 @@ export default function DashboardChannelsPage() {
<>
<Button variant="outline" onClick={() => void loadData()} disabled={loading}>
<RefreshCwIcon className={loading ? "animate-spin" : ""} />
{t("channel.refresh")}
</Button>
<Button onClick={openCreateDialog}>
<PlusIcon />
{t("channel.new")}
</Button>
</>
}
@@ -230,7 +242,7 @@ export default function DashboardChannelsPage() {
value={nameInput}
onChange={(event) => setNameInput(event.target.value)}
onKeyDown={handleFilterKeyDown}
placeholder="按渠道名称筛选"
placeholder={t("channel.filterName")}
className="w-full sm:w-56"
/>
<div className="relative w-full sm:w-72">
@@ -239,7 +251,7 @@ export default function DashboardChannelsPage() {
value={channelIdInput}
onChange={(event) => setChannelIdInput(event.target.value)}
onKeyDown={handleFilterKeyDown}
placeholder="按 channelId 筛选"
placeholder={t("channel.filterChannelId")}
className="pl-9"
/>
</div>
@@ -247,9 +259,9 @@ export default function DashboardChannelsPage() {
<OptionCombobox
value={channelTypeInput}
options={[...channelTypeOptions]}
placeholder="全部类型"
searchPlaceholder="搜索渠道类型"
emptyText="未找到渠道类型"
placeholder={t("channel.allTypes")}
searchPlaceholder={t("channel.searchType")}
emptyText={t("channel.emptyType")}
onChange={setChannelTypeInput}
/>
</div>
@@ -257,15 +269,15 @@ export default function DashboardChannelsPage() {
<OptionCombobox
value={statusInput}
options={[...statusOptions]}
placeholder="全部状态"
searchPlaceholder="搜索状态"
emptyText="未找到状态"
placeholder={t("status.all")}
searchPlaceholder={t("channel.searchStatus")}
emptyText={t("channel.emptyStatus")}
onChange={setStatusInput}
/>
</div>
<Button variant="outline" onClick={applyFilters} disabled={loading}>
<SearchIcon />
{t("channel.query")}
</Button>
</DashboardToolbar>
@@ -287,12 +299,12 @@ export default function DashboardChannelsPage() {
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>{t("channel.columnChannel")}</TableHead>
<TableHead>{t("channel.columnType")}</TableHead>
<TableHead>ChannelID</TableHead>
<TableHead> Agent</TableHead>
<TableHead></TableHead>
<TableHead className="w-[88px] text-right"></TableHead>
<TableHead>{t("channel.columnAgent")}</TableHead>
<TableHead>{t("channel.columnStatus")}</TableHead>
<TableHead className="w-[88px] text-right">{t("channel.columnActions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -300,8 +312,8 @@ export default function DashboardChannelsPage() {
<DashboardTableStateRow
colSpan={6}
loading={loading}
loadingText="正在加载接入渠道..."
emptyText="暂无接入渠道"
loadingText={t("channel.loading")}
emptyText={t("channel.empty")}
/>
) : null}
{result.results.map((item) => (
@@ -313,12 +325,12 @@ export default function DashboardChannelsPage() {
</div>
<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>
</TableCell>
<TableCell>
<Badge variant="outline">{getChannelTypeLabel(item.channelType)}</Badge>
<Badge variant="outline">{getChannelTypeLabel(item.channelType, t)}</Badge>
</TableCell>
<TableCell className="font-mono text-xs">{item.channelId || "-"}</TableCell>
<TableCell>{item.aiAgentName || "-"}</TableCell>
@@ -328,22 +340,22 @@ export default function DashboardChannelsPage() {
checked={item.status === Status.Ok}
disabled={actionLoadingId === item.id}
onCheckedChange={() => void handleToggleStatus(item)}
aria-label={`${item.name} 状态切换`}
aria-label={t("channel.toggleStatus", { name: item.name })}
/>
<Badge variant={item.status === Status.Ok ? "default" : "outline"}>
{getEnumLabel(StatusLabels, item.status as Status)}
{getStatusLabel(item.status as Status, t)}
</Badge>
</div>
</TableCell>
<TableCell className="text-right">
<ButtonGroup className="ml-auto">
<Button variant="outline" size="sm" onClick={() => openEditDialog(item)}>
{t("channel.edit")}
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={<Button variant="outline" size="icon-sm" className="ml-auto" />}
aria-label={`更多操作 ${item.name}`}
aria-label={t("channel.moreActions", { name: item.name })}
>
<MoreHorizontalIcon />
</DropdownMenuTrigger>
@@ -354,7 +366,7 @@ export default function DashboardChannelsPage() {
onClick={() => void handleDelete(item)}
>
<Trash2Icon className="size-4" />
{t("channel.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -1,6 +1,6 @@
"use client"
import { useEffect, useState } from "react"
import { useEffect, useMemo, useState } from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import type { Resolver } from "react-hook-form"
import { useForm } from "react-hook-form"
@@ -21,6 +21,7 @@ import {
type AdminCompany,
type CreateAdminCompanyPayload,
} from "@/lib/api/company"
import { useI18n } from "@/i18n/provider"
type CompanyEditDialogProps = {
open: boolean
@@ -31,19 +32,19 @@ type CompanyEditDialogProps = {
onSubmit: (payload: CreateAdminCompanyPayload) => Promise<void>
}
const companyFormSchema = z.object({
name: z.string().trim().min(1, "公司名称不能为空"),
code: z.string().trim(),
remark: z.string().trim(),
})
type EditForm = {
name: string
code: string
remark: string
}
type EditForm = z.infer<typeof companyFormSchema>
const editFormResolver = zodResolver(companyFormSchema as never) as Resolver<
z.input<typeof companyFormSchema>,
undefined,
z.output<typeof companyFormSchema>
>
function createSchema(t: (key: string) => string) {
return z.object({
name: z.string().trim().min(1, t("company.nameRequired")),
code: z.string().trim(),
remark: z.string().trim(),
})
}
const emptyForm: EditForm = {
name: "",
@@ -112,8 +113,19 @@ function CompanyEditDialogBody({
onOpenChange,
onSubmit,
}: CompanyEditDialogBodyProps) {
const t = useI18n()
const formId = "company-edit-form"
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<
z.input<typeof companyFormSchema>,
undefined,
@@ -154,7 +166,7 @@ function CompanyEditDialogBody({
<ProjectDialog
open={open}
onOpenChange={onOpenChange}
title={itemId ? "编辑公司" : "新建公司"}
title={itemId ? t("company.editTitle") : t("company.createTitle")}
size="md"
allowFullscreen
footer={
@@ -165,26 +177,26 @@ function CompanyEditDialogBody({
onClick={() => onOpenChange(false)}
disabled={saving}
>
{t("company.cancel")}
</Button>
<Button type="submit" form={formId} disabled={saving || loading}>
{saving ? "保存中..." : itemId ? "保存" : "创建"}
{saving ? t("company.saving") : itemId ? t("company.save") : t("company.create")}
</Button>
</>
}
>
{loading ? (
<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>
) : (
<form id={formId} onSubmit={handleSubmit(onFormSubmit)} className="space-y-4">
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="company-name"></FieldLabel>
<FieldLabel htmlFor="company-name">{t("company.columnName")}</FieldLabel>
<FieldContent>
<Input
id="company-name"
placeholder="请输入公司名称"
placeholder={t("company.namePlaceholder")}
aria-invalid={!!errors.name}
{...register("name")}
/>
@@ -192,11 +204,11 @@ function CompanyEditDialogBody({
</FieldContent>
</Field>
<Field data-invalid={!!errors.code}>
<FieldLabel htmlFor="company-code"></FieldLabel>
<FieldLabel htmlFor="company-code">{t("company.columnCode")}</FieldLabel>
<FieldContent>
<Input
id="company-code"
placeholder="可选"
placeholder={t("company.optional")}
aria-invalid={!!errors.code}
{...register("code")}
/>
@@ -204,11 +216,11 @@ function CompanyEditDialogBody({
</FieldContent>
</Field>
<Field data-invalid={!!errors.remark}>
<FieldLabel htmlFor="company-remark"></FieldLabel>
<FieldLabel htmlFor="company-remark">{t("company.columnRemark")}</FieldLabel>
<FieldContent>
<Textarea
id="company-remark"
placeholder="可选"
placeholder={t("company.remarkPlaceholder")}
rows={4}
aria-invalid={!!errors.remark}
{...register("remark")}
+51 -39
View File
@@ -50,19 +50,21 @@ import {
} from "@/lib/api/company"
import { getEnumOptions } from "@/lib/enums"
import { Status, StatusLabels } from "@/lib/generated/enums"
import { useI18n } from "@/i18n/provider"
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
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")
}
export default function DashboardCompaniesPage() {
const t = useI18n()
const [nameInput, setNameInput] = useState("")
const [codeInput, setCodeInput] = useState("")
const [statusFilterInput, setStatusFilterInput] = useState("all")
@@ -93,11 +95,21 @@ export default function DashboardCompaniesPage() {
})
setResult(data)
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载公司列表失败")
toast.error(error instanceof Error ? error.message : t("company.loadFailed"))
} finally {
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(() => {
void loadData()
@@ -143,16 +155,16 @@ export default function DashboardCompaniesPage() {
try {
if (editingItem) {
await updateCompany({ id: editingItem.id, ...payload })
toast.success(`已更新公司:${editingItem.name}`)
toast.success(t("company.updated", { name: editingItem.name }))
} else {
await createCompany(payload)
toast.success(`已创建公司:${payload.name}`)
toast.success(t("company.created", { name: payload.name }))
}
setDialogOpen(false)
setEditingItem(null)
await loadData()
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存公司失败")
toast.error(error instanceof Error ? error.message : t("company.saveFailed"))
} finally {
setSaving(false)
}
@@ -163,10 +175,10 @@ export default function DashboardCompaniesPage() {
try {
const nextStatus = item.status === 0 ? 1 : 0
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()
} catch (error) {
toast.error(error instanceof Error ? error.message : "更新状态失败")
toast.error(error instanceof Error ? error.message : t("company.statusUpdateFailed"))
} finally {
setActionLoadingId(null)
}
@@ -176,10 +188,10 @@ export default function DashboardCompaniesPage() {
setActionLoadingId(item.id)
try {
await deleteCompany(item.id)
toast.success(`已删除公司:${item.name}`)
toast.success(t("company.deleted", { name: item.name }))
await loadData()
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除公司失败")
toast.error(error instanceof Error ? error.message : t("company.deleteFailed"))
} finally {
setActionLoadingId(null)
}
@@ -193,11 +205,11 @@ export default function DashboardCompaniesPage() {
<>
<Button variant="outline" onClick={() => void loadData()} disabled={loading}>
<RefreshCwIcon className={loading ? "animate-spin" : ""} />
{t("company.refresh")}
</Button>
<Button onClick={openCreateDialog}>
<PlusIcon />
{t("company.new")}
</Button>
</>
}
@@ -208,7 +220,7 @@ export default function DashboardCompaniesPage() {
value={nameInput}
onChange={(event) => setNameInput(event.target.value)}
onKeyDown={handleFilterKeyDown}
placeholder="按公司名称筛选"
placeholder={t("company.filterName")}
className="pl-9"
/>
</div>
@@ -216,20 +228,20 @@ export default function DashboardCompaniesPage() {
value={codeInput}
onChange={(event) => setCodeInput(event.target.value)}
onKeyDown={handleFilterKeyDown}
placeholder="按公司编码筛选"
placeholder={t("company.filterCode")}
className="w-full sm:w-44"
/>
<div className="w-full sm:w-36">
<OptionCombobox
value={statusFilterInput}
onChange={setStatusFilterInput}
placeholder="全部状态"
placeholder={t("status.all")}
options={[...listStatusOptions]}
/>
</div>
<Button variant="outline" onClick={applyFilters} disabled={loading}>
<SearchIcon />
{t("company.query")}
</Button>
</DashboardToolbar>
@@ -252,12 +264,12 @@ export default function DashboardCompaniesPage() {
<TableHeader>
<TableRow>
<TableHead className="w-20">ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-28"></TableHead>
<TableHead className="w-24"></TableHead>
<TableHead></TableHead>
<TableHead className="w-40"></TableHead>
<TableHead>{t("company.columnName")}</TableHead>
<TableHead>{t("company.columnCode")}</TableHead>
<TableHead className="w-28">{t("company.columnCustomerCount")}</TableHead>
<TableHead className="w-24">{t("company.columnStatus")}</TableHead>
<TableHead>{t("company.columnRemark")}</TableHead>
<TableHead className="w-40">{t("company.columnActions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -265,8 +277,8 @@ export default function DashboardCompaniesPage() {
<DashboardTableStateRow
colSpan={7}
loading={loading}
loadingText="正在加载公司数据..."
emptyText="暂无公司数据"
loadingText={t("company.loading")}
emptyText={t("company.empty")}
/>
) : (
result.results.map((item) => {
@@ -287,7 +299,7 @@ export default function DashboardCompaniesPage() {
: "secondary"
}
>
{StatusLabels[item.status as Status] ?? "未知"}
{StatusLabels[item.status as Status] ? getStatusLabel(item.status as Status, t) : t("company.unknownStatus")}
</Badge>
</TableCell>
<TableCell className="max-w-[320px]">
@@ -296,14 +308,14 @@ export default function DashboardCompaniesPage() {
<TableCell>
<ButtonGroup className="w-full justify-end">
<Button variant="outline" size="sm" onClick={() => openEditDialog(item)}>
{t("company.edit")}
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="outline" size="sm" disabled={actionLoading} />
}
aria-label={`更多操作 ${item.name}`}
aria-label={t("company.moreActions", { name: item.name })}
>
<MoreHorizontalIcon />
</DropdownMenuTrigger>
@@ -313,16 +325,16 @@ export default function DashboardCompaniesPage() {
onClick={() => void handleToggleStatus(item)}
>
{actionLoading ? (
"处理中..."
t("company.processing")
) : item.status === Status.Ok ? (
<>
<BanIcon />
{t("company.disable")}
</>
) : (
<>
<CheckCircle2Icon />
{t("company.enable")}
</>
)}
</DropdownMenuItem>
@@ -332,7 +344,7 @@ export default function DashboardCompaniesPage() {
onClick={() => void handleDelete(item)}
>
<Trash2Icon />
{t("company.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -26,6 +26,7 @@ import {
renderIMMessageHTML,
} from "@/lib/im-message";
import { formatDateTime } from "@/lib/utils";
import { useI18n } from "@/i18n/provider";
type ConversationDetailDialogProps = {
open: boolean;
@@ -34,7 +35,7 @@ type ConversationDetailDialogProps = {
item: AdminConversation | null;
detail: AdminConversationDetail | null;
messages?: AdminMessage[] | null;
/** 是否还有更早消息(cursor 分页) */
/** Whether older messages are available through cursor pagination. */
messagesHasMore?: boolean;
loadingMoreMessages?: boolean;
onLoadMoreMessages?: () => void | Promise<void>;
@@ -46,44 +47,53 @@ type ConversationDetailDialogProps = {
onOpenClose: () => void;
};
function getStatusMeta(status: number) {
function getStatusMeta(
status: number,
t: (key: string, values?: Record<string, string | number>) => string
) {
switch (status) {
case 1:
return { label: "AI接待中", variant: "secondary" as const };
return { label: t("conversation.filterAiServing"), variant: "secondary" as const };
case 2:
return { label: "待接入", variant: "outline" as const };
return { label: t("conversation.filterPending"), variant: "outline" as const };
case 3:
return { label: "处理中", variant: "secondary" as const };
return { label: t("conversation.filterActive"), variant: "secondary" as const };
case 4:
return { label: "已关闭", variant: "outline" as const };
return { label: t("conversation.filterClosed"), variant: "outline" as const };
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) {
case 1:
return "AI 接待";
return t("conversationMonitor.serviceAi");
case 2:
return "人工接待";
return t("conversationMonitor.serviceHuman");
case 3:
return "AI 优先";
return t("conversationMonitor.serviceAiFirst");
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) {
case "agent":
return message.senderName || "客服";
return message.senderName || t("conversationMonitor.senderAgent");
case "customer":
return message.senderName || "用户";
return message.senderName || t("conversationMonitor.senderCustomer");
case "ai":
return "AI";
case "system":
return "系统";
return t("conversationMonitor.senderSystem");
default:
return message.senderType;
}
@@ -150,12 +160,13 @@ export function ConversationDetailDialog({
onRead,
onOpenClose,
}: ConversationDetailDialogProps) {
const t = useI18n();
const messages = Array.isArray(rawMessages) ? rawMessages : [];
const currentConversation = detail ?? item;
const isClosedConversation = currentConversation?.status === 4;
const isPendingConversation = currentConversation?.status === 2;
const statusMeta = currentConversation
? getStatusMeta(currentConversation.status)
? getStatusMeta(currentConversation.status, t)
: null;
const messageBottomRef = useRef<HTMLDivElement | null>(null);
const messagesScrollRootRef = useRef<HTMLDivElement | null>(null);
@@ -249,7 +260,7 @@ export function ConversationDetailDialog({
onOpenChange={onOpenChange}
title={
<div className="flex items-center gap-3">
<span>{currentConversation?.customerName || "会话详情"}</span>
<span>{currentConversation?.customerName || t("conversationMonitor.detailFallbackTitle")}</span>
<div>
{statusMeta ? (
@@ -268,8 +279,10 @@ export function ConversationDetailDialog({
<div className="flex w-full flex-wrap items-center justify-between gap-3">
<div className="text-sm text-muted-foreground">
{currentConversation
? `最后活跃:${formatDateTime(currentConversation.lastMessageAt)}`
: "暂无会话信息"}
? t("conversationMonitor.lastActivePrefix", {
time: formatDateTime(currentConversation.lastMessageAt),
})
: t("conversationMonitor.noConversationInfo")}
</div>
<div className="flex flex-wrap gap-2">
<Button
@@ -278,7 +291,7 @@ export function ConversationDetailDialog({
disabled={saving || !currentConversation || currentConversation.status !== 2}
>
<MessageCircleMoreIcon />
{saving ? "处理中..." : "分配会话"}
{saving ? t("conversationMonitor.processing") : t("conversationMonitor.assign")}
</Button>
<Button
variant="outline"
@@ -288,7 +301,7 @@ export function ConversationDetailDialog({
}
>
<MessageCircleMoreIcon />
{saving ? "处理中..." : "重试分配"}
{saving ? t("conversationMonitor.processing") : t("conversationMonitor.retryDispatch")}
</Button>
<Button
variant="outline"
@@ -296,7 +309,7 @@ export function ConversationDetailDialog({
disabled={saving || !currentConversation}
>
<CheckCheckIcon />
{saving ? "处理中..." : "标记已读"}
{saving ? t("conversationMonitor.processing") : t("conversationMonitor.markRead")}
</Button>
<Button
type="button"
@@ -305,7 +318,7 @@ export function ConversationDetailDialog({
disabled={saving || !currentConversation || currentConversation.status !== 3}
>
<MessageCircleMoreIcon />
{saving ? "处理中..." : "转接会话"}
{saving ? t("conversationMonitor.processing") : t("conversationMonitor.transfer")}
</Button>
{!isClosedConversation ? (
<Button
@@ -314,7 +327,7 @@ export function ConversationDetailDialog({
disabled={saving || !currentConversation}
>
<EyeIcon />
{saving ? "处理中..." : "关闭会话"}
{saving ? t("conversationMonitor.processing") : t("conversationMonitor.close")}
</Button>
) : null}
</div>
@@ -323,7 +336,7 @@ export function ConversationDetailDialog({
>
{loading ? (
<div className="flex min-h-0 flex-1 items-center justify-center text-sm text-muted-foreground">
...
{t("conversationMonitor.loadingDetail")}
</div>
) : currentConversation ? (
<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="grid grid-cols-2 gap-3 text-sm">
<InfoItem
label="接待模式"
value={getServiceModeLabel(currentConversation.serviceMode)}
label={t("conversationMonitor.columnServiceMode")}
value={getServiceModeLabel(currentConversation.serviceMode, t)}
/>
<InfoItem
label="当前客服"
label={t("conversationMonitor.currentAssignee")}
value={currentConversation.currentAssigneeName || "-"}
/>
<InfoItem
label="渠道ID"
label={t("conversation.channelId")}
value={`${currentConversation.channelId || "-"}`}
/>
<InfoItem
label="客服未读"
label={t("conversationMonitor.agentUnread")}
value={`${currentConversation.agentUnreadCount}`}
/>
<InfoItem
label="用户未读"
label={t("conversationMonitor.customerUnread")}
value={`${currentConversation.customerUnreadCount}`}
/>
<InfoItem
label="最后活跃"
label={t("conversationMonitor.columnLastActive")}
value={formatDateTime(currentConversation.lastMessageAt)}
fullWidth
/>
<InfoItem
label="关闭时间"
label={t("conversationMonitor.closedAt")}
value={formatDateTime(currentConversation.closedAt)}
fullWidth
/>
@@ -369,9 +382,13 @@ export function ConversationDetailDialog({
<div className="space-y-4 p-6">
<section className="space-y-3">
<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">
{detail?.participants?.length ?? 0}
{t("conversationMonitor.participantCount", {
count: detail?.participants?.length ?? 0,
})}
</span>
</div>
{detail?.participants?.length ? (
@@ -387,17 +404,21 @@ export function ConversationDetailDialog({
</span>
</div>
<div className="mt-1 text-sm text-muted-foreground">
{getParticipantIdentity(participant)}
{t("conversationMonitor.participantIdentity", {
value: getParticipantIdentity(participant),
})}
</div>
<div className="mt-1 text-xs text-muted-foreground">
{formatDateTime(participant.joinedAt)}
{t("conversationMonitor.participantJoinedAt", {
time: formatDateTime(participant.joinedAt),
})}
</div>
</div>
))}
</div>
) : (
<div className="rounded-lg border border-dashed bg-background p-4 text-sm text-muted-foreground">
{t("conversationMonitor.emptyParticipants")}
</div>
)}
</section>
@@ -406,16 +427,6 @@ export function ConversationDetailDialog({
</aside>
<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">
<ScrollArea className="h-full min-h-0 bg-muted/10">
<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"
>
{loadingMoreMessages
? "正在加载更早的消息…"
: "继续上滑加载更早消息"}
? t("conversationMonitor.loadingOlder")
: t("conversationMonitor.loadOlderHint")}
</div>
) : null}
{messages.length ? (
@@ -449,13 +460,13 @@ export function ConversationDetailDialog({
<div
className={`text-xs text-muted-foreground ${layout.metaClassName}`}
>
<span>{getSenderLabel(message)}</span>
<span>{getSenderLabel(message, t)}</span>
<span className="mx-2">·</span>
<span>{formatDateTime(message.sentAt)}</span>
{isRecalled ? (
<>
<span className="mx-2">·</span>
<span></span>
<span>{t("conversationMonitor.messageRecalled")}</span>
</>
) : null}
</div>
@@ -463,7 +474,9 @@ export function ConversationDetailDialog({
className={`rounded-2xl border px-4 py-3 text-sm leading-6 ${layout.bubbleClassName}`}
>
{isRecalled ? (
<div className="text-muted-foreground"></div>
<div className="text-muted-foreground">
{t("conversationMonitor.messageRecalledBody")}
</div>
) : isHtmlMessage ? (
<ImMessageHTML
html={renderIMMessageHTML(message)}
@@ -485,8 +498,14 @@ export function ConversationDetailDialog({
<div
className={`text-xs text-muted-foreground ${layout.metaClassName}`}
>
{message.agentRead ? "已读" : "未读"} / {" "}
{message.customerRead ? "已读" : "未读"}
{t("conversationMonitor.readStatus", {
agent: message.agentRead
? t("conversationMonitor.read")
: t("conversationMonitor.unread"),
customer: message.customerRead
? t("conversationMonitor.read")
: t("conversationMonitor.unread"),
})}
</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">
{t("conversationMonitor.emptyMessages")}
</div>
)}
<div ref={messageBottomRef} />
@@ -505,7 +524,7 @@ export function ConversationDetailDialog({
</div>
) : (
<div className="flex min-h-0 flex-1 items-center justify-center text-sm text-muted-foreground">
{t("conversationMonitor.emptyDetail")}
</div>
)}
</ProjectDialog>
@@ -534,10 +553,11 @@ type MessageImageProps = {
};
function MessageImage({ src, alt, onPreview }: MessageImageProps) {
const t = useI18n();
if (!src) {
return (
<div className="text-sm whitespace-pre-wrap break-words">
{alt || "[图片]"}
{alt || t("conversationMonitor.imageFallback")}
</div>
);
}
@@ -550,7 +570,7 @@ function MessageImage({ src, alt, onPreview }: MessageImageProps) {
>
<Image
src={src}
alt={alt || "消息图片"}
alt={alt || t("conversationMonitor.messageImageAlt")}
width={480}
height={360}
className="max-h-64 w-auto max-w-full rounded-md object-contain"
+116 -93
View File
@@ -7,7 +7,7 @@ import {
RefreshCwIcon,
SearchIcon,
} 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 { ConversationCloseDialog } from "@/components/conversation-actions/close-dialog"
@@ -33,13 +33,6 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
Table,
TableBody,
@@ -66,50 +59,57 @@ import {
type PageResult,
type TagTree,
} from "@/lib/api/admin"
import { useI18n } from "@/i18n/provider"
import { formatDateTime } from "@/lib/utils"
import { ConversationDetailDialog } from "./_components/detail"
const RECONNECT_BASE_DELAY = 2000
const RECONNECT_MAX_DELAY = 30000
const statusOptions = [
{ value: "all", label: "全部状态" },
{ value: "1", label: "AI接待中" },
{ value: "2", label: "待接入" },
{ value: "3", label: "处理中" },
{ value: "4", label: "已关闭" },
] as const
function getStatusMeta(status: number) {
function getStatusMeta(
status: number,
t: (key: string, values?: Record<string, string | number>) => string
) {
switch (status) {
case 1:
return { label: "AI接待中", variant: "secondary" as const }
return { label: t("conversation.filterAiServing"), variant: "secondary" as const }
case 2:
return { label: "待接入", variant: "outline" as const }
return { label: t("conversation.filterPending"), variant: "outline" as const }
case 3:
return { label: "处理中", variant: "secondary" as const }
return { label: t("conversation.filterActive"), variant: "secondary" as const }
case 4:
return { label: "已关闭", variant: "outline" as const }
return { label: t("conversation.filterClosed"), variant: "outline" as const }
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) {
case 1:
return "AI 接待"
return t("conversationMonitor.serviceAi")
case 2:
return "人工接待"
return t("conversationMonitor.serviceHuman")
case 3:
return "AI 优先"
return t("conversationMonitor.serviceAiFirst")
default:
return "未定义"
return t("conversationMonitor.serviceUndefined")
}
}
function getStatusLabel(value: string) {
return statusOptions.find((item) => item.value === value)?.label ?? "全部状态"
function getStatusOptions(
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(
@@ -131,6 +131,8 @@ function buildTagOptions(
}
export default function DashboardConversationsPage() {
const t = useI18n()
const statusOptions = useMemo(() => getStatusOptions(t), [t])
const [keywordInput, setKeywordInput] = useState("")
const [statusFilterInput, setStatusFilterInput] = useState("all")
const [tagFilterInput, setTagFilterInput] = useState("0")
@@ -144,13 +146,13 @@ export default function DashboardConversationsPage() {
const [page, setPage] = useState(1)
const [limit, setLimit] = useState(20)
const [tagOptions, setTagOptions] = useState<ComboboxOption[]>([
{ value: "0", label: "全部标签" },
{ value: "0", label: t("conversationMonitor.allTags") },
])
const [assigneeOptions, setAssigneeOptions] = useState<ComboboxOption[]>([
{ value: "0", label: "全部指派人" },
{ value: "0", label: t("conversationMonitor.allAssignees") },
])
const [agentTeamOptions, setAgentTeamOptions] = useState<ComboboxOption[]>([
{ value: "0", label: "全部客服组" },
{ value: "0", label: t("conversationMonitor.allTeams") },
])
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
@@ -194,11 +196,11 @@ export default function DashboardConversationsPage() {
})
setResult(data)
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载会话列表失败")
toast.error(error instanceof Error ? error.message : t("conversationMonitor.loadListFailed"))
} finally {
setLoading(false)
}
}, [keyword, limit, page, statusFilter, tagFilter, assigneeFilter, agentTeamFilter])
}, [keyword, limit, page, statusFilter, tagFilter, assigneeFilter, agentTeamFilter, t])
useEffect(() => {
let cancelled = false
@@ -212,18 +214,18 @@ export default function DashboardConversationsPage() {
])
if (!cancelled) {
setTagOptions([
{ value: "0", label: "全部标签" },
{ value: "0", label: t("conversationMonitor.allTags") },
...buildTagOptions(tagData),
])
setAssigneeOptions([
{ value: "0", label: "全部指派人" },
{ value: "0", label: t("conversationMonitor.allAssignees") },
...assigneeData.map((item: AdminAgentProfile) => ({
value: String(item.userId),
label: item.displayName || item.nickname || item.username || `#${item.userId}`,
})),
])
setAgentTeamOptions([
{ value: "0", label: "全部客服组" },
{ value: "0", label: t("conversationMonitor.allTeams") },
...teamData.map((item: AdminAgentTeam) => ({
value: String(item.id),
label: item.name,
@@ -232,7 +234,7 @@ export default function DashboardConversationsPage() {
}
} catch (error) {
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 () => {
cancelled = true
}
}, [])
}, [t])
useEffect(() => {
detailItemRef.current = detailItem
@@ -290,7 +292,7 @@ export default function DashboardConversationsPage() {
try {
socket = new WebSocket(createAdminWebSocketUrl())
} catch (error) {
toast.error(error instanceof Error ? error.message : "连接实时服务失败")
toast.error(error instanceof Error ? error.message : t("conversationMonitor.realtimeConnectFailed"))
scheduleReconnect()
return
}
@@ -382,7 +384,7 @@ export default function DashboardConversationsPage() {
}
subscribedConversationIdRef.current = null
}
}, [loadConversations])
}, [loadConversations, t])
useEffect(() => {
const socket = websocketRef.current
@@ -466,7 +468,7 @@ export default function DashboardConversationsPage() {
setDetailMessagesNextCursor(messages.cursor ?? "")
setDetailMessagesHasMore(Boolean(messages.hasMore))
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载会话详情失败")
toast.error(error instanceof Error ? error.message : t("conversationMonitor.loadDetailFailed"))
} finally {
setDetailLoading(false)
}
@@ -492,7 +494,7 @@ export default function DashboardConversationsPage() {
setDetailMessagesNextCursor(page.cursor ?? "")
setDetailMessagesHasMore(Boolean(page.hasMore))
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载更多消息失败")
toast.error(error instanceof Error ? error.message : t("conversationMonitor.loadMoreMessagesFailed"))
} finally {
setDetailMessagesLoadingMore(false)
}
@@ -501,6 +503,7 @@ export default function DashboardConversationsPage() {
detailMessagesHasMore,
detailMessagesLoadingMore,
detailMessagesNextCursor,
t,
])
async function openDetail(item: AdminConversation) {
@@ -603,13 +606,13 @@ export default function DashboardConversationsPage() {
setActionLoadingId(item.id)
try {
await markConversationRead(item.id)
toast.success(`已标记已读:${item.customerName || `#${item.id}`}`)
toast.success(t("conversationMonitor.markedRead", { name: item.customerName || `#${item.id}` }))
await loadConversations()
if (detailItem?.id === item.id) {
await refreshDetail()
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "标记已读失败")
toast.error(error instanceof Error ? error.message : t("conversationMonitor.markReadFailed"))
} finally {
setActionLoadingId(null)
}
@@ -619,13 +622,13 @@ export default function DashboardConversationsPage() {
setActionLoadingId(item.id)
try {
await dispatchConversation(item.id)
toast.success(`已触发自动分配:${item.customerName || `#${item.id}`}`)
toast.success(t("conversationMonitor.dispatchTriggered", { name: item.customerName || `#${item.id}` }))
await loadConversations()
if (detailItem?.id === item.id) {
await refreshDetail()
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "自动分配失败")
toast.error(error instanceof Error ? error.message : t("conversationMonitor.dispatchFailed"))
} finally {
setActionLoadingId(null)
}
@@ -649,7 +652,7 @@ export default function DashboardConversationsPage() {
disabled={loading || refreshing}
>
<RefreshCwIcon className={loading || refreshing ? "animate-spin" : ""} />
{t("conversationMonitor.refresh")}
</Button>
}
>
@@ -659,29 +662,25 @@ export default function DashboardConversationsPage() {
value={keywordInput}
onChange={(event) => setKeywordInput(event.target.value)}
onKeyDown={handleFilterKeyDown}
placeholder="按主题或摘要筛选"
placeholder={t("conversationMonitor.keywordPlaceholder")}
className="pl-9"
/>
</div>
<Select value={statusFilterInput} onValueChange={handleStatusFilterChange}>
<SelectTrigger className="w-full sm:w-36">
<SelectValue>{getStatusLabel(statusFilterInput)}</SelectValue>
</SelectTrigger>
<SelectContent>
{statusOptions.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="w-full sm:w-36">
<OptionCombobox
value={statusFilterInput}
options={statusOptions}
placeholder={t("conversationMonitor.allStatuses")}
onChange={handleStatusFilterChange}
/>
</div>
<div className="w-full sm:w-64">
<OptionCombobox
value={tagFilterInput}
options={tagOptions}
placeholder="选择标签"
searchPlaceholder="搜索标签路径"
emptyText="没有匹配标签"
placeholder={t("conversationMonitor.selectTag")}
searchPlaceholder={t("conversationMonitor.searchTagPath")}
emptyText={t("conversationMonitor.emptyTags")}
onChange={setTagFilterInput}
/>
</div>
@@ -689,9 +688,9 @@ export default function DashboardConversationsPage() {
<OptionCombobox
value={assigneeFilterInput}
options={assigneeOptions}
placeholder="选择指派人"
searchPlaceholder="搜索指派人"
emptyText="没有匹配指派人"
placeholder={t("conversationMonitor.selectAssignee")}
searchPlaceholder={t("conversationMonitor.searchAssignee")}
emptyText={t("conversationMonitor.emptyAssignees")}
onChange={setAssigneeFilterInput}
/>
</div>
@@ -699,15 +698,15 @@ export default function DashboardConversationsPage() {
<OptionCombobox
value={agentTeamFilterInput}
options={agentTeamOptions}
placeholder="选择客服组"
searchPlaceholder="搜索客服组"
emptyText="没有匹配客服组"
placeholder={t("conversationMonitor.selectTeam")}
searchPlaceholder={t("conversationMonitor.searchTeam")}
emptyText={t("conversationMonitor.emptyTeams")}
onChange={setAgentTeamFilterInput}
/>
</div>
<Button variant="outline" onClick={applyFilters} disabled={loading}>
<SearchIcon />
{t("conversationMonitor.query")}
</Button>
</DashboardToolbar>
@@ -726,39 +725,51 @@ export default function DashboardConversationsPage() {
<Table>
<TableHeader className="bg-muted/40">
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-28 text-right"></TableHead>
<TableHead>{t("conversationMonitor.columnConversation")}</TableHead>
<TableHead>{t("conversationMonitor.columnStatus")}</TableHead>
<TableHead>{t("conversationMonitor.columnServiceMode")}</TableHead>
<TableHead>{t("conversationMonitor.columnAssignee")}</TableHead>
<TableHead>{t("conversationMonitor.columnUnread")}</TableHead>
<TableHead>{t("conversationMonitor.columnLastActive")}</TableHead>
<TableHead className="w-28 text-right">
{t("conversationMonitor.columnActions")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{result.results.map((item) => {
const statusMeta = getStatusMeta(item.status)
const statusMeta = getStatusMeta(item.status, t)
return (
<TableRow key={item.id}>
<TableCell className="max-w-60">
<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">
ID{item.channelId || "-"}
{t("conversationMonitor.channelIdValue", {
id: item.channelId || "-",
})}
</div>
<div className="mt-1 line-clamp-2 text-sm text-muted-foreground">
{item.lastMessageSummary || "暂无最新消息摘要"}
{item.lastMessageSummary || t("conversationMonitor.noLatestSummary")}
</div>
</div>
</TableCell>
<TableCell>
<Badge variant={statusMeta.variant}>{statusMeta.label}</Badge>
</TableCell>
<TableCell>{getServiceModeLabel(item.serviceMode)}</TableCell>
<TableCell>{getServiceModeLabel(item.serviceMode, t)}</TableCell>
<TableCell>{item.currentAssigneeName || "-"}</TableCell>
<TableCell>
{item.agentUnreadCount} / {item.customerUnreadCount}
{t("conversationMonitor.unreadSummary", {
agent: item.agentUnreadCount,
customer: item.customerUnreadCount,
})}
</TableCell>
<TableCell>{formatDateTime(item.lastMessageAt)}</TableCell>
<TableCell className="text-right">
@@ -769,12 +780,14 @@ export default function DashboardConversationsPage() {
onClick={() => void openDetail(item)}
disabled={actionLoadingId === item.id}
>
{t("conversationMonitor.view")}
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={<Button variant="outline" size="icon-sm" />}
aria-label={`更多操作 ${item.customerName || item.id}`}
aria-label={t("conversationMonitor.moreActions", {
name: item.customerName || item.id,
})}
>
<MoreHorizontalIcon />
</DropdownMenuTrigger>
@@ -784,28 +797,36 @@ export default function DashboardConversationsPage() {
disabled={actionLoadingId === item.id || item.status !== 2}
>
<MessageCircleMoreIcon />
{actionLoadingId === item.id ? "处理中..." : "分配会话"}
{actionLoadingId === item.id
? t("conversationMonitor.processing")
: t("conversationMonitor.assign")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => void handleDispatch(item)}
disabled={actionLoadingId === item.id || item.status !== 2}
>
<RefreshCwIcon />
{actionLoadingId === item.id ? "处理中..." : "重试分配"}
{actionLoadingId === item.id
? t("conversationMonitor.processing")
: t("conversationMonitor.retryDispatch")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => void handleRead(item)}
disabled={actionLoadingId === item.id}
>
<CheckCheckIcon />
{actionLoadingId === item.id ? "处理中..." : "标记已读"}
{actionLoadingId === item.id
? t("conversationMonitor.processing")
: t("conversationMonitor.markRead")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => openTransfer(item)}
disabled={actionLoadingId === item.id || item.status !== 3}
>
<MessageCircleMoreIcon />
{actionLoadingId === item.id ? "处理中..." : "转接会话"}
{actionLoadingId === item.id
? t("conversationMonitor.processing")
: t("conversationMonitor.transfer")}
</DropdownMenuItem>
{item.status !== 4 ? (
<DropdownMenuItem
@@ -813,7 +834,9 @@ export default function DashboardConversationsPage() {
disabled={actionLoadingId === item.id}
>
<RefreshCwIcon />
{actionLoadingId === item.id ? "处理中..." : "关闭会话"}
{actionLoadingId === item.id
? t("conversationMonitor.processing")
: t("conversationMonitor.close")}
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
@@ -827,8 +850,8 @@ export default function DashboardConversationsPage() {
<DashboardTableStateRow
colSpan={7}
loading={loading}
loadingText="正在加载会话记录..."
emptyText="没有匹配的会话记录"
loadingText={t("conversationMonitor.loadingRows")}
emptyText={t("conversationMonitor.emptyRows")}
/>
) : null}
</TableBody>
@@ -7,6 +7,7 @@ import {
SharedMessageEditor,
type UploadedMessageEditorImage,
} from "@/components/chat/shared-message-editor"
import { useI18n } from "@/i18n/provider"
import { fetchQuickReplyListAll, type AdminQuickReply } from "@/lib/api/admin"
type AgentMessageEditorProps = {
@@ -24,6 +25,7 @@ export function AgentMessageEditor({
onUploadImage,
onSendAttachment,
}: AgentMessageEditorProps) {
const t = useI18n()
const [quickReplies, setQuickReplies] = useState<AdminQuickReply[]>([])
const [loadingQuickReplies, setLoadingQuickReplies] = useState(true)
const [quickReplyPickerOpen, setQuickReplyPickerOpen] = useState(false)
@@ -38,7 +40,7 @@ export function AgentMessageEditor({
})
.catch((error) => {
if (!cancelled) {
toast.error(error instanceof Error ? error.message : "加载快捷回复失败")
toast.error(error instanceof Error ? error.message : t("conversation.loadQuickRepliesFailed"))
}
})
.finally(() => {
@@ -49,7 +51,7 @@ export function AgentMessageEditor({
return () => {
cancelled = true
}
}, [])
}, [t])
return (
<SharedMessageEditor
@@ -6,6 +6,7 @@ import { toast } from "sonner";
import { ConversationTransferDialog } from "@/components/conversation-actions/transfer-dialog";
import { ImMessageHTML } from "@/components/im-message-html";
import { useImageLightbox } from "@/components/image-lightbox";
import { useI18n } from "@/i18n/provider";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
@@ -39,6 +40,7 @@ import { AgentMessageEditor } from "./agent-message-editor";
const EMPTY_AGENT_MESSAGES: AgentMessage[] = [];
export function ChatPanel() {
const t = useI18n();
const conversation = useAgentConversationsStore(
agentConversationSelectors.selectedConversation,
);
@@ -119,8 +121,8 @@ export function ChatPanel() {
}, [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(
(attempts = 4) => {
@@ -170,7 +172,7 @@ export function ChatPanel() {
return;
}
void markSelectedConversationRead().catch((error) => {
toast.error(error instanceof Error ? error.message : "设置已读失败");
toast.error(error instanceof Error ? error.message : t("conversation.markReadFailed"));
});
}, [
conversation,
@@ -178,6 +180,7 @@ export function ChatPanel() {
isNearBottom,
loading,
markSelectedConversationRead,
t,
]);
useEffect(() => {
@@ -282,7 +285,7 @@ export function ChatPanel() {
await loadOlderMessages();
} catch (error) {
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;
await sendMessage(html);
} 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;
const session = readSession();
if (!session?.user?.id) {
toast.error("未登录或登录已过期");
toast.error(t("conversation.claimRequiresSignIn"));
return;
}
@@ -309,15 +312,15 @@ export function ChatPanel() {
await assignAgentConversation(
conversation.id,
session.user.id,
"认领会话",
t("conversation.claimReason"),
);
switchToMyActiveIfNeeded();
setClaimDialogOpen(false);
toast.success("认领成功");
toast.success(t("conversation.claimSuccess"));
await reloadConversationData(conversation.id);
} catch (error) {
toast.error(error instanceof Error ? error.message : "认领会话失败");
toast.error(error instanceof Error ? error.message : t("conversation.claimFailed"));
} finally {
setClaiming(false);
}
@@ -332,9 +335,13 @@ export function ChatPanel() {
return (
<div className="mt-10 flex flex-1 items-center justify-center px-4">
<div className="text-center text-muted-foreground">
<p className="text-lg"></p>
<p className="mt-1 text-sm lg:hidden"></p>
<p className="mt-1 hidden text-sm lg:block"></p>
<p className="text-lg">{t("conversation.empty")}</p>
<p className="mt-1 text-sm lg:hidden">
{t("conversation.noConversationMobile")}
</p>
<p className="mt-1 hidden text-sm lg:block">
{t("conversation.selectConversationToChat")}
</p>
</div>
</div>
);
@@ -355,13 +362,13 @@ export function ChatPanel() {
disabled={messagesLoadingMore}
onClick={() => void handleLoadOlder()}
>
{messagesLoadingMore ? "加载中…" : "加载更早的消息"}
{messagesLoadingMore ? t("conversation.loading") : t("conversation.loadOlder")}
</Button>
</div>
) : null}
{loading ? (
<div className="py-8 text-center text-sm text-muted-foreground">
...
{t("conversation.loading")}
</div>
) : messages.length > 0 ? (
messages.map((message) => (
@@ -378,7 +385,7 @@ export function ChatPanel() {
))
) : (
<div className="py-8 text-center text-sm text-muted-foreground">
{t("conversation.emptyMessages")}
</div>
)}
</div>
@@ -389,11 +396,11 @@ export function ChatPanel() {
<div className="h-full overflow-auto border-t border-border/80 bg-card">
{isClosedConversation ? (
<div className="h-full flex justify-center items-center">
{t("conversation.closedNotice")}
</div>
) : conversation?.status === 1 ? (
<div className="h-full flex justify-center items-center">
AI
{t("conversation.aiServingNotice")}
</div>
) : isPendingConversation ? (
<div className="h-full flex justify-center items-center">
@@ -403,7 +410,7 @@ export function ChatPanel() {
disabled={claiming}
size="sm"
>
{claiming ? "认领中..." : "认领"}
{claiming ? t("conversation.claiming") : t("conversation.claim")}
</Button>
</div>
</div>
@@ -424,7 +431,7 @@ export function ChatPanel() {
try {
await sendAttachment(file);
} 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}>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogTitle>{t("conversation.claimTitle")}</DialogTitle>
<DialogDescription>
{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>
</DialogHeader>
<DialogFooter>
@@ -491,14 +501,14 @@ export function ChatPanel() {
disabled={claiming}
onClick={() => setClaimDialogOpen(false)}
>
{t("conversation.cancel")}
</Button>
<Button
type="button"
disabled={claiming}
onClick={() => void handleClaim()}
>
{claiming ? "认领中..." : "确认认领"}
{claiming ? t("conversation.claiming") : t("conversation.confirmClaim")}
</Button>
</DialogFooter>
</DialogContent>
@@ -532,22 +542,25 @@ const MessageItem = memo(
recalling,
onRecall,
}: MessageItemProps) {
const t = useI18n();
const { open: openImageLightbox } = useImageLightbox();
const isCustomer = message.senderType === "customer";
const isAi = message.senderType === "ai";
const isAgentSide = message.senderType === "agent" || isAi;
const isRecalled = Boolean(message.recalledAt) || message.sendStatus === 6;
const senderName = isCustomer
? message.senderName || "客户"
? message.senderName || t("conversation.customerSender")
: isAi
? "AI"
: message.senderName || "客服";
: message.senderName || t("conversation.agentSender");
const agentAvatarSrc =
isAgentSide && !isAi && message.senderAvatar?.trim()
? message.senderAvatar.trim()
: undefined;
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
? "border border-primary/15 bg-primary/5 text-foreground shadow-sm"
: isAgentSide
@@ -597,9 +610,13 @@ const MessageItem = memo(
</div>
<div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
<span>{formatDateTime(message.sentAt || "")}</span>
{isRecalled ? <span></span> : null}
{isRecalled ? <span>{t("conversation.messageRecalled")}</span> : null}
{message.sendStatus === 2 && !isRecalled && (
<span>{message.customerRead ? "客户已读" : "客户未读"}</span>
<span>
{message.customerRead
? t("conversation.customerRead")
: t("conversation.customerUnread")}
</span>
)}
{showRecallAction ? (
<Button
@@ -610,11 +627,11 @@ const MessageItem = memo(
disabled={recalling}
onClick={() => {
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>
) : null}
</div>
@@ -631,7 +648,7 @@ const MessageItem = memo(
<Avatar className="size-8 shrink-0">
<AvatarImage src="" />
<AvatarFallback className={avatarClassName}>
{t("conversation.customerAvatar")}
</AvatarFallback>
</Avatar>
<div className="max-w-[70%]">
@@ -652,7 +669,7 @@ const MessageItem = memo(
</div>
<div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
<span>{formatDateTime(message.sentAt || "")}</span>
{isRecalled ? <span></span> : null}
{isRecalled ? <span>{t("conversation.messageRecalled")}</span> : null}
</div>
</div>
</>
@@ -43,20 +43,31 @@ import {
} from "@/lib/api/customer-contact";
import {
ContactType,
ContactTypeLabels,
Gender,
GenderLabels,
} from "@/lib/generated/enums";
import { useAgentConversationsStore } from "@/lib/stores/agent-conversations";
import { cn, formatDateTime } from "@/lib/utils";
import { useI18n } from "@/i18n/provider";
import {
ConversationTagBadges,
ConversationTagPicker,
} from "./conversation-tag-picker";
import { TicketStatusBadge } from "../../tickets/_components/ticket-status-badge";
function contactTypeLabel(contactType: ContactType | string) {
return ContactTypeLabels[contactType as ContactType] ?? contactType;
function contactTypeLabel(
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 }) {
@@ -113,6 +124,7 @@ function SectionHeading({
}
function UnlinkedCustomerEmpty({ conversation }: { conversation: AgentConversation }) {
const t = useI18n();
const [linkDialogOpen, setLinkDialogOpen] = useState(false);
const loadConversations = useAgentConversationsStore((s) => s.loadConversations);
@@ -120,9 +132,11 @@ function UnlinkedCustomerEmpty({ conversation }: { conversation: AgentConversati
<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">
<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">
{t("conversation.unlinkedCustomerDescription")}
</p>
<Button
type="button"
@@ -130,7 +144,7 @@ function UnlinkedCustomerEmpty({ conversation }: { conversation: AgentConversati
onClick={() => setLinkDialogOpen(true)}
>
<Link2Icon className="size-4" />
{t("conversation.linkOrCreateCustomer")}
</Button>
</div>
<CustomerLinkOrCreateDialog
@@ -144,6 +158,7 @@ function UnlinkedCustomerEmpty({ conversation }: { conversation: AgentConversati
}
function MissingCustomerEmpty({ conversation }: { conversation: AgentConversation }) {
const t = useI18n();
const [linkDialogOpen, setLinkDialogOpen] = useState(false);
const loadConversations = useAgentConversationsStore((s) => s.loadConversations);
@@ -151,9 +166,11 @@ function MissingCustomerEmpty({ conversation }: { conversation: AgentConversatio
<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">
<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">
{t("conversation.missingCustomerDescription")}
</p>
<Button
type="button"
@@ -161,14 +178,14 @@ function MissingCustomerEmpty({ conversation }: { conversation: AgentConversatio
onClick={() => setLinkDialogOpen(true)}
>
<Link2Icon className="size-4" />
{t("conversation.relinkOrCreateCustomer")}
</Button>
</div>
<div className="space-y-2">
<SectionHeading></SectionHeading>
<SectionHeading>{t("conversation.conversationOwner")}</SectionHeading>
<div className="space-y-2">
<DetailRow label="渠道ID" value={conversation.channelId ? `${conversation.channelId}` : "-"} />
<DetailRow label="客户ID" value={conversation.customerId ? `${conversation.customerId}` : "-"} />
<DetailRow label={t("conversation.channelId")} value={conversation.channelId ? `${conversation.channelId}` : "-"} />
<DetailRow label={t("conversation.customerId")} value={conversation.customerId ? `${conversation.customerId}` : "-"} />
</div>
</div>
<CustomerLinkOrCreateDialog
@@ -192,6 +209,7 @@ export function ConversationInfoPanel({
className,
variant = "default",
}: ConversationInfoPanelProps) {
const t = useI18n();
const embedded = variant === "embedded";
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">
<h2 className="text-sm font-medium text-foreground"></h2>
<h2 className="text-sm font-medium text-foreground">
{t("conversation.conversationInfo")}
</h2>
</div>
<div
@@ -217,8 +237,8 @@ export function ConversationInfoPanel({
{!conversation ? (
<p className="pt-4 text-sm text-muted-foreground">
{embedded
? "请选择会话以查看会话信息"
: "请选择左侧会话以查看会话信息"}
? t("conversation.selectConversationForInfo")
: t("conversation.selectSidebarConversationForInfo")}
</p>
) : (
<div className="space-y-4 py-3">
@@ -235,6 +255,7 @@ function ConversationTagSection({
}: {
conversation: AgentConversation;
}) {
const t = useI18n();
const setConversationTags = useAgentConversationsStore(
(state) => state.setConversationTags,
);
@@ -253,7 +274,7 @@ function ConversationTagSection({
}
} catch (error) {
if (!cancelled) {
toast.error(error instanceof Error ? error.message : "加载标签失败");
toast.error(error instanceof Error ? error.message : t("conversation.loadTagsFailed"));
}
} finally {
if (!cancelled) {
@@ -267,7 +288,7 @@ function ConversationTagSection({
return () => {
cancelled = true;
};
}, []);
}, [t]);
return (
<section className="space-y-2 border-t pt-2">
@@ -283,14 +304,14 @@ function ConversationTagSection({
/>
}
>
{t("conversation.conversationTags")}
</SectionHeading>
<ConversationTagBadges
tags={conversation.tags}
availableTags={availableTags}
/>
{!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}
</section>
);
@@ -317,6 +338,7 @@ type CustomerLinkedBodyProps = {
};
function CustomerLinkedBody({ conversation, customerId }: CustomerLinkedBodyProps) {
const t = useI18n();
const [loading, setLoading] = useState(true);
const [customer, setCustomer] = useState<AdminCustomer | null>(null);
const [contacts, setContacts] = useState<AdminCustomerContact[]>([]);
@@ -337,14 +359,14 @@ function CustomerLinkedBody({ conversation, customerId }: CustomerLinkedBodyProp
const list = await fetchCustomerContacts(customerId);
setContacts(Array.isArray(list) ? list : []);
} catch (e) {
const msg = e instanceof Error ? e.message : "加载客户信息失败";
const msg = e instanceof Error ? e.message : t("conversation.loadCustomerFailed");
toast.error(msg);
setCustomer(null);
setContacts([]);
} finally {
setLoading(false);
}
}, [customerId]);
}, [customerId, t]);
useEffect(() => {
void load();
@@ -360,7 +382,7 @@ function CustomerLinkedBody({ conversation, customerId }: CustomerLinkedBodyProp
if (loading && !customer) {
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 genderLabel =
customer.gender === Gender.Male || customer.gender === Gender.Female
? GenderLabels[customer.gender as Gender] ?? String(customer.gender)
customer.gender === Gender.Male
? t("conversation.genderMale")
: customer.gender === Gender.Female
? t("conversation.genderFemale")
: null;
return (
<div className="space-y-4">
{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">
{t("conversation.customerProfileEmpty")}
</div>
) : null}
@@ -415,29 +439,29 @@ function CustomerLinkedBody({ conversation, customerId }: CustomerLinkedBodyProp
onClick={() => setCustomerEditOpen(true)}
>
<PencilIcon className="size-3.5" />
{t("conversation.edit")}
</Button>
</div>
<div className="space-y-2">
<DetailRow
label="最近活跃"
label={t("conversation.lastActive")}
value={
customer.lastActiveAt ? formatDateTime(customer.lastActiveAt) : ""
}
/>
<DetailRow
label="备注"
label={t("conversation.remark")}
value={customer.remark.trim() ? customer.remark : ""}
valueClassName="whitespace-pre-wrap"
/>
<DetailRow
label="创建时间"
label={t("conversation.createdAt")}
value={formatDateTime(customer.createdAt)}
valueClassName="whitespace-pre-wrap"
/>
<DetailRow
label="更新时间"
label={t("conversation.updatedAt")}
value={formatDateTime(customer.updatedAt)}
valueClassName="whitespace-pre-wrap"
/>
@@ -446,16 +470,16 @@ function CustomerLinkedBody({ conversation, customerId }: CustomerLinkedBodyProp
<section className="space-y-2">
{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">
{contacts.map((row) => {
const tags: string[] = [];
if (row.isPrimary) {
tags.push("主");
tags.push(t("conversation.primaryContactBadge"));
}
if (row.isVerified) {
tags.push("已验证");
tags.push(t("conversation.verifiedContactBadge"));
}
return (
<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">
{row.contactValue}
<span className="ml-2 text-xs font-normal text-muted-foreground">
{contactTypeLabel(row.contactType)}
{contactTypeLabel(row.contactType, t)}
</span>
{tags.length > 0 ? (
<span className="ml-2 text-xs text-muted-foreground">
@@ -516,28 +540,28 @@ function CustomerLinkedBody({ conversation, customerId }: CustomerLinkedBodyProp
onClick={() => setCompanyEditOpen(true)}
>
<PencilIcon className="size-3.5" />
{t("conversation.edit")}
</Button>
</div>
<div className="space-y-2 pt-1">
<DetailRow
label="创建"
label={t("conversation.createdAt")}
value={formatDateTime(company.createdAt)}
/>
<DetailRow
label="更新"
label={t("conversation.updatedAt")}
value={formatDateTime(company.updatedAt)}
/>
</div>
<DetailRow
label="备注"
label={t("conversation.remark")}
value={company.remark.trim() ? company.remark : ""}
valueClassName="whitespace-pre-wrap"
/>
</div>
) : (
<p className="text-sm text-muted-foreground">
{t("conversation.companyUnavailable")}
</p>
)}
</section>
@@ -559,11 +583,11 @@ function CustomerLinkedBody({ conversation, customerId }: CustomerLinkedBodyProp
setCustomerEditSaving(true);
try {
await saveCustomerProfile({ ...payload, id: customer.id });
toast.success("已保存");
toast.success(t("conversation.saved"));
void load();
setCustomerEditOpen(false);
} catch (e) {
toast.error(e instanceof Error ? e.message : "保存失败");
toast.error(e instanceof Error ? e.message : t("conversation.saveFailed"));
} finally {
setCustomerEditSaving(false);
}
@@ -584,6 +608,7 @@ function CustomerLinkedBody({ conversation, customerId }: CustomerLinkedBodyProp
}
function RelatedTicketsSection({ conversation }: { conversation: AgentConversation }) {
const t = useI18n();
const [tickets, setTickets] = useState<TicketItem[]>([]);
const [loading, setLoading] = useState(false);
@@ -602,7 +627,7 @@ function RelatedTicketsSection({ conversation }: { conversation: AgentConversati
}
} catch (error) {
if (!cancelled) {
toast.error(error instanceof Error ? error.message : "加载关联工单失败");
toast.error(error instanceof Error ? error.message : t("conversation.loadTicketsFailed"));
}
} finally {
if (!cancelled) {
@@ -614,13 +639,13 @@ function RelatedTicketsSection({ conversation }: { conversation: AgentConversati
return () => {
cancelled = true;
};
}, [conversation.id]);
}, [conversation.id, t]);
return (
<section className="space-y-2 border-t pt-2">
<SectionHeading></SectionHeading>
<SectionHeading>{t("conversation.relatedTickets")}</SectionHeading>
{loading ? (
<p className="text-sm text-muted-foreground"></p>
<p className="text-sm text-muted-foreground">{t("conversation.loadingTickets")}</p>
) : tickets.length > 0 ? (
<div className="space-y-2">
{tickets.map((ticket) => (
@@ -648,7 +673,7 @@ function RelatedTicketsSection({ conversation }: { conversation: AgentConversati
))}
</div>
) : (
<p className="text-sm text-muted-foreground"></p>
<p className="text-sm text-muted-foreground">{t("conversation.noRelatedTickets")}</p>
)}
</section>
);
@@ -667,6 +692,7 @@ function CompanyEditDialog({
company,
onSaved,
}: CompanyEditDialogProps) {
const t = useI18n();
const [name, setName] = useState("");
const [code, setCode] = useState("");
const [remark, setRemark] = useState("");
@@ -684,7 +710,7 @@ function CompanyEditDialog({
const handleSubmit = async () => {
const trimmedName = name.trim();
if (!trimmedName) {
toast.error("公司名称不能为空");
toast.error(t("conversation.companyNameRequired"));
return;
}
setSaving(true);
@@ -695,11 +721,11 @@ function CompanyEditDialog({
code: code.trim(),
remark: remark.trim(),
});
toast.success("已保存");
toast.success(t("conversation.saved"));
onSaved();
onOpenChange(false);
} catch (e) {
toast.error(e instanceof Error ? e.message : "保存失败");
toast.error(e instanceof Error ? e.message : t("conversation.saveFailed"));
} finally {
setSaving(false);
}
@@ -709,23 +735,23 @@ function CompanyEditDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md" showCloseButton>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogTitle>{t("conversation.editCompany")}</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4 py-1">
<Field orientation="vertical">
<FieldLabel htmlFor="co-name"></FieldLabel>
<FieldLabel htmlFor="co-name">{t("conversation.companyName")}</FieldLabel>
<FieldContent>
<Input id="co-name" value={name} onChange={(e) => setName(e.target.value)} />
</FieldContent>
</Field>
<Field orientation="vertical">
<FieldLabel htmlFor="co-code"></FieldLabel>
<FieldLabel htmlFor="co-code">{t("conversation.companyCode")}</FieldLabel>
<FieldContent>
<Input id="co-code" value={code} onChange={(e) => setCode(e.target.value)} />
</FieldContent>
</Field>
<Field orientation="vertical">
<FieldLabel htmlFor="co-remark"></FieldLabel>
<FieldLabel htmlFor="co-remark">{t("conversation.remark")}</FieldLabel>
<FieldContent>
<Textarea
id="co-remark"
@@ -738,10 +764,10 @@ function CompanyEditDialog({
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
{t("conversation.cancel")}
</Button>
<Button type="button" disabled={saving} onClick={() => void handleSubmit()}>
{saving ? "保存中…" : "保存"}
{saving ? t("conversation.saving") : t("conversation.save")}
</Button>
</DialogFooter>
</DialogContent>
@@ -4,13 +4,10 @@ import { UserIcon } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { ScrollArea } from "@/components/ui/scroll-area";
import { getEnumLabel } from "@/lib/enums";
import {
IMConversationStatus,
IMConversationStatusLabels,
} from "@/lib/generated/enums";
import { IMConversationStatus } from "@/lib/generated/enums";
import { useAgentConversationsStore } from "@/lib/stores/agent-conversations";
import { formatDateTime } from "@/lib/utils";
import { useI18n } from "@/i18n/provider";
function getStatusVariant(status: number) {
switch (status) {
@@ -32,6 +29,7 @@ type ConversationListProps = {
}
export function ConversationList({ onAfterSelect }: ConversationListProps) {
const t = useI18n()
const conversations = useAgentConversationsStore((state) => state.conversations)
const loading = useAgentConversationsStore((state) => state.conversationsLoading)
const selectedId = useAgentConversationsStore((state) => state.selectedConversationId)
@@ -41,7 +39,7 @@ export function ConversationList({ onAfterSelect }: ConversationListProps) {
<ScrollArea className="overflow-auto">
{loading ? (
<div className="p-6 text-center text-sm text-muted-foreground">
...
{t("conversation.loading")}
</div>
) : conversations.length > 0 ? (
conversations.map((conversation) => {
@@ -72,7 +70,10 @@ export function ConversationList({ onAfterSelect }: ConversationListProps) {
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<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>
{conversation.agentUnreadCount > 0 ? (
<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">
{conversation.lastMessageAt
? formatDateTime(conversation.lastMessageAt)
: "暂无时间"}
: t("conversation.noTime")}
</div>
</div>
</div>
<div className="mt-0.5 truncate text-xs leading-4 text-muted-foreground">
{conversation.lastMessageSummary || "暂无最新消息"}
{conversation.lastMessageSummary || t("conversation.noLatestMessage")}
</div>
<div className="mt-1 flex items-center gap-1 text-[10px] text-muted-foreground">
<span
@@ -98,12 +99,14 @@ export function ConversationList({ onAfterSelect }: ConversationListProps) {
conversation.status
)}`}
>
{getEnumLabel(IMConversationStatusLabels, conversation.status)}
{getConversationStatusLabel(conversation.status, t)}
</span>
{conversation.status === IMConversationStatus.Pending &&
conversation.currentTeamName ? (
<span className="rounded-md bg-muted px-1.5 py-0.5">
{conversation.currentTeamName}
{t("conversation.teamOnDuty", {
name: conversation.currentTeamName,
})}
</span>
) : null}
</div>
@@ -113,9 +116,27 @@ export function ConversationList({ onAfterSelect }: ConversationListProps) {
})
) : (
<div className="p-6 text-center text-sm text-muted-foreground">
{t("conversation.empty")}
</div>
)}
</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,
} from "@/lib/api/agent"
import { type TagTree } from "@/lib/api/admin"
import { useI18n } from "@/i18n/provider"
import { cn } from "@/lib/utils"
type TagNode = TagTree & {
@@ -73,6 +74,7 @@ export function ConversationTagPicker({
loading = false,
onTagsChange,
}: ConversationTagPickerProps) {
const t = useI18n()
const [pendingTagId, setPendingTagId] = useState<number | null>(null)
const flattenedTags = useMemo(() => flattenTagTree(availableTags), [availableTags])
@@ -106,9 +108,9 @@ export function ConversationTagPicker({
})
}
onTagsChange(nextTags)
toast.success(exists ? "已移除会话标签" : "已添加会话标签")
toast.success(exists ? t("conversation.tagRemoved") : t("conversation.tagAdded"))
} catch (error) {
toast.error(error instanceof Error ? error.message : "更新会话标签失败")
toast.error(error instanceof Error ? error.message : t("conversation.tagUpdateFailed"))
} finally {
setPendingTagId(null)
}
@@ -123,12 +125,12 @@ export function ConversationTagPicker({
variant="ghost"
size="sm"
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" />
{t("conversation.edit")}
</PopoverTrigger>
<PopoverContent
align="end"
@@ -136,14 +138,14 @@ export function ConversationTagPicker({
onClick={(event) => event.stopPropagation()}
>
<Command>
<CommandInput placeholder="搜索标签" />
<CommandInput placeholder={t("conversation.searchTags")} />
<CommandList>
{loading ? <CommandEmpty>...</CommandEmpty> : null}
{loading ? <CommandEmpty>{t("conversation.loadingTags")}</CommandEmpty> : null}
{!loading && flattenedTags.length === 0 ? (
<CommandEmpty></CommandEmpty>
<CommandEmpty>{t("conversation.emptyTags")}</CommandEmpty>
) : null}
{!loading ? (
<CommandGroup heading="标签">
<CommandGroup heading={t("conversation.tagGroup")}>
{flattenedTags.map((tag) => {
const checked = selectedTagIds.has(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 { Sheet, SheetContent } from "@/components/ui/sheet";
import { useAgentConversationRealtime } from "@/hooks/use-agent-conversation-realtime";
import { useI18n } from "@/i18n/provider";
import {
agentConversationFilterOptions,
agentConversationSelectors,
@@ -61,6 +62,7 @@ function getCustomerOnlineDotClassName(online?: boolean) {
}
export default function ConversationsPage() {
const t = useI18n();
const conversation = useAgentConversationsStore(
agentConversationSelectors.selectedConversation,
);
@@ -117,11 +119,13 @@ export default function ConversationsPage() {
const currentFilterOption =
agentConversationFilterOptions.find((opt) => opt.value === conversationFilter) ??
agentConversationFilterOptions[0];
const getFilterLabel = (labelKey: string) => t(labelKey);
useEffect(() => {
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) {
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" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-44 min-w-44">
@@ -193,7 +201,7 @@ export default function ConversationsPage() {
>
{agentConversationFilterOptions.map((opt) => (
<DropdownMenuRadioItem key={opt.value} value={opt.value}>
{opt.label}
{getFilterLabel(opt.labelKey)}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
@@ -216,7 +224,7 @@ export default function ConversationsPage() {
value={opt.value}
className="shrink-0 px-2.5 text-xs sm:text-sm"
>
{opt.label}
{getFilterLabel(opt.labelKey)}
</TabsTrigger>
))}
</TabsList>
@@ -233,7 +241,7 @@ export default function ConversationsPage() {
key={opt.value}
className="shrink-0 px-2.5 text-xs sm:text-sm"
>
{opt.label}
{getFilterLabel(opt.labelKey)}
</span>
))}
</div>
@@ -281,13 +289,16 @@ export default function ConversationsPage() {
<Avatar className="size-8 shrink-0 lg:size-9">
<AvatarImage src="" />
<AvatarFallback className="bg-primary/10 text-sm text-primary">
{t("conversation.customerAvatar")}
</AvatarFallback>
</Avatar>
<div className="min-w-0">
<div className="flex items-center gap-2">
<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>
<span
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
? t("conversation.customerOnline")
: t("conversation.customerOffline")}
</span>
</div>
<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 ? (
<>
<span className="text-muted-foreground/60"> / </span>
<span></span>
<span>{t("conversation.linkedCustomer")}</span>
</>
) : null}
</p>
@@ -315,12 +328,14 @@ export default function ConversationsPage() {
</>
) : (
<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">
{t("conversation.openMenuSelectConversation")}
</p>
<p className="mt-0.5 hidden truncate text-[12px] text-muted-foreground lg:block">
{t("conversation.selectConversationFromSidebar")}
</p>
</div>
)}
@@ -331,7 +346,7 @@ export default function ConversationsPage() {
size="icon"
className={`${workbenchIconButtonClassName} lg:hidden`}
disabled={!conversation}
aria-label="会话信息"
aria-label={t("conversation.conversationInfo")}
onClick={() => setMobileCustomerSheetOpen(true)}
>
<CircleUserRoundIcon className="size-4" />
@@ -355,21 +370,21 @@ export default function ConversationsPage() {
disabled={!conversation}
>
<FilePlus2Icon />
{t("conversation.createTicket")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setTransferOpen(true)}
disabled={!conversation || conversation.status !== 3}
>
<ArrowRightLeftIcon />
{t("conversation.transferConversation")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setCloseOpen(true)}
disabled={!conversation || conversation.status === 4}
>
<CircleXIcon />
{t("conversation.closeConversation")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -378,7 +393,11 @@ export default function ConversationsPage() {
size="icon"
className={`${workbenchIconButtonClassName} hidden lg:flex`}
onClick={handleInfoPanelToggle}
aria-label={infoPanelCollapsed ? "展开会话信息" : "收起会话信息"}
aria-label={
infoPanelCollapsed
? t("conversation.expandConversationInfo")
: t("conversation.collapseConversationInfo")
}
>
{infoPanelCollapsed ? (
<ChevronLeft className="size-4" />
@@ -396,11 +415,10 @@ export default function ConversationsPage() {
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">
{/* H5 无左侧导航:顶栏 h-12、left-0lg 起有 w-14 侧栏,与 layout 一致 */}
{mobileMenuOpen && (
<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"
onClick={() => setMobileMenuOpen(false)}
/>
+69 -100
View File
@@ -33,13 +33,6 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
@@ -57,41 +50,20 @@ import {
updateCustomerStatus,
type AdminCustomer,
} from "@/lib/api/customer";
import { getEnumLabel, getEnumOptions } from "@/lib/enums";
import {
Gender,
GenderLabels,
Status,
StatusLabels,
} from "@/lib/generated/enums";
import { Gender, Status } from "@/lib/generated/enums";
import { useI18n } from "@/i18n/provider";
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(
value: 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() {
const t = useI18n();
const [keywordInput, setKeywordInput] = useState("");
const [statusFilterInput, setStatusFilterInput] = useState("all");
const [genderFilterInput, setGenderFilterInput] = useState("all");
@@ -103,7 +75,7 @@ export default function DashboardCustomersPage() {
const [companyFilter, setCompanyFilter] = useState("0");
const [companyOptions, setCompanyOptions] = useState<ComboboxOption[]>([
{ value: "0", label: "全部公司" },
{ value: "0", label: t("customer.allCompanies") },
]);
const [companyNameMap, setCompanyNameMap] = useState<Record<number, string>>(
{},
@@ -120,13 +92,30 @@ export default function DashboardCustomersPage() {
results: [],
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(() => {
async function loadCompanies() {
try {
const data = await fetchCompanies({ status: 0, page: 1, limit: 500 });
const opts: ComboboxOption[] = [
{ value: "0", label: "全部公司" },
{ value: "0", label: t("customer.allCompanies") },
...data.results.map((item) => ({
value: String(item.id),
label: item.name,
@@ -143,7 +132,7 @@ export default function DashboardCustomersPage() {
}
}
void loadCompanies();
}, []);
}, [t]);
const loadData = useCallback(async () => {
setLoading(true);
@@ -158,11 +147,11 @@ export default function DashboardCustomersPage() {
});
setResult(data);
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载客户列表失败");
toast.error(error instanceof Error ? error.message : t("customer.loadFailed"));
} finally {
setLoading(false);
}
}, [companyFilter, genderFilter, keyword, limit, page, statusFilter]);
}, [companyFilter, genderFilter, keyword, limit, page, statusFilter, t]);
useEffect(() => {
void loadData();
@@ -171,9 +160,9 @@ export default function DashboardCustomersPage() {
const companyFilterLabel = useMemo(() => {
return (
companyOptions.find((item) => item.value === companyFilterInput)?.label ??
"全部公司"
t("customer.allCompanies")
);
}, [companyFilterInput, companyOptions]);
}, [companyFilterInput, companyOptions, t]);
function applyFilters() {
setKeyword(keywordInput);
@@ -217,14 +206,14 @@ export default function DashboardCustomersPage() {
await saveCustomerProfile(payload);
toast.success(
editingItem
? `已更新客户:${editingItem.name}`
: `已创建客户:${payload.name}`,
? t("customer.updated", { name: editingItem.name })
: t("customer.created", { name: payload.name }),
);
setDialogOpen(false);
setEditingItem(null);
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存客户失败");
toast.error(error instanceof Error ? error.message : t("customer.saveFailed"));
} finally {
setSaving(false);
}
@@ -235,10 +224,10 @@ export default function DashboardCustomersPage() {
try {
const nextStatus = item.status === 0 ? 1 : 0;
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();
} catch (error) {
toast.error(error instanceof Error ? error.message : "更新状态失败");
toast.error(error instanceof Error ? error.message : t("customer.statusUpdateFailed"));
} finally {
setActionLoadingId(null);
}
@@ -248,17 +237,19 @@ export default function DashboardCustomersPage() {
setActionLoadingId(item.id);
try {
await deleteCustomer(item.id);
toast.success(`已删除客户:${item.name}`);
toast.success(t("customer.deleted", { name: item.name }));
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除客户失败");
toast.error(error instanceof Error ? error.message : t("customer.deleteFailed"));
} finally {
setActionLoadingId(null);
}
}
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 (
@@ -268,7 +259,7 @@ export default function DashboardCustomersPage() {
actions={
<Button onClick={openCreateDialog}>
<PlusIcon />
{t("customer.new")}
</Button>
}
>
@@ -278,60 +269,38 @@ export default function DashboardCustomersPage() {
value={keywordInput}
onChange={(event) => setKeywordInput(event.target.value)}
onKeyDown={handleFilterKeyDown}
placeholder="姓名、手机、邮箱、公司、联系方式"
placeholder={t("customer.keywordPlaceholder")}
className="pl-9"
/>
</div>
<Select
<OptionCombobox
value={genderFilterInput}
onValueChange={(v) => setGenderFilterInput(v ?? "all")}
>
<SelectTrigger className="w-full xl:w-28">
<SelectValue>
{getLabel(genderFilterInput, genderOptions)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{genderOptions.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
options={genderOptions}
placeholder={getLabel(genderFilterInput, genderOptions, t("customer.select"))}
onChange={(v) => setGenderFilterInput(v)}
/>
<div className="w-full xl:w-56">
<OptionCombobox
value={companyFilterInput}
options={companyOptions}
placeholder={companyFilterLabel}
searchPlaceholder="搜索公司名称"
searchPlaceholder={t("customer.searchCompany")}
onChange={(v) => setCompanyFilterInput(v)}
/>
</div>
<Select
<OptionCombobox
value={statusFilterInput}
onValueChange={(v) => setStatusFilterInput(v ?? "all")}
>
<SelectTrigger className="w-full xl:w-28">
<SelectValue>
{getLabel(statusFilterInput, listStatusOptions)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{listStatusOptions.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
options={listStatusOptions}
placeholder={getLabel(statusFilterInput, listStatusOptions, t("customer.select"))}
onChange={(v) => setStatusFilterInput(v)}
/>
<Button variant="outline" onClick={applyFilters} disabled={loading}>
<SearchIcon />
{t("customer.query")}
</Button>
</DashboardToolbar>
@@ -354,13 +323,13 @@ export default function DashboardCustomersPage() {
<TableHeader>
<TableRow>
<TableHead className="w-20">ID</TableHead>
<TableHead></TableHead>
<TableHead className="w-20"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-24"></TableHead>
<TableHead className="w-40"></TableHead>
<TableHead>{t("customer.columnName")}</TableHead>
<TableHead className="w-20">{t("customer.columnGender")}</TableHead>
<TableHead>{t("customer.columnCompany")}</TableHead>
<TableHead>{t("customer.columnMobile")}</TableHead>
<TableHead>{t("customer.columnEmail")}</TableHead>
<TableHead className="w-24">{t("customer.columnStatus")}</TableHead>
<TableHead className="w-40">{t("customer.columnActions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -368,8 +337,8 @@ export default function DashboardCustomersPage() {
<DashboardTableStateRow
colSpan={8}
loading={loading}
loadingText="正在加载客户数据..."
emptyText="暂无客户数据"
loadingText={t("customer.loading")}
emptyText={t("customer.empty")}
/>
) : (
result.results.map((item) => {
@@ -397,7 +366,7 @@ export default function DashboardCustomersPage() {
<Badge
variant={item.status === 0 ? "default" : "secondary"}
>
{item.status === 0 ? "启用" : "禁用"}
{item.status === 0 ? t("status.ok") : t("status.disabled")}
</Badge>
</TableCell>
<TableCell>
@@ -407,7 +376,7 @@ export default function DashboardCustomersPage() {
size="sm"
onClick={() => openEditDialog(item)}
>
{t("customer.edit")}
</Button>
<DropdownMenu>
<DropdownMenuTrigger
@@ -418,7 +387,7 @@ export default function DashboardCustomersPage() {
disabled={actionLoading}
/>
}
aria-label={`更多操作 ${item.name}`}
aria-label={t("customer.moreActions", { name: item.name })}
>
<MoreHorizontalIcon />
</DropdownMenuTrigger>
@@ -431,16 +400,16 @@ export default function DashboardCustomersPage() {
onClick={() => void handleToggleStatus(item)}
>
{actionLoadingId === item.id ? (
"处理中..."
t("customer.processing")
) : item.status === 0 ? (
<>
<BanIcon />
{t("customer.disable")}
</>
) : (
<>
<CheckCircle2Icon />
{t("customer.enable")}
</>
)}
</DropdownMenuItem>
@@ -450,7 +419,7 @@ export default function DashboardCustomersPage() {
onClick={() => void handleDelete(item)}
>
<Trash2Icon />
{t("customer.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
+10 -5
View File
@@ -1,15 +1,20 @@
"use client"
import { DashboardPlaceholder } from "@/components/dashboard-placeholder"
import { useI18n } from "@/i18n/provider"
export default function DashboardHelpPage() {
const t = useI18n()
return (
<DashboardPlaceholder
eyebrow="Help"
title="帮助中心骨架"
description="帮助中心用于沉淀产品说明、权限约定、渠道接入流程与常见问题。"
title={t("help.title")}
description={t("help.description")}
nextSteps={[
"汇总项目开发规范与后台使用说明。",
"补充第三方接入流程图和配置校验清单。",
"后续可接入 markdown 或文档中心能力。",
t("help.step1"),
t("help.step2"),
t("help.step3"),
]}
/>
)
@@ -17,12 +17,15 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Textarea } from "@/components/ui/textarea";
import { useI18n } from "@/i18n/provider";
import { getKnowledgeAnswerStatusLabel } from "@/lib/knowledge-i18n";
type DebugPanelProps = {
knowledgeBaseId: number | null;
};
export function DebugPanel({ knowledgeBaseId }: DebugPanelProps) {
const t = useI18n();
const [question, setQuestion] = useState("");
const [topK, setTopK] = useState("5");
const [scoreThreshold, setScoreThreshold] = useState("0.2");
@@ -34,11 +37,11 @@ export function DebugPanel({ knowledgeBaseId }: DebugPanelProps) {
async function handleSearch() {
if (!knowledgeBaseId) {
toast.error("请先选择知识库");
toast.error(t("knowledge.selectKnowledgeBaseFirst"));
return;
}
if (!question.trim()) {
toast.error("请输入调试问题");
toast.error(t("knowledge.debugQuestionRequired"));
return;
}
@@ -52,9 +55,9 @@ export function DebugPanel({ knowledgeBaseId }: DebugPanelProps) {
rerankLimit: Number(rerankLimit) || undefined,
});
setSearchResult(data);
toast.success(`检索完成,命中 ${data.hitCount}`);
toast.success(t("knowledge.searchCompleted", { count: data.hitCount }));
} catch (error) {
toast.error(error instanceof Error ? error.message : "检索失败");
toast.error(error instanceof Error ? error.message : t("knowledge.searchFailed"));
} finally {
setSearching(false);
}
@@ -62,11 +65,11 @@ export function DebugPanel({ knowledgeBaseId }: DebugPanelProps) {
async function handleAnswer() {
if (!knowledgeBaseId) {
toast.error("请先选择知识库");
toast.error(t("knowledge.selectKnowledgeBaseFirst"));
return;
}
if (!question.trim()) {
toast.error("请输入调试问题");
toast.error(t("knowledge.debugQuestionRequired"));
return;
}
@@ -80,9 +83,11 @@ export function DebugPanel({ knowledgeBaseId }: DebugPanelProps) {
rerankLimit: Number(rerankLimit) || undefined,
});
setAnswerResult(data);
toast.success(`问答完成,状态:${data.answerStatusName}`);
toast.success(t("knowledge.answerCompleted", {
status: getKnowledgeAnswerStatusLabel(data.answerStatus, data.answerStatusName, t),
}));
} catch (error) {
toast.error(error instanceof Error ? error.message : "问答失败");
toast.error(error instanceof Error ? error.message : t("knowledge.answerFailed"));
} finally {
setAnswering(false);
}
@@ -94,32 +99,32 @@ export function DebugPanel({ knowledgeBaseId }: DebugPanelProps) {
<Textarea
value={question}
onChange={(event) => setQuestion(event.target.value)}
placeholder="输入问题,测试知识库召回和回答效果"
placeholder={t("knowledge.debugQuestionPlaceholder")}
rows={5}
className="text-sm"
/>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1.5">
<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 className="space-y-1.5">
<Label htmlFor="threshold" className="text-xs"></Label>
<Input id="threshold" value={scoreThreshold} onChange={(event) => setScoreThreshold(event.target.value)} placeholder="最低分数" className="h-8" />
<Label htmlFor="threshold" className="text-xs">{t("knowledge.scoreThreshold")}</Label>
<Input id="threshold" value={scoreThreshold} onChange={(event) => setScoreThreshold(event.target.value)} placeholder={t("knowledge.scoreThresholdPlaceholder")} className="h-8" />
</div>
<div className="space-y-1.5">
<Label htmlFor="rerank" className="text-xs"></Label>
<Input id="rerank" value={rerankLimit} onChange={(event) => setRerankLimit(event.target.value)} placeholder="重排条数" className="h-8" />
<Label htmlFor="rerank" className="text-xs">{t("knowledge.rerankLimitLabel")}</Label>
<Input id="rerank" value={rerankLimit} onChange={(event) => setRerankLimit(event.target.value)} placeholder={t("knowledge.rerankLimitPlaceholder")} className="h-8" />
</div>
</div>
<div className="flex gap-2">
<Button className="flex-1" variant="outline" onClick={() => void handleSearch()} disabled={searching}>
<SearchIcon className="mr-2 size-4" />
{searching ? "检索中..." : "调试检索"}
{searching ? t("knowledge.searching") : t("knowledge.debugSearch")}
</Button>
<Button className="flex-1" onClick={() => void handleAnswer()} disabled={answering}>
<SparklesIcon className="mr-2 size-4" />
{answering ? "生成中..." : "调试问答"}
{answering ? t("knowledge.generating") : t("knowledge.debugAnswer")}
</Button>
</div>
</div>
@@ -131,12 +136,14 @@ export function DebugPanel({ knowledgeBaseId }: DebugPanelProps) {
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm">
<BotIcon className="size-4" />
{t("knowledge.answerResult")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<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">
{answerResult.latencyMs}ms · {answerResult.modelName || "fallback"}
</span>
@@ -146,7 +153,7 @@ export function DebugPanel({ knowledgeBaseId }: DebugPanelProps) {
</div>
{answerResult.citations.length > 0 ? (
<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) => (
<div
key={`${citation.documentId}-${citation.chunkNo}-${citation.sectionPath}`}
@@ -175,11 +182,11 @@ export function DebugPanel({ knowledgeBaseId }: DebugPanelProps) {
{searchResult ? (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm"></CardTitle>
<CardTitle className="text-sm">{t("knowledge.searchHits")}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="text-xs text-muted-foreground">
{searchResult.hitCount} · {searchResult.latencyMs}ms
{t("knowledge.hitsSummary", { count: searchResult.hitCount, latency: searchResult.latencyMs })}
</div>
{searchResult.results.map((item) => (
<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) {
return `FAQ ${item.faqId}`
}
return `文档 ${item.documentId ?? 0}`
return `Document ${item.documentId ?? 0}`
}
@@ -1,8 +1,8 @@
"use client"
import { useEffect, useState } from "react"
import { useEffect, useMemo, useState } from "react"
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 { ProjectDialog } from "@/components/project-dialog"
@@ -23,6 +23,7 @@ import {
import {
KnowledgeDocumentContentType,
} from "@/lib/generated/enums"
import { useI18n } from "@/i18n/provider"
type DocumentEditDialogProps = {
open: boolean
@@ -39,18 +40,21 @@ const emptyForm: EditForm = {
content: "",
}
const knowledgeDocumentFormSchema = z.object({
title: z.string().trim().min(1, "标题不能为空").max(255, "标题最多255个字符"),
contentType: z.string().trim().min(1, "请选择内容类型"),
content: z.string().trim().min(1, "内容不能为空"),
})
type TFunction = (key: string, values?: Record<string, string | number>) => string
type EditForm = z.infer<typeof knowledgeDocumentFormSchema>
const editFormResolver = zodResolver(knowledgeDocumentFormSchema as never) as Resolver<
z.input<typeof knowledgeDocumentFormSchema>,
undefined,
z.output<typeof knowledgeDocumentFormSchema>
>
function createKnowledgeDocumentFormSchema(t: TFunction) {
return z.object({
title: z.string().trim().min(1, t("knowledge.documentTitleRequired")).max(255, t("knowledge.documentTitleMax")),
contentType: z.string().trim().min(1, t("knowledge.contentTypeRequired")),
content: z.string().trim().min(1, t("knowledge.contentRequired")),
})
}
type EditForm = {
title: string
contentType: string
content: string
}
function buildForm(item: KnowledgeDocument | null): EditForm {
if (!item) {
@@ -112,13 +116,15 @@ function DocumentFormDialogBody({
onOpenChange,
onSubmit,
}: DocumentFormDialogBodyProps) {
const t = useI18n()
const formId = "knowledge-document-edit-form"
const [loading, setLoading] = useState(false)
const form = useForm<
z.input<typeof knowledgeDocumentFormSchema>,
undefined,
z.output<typeof knowledgeDocumentFormSchema>
>({
const knowledgeDocumentFormSchema = useMemo(() => createKnowledgeDocumentFormSchema(t), [t])
const editFormResolver = useMemo(
() => zodResolver(knowledgeDocumentFormSchema) as Resolver<EditForm>,
[knowledgeDocumentFormSchema],
)
const form = useForm<EditForm>({
resolver: editFormResolver,
defaultValues: emptyForm,
})
@@ -163,7 +169,7 @@ function DocumentFormDialogBody({
<ProjectDialog
open={true}
onOpenChange={onOpenChange}
title={itemId ? "编辑文档" : "新建文档"}
title={itemId ? t("knowledge.editDocumentTitle") : t("knowledge.createDocumentTitle")}
size="xl"
allowFullscreen
footer={
@@ -174,26 +180,26 @@ function DocumentFormDialogBody({
onClick={() => onOpenChange(false)}
disabled={saving}
>
{t("knowledge.cancel")}
</Button>
<Button type="submit" form={formId} disabled={saving || loading}>
{saving ? "保存中..." : itemId ? "保存" : "创建"}
{saving ? t("knowledge.saving") : itemId ? t("knowledge.save") : t("knowledge.create")}
</Button>
</>
}
>
{loading ? (
<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>
) : (
<form id={formId} onSubmit={handleSubmit(onFormSubmit)} className="space-y-4">
<Field data-invalid={!!errors.title}>
<FieldLabel htmlFor="doc-title"></FieldLabel>
<FieldLabel htmlFor="doc-title">{t("knowledge.documentTitle")}</FieldLabel>
<FieldContent>
<Input
id="doc-title"
placeholder="文档标题"
placeholder={t("knowledge.documentTitlePlaceholder")}
aria-invalid={!!errors.title}
{...register("title")}
/>
@@ -202,7 +208,7 @@ function DocumentFormDialogBody({
</Field>
<Field data-invalid={!!errors.content}>
<FieldLabel htmlFor="doc-content"></FieldLabel>
<FieldLabel htmlFor="doc-content">{t("knowledge.content")}</FieldLabel>
<FieldContent>
<Controller
control={control}
@@ -223,7 +229,7 @@ function DocumentFormDialogBody({
shouldValidate: true,
})
}}
placeholder="请输入文档内容"
placeholder={t("knowledge.contentPlaceholder")}
disabled={saving}
/>
)}
@@ -8,7 +8,7 @@ import {
Trash2Icon,
WrenchIcon,
} from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { ListPagination } from "@/components/list-pagination";
@@ -27,14 +27,8 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { OptionCombobox } from "@/components/option-combobox";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Tooltip,
TooltipContent,
@@ -51,11 +45,10 @@ import {
type KnowledgeDocumentListItem,
type PageResult,
} from "@/lib/api/admin";
import { getEnumLabel, getEnumOptions } from "@/lib/enums";
import { useI18n } from "@/i18n/provider";
import {
KnowledgeDocumentIndexStatus,
KnowledgeDocumentIndexStatusLabels,
StatusLabels
Status,
} from "@/lib/generated/enums";
import { cn, formatDateTime } from "@/lib/utils";
import { DocumentEditDialog } from "./document-edit";
@@ -73,15 +66,32 @@ export type DocumentListActionState = {
loading: boolean;
};
const statusOptions = [
{ value: "all", label: "全部状态" },
...getEnumOptions(StatusLabels),
] as const;
type TFunction = (key: string, values?: Record<string, string | number>) => string;
const indexStatusOptions = [
{ value: "all", label: "全部索引状态" },
...getEnumOptions(KnowledgeDocumentIndexStatusLabels),
] as const;
function getStatusOptions(t: TFunction) {
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") },
];
}
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) {
switch (status) {
@@ -94,10 +104,10 @@ function getIndexStatusBadgeVariant(status: string) {
}
}
function renderIndexStatusBadge(item: KnowledgeDocumentListItem) {
function renderIndexStatusBadge(item: KnowledgeDocumentListItem, t: TFunction) {
const badge = (
<Badge variant={getIndexStatusBadgeVariant(item.indexStatus)}>
{item.indexStatusName}
{getIndexStatusLabel(item.indexStatus, t)}
</Badge>
)
@@ -125,6 +135,7 @@ function renderIndexStatusBadge(item: KnowledgeDocumentListItem) {
const VIEW_MODE_STORAGE_KEY = "knowledge-document-view-mode";
export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentListProps) {
const t = useI18n();
const [keywordInput, setKeywordInput] = useState("");
const [statusFilterInput, setStatusFilterInput] = useState("all");
const [indexStatusFilterInput, setIndexStatusFilterInput] = useState("all");
@@ -149,6 +160,8 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL
results: [],
page: { page: 1, limit: 20, total: 0 },
});
const statusOptions = useMemo(() => getStatusOptions(t), [t]);
const indexStatusOptions = useMemo(() => getIndexStatusOptions(t), [t]);
const loadData = useCallback(async (options?: {
keyword?: string;
@@ -181,11 +194,11 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL
});
setDocuments(data);
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载文档失败");
toast.error(error instanceof Error ? error.message : t("knowledge.loadDocumentsFailed"));
} finally {
setLoading(false);
}
}, [indexStatusFilter, keyword, statusFilter, knowledgeBaseId, limit, page]);
}, [indexStatusFilter, keyword, statusFilter, knowledgeBaseId, limit, page, t]);
useEffect(() => {
void loadData();
@@ -277,16 +290,16 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL
id: editingItem.id,
...payload,
});
toast.success(`已更新文档:${editingItem.title}`);
toast.success(t("knowledge.documentUpdated", { title: editingItem.title }));
} else {
await createKnowledgeDocument(payload);
toast.success(`已创建文档:${payload.title}`);
toast.success(t("knowledge.documentCreated", { title: payload.title }));
}
setDialogOpen(false);
setEditingItem(null);
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存文档失败");
toast.error(error instanceof Error ? error.message : t("knowledge.documentSaveFailed"));
} finally {
setSaving(false);
}
@@ -296,10 +309,10 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL
setActionLoadingMap((prev) => ({ ...prev, [item.id]: { ...prev[item.id], delete: true } }));
try {
await deleteKnowledgeDocument(item.id);
toast.success(`已删除文档:${item.title}`);
toast.success(t("knowledge.documentDeleted", { title: item.title }));
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除文档失败");
toast.error(error instanceof Error ? error.message : t("knowledge.documentDeleteFailed"));
} finally {
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 } }));
try {
await buildKnowledgeDocumentIndex(item.id);
toast.success(`已重建索引:${item.title}`);
toast.success(t("knowledge.documentIndexRebuilt", { title: item.title }));
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : "重建索引失败");
toast.error(error instanceof Error ? error.message : t("knowledge.documentIndexRebuildFailed"));
} finally {
setActionLoadingMap((prev) => ({ ...prev, [item.id]: { ...prev[item.id], rebuildIndex: false } }));
}
@@ -322,7 +335,7 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL
return (
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
<FileTextIcon className="mb-2 size-12 opacity-50" />
<p></p>
<p>{t("knowledge.selectKnowledgeBaseForDocuments")}</p>
</div>
);
}
@@ -338,57 +351,26 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL
value={keywordInput}
onChange={(event) => setKeywordInput(event.target.value)}
onKeyDown={handleFilterKeyDown}
placeholder="搜索文档标题"
placeholder={t("knowledge.searchDocumentTitle")}
className="h-8 pl-8 text-xs"
/>
</div>
<Select
<OptionCombobox
value={statusFilterInput}
onValueChange={handleStatusFilterChange}
>
<SelectTrigger className="h-8 w-32 text-xs">
<SelectValue>
{statusFilterInput === "all" ? "全部状态" : 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>
<Select
onChange={handleStatusFilterChange}
options={statusOptions}
placeholder={t("knowledge.allStatus")}
searchPlaceholder={t("knowledge.searchStatus")}
emptyText={t("knowledge.emptyStatus")}
/>
<OptionCombobox
value={indexStatusFilterInput}
onValueChange={handleIndexStatusFilterChange}
>
<SelectTrigger className="h-8 w-36 text-xs">
<SelectValue>
{indexStatusFilterInput === "all"
? "全部索引状态"
: 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>
onChange={handleIndexStatusFilterChange}
options={indexStatusOptions}
placeholder={t("knowledge.allIndexStatus")}
searchPlaceholder={t("knowledge.searchStatus")}
emptyText={t("knowledge.emptyStatus")}
/>
</div>
</div>
<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="flex items-center gap-2">
<div className="text-sm font-medium">{item.title}</div>
{renderIndexStatusBadge(item)}
{renderIndexStatusBadge(item, t)}
</div>
<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>{formatDateTime(item.createdAt)}</span>
<span className={cn(item.indexStatus === KnowledgeDocumentIndexStatus.Failed && "text-destructive")}>
{item.indexStatus === KnowledgeDocumentIndexStatus.Indexed
? `已索引 ${formatDateTime(item.indexedAt)}`
: item.indexStatusName}
? t("knowledge.indexedAt", { time: formatDateTime(item.indexedAt) })
: getIndexStatusLabel(item.indexStatus, t)}
</span>
</div>
</div>
@@ -429,25 +411,25 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL
className="size-6"
/>
}
aria-label={`更多操作 ${item.title}`}
aria-label={t("knowledge.moreActions", { name: item.title })}
>
<MoreHorizontalIcon className="size-3.5" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-32 min-w-32">
<DropdownMenuItem onClick={() => openEditDialog(item)}>
<PencilIcon className="mr-2 size-3.5" />
{t("knowledge.edit")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => void handleBuildIndex(item)}>
<WrenchIcon className="mr-2 size-3.5" />
{actionLoadingMap[item.id]?.rebuildIndex ? "执行中..." : "重建索引"}
{actionLoadingMap[item.id]?.rebuildIndex ? t("knowledge.running") : t("knowledge.rebuildIndex")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => void handleDelete(item)}
className="text-destructive focus:text-destructive"
>
<Trash2Icon className="mr-2 size-3.5" />
{actionLoadingMap[item.id]?.delete ? "删除中..." : "删除"}
{actionLoadingMap[item.id]?.delete ? t("knowledge.deleting") : t("knowledge.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -457,11 +439,11 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL
<ContextMenuContent className="w-40">
<ContextMenuItem onClick={() => openEditDialog(item)}>
<PencilIcon className="mr-2 size-3.5" />
{t("knowledge.edit")}
</ContextMenuItem>
<ContextMenuItem onClick={() => void handleBuildIndex(item)} disabled={actionLoadingMap[item.id]?.rebuildIndex}>
<WrenchIcon className="mr-2 size-3.5" />
{actionLoadingMap[item.id]?.rebuildIndex ? "执行中..." : "重建索引"}
{actionLoadingMap[item.id]?.rebuildIndex ? t("knowledge.running") : t("knowledge.rebuildIndex")}
</ContextMenuItem>
<ContextMenuItem
onClick={() => void handleDelete(item)}
@@ -469,7 +451,7 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL
disabled={actionLoadingMap[item.id]?.delete}
>
<Trash2Icon className="mr-2 size-3.5" />
{actionLoadingMap[item.id]?.delete ? "删除中..." : "删除"}
{actionLoadingMap[item.id]?.delete ? t("knowledge.deleting") : t("knowledge.delete")}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
@@ -483,12 +465,12 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<div className="truncate text-sm font-medium">{item.title}</div>
{renderIndexStatusBadge(item)}
{renderIndexStatusBadge(item, t)}
</div>
<div className="mt-1 text-xs text-muted-foreground">
{item.indexStatus === KnowledgeDocumentIndexStatus.Indexed
? `索引时间:${formatDateTime(item.indexedAt)}`
: item.indexError || item.indexStatusName}
? t("knowledge.indexTime", { time: formatDateTime(item.indexedAt) })
: item.indexError || getIndexStatusLabel(item.indexStatus, t)}
</div>
</div>
<div className="shrink-0 text-xs text-muted-foreground">
@@ -503,25 +485,25 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL
className="size-6"
/>
}
aria-label={`更多操作 ${item.title}`}
aria-label={t("knowledge.moreActions", { name: item.title })}
>
<MoreHorizontalIcon className="size-3.5" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-32 min-w-32">
<DropdownMenuItem onClick={() => openEditDialog(item)}>
<PencilIcon className="mr-2 size-3.5" />
{t("knowledge.edit")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => void handleBuildIndex(item)}>
<WrenchIcon className="mr-2 size-3.5" />
{actionLoadingMap[item.id]?.rebuildIndex ? "执行中..." : "重建索引"}
{actionLoadingMap[item.id]?.rebuildIndex ? t("knowledge.running") : t("knowledge.rebuildIndex")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => void handleDelete(item)}
className="text-destructive focus:text-destructive"
>
<Trash2Icon className="mr-2 size-3.5" />
{actionLoadingMap[item.id]?.delete ? "删除中..." : "删除"}
{actionLoadingMap[item.id]?.delete ? t("knowledge.deleting") : t("knowledge.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -530,11 +512,11 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL
<ContextMenuContent className="w-40">
<ContextMenuItem onClick={() => openEditDialog(item)}>
<PencilIcon className="mr-2 size-3.5" />
{t("knowledge.edit")}
</ContextMenuItem>
<ContextMenuItem onClick={() => void handleBuildIndex(item)} disabled={actionLoadingMap[item.id]?.rebuildIndex}>
<WrenchIcon className="mr-2 size-3.5" />
{actionLoadingMap[item.id]?.rebuildIndex ? "执行中..." : "重建索引"}
{actionLoadingMap[item.id]?.rebuildIndex ? t("knowledge.running") : t("knowledge.rebuildIndex")}
</ContextMenuItem>
<ContextMenuItem
onClick={() => void handleDelete(item)}
@@ -542,7 +524,7 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL
disabled={actionLoadingMap[item.id]?.delete}
>
<Trash2Icon className="mr-2 size-3.5" />
{actionLoadingMap[item.id]?.delete ? "删除中..." : "删除"}
{actionLoadingMap[item.id]?.delete ? t("knowledge.deleting") : t("knowledge.delete")}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
@@ -550,7 +532,7 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL
))}
{!loading && documents.results.length === 0 ? (
<div className="py-8 text-center text-sm text-muted-foreground">
{t("knowledge.emptyDocuments")}
</div>
) : null}
</div>
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm, type Resolver } from "react-hook-form";
import { z } from "zod/v4";
@@ -15,6 +15,7 @@ import {
type CreateKnowledgeFAQPayload,
type KnowledgeFAQ,
} from "@/lib/api/admin";
import { useI18n } from "@/i18n/provider";
type FAQEditDialogProps = {
open: boolean;
@@ -25,20 +26,23 @@ type FAQEditDialogProps = {
onSubmit: (payload: CreateKnowledgeFAQPayload) => Promise<void>;
};
const formSchema = z.object({
question: z.string().trim().min(1, "问题不能为空").max(500, "问题最多500个字符"),
answer: z.string().trim().min(1, "答案不能为空"),
type TFunction = (key: string, values?: Record<string, string | number>) => string;
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(),
remark: z.string().trim().max(500, "备注最多500个字符"),
});
remark: z.string().trim().max(500, t("knowledge.remarkMax")),
});
}
type EditForm = z.infer<typeof formSchema>;
const resolver = zodResolver(formSchema as never) as Resolver<
z.input<typeof formSchema>,
undefined,
z.output<typeof formSchema>
>;
type EditForm = {
question: string;
answer: string;
similarQuestionsText: string;
remark: string;
};
const emptyForm: EditForm = {
question: "",
@@ -113,13 +117,15 @@ function FAQEditDialogBody({
onOpenChange,
onSubmit,
}: FAQEditDialogBodyProps) {
const t = useI18n();
const [loading, setLoading] = useState(false);
const formId = "knowledge-faq-edit-form";
const form = useForm<
z.input<typeof formSchema>,
undefined,
z.output<typeof formSchema>
>({
const formSchema = useMemo(() => createFormSchema(t), [t]);
const resolver = useMemo(
() => zodResolver(formSchema) as Resolver<EditForm>,
[formSchema],
);
const form = useForm<EditForm>({
resolver,
defaultValues: emptyForm,
});
@@ -157,56 +163,56 @@ function FAQEditDialogBody({
<ProjectDialog
open={open}
onOpenChange={onOpenChange}
title={itemId ? "编辑FAQ" : "新建FAQ"}
title={itemId ? t("knowledge.editFAQTitle") : t("knowledge.createFAQTitle")}
allowFullscreen
size="xl"
footer={
<>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
{t("knowledge.cancel")}
</Button>
<Button type="submit" form={formId} disabled={saving || loading}>
{saving ? "保存中..." : itemId ? "保存" : "创建"}
{saving ? t("knowledge.saving") : itemId ? t("knowledge.save") : t("knowledge.create")}
</Button>
</>
}
>
{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">
<Field data-invalid={!!errors.question}>
<FieldLabel htmlFor="faq-question"></FieldLabel>
<FieldLabel htmlFor="faq-question">{t("knowledge.standardQuestion")}</FieldLabel>
<FieldContent>
<Input id="faq-question" placeholder="请输入标准问题" {...register("question")} />
<Input id="faq-question" placeholder={t("knowledge.questionPlaceholder")} {...register("question")} />
<FieldError errors={[errors.question]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.answer}>
<FieldLabel htmlFor="faq-answer"></FieldLabel>
<FieldLabel htmlFor="faq-answer">{t("knowledge.answer")}</FieldLabel>
<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]} />
</FieldContent>
</Field>
<Field>
<FieldLabel htmlFor="faq-similar-questions"></FieldLabel>
<FieldLabel htmlFor="faq-similar-questions">{t("knowledge.similarQuestions")}</FieldLabel>
<FieldContent>
<Textarea
id="faq-similar-questions"
rows={5}
placeholder={"一行一个相似问题"}
placeholder={t("knowledge.similarQuestionsPlaceholder")}
{...register("similarQuestionsText")}
/>
</FieldContent>
</Field>
<Field data-invalid={!!errors.remark}>
<FieldLabel htmlFor="faq-remark"></FieldLabel>
<FieldLabel htmlFor="faq-remark">{t("knowledge.remark")}</FieldLabel>
<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]} />
</FieldContent>
</Field>
@@ -215,4 +221,3 @@ function FAQEditDialogBody({
</ProjectDialog>
);
}
@@ -16,6 +16,7 @@ import {
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { createKnowledgeFAQ, type CreateKnowledgeFAQPayload } from "@/lib/api/admin";
import { useI18n } from "@/i18n/provider";
type FAQImportDialogProps = {
open: boolean;
@@ -39,24 +40,20 @@ type ParseResult = {
warnings: string[];
};
const templateContent = [
"question,answer,similarQuestions,remark",
'"如何重置密码?","进入个人设置后点击重置密码。","忘记密码|密码重置在哪里","账号类FAQ"',
'"支持哪些接入渠道?","目前支持网站组件、企业微信等渠道接入。","有哪些渠道|支持什么渠道","渠道说明"',
].join("\n");
type TFunction = (key: string, values?: Record<string, string | number>) => string;
const acceptedHeaderMap: Record<string, keyof Omit<ParsedFAQRow, "rowNo">> = {
question: "question",
"标准问题": "question",
"问题": "question",
answer: "answer",
"答案": "answer",
similarquestions: "similarQuestions",
"similarQuestions": "similarQuestions",
"相似问": "similarQuestions",
"相似问题": "similarQuestions",
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) {
@@ -134,10 +131,10 @@ function parseSimilarQuestions(value: string) {
.filter(Boolean);
}
function parseFAQFileContent(input: string): ParseResult {
function parseFAQFileContent(input: string, t: TFunction): ParseResult {
const table = parseDelimitedText(input);
if (table.length === 0) {
throw new Error("文件内容为空");
throw new Error(t("knowledge.fileEmpty"));
}
const headerRow = table[0];
@@ -150,7 +147,7 @@ function parseFAQFileContent(input: string): ParseResult {
}
if (!headerMap.has("question") || !headerMap.has("answer")) {
throw new Error("导入模板缺少 question/answer 列");
throw new Error(t("knowledge.missingFAQColumns"));
}
const rows: ParsedFAQRow[] = [];
@@ -168,7 +165,7 @@ function parseFAQFileContent(input: string): ParseResult {
continue;
}
if (!question || !answer) {
warnings.push(`${rowNo} 行缺少问题或答案,已跳过`);
warnings.push(t("knowledge.skipRowMissingFAQ", { row: rowNo }));
continue;
}
@@ -185,6 +182,11 @@ function parseFAQFileContent(input: string): ParseResult {
}
function downloadTemplate() {
const templateContent = [
"question,answer,similarQuestions,remark",
'"How do I reset my password?","Open profile settings and choose Reset Password.","forgot password|where is reset password","Account FAQ"',
'"Which channels are supported?","Web chat and WeCom customer service channels are currently supported.","available channels|supported channels","Channel guide"',
].join("\n");
const blob = new Blob([templateContent], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
@@ -212,6 +214,7 @@ export function FAQImportDialog({
onImportingChange,
onImported,
}: FAQImportDialogProps) {
const t = useI18n();
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [fileName, setFileName] = useState("");
const [rows, setRows] = useState<ParsedFAQRow[]>([]);
@@ -236,18 +239,18 @@ export function FAQImportDialog({
try {
const content = await file.text();
const parsed = parseFAQFileContent(content);
const parsed = parseFAQFileContent(content, t);
setFileName(file.name);
setRows(parsed.rows);
setWarnings(parsed.warnings);
if (parsed.rows.length === 0) {
toast.error("没有可导入的FAQ记录");
toast.error(t("knowledge.importNoRows"));
} else {
toast.success(`已解析 ${parsed.rows.length} 条FAQ`);
toast.success(t("knowledge.parsedFAQRows", { count: parsed.rows.length }));
}
} catch (error) {
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;
} catch (error) {
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();
if (successCount > 0) {
toast.success(`成功导入 ${successCount} 条FAQ`);
toast.success(t("knowledge.importSuccess", { count: successCount }));
}
if (failedRows.length > 0) {
toast.error(`${failedRows.length} 条FAQ导入失败`);
toast.error(t("knowledge.importSomeFailed", { count: failedRows.length }));
setWarnings((current) => [...current, ...failedRows]);
return;
}
@@ -299,27 +305,31 @@ export function FAQImportDialog({
}
onOpenChange(nextOpen);
}}
title="导入FAQ"
description="上传 CSV 文件批量导入 FAQ。必填列为 question、answersimilarQuestions 使用 | 或换行分隔。"
title={t("knowledge.importFAQTitle")}
description={t("knowledge.importFAQDescription")}
size="lg"
footer={
<>
<Button type="button" variant="outline" onClick={() => downloadTemplate()}>
<DownloadIcon className="size-4" />
{t("knowledge.downloadTemplate")}
</Button>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={importing}>
{t("knowledge.cancel")}
</Button>
<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>
</>
}
>
<FieldGroup>
<Field>
<FieldLabel htmlFor="faq-import-file"></FieldLabel>
<FieldLabel htmlFor="faq-import-file">{t("knowledge.importFile")}</FieldLabel>
<FieldContent>
<div className="flex items-center gap-2">
<Input
@@ -335,18 +345,18 @@ export function FAQImportDialog({
onClick={() => fileInputRef.current?.click()}
>
<FileUpIcon className="size-4" />
{t("knowledge.chooseFile")}
</Button>
</div>
<FieldDescription>
UTF-8 CSV
{t("knowledge.importFileDescription")}
</FieldDescription>
</FieldContent>
</Field>
{fileName ? (
<div className="rounded-md border bg-muted/20 px-3 py-2 text-sm">
{fileName}
{t("knowledge.currentFile", { name: fileName })}
</div>
) : 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="mb-2 flex items-center gap-2 font-medium">
<InfoIcon className="size-4" />
{t("knowledge.importHint")}
</div>
<ul className="space-y-1">
{warnings.map((item, index) => (
@@ -366,7 +376,7 @@ export function FAQImportDialog({
<div className="rounded-md border">
<div className="border-b px-4 py-3 text-sm font-medium">
{t("knowledge.importPreview")}
</div>
{previewRows.length > 0 ? (
<ScrollArea className="max-h-80">
@@ -374,7 +384,7 @@ export function FAQImportDialog({
{previewRows.map((row) => (
<div key={row.rowNo} className="space-y-2 px-4 py-3 text-sm">
<div>
<span className="text-muted-foreground"> {row.rowNo} </span>
<span className="text-muted-foreground">{t("knowledge.rowNumber", { row: row.rowNo })}</span>
</div>
<div>
<div className="font-medium">{row.question}</div>
@@ -383,16 +393,18 @@ export function FAQImportDialog({
</div>
</div>
<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 className="text-muted-foreground">{row.remark || "无"}</div>
<div className="text-muted-foreground">{t("knowledge.remark")}{row.remark || t("knowledge.none")}</div>
</div>
))}
</div>
</ScrollArea>
) : (
<div className="px-4 py-12 text-center text-sm text-muted-foreground">
5 FAQ
{t("knowledge.previewAfterUpload")}
</div>
)}
</div>
@@ -6,7 +6,7 @@ import {
Trash2Icon,
WrenchIcon,
} from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { ListPagination } from "@/components/list-pagination";
@@ -20,13 +20,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { OptionCombobox } from "@/components/option-combobox";
import {
TableBody,
TableCell,
@@ -50,11 +44,10 @@ import {
type KnowledgeFAQ,
type PageResult,
} from "@/lib/api/admin";
import { getEnumLabel, getEnumOptions } from "@/lib/enums";
import {
KnowledgeDocumentIndexStatus,
KnowledgeDocumentIndexStatusLabels,
} from "@/lib/generated/enums";
import { useI18n } from "@/i18n/provider";
import { formatDateTime } from "@/lib/utils";
import { FAQEditDialog } from "./faq-edit";
import { FAQImportDialog } from "./faq-import-dialog";
@@ -72,10 +65,23 @@ export type FAQListActionState = {
importing: boolean;
};
const indexStatusOptions = [
{ value: "all", label: "全部索引状态" },
...getEnumOptions(KnowledgeDocumentIndexStatusLabels),
] as const;
type TFunction = (key: string, values?: Record<string, string | number>) => string;
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) {
switch (status) {
@@ -88,10 +94,10 @@ function getIndexStatusBadgeVariant(status: string) {
}
}
function renderIndexStatusBadge(item: KnowledgeFAQ) {
function renderIndexStatusBadge(item: KnowledgeFAQ, t: TFunction) {
const badge = (
<Badge variant={getIndexStatusBadgeVariant(item.indexStatus)}>
{item.indexStatusName}
{getIndexStatusLabel(item.indexStatus, t)}
</Badge>
);
@@ -120,6 +126,7 @@ export function FAQList({
knowledgeBaseId,
onActionStateChange,
}: FAQListProps) {
const t = useI18n();
const [keywordInput, setKeywordInput] = useState("");
const [indexStatusFilterInput, setIndexStatusFilterInput] = useState("all");
const [keyword, setKeyword] = useState("");
@@ -139,6 +146,7 @@ export function FAQList({
results: [],
page: { page: 1, limit: 20, total: 0 },
});
const indexStatusOptions = useMemo(() => getIndexStatusOptions(t), [t]);
const loadData = useCallback(
async (options?: {
@@ -172,12 +180,12 @@ export function FAQList({
});
setResult(data);
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载FAQ失败");
toast.error(error instanceof Error ? error.message : t("knowledge.loadFAQFailed"));
} finally {
setLoading(false);
}
},
[indexStatusFilter, keyword, knowledgeBaseId, limit, page],
[indexStatusFilter, keyword, knowledgeBaseId, limit, page, t],
);
useEffect(() => {
@@ -221,9 +229,9 @@ export function FAQList({
setDialogOpen(false);
setEditingItem(null);
await loadData();
toast.success("FAQ已保存");
toast.success(t("knowledge.faqSaved"));
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存FAQ失败");
toast.error(error instanceof Error ? error.message : t("knowledge.faqSaveFailed"));
} finally {
setSaving(false);
}
@@ -236,10 +244,10 @@ export function FAQList({
}));
try {
await deleteKnowledgeFAQ(item.id);
toast.success("FAQ已删除");
toast.success(t("knowledge.faqDeleted"));
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除FAQ失败");
toast.error(error instanceof Error ? error.message : t("knowledge.faqDeleteFailed"));
} finally {
setActionLoadingMap((prev) => ({
...prev,
@@ -255,10 +263,10 @@ export function FAQList({
}));
try {
await buildKnowledgeFAQIndex(item.id);
toast.success("FAQ索引已重建");
toast.success(t("knowledge.faqIndexRebuilt"));
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : "重建FAQ索引失败");
toast.error(error instanceof Error ? error.message : t("knowledge.faqIndexRebuildFailed"));
} finally {
setActionLoadingMap((prev) => ({
...prev,
@@ -270,7 +278,7 @@ export function FAQList({
if (!knowledgeBaseId) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
FAQ知识库查看FAQ
{t("knowledge.selectFAQBase")}
</div>
);
}
@@ -289,38 +297,24 @@ export function FAQList({
applyFilters();
}
}}
placeholder="按问题搜索FAQ"
placeholder={t("knowledge.searchFAQ")}
className="pl-9"
/>
</div>
<Select
<OptionCombobox
value={indexStatusFilterInput}
onValueChange={(value) => setIndexStatusFilterInput(value ?? "all")}
>
<SelectTrigger className="w-40">
<SelectValue>
{indexStatusFilterInput === "all"
? "全部索引状态"
: getEnumLabel(
KnowledgeDocumentIndexStatusLabels,
indexStatusFilterInput as KnowledgeDocumentIndexStatus,
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{indexStatusOptions.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
onChange={(value) => setIndexStatusFilterInput(value ?? "all")}
options={indexStatusOptions}
placeholder={t("knowledge.allIndexStatus")}
searchPlaceholder={t("knowledge.searchStatus")}
emptyText={t("knowledge.emptyStatus")}
/>
<Button
variant="outline"
onClick={applyFilters}
disabled={loading}
>
{t("knowledge.query")}
</Button>
</div>
@@ -329,11 +323,11 @@ export function FAQList({
<table className="w-full min-w-max caption-bottom text-sm">
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-20 text-right"></TableHead>
<TableHead>{t("knowledge.question")}</TableHead>
<TableHead>{t("knowledge.indexStatus")}</TableHead>
<TableHead>{t("knowledge.similarQuestions")}</TableHead>
<TableHead>{t("knowledge.updatedAt")}</TableHead>
<TableHead className="w-20 text-right">{t("knowledge.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -345,7 +339,7 @@ export function FAQList({
{item.answer}
</div>
</TableCell>
<TableCell>{renderIndexStatusBadge(item)}</TableCell>
<TableCell>{renderIndexStatusBadge(item, t)}</TableCell>
<TableCell>
{Array.isArray(item.similarQuestions)
? item.similarQuestions.length
@@ -362,12 +356,12 @@ export function FAQList({
setDialogOpen(true);
}}
>
{t("knowledge.edit")}
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={<Button variant="outline" size="icon-sm" />}
aria-label={`更多操作 ${item.question}`}
aria-label={t("knowledge.moreActions", { name: item.question })}
>
<MoreHorizontalIcon className="size-4" />
</DropdownMenuTrigger>
@@ -377,8 +371,8 @@ export function FAQList({
>
<WrenchIcon className="mr-2 size-4" />
{actionLoadingMap[item.id]?.rebuildIndex
? "重建中..."
: "重建索引"}
? t("knowledge.rebuilding")
: t("knowledge.rebuildIndex")}
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
@@ -386,8 +380,8 @@ export function FAQList({
>
<Trash2Icon className="mr-2 size-4" />
{actionLoadingMap[item.id]?.delete
? "删除中..."
: "删除"}
? t("knowledge.deleting")
: t("knowledge.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -401,7 +395,7 @@ export function FAQList({
colSpan={5}
className="py-12 text-center text-sm text-muted-foreground"
>
FAQ
{t("knowledge.emptyFAQ")}
</TableCell>
</TableRow>
) : null}
@@ -1,8 +1,8 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useState } from "react";
import { Controller, Resolver, useForm } from "react-hook-form";
import { useEffect, useMemo, useState } from "react";
import { Controller, type Resolver, useForm } from "react-hook-form";
import { z } from "zod/v4";
import { OptionCombobox } from "@/components/option-combobox";
@@ -15,27 +15,17 @@ import {
FieldLabel,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import {
fetchKnowledgeBase,
type CreateKnowledgeBasePayload,
type KnowledgeBase,
} from "@/lib/api/admin";
import { getEnumLabel, getEnumOptions } from "@/lib/enums";
import { useI18n } from "@/i18n/provider";
import {
KnowledgeAnswerMode,
KnowledgeAnswerModeLabels,
KnowledgeBaseType,
KnowledgeBaseTypeLabels,
KnowledgeChunkProvider,
KnowledgeChunkProviderLabels,
} from "@/lib/generated/enums";
type KnowledgeBaseEditDialogProps = {
@@ -61,29 +51,61 @@ const emptyForm: EditForm = {
remark: "",
};
const knowledgeBaseFormSchema = z.object({
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 TFunction = (key: string, values?: Record<string, string | number>) => string;
type EditForm = z.infer<typeof knowledgeBaseFormSchema>;
const editFormResolver = zodResolver(
knowledgeBaseFormSchema as never,
) as Resolver<
z.input<typeof knowledgeBaseFormSchema>,
undefined,
z.output<typeof knowledgeBaseFormSchema>
>;
function createKnowledgeBaseFormSchema(t: TFunction) {
return z.object({
name: z.string().trim().min(1, t("knowledge.nameRequired")).max(100, t("knowledge.nameMax")),
description: z.string().trim().max(500, t("knowledge.descriptionMax")),
knowledgeType: z.string().trim().min(1, t("knowledge.typeRequired")),
defaultTopK: z.string().trim().min(1, t("knowledge.topKRequired")),
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 {
if (!item) {
@@ -161,13 +183,18 @@ function KnowledgeBaseFormDialogBody({
onOpenChange,
onSubmit,
}: KnowledgeBaseFormDialogBodyProps) {
const t = useI18n();
const formId = "knowledge-base-edit-form";
const [loading, setLoading] = useState(false);
const form = useForm<
z.input<typeof knowledgeBaseFormSchema>,
undefined,
z.output<typeof knowledgeBaseFormSchema>
>({
const knowledgeBaseFormSchema = useMemo(() => createKnowledgeBaseFormSchema(t), [t]);
const editFormResolver = useMemo(
() => zodResolver(knowledgeBaseFormSchema) as Resolver<EditForm>,
[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,
defaultValues: buildForm(null),
});
@@ -228,7 +255,7 @@ function KnowledgeBaseFormDialogBody({
<ProjectDialog
open={open}
onOpenChange={onOpenChange}
title={itemId ? "编辑知识库" : "新建知识库"}
title={itemId ? t("knowledge.editBaseTitle") : t("knowledge.createBaseTitle")}
size="lg"
footer={
<>
@@ -238,17 +265,17 @@ function KnowledgeBaseFormDialogBody({
onClick={() => onOpenChange(false)}
disabled={saving || loading}
>
{t("knowledge.cancel")}
</Button>
<Button type="submit" form={formId} disabled={saving || loading}>
{saving ? "保存中..." : itemId ? "保存" : "创建"}
{saving ? t("knowledge.saving") : itemId ? t("knowledge.save") : t("knowledge.create")}
</Button>
</>
}
>
{loading ? (
<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>
) : (
<form
@@ -257,7 +284,7 @@ function KnowledgeBaseFormDialogBody({
className="space-y-4"
>
<Field data-invalid={!!errors.knowledgeType}>
<FieldLabel htmlFor="kb-knowledge-type"></FieldLabel>
<FieldLabel htmlFor="kb-knowledge-type">{t("knowledge.knowledgeType")}</FieldLabel>
<FieldContent>
<Controller
control={control}
@@ -265,13 +292,10 @@ function KnowledgeBaseFormDialogBody({
render={({ field }) => (
<OptionCombobox
value={field.value}
options={getEnumOptions(KnowledgeBaseTypeLabels).map((option) => ({
value: String(option.value),
label: option.label,
}))}
placeholder="选择知识库类型"
searchPlaceholder="搜索知识库类型"
emptyText="没有匹配的知识库类型"
options={knowledgeTypeOptions}
placeholder={t("knowledge.selectKnowledgeType")}
searchPlaceholder={t("knowledge.searchKnowledgeType")}
emptyText={t("knowledge.emptyKnowledgeType")}
onChange={field.onChange}
/>
)}
@@ -281,11 +305,11 @@ function KnowledgeBaseFormDialogBody({
</Field>
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="kb-name"></FieldLabel>
<FieldLabel htmlFor="kb-name">{t("knowledge.name")}</FieldLabel>
<FieldContent>
<Input
id="kb-name"
placeholder="知识库名称"
placeholder={t("knowledge.namePlaceholder")}
aria-invalid={!!errors.name}
{...register("name")}
/>
@@ -294,11 +318,11 @@ function KnowledgeBaseFormDialogBody({
</Field>
<Field data-invalid={!!errors.description}>
<FieldLabel htmlFor="kb-description"></FieldLabel>
<FieldLabel htmlFor="kb-description">{t("knowledge.description")}</FieldLabel>
<FieldContent>
<Textarea
id="kb-description"
placeholder="知识库描述"
placeholder={t("knowledge.descriptionPlaceholder")}
rows={3}
aria-invalid={!!errors.description}
{...register("description")}
@@ -310,7 +334,7 @@ function KnowledgeBaseFormDialogBody({
{!isFAQKnowledgeBase ? (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
<Field data-invalid={!!errors.chunkProvider}>
<FieldLabel htmlFor="kb-chunk-provider"></FieldLabel>
<FieldLabel htmlFor="kb-chunk-provider">{t("knowledge.chunkProvider")}</FieldLabel>
<FieldContent>
<Controller
control={control}
@@ -318,15 +342,10 @@ function KnowledgeBaseFormDialogBody({
render={({ field }) => (
<OptionCombobox
value={field.value}
options={getEnumOptions(KnowledgeChunkProviderLabels)
.filter((option) => option.value !== KnowledgeChunkProvider.FAQ)
.map((option) => ({
value: String(option.value),
label: option.label,
}))}
placeholder="选择分块策略"
searchPlaceholder="搜索分块策略"
emptyText="没有匹配的分块策略"
options={chunkProviderOptions}
placeholder={t("knowledge.selectChunkProvider")}
searchPlaceholder={t("knowledge.searchChunkProvider")}
emptyText={t("knowledge.emptyChunkProvider")}
onChange={field.onChange}
/>
)}
@@ -336,7 +355,7 @@ function KnowledgeBaseFormDialogBody({
</Field>
<Field data-invalid={!!errors.chunkTargetTokens}>
<FieldLabel htmlFor="kb-chunk-target-tokens">
Token
{t("knowledge.targetToken")}
</FieldLabel>
<FieldContent>
<Input
@@ -351,7 +370,7 @@ function KnowledgeBaseFormDialogBody({
</FieldContent>
</Field>
<Field data-invalid={!!errors.chunkMaxTokens}>
<FieldLabel htmlFor="kb-chunk-max-tokens"> Token</FieldLabel>
<FieldLabel htmlFor="kb-chunk-max-tokens">{t("knowledge.maxToken")}</FieldLabel>
<FieldContent>
<Input
id="kb-chunk-max-tokens"
@@ -366,7 +385,7 @@ function KnowledgeBaseFormDialogBody({
</Field>
<Field data-invalid={!!errors.chunkOverlapTokens}>
<FieldLabel htmlFor="kb-chunk-overlap-tokens">
Token
{t("knowledge.overlapToken")}
</FieldLabel>
<FieldContent>
<Input
@@ -385,7 +404,7 @@ function KnowledgeBaseFormDialogBody({
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
<Field data-invalid={!!errors.defaultTopK}>
<FieldLabel htmlFor="kb-default-top-k">TopK</FieldLabel>
<FieldLabel htmlFor="kb-default-top-k">{t("knowledge.defaultTopK")}</FieldLabel>
<FieldContent>
<Input
id="kb-default-top-k"
@@ -400,7 +419,7 @@ function KnowledgeBaseFormDialogBody({
</Field>
<Field data-invalid={!!errors.defaultScoreThreshold}>
<FieldLabel htmlFor="kb-default-score-threshold">
{t("knowledge.defaultScoreThreshold")}
</FieldLabel>
<FieldContent>
<Input
@@ -417,7 +436,7 @@ function KnowledgeBaseFormDialogBody({
</Field>
<Field data-invalid={!!errors.defaultRerankLimit}>
<FieldLabel htmlFor="kb-default-rerank-limit">
{t("knowledge.defaultRerankLimit")}
</FieldLabel>
<FieldContent>
<Input
@@ -432,37 +451,20 @@ function KnowledgeBaseFormDialogBody({
</FieldContent>
</Field>
<Field data-invalid={!!errors.answerMode}>
<FieldLabel htmlFor="kb-answer-mode"></FieldLabel>
<FieldLabel htmlFor="kb-answer-mode">{t("knowledge.answerMode")}</FieldLabel>
<FieldContent>
<Controller
control={control}
name="answerMode"
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger
id="kb-answer-mode"
aria-invalid={!!errors.answerMode}
>
<SelectValue placeholder="选择回答模式">
{field.value
? 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>
<OptionCombobox
value={field.value}
options={answerModeOptions}
placeholder={t("knowledge.selectAnswerMode")}
searchPlaceholder={t("knowledge.searchAnswerMode")}
emptyText={t("knowledge.emptyAnswerMode")}
onChange={field.onChange}
/>
)}
/>
<FieldError errors={[errors.answerMode]} />
@@ -471,11 +473,11 @@ function KnowledgeBaseFormDialogBody({
</div>
<Field data-invalid={!!errors.remark}>
<FieldLabel htmlFor="kb-remark"></FieldLabel>
<FieldLabel htmlFor="kb-remark">{t("knowledge.remark")}</FieldLabel>
<FieldContent>
<Textarea
id="kb-remark"
placeholder="备注信息"
placeholder={t("knowledge.remarkPlaceholder")}
rows={2}
aria-invalid={!!errors.remark}
{...register("remark")}
@@ -29,7 +29,7 @@ import {
Trash2Icon,
} from "lucide-react";
import type { CSSProperties } from "react";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -47,13 +47,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { OptionCombobox } from "@/components/option-combobox";
import {
createKnowledgeBase,
deleteKnowledgeBase,
@@ -64,8 +58,8 @@ import {
type CreateKnowledgeBasePayload,
type KnowledgeBase,
} from "@/lib/api/admin";
import { getEnumLabel, getEnumOptions } from "@/lib/enums";
import { KnowledgeBaseType, StatusLabels } from "@/lib/generated/enums";
import { useI18n } from "@/i18n/provider";
import { KnowledgeBaseType, Status } from "@/lib/generated/enums";
import { cn } from "@/lib/utils";
import { EditDialog } from "./knowledge-base-edit";
@@ -74,10 +68,16 @@ type KnowledgeBaseListProps = {
onSelectKnowledgeBase: (knowledgeBase: KnowledgeBase | null) => void;
};
const statusOptions = [
{ value: "all", label: "全部状态" },
...getEnumOptions(StatusLabels),
] as const;
type TFunction = (key: string, values?: Record<string, string | number>) => string;
function getStatusOptions(t: TFunction) {
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 = {
item: KnowledgeBase;
@@ -89,6 +89,7 @@ type SortableKnowledgeBaseCardProps = {
onRebuildIndex: () => void;
deleteLoadingId: number | null;
rebuildIndexLoadingId: number | null;
t: TFunction;
};
function SortableKnowledgeBaseCard({
@@ -101,6 +102,7 @@ function SortableKnowledgeBaseCard({
onRebuildIndex,
deleteLoadingId,
rebuildIndexLoadingId,
t,
}: SortableKnowledgeBaseCardProps) {
const {
attributes,
@@ -157,7 +159,7 @@ function SortableKnowledgeBaseCard({
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" />
</DropdownMenuTrigger>
@@ -169,7 +171,7 @@ function SortableKnowledgeBaseCard({
}}
>
<PencilIcon className="mr-2 size-3.5" />
{t("knowledge.edit")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
@@ -178,7 +180,7 @@ function SortableKnowledgeBaseCard({
}}
>
<RefreshCwIcon className="mr-2 size-3.5" />
{rebuildIndexLoadingId === item.id ? "重建中..." : "重建索引"}
{rebuildIndexLoadingId === item.id ? t("knowledge.rebuilding") : t("knowledge.rebuildIndex")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
@@ -188,7 +190,7 @@ function SortableKnowledgeBaseCard({
className="text-destructive focus:text-destructive"
>
<Trash2Icon className="mr-2 size-3.5" />
{deleteLoadingId === item.id ? "删除中..." : "删除"}
{deleteLoadingId === item.id ? t("knowledge.deleting") : t("knowledge.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -201,7 +203,7 @@ function SortableKnowledgeBaseCard({
}}
>
<PencilIcon className="mr-2 size-3.5" />
{t("knowledge.edit")}
</ContextMenuItem>
<ContextMenuItem
onClick={(e) => {
@@ -210,7 +212,7 @@ function SortableKnowledgeBaseCard({
}}
>
<RefreshCwIcon className="mr-2 size-3.5" />
{rebuildIndexLoadingId === item.id ? "重建中..." : "重建索引"}
{rebuildIndexLoadingId === item.id ? t("knowledge.rebuilding") : t("knowledge.rebuildIndex")}
</ContextMenuItem>
<ContextMenuItem
onClick={(e) => {
@@ -220,7 +222,7 @@ function SortableKnowledgeBaseCard({
variant="destructive"
>
<Trash2Icon className="mr-2 size-3.5" />
{deleteLoadingId === item.id ? "删除中..." : "删除"}
{deleteLoadingId === item.id ? t("knowledge.deleting") : t("knowledge.delete")}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
@@ -231,6 +233,7 @@ export function KnowledgeBaseList({
selectedKnowledgeBaseId,
onSelectKnowledgeBase,
}: KnowledgeBaseListProps) {
const t = useI18n();
const [keywordInput, setKeywordInput] = useState("");
const [statusFilterInput, setStatusFilterInput] = useState("all");
const [keyword, setKeyword] = useState("");
@@ -245,6 +248,7 @@ export function KnowledgeBaseList({
const [dialogOpen, setDialogOpen] = useState(false);
const [editingItemId, setEditingItemId] = useState<number | null>(null);
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
const statusOptions = useMemo(() => getStatusOptions(t), [t]);
const sensors = useSensors(
useSensor(MouseSensor, {
@@ -268,11 +272,11 @@ export function KnowledgeBaseList({
});
setKnowledgeBases(data.results);
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载知识库失败");
toast.error(error instanceof Error ? error.message : t("knowledge.loadBasesFailed"));
} finally {
setLoading(false);
}
}, [keyword, statusFilter]);
}, [keyword, statusFilter, t]);
useEffect(() => {
void loadData();
@@ -340,16 +344,16 @@ export function KnowledgeBaseList({
const editingItem = knowledgeBases.find(
(item) => item.id === editingItemId,
);
toast.success(`已更新知识库:${editingItem?.name || payload.name}`);
toast.success(t("knowledge.baseUpdated", { name: editingItem?.name || payload.name }));
} else {
await createKnowledgeBase(payload);
toast.success(`已创建知识库:${payload.name}`);
toast.success(t("knowledge.baseCreated", { name: payload.name }));
}
setDialogOpen(false);
setEditingItemId(null);
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存知识库失败");
toast.error(error instanceof Error ? error.message : t("knowledge.baseSaveFailed"));
} finally {
setSaving(false);
}
@@ -359,13 +363,13 @@ export function KnowledgeBaseList({
setDeleteLoadingId(item.id);
try {
await deleteKnowledgeBase(item.id);
toast.success(`已删除知识库:${item.name}`);
toast.success(t("knowledge.baseDeleted", { name: item.name }));
if (selectedKnowledgeBaseId === item.id) {
onSelectKnowledgeBase(null);
}
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除知识库失败");
toast.error(error instanceof Error ? error.message : t("knowledge.baseDeleteFailed"));
} finally {
setDeleteLoadingId(null);
}
@@ -375,10 +379,10 @@ export function KnowledgeBaseList({
setRebuildIndexLoadingId(item.id);
try {
await rebuildKnowledgeBaseIndex(item.id);
toast.success(`已开始重建知识库索引:${item.name}`);
toast.success(t("knowledge.rebuildStarted", { name: item.name }));
} catch (error) {
toast.error(
error instanceof Error ? error.message : "重建知识库索引失败",
error instanceof Error ? error.message : t("knowledge.rebuildFailed"),
);
} finally {
setRebuildIndexLoadingId(null);
@@ -404,12 +408,12 @@ export function KnowledgeBaseList({
try {
await updateKnowledgeBaseSort(nextResults.map((item) => item.id));
toast.success("知识库排序已更新");
toast.success(t("knowledge.sortUpdated"));
await loadData();
} catch (error) {
setKnowledgeBases(previousResults);
toast.error(
error instanceof Error ? error.message : "更新知识库排序失败",
error instanceof Error ? error.message : t("knowledge.sortUpdateFailed"),
);
} finally {
setSorting(false);
@@ -421,7 +425,7 @@ export function KnowledgeBaseList({
<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 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">
<Button
variant="ghost"
@@ -451,33 +455,18 @@ export function KnowledgeBaseList({
value={keywordInput}
onChange={(event) => setKeywordInput(event.target.value)}
onKeyDown={handleFilterKeyDown}
placeholder="搜索知识库"
placeholder={t("knowledge.searchBase")}
className="h-8 pl-8 text-xs"
/>
</div>
<Select
<OptionCombobox
value={statusFilterInput}
onValueChange={handleStatusFilterChange}
>
<SelectTrigger className="h-8 w-28 text-xs">
<SelectValue>
{statusFilterInput === "all"
? "全部状态"
: 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>
onChange={handleStatusFilterChange}
options={statusOptions}
placeholder={t("knowledge.allStatus")}
searchPlaceholder={t("knowledge.searchBase")}
emptyText={t("knowledge.emptyBases")}
/>
</div>
</div>
<ScrollArea className="flex-1">
@@ -503,13 +492,14 @@ export function KnowledgeBaseList({
onRebuildIndex={() => void handleRebuildIndex(item)}
deleteLoadingId={deleteLoadingId}
rebuildIndexLoadingId={rebuildIndexLoadingId}
t={t}
/>
))}
</SortableContext>
</DndContext>
{!loading && knowledgeBases.length === 0 ? (
<div className="py-8 text-center text-sm text-muted-foreground">
{t("knowledge.emptyBases")}
</div>
) : null}
</div>
@@ -19,6 +19,13 @@ import {
} from "@/components/ui/drawer"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Separator } from "@/components/ui/separator"
import { useI18n } from "@/i18n/provider"
import {
getKnowledgeAnswerStatusLabel,
getKnowledgeChunkProviderLabel,
getKnowledgeRetrieveChannelLabel,
getKnowledgeRetrieveSceneLabel,
} from "@/lib/knowledge-i18n"
type RetrieveLogDetailDrawerProps = {
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)
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 (
<div className="space-y-3">
{citations.map((item) => (
<div key={item.id} className="rounded-lg border p-3">
<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>
</div>
<div className="mt-1 text-xs text-muted-foreground">
{item.sectionPath || item.title || "未记录章节"}
{item.sectionPath || item.title || t("knowledge.unrecordedSection")}
</div>
<div className="mt-2 text-sm leading-6 whitespace-pre-wrap text-foreground/90">
{item.snippet || "-"}
@@ -67,6 +76,7 @@ export function RetrieveLogDetailDrawer({
retrieveLogId,
onOpenChange,
}: RetrieveLogDetailDrawerProps) {
const t = useI18n()
const [loading, setLoading] = useState(false)
const [detail, setDetail] = useState<KnowledgeRetrieveLogDetail | null>(null)
@@ -80,11 +90,11 @@ export function RetrieveLogDetailDrawer({
const data = await fetchKnowledgeRetrieveLog(retrieveLogId)
setDetail(data)
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载检索日志详情失败")
toast.error(error instanceof Error ? error.message : t("knowledge.loadRetrieveLogDetailFailed"))
} finally {
setLoading(false)
}
}, [retrieveLogId])
}, [retrieveLogId, t])
useEffect(() => {
if (open && retrieveLogId) {
@@ -102,105 +112,115 @@ export function RetrieveLogDetailDrawer({
<Drawer open={open} onOpenChange={onOpenChange} direction="right">
<DrawerContent className="max-w-3xl">
<DrawerHeader>
<DrawerTitle></DrawerTitle>
<DrawerTitle>{t("knowledge.retrieveLogDetail")}</DrawerTitle>
<DrawerDescription>
{detail?.log.question || (loading ? "加载中..." : "未找到检索日志")}
{detail?.log.question || (loading ? t("knowledge.loading") : t("knowledge.retrieveLogNotFound"))}
</DrawerDescription>
</DrawerHeader>
<ScrollArea className="h-[calc(100vh-6rem)] px-4 pb-6">
{!detail ? (
<div className="py-6 text-sm text-muted-foreground">
{loading ? "正在加载详情..." : "暂无详情数据"}
{loading ? t("knowledge.loadingDetail") : t("knowledge.emptyDetail")}
</div>
) : (
<div className="space-y-6 pb-6">
<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>
<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>
<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>
<div>
<div className="text-xs text-muted-foreground"> / </div>
<div className="mt-1 text-sm">{detail.log.channelName} / {detail.log.sceneName}</div>
<div className="text-xs text-muted-foreground">{t("knowledge.channelScene")}</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 className="text-xs text-muted-foreground">Request ID</div>
<div className="mt-1 break-all font-mono text-xs">{detail.log.requestId || "-"}</div>
</div>
<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>
<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>
<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>
</div>
</section>
<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">
<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="Max Tokens" value={detail.log.chunkMaxTokens} />
<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} />
</div>
</section>
<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">
<Metric label="回答状态" value={detail.log.answerStatusName} />
<Metric label="命中数" value={detail.log.hitCount} />
<Metric label="引用数" value={detail.log.citationCount} />
<Metric label="上下文 Chunk" value={detail.log.usedChunkCount} />
<Metric
label={t("knowledge.answerStatus")}
value={getKnowledgeAnswerStatusLabel(detail.log.answerStatus, detail.log.answerStatusName, t)}
/>
<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="检索耗时" value={`${detail.log.retrieveMs} ms`} />
<Metric label="生成耗时" value={`${detail.log.generateMs} ms`} />
<Metric label="总耗时" value={`${detail.log.latencyMs} ms`} />
<Metric label={t("knowledge.retrieveMs")} value={`${detail.log.retrieveMs} ms`} />
<Metric label={t("knowledge.generateMs")} value={`${detail.log.generateMs} ms`} />
<Metric label={t("knowledge.totalMs")} value={`${detail.log.latencyMs} ms`} />
<Metric label="Prompt Tokens" value={detail.log.promptTokens} />
<Metric label="Completion Tokens" value={detail.log.completionTokens} />
<Metric label="模型" value={detail.log.modelName || "-"} mono />
<Metric label="会话 ID" value={detail.log.sessionId || "-"} mono />
<Metric label={t("knowledge.model")} value={detail.log.modelName || "-"} mono />
<Metric label={t("knowledge.sessionId")} value={detail.log.sessionId || "-"} mono />
</div>
</section>
<section className="space-y-3">
<h3 className="text-sm font-semibold"></h3>
<CitationList hits={detail.hits} />
<h3 className="text-sm font-semibold">{t("knowledge.citationSources")}</h3>
<CitationList hits={detail.hits} t={t} />
</section>
<section className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold"></h3>
<div className="text-xs text-muted-foreground">{detail.hits.length} </div>
<h3 className="text-sm font-semibold">{t("knowledge.hitDetails")}</h3>
<div className="text-xs text-muted-foreground">{t("knowledge.itemsCount", { count: detail.hits.length })}</div>
</div>
<div className="space-y-3">
{detail.hits.map((item) => (
<div key={item.id} className="rounded-lg border p-3">
<div className="flex flex-wrap items-center gap-2">
<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"}>
{item.usedInAnswer ? "已入上下文" : "未入上下文"}
{item.usedInAnswer ? t("knowledge.usedInContext") : t("knowledge.notUsedInContext")}
</Badge>
{item.isCitation ? <Badge></Badge> : null}
{item.isCitation ? <Badge>{t("knowledge.citations")}</Badge> : null}
</div>
<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>Provider{item.provider || "-"}</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) {
return item.faqQuestion
}
@@ -241,7 +261,7 @@ function getHitSourceLabel(item: KnowledgeRetrieveHit) {
if (item.faqId > 0) {
return `FAQ #${item.faqId}`
}
return `文档 #${item.documentId}`
return `${t("knowledge.document")} #${item.documentId}`
}
function Metric({
@@ -10,13 +10,18 @@ import {
type PageResult,
} from "@/lib/api/admin"
import {
KnowledgeAnswerStatusLabels,
KnowledgeChunkProviderLabels,
KnowledgeRetrieveChannelLabels,
KnowledgeRetrieveSceneLabels,
KnowledgeAnswerStatus,
KnowledgeRetrieveChannel,
KnowledgeRetrieveScene,
} from "@/lib/generated/enums"
import { getEnumOptions } from "@/lib/enums"
import {
getKnowledgeAnswerStatusLabel,
getKnowledgeChunkProviderLabel,
getKnowledgeRetrieveChannelLabel,
getKnowledgeRetrieveSceneLabel,
} from "@/lib/knowledge-i18n"
import { formatDateTime } from "@/lib/utils"
import { useI18n } from "@/i18n/provider"
import { ListPagination } from "@/components/list-pagination"
import { OptionCombobox } from "@/components/option-combobox"
import { Badge } from "@/components/ui/badge"
@@ -36,43 +41,70 @@ type RetrieveLogListProps = {
knowledgeBaseId: number | null
}
const channelOptions = [
{ value: "all", label: "全部渠道" },
...getEnumOptions(KnowledgeRetrieveChannelLabels).map((item) => ({
value: String(item.value),
label: item.label,
})),
]
type TFunction = (key: string, values?: Record<string, string | number>) => string
const sceneOptions = [
{ value: "all", label: "全部场景" },
...getEnumOptions(KnowledgeRetrieveSceneLabels).map((item) => ({
value: String(item.value),
label: item.label,
})),
]
function getChannelOptions(t: TFunction) {
return [
{ value: "all", label: t("knowledge.allChannels") },
{ value: KnowledgeRetrieveChannel.IM, label: t("knowledge.channelIM") },
{ value: KnowledgeRetrieveChannel.AgentAssist, label: t("knowledge.channelAgentAssist") },
{ value: KnowledgeRetrieveChannel.API, label: t("knowledge.channelAPI") },
{ value: KnowledgeRetrieveChannel.Debug, label: t("knowledge.channelDebug") },
]
}
const answerStatusOptions = [
{ value: "all", label: "全部回答状态" },
...getEnumOptions(KnowledgeAnswerStatusLabels).map((item) => ({
value: String(item.value),
label: item.label,
})),
]
function getSceneOptions(t: TFunction) {
return [
{ value: "all", label: t("knowledge.allScenes") },
{ value: KnowledgeRetrieveScene.FirstResponse, label: t("knowledge.sceneFirstResponse") },
{ value: KnowledgeRetrieveScene.Assist, label: t("knowledge.sceneAssist") },
{ value: KnowledgeRetrieveScene.QA, label: t("knowledge.sceneQA") },
]
}
const providerOptions = [
{ value: "all", label: "全部切分策略" },
...getEnumOptions(KnowledgeChunkProviderLabels).map((item) => ({
value: String(item.value),
label: item.label,
})),
]
function getAnswerStatusOptions(t: TFunction) {
return [
{ value: "all", label: t("knowledge.allAnswerStatus") },
{ value: String(KnowledgeAnswerStatus.Normal), label: t("knowledge.answerNormal") },
{ 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 = [
{ value: "all", label: "全部 Rerank" },
{ value: "1", label: "已启用 Rerank" },
{ value: "0", label: "未启用 Rerank" },
]
function getProviderOptions(t: TFunction) {
return [
{ value: "all", label: t("knowledge.allChunkProviders") },
{ 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" {
switch (status) {
@@ -92,6 +124,7 @@ function getAnswerStatusVariant(status: number): "default" | "secondary" | "outl
export function RetrieveLogList({
knowledgeBaseId,
}: RetrieveLogListProps) {
const t = useI18n()
const [questionInput, setQuestionInput] = useState("")
const [question, setQuestion] = useState("")
const [channel, setChannel] = useState("all")
@@ -108,6 +141,11 @@ export function RetrieveLogList({
results: [],
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 () => {
if (!knowledgeBaseId) {
@@ -131,11 +169,11 @@ export function RetrieveLogList({
})
setResult(data)
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载检索日志失败")
toast.error(error instanceof Error ? error.message : t("knowledge.loadRetrieveLogsFailed"))
} finally {
setLoading(false)
}
}, [answerStatus, channel, chunkProvider, knowledgeBaseId, limit, page, question, rerankEnabled, scene])
}, [answerStatus, channel, chunkProvider, knowledgeBaseId, limit, page, question, rerankEnabled, scene, t])
useEffect(() => {
void loadData()
@@ -149,13 +187,13 @@ export function RetrieveLogList({
const emptyStateText = useMemo(() => {
if (!knowledgeBaseId) {
return "请选择一个知识库查看检索日志"
return t("knowledge.selectBaseForLogs")
}
if (loading) {
return "正在加载检索日志..."
return t("knowledge.loadingRetrieveLogs")
}
return "当前知识库还没有检索日志"
}, [knowledgeBaseId, loading])
return t("knowledge.emptyRetrieveLogs")
}, [knowledgeBaseId, loading, t])
function applyFilters() {
setQuestion(questionInput)
@@ -194,16 +232,16 @@ export function RetrieveLogList({
value={questionInput}
onChange={(event) => setQuestionInput(event.target.value)}
onKeyDown={handleQuestionKeyDown}
placeholder="按问题关键字筛选"
placeholder={t("knowledge.filterQuestion")}
className="pl-9"
/>
</div>
<OptionCombobox value={channel} options={channelOptions} placeholder="选择渠道" onChange={setChannel} />
<OptionCombobox value={scene} options={sceneOptions} placeholder="选择场景" onChange={setScene} />
<OptionCombobox value={answerStatus} options={answerStatusOptions} placeholder="回答状态" onChange={setAnswerStatus} />
<OptionCombobox value={chunkProvider} options={providerOptions} placeholder="切分策略" onChange={setChunkProvider} />
<OptionCombobox value={channel} options={channelOptions} placeholder={t("knowledge.selectChannel")} onChange={setChannel} />
<OptionCombobox value={scene} options={sceneOptions} placeholder={t("knowledge.selectScene")} onChange={setScene} />
<OptionCombobox value={answerStatus} options={answerStatusOptions} placeholder={t("knowledge.answerStatus")} onChange={setAnswerStatus} />
<OptionCombobox value={chunkProvider} options={providerOptions} placeholder={t("knowledge.chunkStrategy")} onChange={setChunkProvider} />
<OptionCombobox value={rerankEnabled} options={rerankOptions} placeholder="Rerank" onChange={setRerankEnabled} />
<Button onClick={applyFilters}></Button>
<Button onClick={applyFilters}>{t("knowledge.filter")}</Button>
</div>
</div>
@@ -212,15 +250,15 @@ export function RetrieveLogList({
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-42"></TableHead>
<TableHead></TableHead>
<TableHead className="w-28"></TableHead>
<TableHead className="w-24 text-right"></TableHead>
<TableHead className="w-42">{t("knowledge.time")}</TableHead>
<TableHead>{t("knowledge.question")}</TableHead>
<TableHead className="w-28">{t("knowledge.answerStatus")}</TableHead>
<TableHead className="w-24 text-right">{t("knowledge.hitCount")}</TableHead>
<TableHead className="w-24 text-right">TopScore</TableHead>
<TableHead className="w-28">Provider</TableHead>
<TableHead className="w-24">Rerank</TableHead>
<TableHead className="w-24 text-right"></TableHead>
<TableHead className="w-28 text-right"></TableHead>
<TableHead className="w-24 text-right">{t("knowledge.citations")}</TableHead>
<TableHead className="w-28 text-right">{t("knowledge.duration")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -242,21 +280,21 @@ export function RetrieveLogList({
<div className="space-y-1">
<div className="line-clamp-2 font-medium">{item.question || "-"}</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span>{item.channelName}</span>
<span>{item.sceneName}</span>
<span>{channelLabel(item.channel, item.channelName, t)}</span>
<span>{sceneLabel(item.scene, item.sceneName, t)}</span>
{item.knowledgeBaseName ? <span>{item.knowledgeBaseName}</span> : null}
</div>
</div>
</TableCell>
<TableCell>
<Badge variant={getAnswerStatusVariant(item.answerStatus)}>
{item.answerStatusName}
{answerStatusLabel(item.answerStatus, item.answerStatusName, t)}
</Badge>
</TableCell>
<TableCell className="text-right">{item.hitCount}</TableCell>
<TableCell className="text-right font-mono text-xs">{item.topScore.toFixed(4)}</TableCell>
<TableCell>{item.chunkProvider || "-"}</TableCell>
<TableCell>{item.rerankEnabled ? ` (${item.rerankLimit})` : "否"}</TableCell>
<TableCell>{item.chunkProvider ? providerLabel(item.chunkProvider, t) : "-"}</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.latencyMs} ms</TableCell>
</TableRow>
+15 -13
View File
@@ -9,6 +9,7 @@ import {
} from "@/components/ui/sheet"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import type { KnowledgeBase } from "@/lib/api/admin"
import { useI18n } from "@/i18n/provider"
import {
Bug,
DownloadIcon,
@@ -27,6 +28,7 @@ import { KnowledgeBaseList } from "./_components/knowledge-base-list"
import { RetrieveLogList } from "./_components/retrieve-log-list"
export default function DashboardKnowledgeDocumentsPage() {
const t = useI18n()
const [selectedKnowledgeBase, setSelectedKnowledgeBase] = useState<KnowledgeBase | null>(null)
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
const [debugPanelOpen, setDebugPanelOpen] = useState(false)
@@ -53,7 +55,7 @@ export default function DashboardKnowledgeDocumentsPage() {
size="icon"
className="absolute top-4 left-1/2 z-10 size-7 -translate-x-1/2 rounded-full shadow-sm"
onClick={() => setSidebarCollapsed((value) => !value)}
aria-label={sidebarCollapsed ? "展开知识库列表" : "折叠知识库列表"}
aria-label={sidebarCollapsed ? t("knowledge.expandList") : t("knowledge.collapseList")}
>
{sidebarCollapsed ? (
<PanelLeftOpenIcon className="size-3.5" />
@@ -67,8 +69,8 @@ export default function DashboardKnowledgeDocumentsPage() {
<div className="border-b px-6 py-4">
<div className="flex items-center gap-2">
<TabsList>
<TabsTrigger value="documents">{isFAQKnowledgeBase ? "FAQ" : "文档"}</TabsTrigger>
<TabsTrigger value="retrieveLogs"></TabsTrigger>
<TabsTrigger value="documents">{isFAQKnowledgeBase ? t("knowledge.faq") : t("knowledge.document")}</TabsTrigger>
<TabsTrigger value="retrieveLogs">{t("knowledge.retrieveLogs")}</TabsTrigger>
</TabsList>
{activeTab === "documents" && !isFAQKnowledgeBase && documentActionState ? (
<div className="ml-auto flex items-center gap-1">
@@ -78,7 +80,7 @@ export default function DashboardKnowledgeDocumentsPage() {
className="size-7"
onClick={documentActionState.onRefresh}
disabled={documentActionState.loading}
aria-label="刷新文档"
aria-label={t("knowledge.refreshDocuments")}
>
<RefreshCwIcon className={documentActionState.loading ? "size-4 animate-spin" : "size-4"} />
</Button>
@@ -87,7 +89,7 @@ export default function DashboardKnowledgeDocumentsPage() {
size="icon"
className="size-7"
onClick={() => documentActionState.onChangeViewMode("list")}
aria-label="列表布局"
aria-label={t("knowledge.listLayout")}
>
<LayoutListIcon className="size-4" />
</Button>
@@ -96,7 +98,7 @@ export default function DashboardKnowledgeDocumentsPage() {
size="icon"
className="size-7"
onClick={() => documentActionState.onChangeViewMode("grid")}
aria-label="网格布局"
aria-label={t("knowledge.gridLayout")}
>
<LayoutGridIcon className="size-4" />
</Button>
@@ -105,7 +107,7 @@ export default function DashboardKnowledgeDocumentsPage() {
size="icon"
className="size-7"
onClick={() => setDebugPanelOpen(true)}
aria-label="打开调试面板"
aria-label={t("knowledge.openDebugPanel")}
>
<Bug className="size-4" />
</Button>
@@ -114,7 +116,7 @@ export default function DashboardKnowledgeDocumentsPage() {
size="icon"
className="size-7"
onClick={documentActionState.onCreate}
aria-label="新增文档"
aria-label={t("knowledge.newDocument")}
>
<PlusIcon className="size-4" />
</Button>
@@ -128,7 +130,7 @@ export default function DashboardKnowledgeDocumentsPage() {
className="size-7"
onClick={faqActionState.onRefresh}
disabled={faqActionState.loading}
aria-label="刷新FAQ"
aria-label={t("knowledge.refreshFAQ")}
>
<RefreshCwIcon className={faqActionState.loading ? "size-4 animate-spin" : "size-4"} />
</Button>
@@ -138,7 +140,7 @@ export default function DashboardKnowledgeDocumentsPage() {
className="size-7"
onClick={faqActionState.onImport}
disabled={faqActionState.importing}
aria-label="导入FAQ"
aria-label={t("knowledge.importFAQ")}
>
<DownloadIcon className="size-4" />
</Button>
@@ -147,7 +149,7 @@ export default function DashboardKnowledgeDocumentsPage() {
size="icon"
className="size-7"
onClick={() => setDebugPanelOpen(true)}
aria-label="打开调试面板"
aria-label={t("knowledge.openDebugPanel")}
>
<Bug className="size-4" />
</Button>
@@ -156,7 +158,7 @@ export default function DashboardKnowledgeDocumentsPage() {
size="icon"
className="size-7"
onClick={faqActionState.onCreate}
aria-label="新增FAQ"
aria-label={t("knowledge.newFAQ")}
>
<PlusIcon className="size-4" />
</Button>
@@ -187,7 +189,7 @@ export default function DashboardKnowledgeDocumentsPage() {
<Sheet open={debugPanelOpen} onOpenChange={setDebugPanelOpen}>
<SheetContent side="right" className="min-w-170">
<SheetHeader>
<SheetTitle>RAG </SheetTitle>
<SheetTitle>{t("knowledge.ragDebug")}</SheetTitle>
</SheetHeader>
<DebugPanel knowledgeBaseId={selectedKnowledgeBase?.id ?? null} />
</SheetContent>
+4 -2
View File
@@ -9,6 +9,7 @@ import { AppSidebar } from "@/components/app-sidebar"
import { useAuth } from "@/components/auth-provider"
import { NotificationProvider } from "@/components/notification-provider"
import { SiteHeader } from "@/components/site-header"
import { useI18n } from "@/i18n/provider"
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
export default function DashboardLayout({
@@ -16,6 +17,7 @@ export default function DashboardLayout({
}: {
children: ReactNode
}) {
const t = useI18n()
const { ready, session } = useAuth()
const pathname = usePathname()
const router = useRouter()
@@ -39,9 +41,9 @@ export default function DashboardLayout({
<Loader2Icon className="size-5 animate-spin" />
</div>
<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">
{t("auth.syncingProfile")}
</p>
</div>
</div>
+10 -6
View File
@@ -5,6 +5,7 @@ import { Suspense, useEffect, useRef } from "react"
import { toast } from "sonner"
import { exchangeOIDCTicket } from "@/lib/api/auth"
import { useI18n } from "@/i18n/provider"
export default function OIDCLoginCallbackPage() {
return (
@@ -15,6 +16,7 @@ export default function OIDCLoginCallbackPage() {
}
function OIDCLoginCallbackContent() {
const t = useI18n()
const router = useRouter()
const searchParams = useSearchParams()
const ranRef = useRef(false)
@@ -30,32 +32,34 @@ function OIDCLoginCallbackContent() {
const nextPath = next && next.startsWith("/") ? next : "/dashboard"
if (!ticket) {
toast.error("OIDC 登录票据不存在")
toast.error(t("auth.oidcMissingTicket"))
router.replace("/dashboard/login")
return
}
void exchangeOIDCTicket(ticket)
.then(() => {
toast.success("登录成功,正在进入系统")
toast.success(t("auth.loginSuccess"))
router.replace(nextPath)
})
.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, searchParams])
}, [router, searchParams, t])
return <OIDCLoginCallbackFallback />
}
function OIDCLoginCallbackFallback() {
const t = useI18n()
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="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">
{t("auth.checkingTicket")}
</p>
</div>
</div>
+20 -18
View File
@@ -1,9 +1,20 @@
"use client"
import { LoginForm } from "@/components/login-form"
import { LocaleSwitcher } from "@/components/locale-switcher"
import { useI18n } from "@/i18n/provider"
import { ShieldCheckIcon } from "lucide-react"
import Link from "next/link"
import { Suspense } from "react"
export default function LoginPage() {
const t = useI18n()
const heroPoints = [
t("auth.heroPoint1"),
t("auth.heroPoint2"),
t("auth.heroPoint3"),
]
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="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="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" />
Secure Admin Space
{t("auth.heroBadge")}
</div>
<div className="space-y-3">
<h2 className="max-w-lg text-5xl font-semibold tracking-tight">
AI客服
{t("auth.heroTitle")}
</h2>
<p className="max-w-xl text-sm leading-6 text-white/72">
{t("auth.heroSubtitle")}
</p>
</div>
</div>
<div className="grid gap-3">
{[
{
text: "让每一次咨询,都通向可追踪的结果。",
},
{
text: "从接待到转派到复盘,一条链路跑到底。",
},
{
text: "多渠道接入,智能分配,人机协同提效。",
},
].map((item, index) => (
{heroPoints.map((text, index) => (
<div
key={index}
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 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">
<img
src="/images/logo.svg"
alt="CS Agent"
alt={t("app.brand")}
width="32"
height="32"
className="size-7 shrink-0 object-contain"
/>
CS Agent
{t("app.brand")}
</Link>
<LocaleSwitcher />
</div>
<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">
@@ -5,6 +5,7 @@ import { Suspense, useEffect, useRef } from "react"
import { toast } from "sonner"
import { exchangeWxWorkTicket } from "@/lib/api/auth"
import { useI18n } from "@/i18n/provider"
export default function WxWorkLoginCallbackPage() {
return (
@@ -15,6 +16,7 @@ export default function WxWorkLoginCallbackPage() {
}
function WxWorkLoginCallbackContent() {
const t = useI18n()
const router = useRouter()
const searchParams = useSearchParams()
const ranRef = useRef(false)
@@ -30,21 +32,21 @@ function WxWorkLoginCallbackContent() {
const nextPath = next && next.startsWith("/") ? next : "/dashboard"
if (!ticket) {
toast.error("企业微信登录票据不存在")
toast.error(t("auth.wxworkMissingTicket"))
router.replace("/dashboard/login")
return
}
void exchangeWxWorkTicket(ticket)
.then(() => {
toast.success("登录成功,正在进入系统")
toast.success(t("auth.loginSuccess"))
router.replace(nextPath)
})
.catch((error) => {
toast.error(error instanceof Error ? error.message : "企业微信登录失败")
toast.error(error instanceof Error ? error.message : t("auth.wxworkFailed"))
router.replace("/dashboard/login")
})
}, [router, searchParams])
}, [router, searchParams, t])
return (
<WxWorkLoginCallbackFallback />
@@ -52,12 +54,14 @@ function WxWorkLoginCallbackContent() {
}
function WxWorkLoginCallbackFallback() {
const t = useI18n()
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="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">
{t("auth.checkingTicket")}
</p>
</div>
</div>
+33 -51
View File
@@ -33,10 +33,12 @@ import {
type MCPToolCallResult,
type MCPToolInfo,
} from "@/lib/api/admin";
import { useI18n } from "@/i18n/provider";
const defaultServerCode = "";
export default function MCPDashboardPage() {
const t = useI18n();
const [serverCode, setServerCode] = useState(defaultServerCode);
const [servers, setServers] = useState<MCPServerInfo[]>([]);
const [connection, setConnection] = useState<MCPConnectionResult | null>(
@@ -59,9 +61,9 @@ export default function MCPDashboardPage() {
value: server.code,
label: server.enabled
? `${server.code} (${server.endpoint})`
: `${server.code} (disabled)`,
: `${server.code} (${t("mcp.disabled")})`,
})),
[servers],
[servers, t],
);
useEffect(() => {
@@ -76,7 +78,7 @@ export default function MCPDashboardPage() {
}
} catch (error) {
toast.error(
error instanceof Error ? error.message : "加载 MCP 服务失败",
error instanceof Error ? error.message : t("mcp.loadServersFailed"),
);
} finally {
setLoadingServers(false);
@@ -84,16 +86,16 @@ export default function MCPDashboardPage() {
}
void loadServers();
}, []);
}, [t]);
async function handleTestConnection() {
setTesting(true);
try {
const result = await testMCPConnection(serverCode.trim());
setConnection(result);
toast.success("MCP 连接成功");
toast.success(t("mcp.connectSuccess"));
} catch (error) {
toast.error(error instanceof Error ? error.message : "MCP 连接失败");
toast.error(error instanceof Error ? error.message : t("mcp.connectFailed"));
} finally {
setTesting(false);
}
@@ -104,9 +106,9 @@ export default function MCPDashboardPage() {
try {
const result = await listMCPTools(serverCode.trim());
setTools(result);
toast.success(`已加载 ${result.length} 个工具`);
toast.success(t("mcp.toolsLoaded", { count: result.length }));
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载工具失败");
toast.error(error instanceof Error ? error.message : t("mcp.loadToolsFailed"));
} finally {
setLoadingTools(false);
}
@@ -114,11 +116,11 @@ export default function MCPDashboardPage() {
async function handleCallTool() {
if (!activeTool) {
toast.error("请先选择一个工具");
toast.error(t("mcp.selectToolFirst"));
return;
}
if (argumentsError) {
toast.error("Arguments JSON 格式不合法");
toast.error(t("mcp.argumentsInvalid"));
return;
}
@@ -128,7 +130,7 @@ export default function MCPDashboardPage() {
? (JSON.parse(argumentsText) as Record<string, unknown>)
: {};
} catch {
toast.error("arguments 必须是合法 JSON");
toast.error(t("mcp.argumentsMustBeJson"));
return;
}
@@ -140,9 +142,9 @@ export default function MCPDashboardPage() {
arguments: parsedArguments,
});
setToolResult(result);
toast.success(result.isError ? "工具返回错误结果" : "工具调用成功");
toast.success(result.isError ? t("mcp.toolReturnedError") : t("mcp.toolCallSuccess"));
} catch (error) {
toast.error(error instanceof Error ? error.message : "工具调用失败");
toast.error(error instanceof Error ? error.message : t("mcp.toolCallFailed"));
} finally {
setCallingTool(false);
}
@@ -164,25 +166,6 @@ export default function MCPDashboardPage() {
return (
<>
<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
actions={
<>
@@ -195,7 +178,7 @@ export default function MCPDashboardPage() {
) : (
<PlugZapIcon className="mr-2 size-4" />
)}
{t("mcp.testConnection")}
</Button>
<Button
variant="outline"
@@ -207,20 +190,20 @@ export default function MCPDashboardPage() {
) : (
<WrenchIcon className="mr-2 size-4" />
)}
{t("mcp.listTools")}
</Button>
</>
}
>
<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">
<OptionCombobox
value={serverCode}
options={serverOptions}
placeholder="选择一个 MCP Server"
searchPlaceholder="搜索 serverCode"
emptyText="没有可用的 MCP Server"
placeholder={t("mcp.selectServer")}
searchPlaceholder={t("mcp.searchServer")}
emptyText={t("mcp.emptyServer")}
disabled={loadingServers}
onChange={(value) => {
setServerCode(value);
@@ -235,7 +218,7 @@ export default function MCPDashboardPage() {
{connection ? (
<div className="w-full rounded-lg border bg-muted/30 p-4 text-sm">
<div className="flex flex-wrap items-center gap-2">
<Badge></Badge>
<Badge>{t("mcp.connected")}</Badge>
<span className="font-medium">
{connection.serverName || "-"}
</span>
@@ -254,15 +237,15 @@ export default function MCPDashboardPage() {
{tools.length === 0 ? (
<div className="rounded-lg border border-dashed p-6 text-sm text-muted-foreground">
{t("mcp.emptyToolsHint")}
</div>
) : (
<DashboardTableShell>
<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></div>
<div></div>
<div className="text-right"></div>
<div>{t("mcp.toolName")}</div>
<div>{t("mcp.description")}</div>
<div className="text-right">{t("mcp.actions")}</div>
</div>
{tools.map((tool) => (
<div
@@ -280,7 +263,7 @@ export default function MCPDashboardPage() {
size="sm"
onClick={() => openToolDrawer(tool)}
>
{t("mcp.view")}
</Button>
</div>
</div>
@@ -293,10 +276,9 @@ export default function MCPDashboardPage() {
<Drawer open={drawerOpen} direction="right" onOpenChange={setDrawerOpen}>
<DrawerContent className="min-w-3xl">
<DrawerHeader>
<DrawerTitle>{activeTool?.name || "工具详情"}</DrawerTitle>
<DrawerTitle>{activeTool?.name || t("mcp.toolDetail")}</DrawerTitle>
<DrawerDescription>
使 MCP Server
{t("mcp.detailDescription")}
</DrawerDescription>
</DrawerHeader>
<div className="flex-1 space-y-4 overflow-y-auto px-4 pb-4">
@@ -312,7 +294,7 @@ export default function MCPDashboardPage() {
) : null}
</div>
<p className="mt-3 text-sm text-muted-foreground">
{activeTool.description || "暂无描述"}
{activeTool.description || t("mcp.noDescription")}
</p>
</div>
@@ -354,7 +336,7 @@ export default function MCPDashboardPage() {
) : (
<WrenchIcon className="mr-2 size-4" />
)}
{t("mcp.testTool")}
</Button>
{toolResult ? (
<div className="space-y-4 rounded-lg border p-4">
@@ -364,7 +346,7 @@ export default function MCPDashboardPage() {
toolResult.isError ? "destructive" : "default"
}
>
{toolResult.isError ? "返回错误" : "调用成功"}
{toolResult.isError ? t("mcp.returnedError") : t("mcp.callSuccess")}
</Badge>
<span className="text-sm font-medium">
{toolResult.toolName}
@@ -390,7 +372,7 @@ export default function MCPDashboardPage() {
</div>
<DrawerFooter>
<Button variant="outline" onClick={() => setDrawerOpen(false)}>
{t("mcp.close")}
</Button>
</DrawerFooter>
</DrawerContent>
+21 -17
View File
@@ -1,6 +1,6 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import { useCallback, useEffect, useMemo, useState } from "react"
import { useRouter } from "next/navigation"
import { BellIcon, CheckCheckIcon, RefreshCwIcon } from "lucide-react"
import { toast } from "sonner"
@@ -23,14 +23,10 @@ import {
} from "@/lib/api/notification"
import type { PageResult } from "@/lib/api/admin"
import { cn, formatDateTime } from "@/lib/utils"
const readStatusOptions: Array<{ value: NotificationReadStatus; label: string }> = [
{ value: "all", label: "全部" },
{ value: "unread", label: "未读" },
{ value: "read", label: "已读" },
]
import { useI18n } from "@/i18n/provider"
export default function DashboardNotificationsPage() {
const t = useI18n()
const router = useRouter()
const { refreshUnreadCount } = useNotifications()
const [readStatus, setReadStatus] = useState<NotificationReadStatus>("all")
@@ -42,6 +38,14 @@ export default function DashboardNotificationsPage() {
results: [],
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 () => {
setLoading(true)
@@ -53,11 +57,11 @@ export default function DashboardNotificationsPage() {
})
setResult(data)
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载通知失败")
toast.error(error instanceof Error ? error.message : t("notification.loadFailed"))
} finally {
setLoading(false)
}
}, [limit, page, readStatus])
}, [limit, page, readStatus, t])
useEffect(() => {
void loadData()
@@ -73,7 +77,7 @@ export default function DashboardNotificationsPage() {
router.push(item.actionUrl)
}
} 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 refreshUnreadCount()
await loadData()
toast.success("已全部标记为已读")
toast.success(t("notification.markAllReadSuccess"))
} catch (error) {
toast.error(error instanceof Error ? error.message : "全部已读失败")
toast.error(error instanceof Error ? error.message : t("notification.markAllReadFailed"))
} finally {
setActionLoading(false)
}
@@ -115,7 +119,7 @@ export default function DashboardNotificationsPage() {
<>
<Button variant="outline" onClick={() => void loadData()} disabled={loading}>
<RefreshCwIcon className={cn(loading && "animate-spin")} />
{t("notification.refresh")}
</Button>
<Button
variant="outline"
@@ -123,7 +127,7 @@ export default function DashboardNotificationsPage() {
disabled={actionLoading || result.page.total === 0}
>
<CheckCheckIcon />
{t("notification.markAllRead")}
</Button>
</>
}
@@ -164,8 +168,8 @@ export default function DashboardNotificationsPage() {
>
<div className="flex flex-wrap items-center gap-2">
<BellIcon className="size-4 text-muted-foreground" />
<span className="font-medium">{item.title || "通知"}</span>
{unread ? <Badge></Badge> : <Badge variant="outline"></Badge>}
<span className="font-medium">{item.title || t("notification.fallbackTitle")}</span>
{unread ? <Badge>{t("notification.unread")}</Badge> : <Badge variant="outline">{t("notification.read")}</Badge>}
<span className="ml-auto text-xs text-muted-foreground">
{formatDateTime(item.createdAt)}
</span>
@@ -179,7 +183,7 @@ export default function DashboardNotificationsPage() {
</div>
) : (
<div className="flex min-h-48 items-center justify-center text-sm text-muted-foreground">
{loading ? "正在加载通知" : "暂无通知"}
{loading ? t("notification.loading") : t("notification.empty")}
</div>
)}
</DashboardTableShell>
+50 -26
View File
@@ -1,6 +1,6 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import { useCallback, useEffect, useMemo, useState } from "react"
import { KeyRoundIcon, RefreshCwIcon, RouteIcon, SearchIcon } from "lucide-react"
import { toast } from "sonner"
@@ -28,16 +28,13 @@ import {
type AdminPermission,
type PageResult,
} from "@/lib/api/admin"
import { Status, StatusLabels } from "@/lib/generated/enums"
const listStatusOptions = [
{ 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
import { Status } from "@/lib/generated/enums"
import { useAppLocale, useI18n } from "@/i18n/provider"
import { getPermissionDisplayName, getPermissionGroupName } from "@/lib/permission-i18n"
export default function DashboardPermissionsPage() {
const t = useI18n()
const { locale } = useAppLocale()
const [keywordInput, setKeywordInput] = useState("")
const [groupNameInput, setGroupNameInput] = useState("")
const [statusFilterInput, setStatusFilterInput] = useState("all")
@@ -51,6 +48,15 @@ export default function DashboardPermissionsPage() {
results: [],
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 () => {
setLoading(true)
@@ -64,11 +70,11 @@ export default function DashboardPermissionsPage() {
})
setResult(data)
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载权限失败")
toast.error(error instanceof Error ? error.message : t("permission.loadFailed"))
} finally {
setLoading(false)
}
}, [groupName, keyword, limit, page, statusFilter])
}, [groupName, keyword, limit, page, statusFilter, t])
useEffect(() => {
void loadData()
@@ -103,7 +109,7 @@ export default function DashboardPermissionsPage() {
actions={
<Button variant="outline" onClick={() => void loadData()} disabled={loading}>
<RefreshCwIcon className={loading ? "animate-spin" : ""} />
{t("permission.refresh")}
</Button>
}
>
@@ -113,7 +119,7 @@ export default function DashboardPermissionsPage() {
value={keywordInput}
onChange={(event) => setKeywordInput(event.target.value)}
onKeyDown={handleFilterKeyDown}
placeholder="按权限名称/编码筛选"
placeholder={t("permission.filterKeyword")}
className="pl-9"
/>
</div>
@@ -121,20 +127,20 @@ export default function DashboardPermissionsPage() {
value={groupNameInput}
onChange={(event) => setGroupNameInput(event.target.value)}
onKeyDown={handleFilterKeyDown}
placeholder="按分组筛选"
placeholder={t("permission.filterGroup")}
className="w-full sm:w-44"
/>
<div className="w-full sm:w-36">
<OptionCombobox
value={statusFilterInput}
onChange={setStatusFilterInput}
placeholder="全部状态"
placeholder={t("status.all")}
options={[...listStatusOptions]}
/>
</div>
<Button variant="outline" onClick={applyFilters} disabled={loading}>
<SearchIcon />
{t("permission.query")}
</Button>
</DashboardToolbar>
<DashboardTableShell
@@ -155,11 +161,11 @@ export default function DashboardPermissionsPage() {
<Table>
<TableHeader className="bg-muted/40">
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>{t("permission.columnPermission")}</TableHead>
<TableHead>{t("permission.columnCode")}</TableHead>
<TableHead>{t("permission.columnGroup")}</TableHead>
<TableHead>{t("permission.columnApi")}</TableHead>
<TableHead>{t("permission.columnStatus")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -171,7 +177,9 @@ export default function DashboardPermissionsPage() {
<KeyRoundIcon className="size-4" />
</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>
</div>
@@ -179,7 +187,7 @@ export default function DashboardPermissionsPage() {
<TableCell>
<Badge variant="outline">{item.code}</Badge>
</TableCell>
<TableCell>{item.groupName}</TableCell>
<TableCell>{getPermissionGroupName(item.groupName, locale)}</TableCell>
<TableCell>
<div className="flex items-start gap-2">
<Badge variant="secondary">{item.method || "ANY"}</Badge>
@@ -195,7 +203,7 @@ export default function DashboardPermissionsPage() {
<Badge
variant={item.status === Status.Ok ? "secondary" : "outline"}
>
{StatusLabels[item.status as Status] ?? String(item.status)}
{getStatusLabel(item.status, t)}
</Badge>
</TableCell>
</TableRow>
@@ -204,8 +212,8 @@ export default function DashboardPermissionsPage() {
<DashboardTableStateRow
colSpan={5}
loading={loading}
loadingText="正在加载权限数据..."
emptyText="没有匹配的权限数据"
loadingText={t("permission.loading")}
emptyText={t("permission.empty")}
/>
) : null}
</TableBody>
@@ -214,3 +222,19 @@ export default function DashboardPermissionsPage() {
</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";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { Controller, Resolver, useForm } from "react-hook-form";
import { z } from "zod/v4";
import { OptionCombobox } from "@/components/option-combobox";
import { ProjectDialog } from "@/components/project-dialog";
import { Button } from "@/components/ui/button";
import {
@@ -14,21 +15,15 @@ import {
FieldLabel,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import {
type AdminQuickReply,
type CreateAdminQuickReplyPayload,
fetchQuickReply,
} from "@/lib/api/admin";
import { getEnumLabel, getEnumOptions } from "@/lib/enums";
import { getEnumOptions } from "@/lib/enums";
import { Status, StatusLabels } from "@/lib/generated/enums";
import { useI18n } from "@/i18n/provider";
type QuickReplyFormDialogProps = {
open: boolean;
@@ -46,33 +41,39 @@ const emptyForm: EditForm = {
sortNo: "0",
};
const formStatusOptions = getEnumOptions(StatusLabels).filter(
(item) => Number(item.value) !== Status.Deleted,
);
type EditForm = {
groupName: string;
title: string;
content: string;
status: string;
sortNo: string;
};
const quickReplyFormSchema = z.object({
groupName: z.string().trim().min(1, "分组名称不能为空"),
title: z.string().trim().min(1, "标题不能为空"),
content: z.string().trim().min(1, "回复内容不能为空"),
status: z.enum([String(Status.Ok), String(Status.Disabled)], {
message: "请选择状态",
}),
sortNo: z
.string()
.trim()
.min(1, "排序不能为空")
.regex(/^\d+$/, "排序值必须是大于等于 0 的整数"),
});
function createSchema(t: (key: string) => string) {
return z.object({
groupName: z.string().trim().min(1, t("quickReply.groupNameRequired")),
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)], {
message: t("quickReply.statusRequired"),
}),
sortNo: z
.string()
.trim()
.min(1, t("quickReply.sortRequired"))
.regex(/^\d+$/, t("quickReply.sortInvalid")),
});
}
type EditForm = z.infer<typeof quickReplyFormSchema>;
const editFormResolver = zodResolver(quickReplyFormSchema as never) as Resolver<
z.input<typeof quickReplyFormSchema>,
undefined,
z.output<typeof quickReplyFormSchema>
>;
function getStatusLabel(value: string) {
return getEnumLabel(StatusLabels, Number(value) as Status);
function getLocalizedStatusLabel(value: string | number, t: (key: string) => string) {
const status = Number(value) as Status;
if (status === Status.Disabled) {
return t("status.disabled");
}
if (status === Status.Deleted) {
return t("status.deleted");
}
return t("status.ok");
}
function buildForm(item: AdminQuickReply | null): EditForm {
@@ -137,8 +138,29 @@ function QuickReplyFormDialogBody({
onOpenChange,
onSubmit,
}: QuickReplyFormDialogBodyProps) {
const t = useI18n();
const formId = "quick-reply-edit-form";
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<
z.input<typeof quickReplyFormSchema>,
undefined,
@@ -183,7 +205,7 @@ function QuickReplyFormDialogBody({
<ProjectDialog
open={open}
onOpenChange={onOpenChange}
title={itemId ? "编辑" : "新建"}
title={itemId ? t("quickReply.editTitle") : t("quickReply.createTitle")}
size="md"
allowFullscreen
footer={
@@ -194,26 +216,26 @@ function QuickReplyFormDialogBody({
onClick={() => onOpenChange(false)}
disabled={saving}
>
{t("quickReply.cancel")}
</Button>
<Button type="submit" form={formId} disabled={saving || loading}>
{saving ? "保存中..." : itemId ? "保存" : "创建"}
{saving ? t("quickReply.saving") : itemId ? t("quickReply.save") : t("quickReply.create")}
</Button>
</>
}
>
{loading ? (
<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>
) : (
<form id={formId} onSubmit={handleSubmit(onFormSubmit)} className="space-y-4">
<Field data-invalid={!!errors.groupName}>
<FieldLabel htmlFor="quick-reply-group-name"></FieldLabel>
<FieldLabel htmlFor="quick-reply-group-name">{t("quickReply.groupName")}</FieldLabel>
<FieldContent>
<Input
id="quick-reply-group-name"
placeholder="例如:售前、售后、催单"
placeholder={t("quickReply.groupNamePlaceholder")}
aria-invalid={!!errors.groupName}
{...register("groupName")}
/>
@@ -221,11 +243,11 @@ function QuickReplyFormDialogBody({
</FieldContent>
</Field>
<Field data-invalid={!!errors.title}>
<FieldLabel htmlFor="quick-reply-title"></FieldLabel>
<FieldLabel htmlFor="quick-reply-title">{t("quickReply.title")}</FieldLabel>
<FieldContent>
<Input
id="quick-reply-title"
placeholder="请输入快捷回复标题"
placeholder={t("quickReply.titlePlaceholder")}
aria-invalid={!!errors.title}
{...register("title")}
/>
@@ -233,11 +255,11 @@ function QuickReplyFormDialogBody({
</FieldContent>
</Field>
<Field data-invalid={!!errors.content}>
<FieldLabel htmlFor="quick-reply-content"></FieldLabel>
<FieldLabel htmlFor="quick-reply-content">{t("quickReply.content")}</FieldLabel>
<FieldContent>
<Textarea
id="quick-reply-content"
placeholder="请输入回复内容"
placeholder={t("quickReply.contentPlaceholder")}
rows={6}
aria-invalid={!!errors.content}
{...register("content")}
@@ -247,49 +269,32 @@ function QuickReplyFormDialogBody({
</Field>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field data-invalid={!!errors.status}>
<FieldLabel htmlFor="quick-reply-status"></FieldLabel>
<FieldLabel>{t("quickReply.columnStatus")}</FieldLabel>
<FieldContent>
<Controller
control={control}
name="status"
render={({ field }) => (
<Select
<OptionCombobox
value={field.value}
onValueChange={field.onChange}
modal={false}
>
<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>
options={formStatusOptions}
placeholder={t("quickReply.statusRequired")}
onChange={field.onChange}
/>
)}
/>
<FieldError errors={[errors.status]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.sortNo}>
<FieldLabel htmlFor="quick-reply-sort-no"></FieldLabel>
<FieldLabel htmlFor="quick-reply-sort-no">{t("quickReply.columnSort")}</FieldLabel>
<FieldContent>
<Input
id="quick-reply-sort-no"
type="number"
min={0}
step={1}
placeholder="数字越大越靠前"
placeholder={t("quickReply.sortPlaceholder")}
aria-invalid={!!errors.sortNo}
{...register("sortNo")}
/>
+52 -40
View File
@@ -46,21 +46,23 @@ import {
type CreateAdminQuickReplyPayload,
type PageResult,
} from "@/lib/api/admin"
import { getEnumLabel, getEnumOptions } from "@/lib/enums"
import { getEnumOptions } from "@/lib/enums"
import { Status, StatusLabels } from "@/lib/generated/enums"
import { useI18n } from "@/i18n/provider"
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
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")
}
export default function DashboardQuickRepliesPage() {
const t = useI18n()
const [keywordInput, setKeywordInput] = useState("")
const [groupNameInput, setGroupNameInput] = useState("")
const [statusFilterInput, setStatusFilterInput] = useState("all")
@@ -91,11 +93,21 @@ export default function DashboardQuickRepliesPage() {
})
setResult(data)
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载快捷回复失败")
toast.error(error instanceof Error ? error.message : t("quickReply.loadFailed"))
} finally {
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(() => {
void loadData()
@@ -156,16 +168,16 @@ export default function DashboardQuickRepliesPage() {
id: editingItem.id,
...payload,
})
toast.success(`已更新快捷回复:${editingItem.title}`)
toast.success(t("quickReply.updated", { title: editingItem.title }))
} else {
await createQuickReply(payload)
toast.success(`已创建快捷回复:${payload.title}`)
toast.success(t("quickReply.created", { title: payload.title }))
}
setDialogOpen(false)
setEditingItem(null)
await loadData()
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存快捷回复失败")
toast.error(error instanceof Error ? error.message : t("quickReply.saveFailed"))
} finally {
setSaving(false)
}
@@ -185,11 +197,11 @@ export default function DashboardQuickRepliesPage() {
status: nextStatus,
})
toast.success(
`${nextStatus === Status.Ok ? "启用" : "禁用"}${item.title}`
t(nextStatus === Status.Ok ? "quickReply.enabled" : "quickReply.disabled", { title: item.title })
)
await loadData()
} catch (error) {
toast.error(error instanceof Error ? error.message : "更新状态失败")
toast.error(error instanceof Error ? error.message : t("quickReply.statusUpdateFailed"))
} finally {
setActionLoadingId(null)
}
@@ -199,10 +211,10 @@ export default function DashboardQuickRepliesPage() {
setActionLoadingId(item.id)
try {
await deleteQuickReply(item.id)
toast.success(`已删除快捷回复:${item.title}`)
toast.success(t("quickReply.deleted", { title: item.title }))
await loadData()
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除快捷回复失败")
toast.error(error instanceof Error ? error.message : t("quickReply.deleteFailed"))
} finally {
setActionLoadingId(null)
}
@@ -216,11 +228,11 @@ export default function DashboardQuickRepliesPage() {
<>
<Button variant="outline" onClick={() => void loadData()} disabled={loading}>
<RefreshCwIcon className={loading ? "animate-spin" : undefined} />
{t("quickReply.refresh")}
</Button>
<Button onClick={openCreateDialog}>
<PlusIcon />
{t("quickReply.new")}
</Button>
</>
}
@@ -231,7 +243,7 @@ export default function DashboardQuickRepliesPage() {
value={keywordInput}
onChange={(event) => setKeywordInput(event.target.value)}
onKeyDown={handleFilterKeyDown}
placeholder="按标题筛选"
placeholder={t("quickReply.filterTitle")}
className="pl-9"
/>
</div>
@@ -239,20 +251,20 @@ export default function DashboardQuickRepliesPage() {
value={groupNameInput}
onChange={(event) => setGroupNameInput(event.target.value)}
onKeyDown={handleFilterKeyDown}
placeholder="按分组筛选"
placeholder={t("quickReply.filterGroup")}
className="w-full sm:w-44"
/>
<div className="w-full sm:w-36">
<OptionCombobox
value={statusFilterInput}
onChange={setStatusFilterInput}
placeholder="全部状态"
placeholder={t("status.all")}
options={[...listStatusOptions]}
/>
</div>
<Button variant="outline" onClick={applyFilters} disabled={loading}>
<SearchIcon />
{t("quickReply.query")}
</Button>
</DashboardToolbar>
<DashboardTableShell
@@ -273,12 +285,12 @@ export default function DashboardQuickRepliesPage() {
<Table>
<TableHeader className="bg-muted/40">
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-[92px] text-right"></TableHead>
<TableHead>{t("quickReply.columnQuickReply")}</TableHead>
<TableHead>{t("quickReply.columnGroup")}</TableHead>
<TableHead>{t("quickReply.columnStatus")}</TableHead>
<TableHead>{t("quickReply.columnSort")}</TableHead>
<TableHead>{t("quickReply.columnCreator")}</TableHead>
<TableHead className="w-[92px] text-right">{t("quickReply.columnActions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -306,7 +318,7 @@ export default function DashboardQuickRepliesPage() {
item.status === Status.Ok ? "default" : "outline"
}
>
{getEnumLabel(StatusLabels, item.status as Status)}
{getStatusLabel(item.status as Status, t)}
</Badge>
</TableCell>
<TableCell>{item.sortNo}</TableCell>
@@ -318,12 +330,12 @@ export default function DashboardQuickRepliesPage() {
size="sm"
onClick={() => openEditDialog(item)}
>
{t("quickReply.edit")}
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={<Button variant="outline" size="icon-sm" />}
aria-label={`更多操作 ${item.title}`}
aria-label={t("quickReply.moreActions", { title: item.title })}
>
<MoreHorizontalIcon />
</DropdownMenuTrigger>
@@ -331,17 +343,17 @@ export default function DashboardQuickRepliesPage() {
<DropdownMenuItem onClick={() => void handleToggleStatus(item)}>
<RefreshCwIcon />
{actionLoadingId === item.id
? "处理中..."
? t("quickReply.processing")
: item.status === Status.Ok
? "禁用"
: "启用"}
? t("quickReply.disable")
: t("quickReply.enable")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => void handleDelete(item)}
className="text-destructive focus:text-destructive"
>
<Trash2Icon />
{actionLoadingId === item.id ? "删除中..." : "删除"}
{actionLoadingId === item.id ? t("quickReply.deleting") : t("quickReply.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -353,8 +365,8 @@ export default function DashboardQuickRepliesPage() {
<DashboardTableStateRow
colSpan={6}
loading={loading}
loadingText="正在加载快捷回复..."
emptyText="没有匹配的快捷回复"
loadingText={t("quickReply.loading")}
emptyText={t("quickReply.empty")}
/>
) : null}
</TableBody>
@@ -25,6 +25,9 @@ import {
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
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 = {
open: boolean;
@@ -104,6 +107,8 @@ function AssignPermissionsDrawerBody({
onOpenChange,
onSubmit,
}: AssignPermissionsDrawerBodyProps) {
const t = useI18n();
const { locale } = useAppLocale();
const [keyword, setKeyword] = useState("");
const form = useForm<
z.input<typeof assignPermissionsSchema>,
@@ -128,7 +133,7 @@ function AssignPermissionsDrawerBody({
const output = keyword.trim().toLowerCase();
const filtered = output
? 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()
.includes(output),
)
@@ -142,9 +147,9 @@ function AssignPermissionsDrawerBody({
groups.set(groupName, list);
});
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) {
await onSubmit(values.permissionIds);
@@ -153,9 +158,12 @@ function AssignPermissionsDrawerBody({
return (
<DrawerContent className="min-w-3xl">
<DrawerHeader>
<DrawerTitle></DrawerTitle>
<DrawerTitle>{t("role.assignTitle")}</DrawerTitle>
<DrawerDescription>
{item?.name || "-"} {item?.code ? `(${item.code})` : ""}
{t("role.currentRole", {
name: item ? getRoleDisplayName(item.code, item.name, locale) : "-",
code: item?.code ? `(${item.code})` : "",
})}
</DrawerDescription>
</DrawerHeader>
<form
@@ -164,14 +172,14 @@ function AssignPermissionsDrawerBody({
>
<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">
<FieldLabel></FieldLabel>
<FieldLabel>{t("role.permissionList")}</FieldLabel>
<FieldContent className="flex-1 min-h-0 flex flex-col">
<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" />
<Input
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索权限名称、编码、分组或接口"
placeholder={t("role.searchPermission")}
className="pl-9"
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">
{loading ? (
<div className="py-8 text-center text-sm text-muted-foreground">
...
{t("role.loadingPermissions")}
</div>
) : groupedPermissions.length > 0 ? (
groupedPermissions.map(([groupName, list]) => (
<section key={groupName} className="space-y-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">
{list.length}
{t("role.permissionCount", { count: list.length })}
</span>
</div>
<div className="space-y-1">
@@ -225,7 +233,7 @@ function AssignPermissionsDrawerBody({
/>
<div className="min-w-0 flex-1">
<div className="font-medium">
{permission.name}
{getPermissionDisplayName(permission.code, permission.name, locale)}
</div>
<div className="text-sm text-muted-foreground">
{permission.code}
@@ -243,8 +251,8 @@ function AssignPermissionsDrawerBody({
}
>
{permission.status === 1
? "禁用"
: "启用"}
? t("status.disabled")
: t("status.ok")}
</Badge>
</label>
);
@@ -254,7 +262,7 @@ function AssignPermissionsDrawerBody({
))
) : (
<div className="py-8 text-center text-sm text-muted-foreground">
{t("role.noPermissions")}
</div>
)}
</div>
@@ -267,7 +275,7 @@ function AssignPermissionsDrawerBody({
</div>
<DrawerFooter className="border-t">
<Button type="submit" disabled={saving || loading || !item}>
{saving ? "保存中..." : "保存权限"}
{saving ? t("role.saving") : t("role.savePermissions")}
</Button>
<Button
type="button"
@@ -275,7 +283,7 @@ function AssignPermissionsDrawerBody({
onClick={() => onOpenChange(false)}
disabled={saving}
>
{t("role.cancel")}
</Button>
</DrawerFooter>
</form>
+36 -32
View File
@@ -1,5 +1,6 @@
"use client"
import { useMemo } from "react"
import { Resolver, useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod/v4"
@@ -22,6 +23,7 @@ import {
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { useI18n } from "@/i18n/provider"
type CreateRoleDrawerProps = {
open: boolean
@@ -30,23 +32,11 @@ type CreateRoleDrawerProps = {
onSubmit: (payload: CreateAdminRolePayload) => Promise<void>
}
const createFormSchema = z.object({
name: z.string().trim().min(1, "角色名称不能为空"),
code: z
.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>
>
type CreateForm = {
name: string
code: string
remark: string
}
const emptyForm: CreateForm = {
name: "",
@@ -99,11 +89,25 @@ function CreateRoleDrawerBody({
onOpenChange,
onSubmit,
}: CreateRoleDrawerBodyProps) {
const form = useForm<
z.input<typeof createFormSchema>,
undefined,
z.output<typeof createFormSchema>
>({
const t = useI18n()
const createFormSchema = useMemo(
() =>
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,
defaultValues: buildEmptyForm(),
})
@@ -122,8 +126,8 @@ function CreateRoleDrawerBody({
return (
<DrawerContent className="min-w-2xl">
<DrawerHeader>
<DrawerTitle></DrawerTitle>
<DrawerDescription></DrawerDescription>
<DrawerTitle>{t("role.createTitle")}</DrawerTitle>
<DrawerDescription>{t("role.createDescription")}</DrawerDescription>
</DrawerHeader>
<form
className="flex h-full flex-col"
@@ -131,11 +135,11 @@ function CreateRoleDrawerBody({
>
<div className="space-y-4 overflow-y-auto px-4 pb-4">
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="create-role-name"></FieldLabel>
<FieldLabel htmlFor="create-role-name">{t("role.name")}</FieldLabel>
<FieldContent>
<Input
id="create-role-name"
placeholder="例如:客服主管"
placeholder={t("role.namePlaceholder")}
autoComplete="off"
aria-invalid={!!errors.name}
{...register("name")}
@@ -144,11 +148,11 @@ function CreateRoleDrawerBody({
</FieldContent>
</Field>
<Field data-invalid={!!errors.code}>
<FieldLabel htmlFor="create-role-code"></FieldLabel>
<FieldLabel htmlFor="create-role-code">{t("role.code")}</FieldLabel>
<FieldContent>
<Input
id="create-role-code"
placeholder="例如:support_manager"
placeholder={t("role.codePlaceholder")}
autoComplete="off"
aria-invalid={!!errors.code}
{...register("code")}
@@ -157,11 +161,11 @@ function CreateRoleDrawerBody({
</FieldContent>
</Field>
<Field data-invalid={!!errors.remark}>
<FieldLabel htmlFor="create-role-remark"></FieldLabel>
<FieldLabel htmlFor="create-role-remark">{t("role.remark")}</FieldLabel>
<FieldContent>
<Textarea
id="create-role-remark"
placeholder="可选"
placeholder={t("role.optional")}
aria-invalid={!!errors.remark}
{...register("remark")}
/>
@@ -171,7 +175,7 @@ function CreateRoleDrawerBody({
</div>
<DrawerFooter className="border-t">
<Button type="submit" disabled={saving}>
{saving ? "创建中..." : "创建角色"}
{saving ? t("role.creating") : t("role.create")}
</Button>
<Button
type="button"
@@ -179,7 +183,7 @@ function CreateRoleDrawerBody({
onClick={() => onOpenChange(false)}
disabled={saving}
>
{t("role.cancel")}
</Button>
</DrawerFooter>
</form>
+31 -25
View File
@@ -62,6 +62,8 @@ import {
TableRow,
} from "@/components/ui/table"
import { Status } from "@/lib/generated/enums"
import { useAppLocale, useI18n } from "@/i18n/provider"
import { getRoleDisplayName } from "@/lib/role-i18n"
type SortableRoleRowProps = {
item: AdminRole
@@ -76,6 +78,9 @@ function SortableRoleRow({
actionLoading,
onAssignPermissions,
}: SortableRoleRowProps) {
const t = useI18n()
const { locale } = useAppLocale()
const displayName = getRoleDisplayName(item.code, item.name, locale)
const {
attributes,
listeners,
@@ -109,7 +114,7 @@ function SortableRoleRow({
size="icon"
className="size-8 cursor-grab active:cursor-grabbing"
disabled={disabled}
aria-label={`拖拽排序 ${item.name}`}
aria-label={t("role.dragSort", { name: displayName })}
{...attributes}
{...listeners}
>
@@ -121,7 +126,7 @@ function SortableRoleRow({
<div className="flex size-10 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
<ShieldCheckIcon className="size-4" />
</div>
<div className="font-medium">{item.name}</div>
<div className="font-medium">{displayName}</div>
</div>
</TableCell>
<TableCell>
@@ -132,11 +137,11 @@ function SortableRoleRow({
</TableCell>
<TableCell>
<Badge variant={item.status === Status.Ok ? "secondary" : "outline"}>
{item.status === Status.Ok ? "启用" : "禁用"}
{item.status === Status.Ok ? t("status.ok") : t("status.disabled")}
</Badge>
{item.isSystem ? (
<Badge variant="outline" className="ml-2">
{t("role.system")}
</Badge>
) : null}
</TableCell>
@@ -149,7 +154,7 @@ function SortableRoleRow({
onClick={() => onAssignPermissions(item)}
disabled={disabled || actionLoading}
>
{actionLoading ? "处理中..." : "分配权限"}
{actionLoading ? t("role.processing") : t("role.assignPermissions")}
</Button>
</TableCell>
</TableRow>
@@ -157,6 +162,7 @@ function SortableRoleRow({
}
export default function DashboardRolesPage() {
const t = useI18n()
const [loading, setLoading] = useState(true)
const [sorting, setSorting] = useState(false)
const [creatingOpen, setCreatingOpen] = useState(false)
@@ -191,7 +197,7 @@ export default function DashboardRolesPage() {
try {
setResult(await fetchRoles({ limit: 200 }))
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载角色失败")
toast.error(error instanceof Error ? error.message : t("role.loadFailed"))
} finally {
setLoading(false)
}
@@ -212,11 +218,11 @@ export default function DashboardRolesPage() {
setSavingCreate(true)
try {
const role = await createRole(payload)
toast.success(`已创建角色 ${role.name}`)
toast.success(t("role.created", { name: role.name }))
setCreatingOpen(false)
await loadRoles()
} catch (error) {
toast.error(error instanceof Error ? error.message : "创建角色失败")
toast.error(error instanceof Error ? error.message : t("role.createFailed"))
} finally {
setSavingCreate(false)
}
@@ -240,7 +246,7 @@ export default function DashboardRolesPage() {
)
} catch (error) {
setAssigningRole(null)
toast.error(error instanceof Error ? error.message : "加载权限分配数据失败")
toast.error(error instanceof Error ? error.message : t("role.loadAssignFailed"))
} finally {
setAssignPermissionsLoading(false)
setActionLoadingId(null)
@@ -269,14 +275,14 @@ export default function DashboardRolesPage() {
try {
await updateRoleSort(nextResults.map((item) => item.id))
toast.success("角色排序已更新")
toast.success(t("role.sortUpdated"))
await loadRoles()
} catch (error) {
setResult((current) => ({
...current,
results: previousResults,
}))
toast.error(error instanceof Error ? error.message : "更新角色排序失败")
toast.error(error instanceof Error ? error.message : t("role.sortUpdateFailed"))
} finally {
setSorting(false)
}
@@ -301,13 +307,13 @@ export default function DashboardRolesPage() {
setSavingPermissions(true)
try {
await assignRolePermissions(assigningRole.id, permissionIds)
toast.success(`已更新角色 ${assigningRole.name} 的权限`)
toast.success(t("role.permissionsUpdated", { name: assigningRole.name }))
setAssigningRole(null)
setAssignPermissionOptions([])
setAssignPermissionIds([])
await loadRoles()
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存权限分配失败")
toast.error(error instanceof Error ? error.message : t("role.savePermissionsFailed"))
} finally {
setSavingPermissions(false)
}
@@ -328,7 +334,7 @@ export default function DashboardRolesPage() {
disabled={loading || sorting}
>
<PlusIcon className="size-4" />
{t("role.add")}
</Button>
<Button
variant="outline"
@@ -336,22 +342,22 @@ export default function DashboardRolesPage() {
disabled={loading || sorting}
>
<RefreshCwIcon className={cn((loading || sorting) && "animate-spin")} />
{t("role.refresh")}
</Button>
</>
}
>
<div className="text-sm text-muted-foreground">
{t("role.dragHint")}
</div>
</DashboardToolbar>
<DashboardTableShell
pagination={
<DashboardTableSummary>
<span>
{result.page.page} / {result.page.limit}
{t("role.pageSummary", { page: result.page.page, limit: result.page.limit })}
</span>
<span> {result.page.total} </span>
<span>{t("pagination.total", { total: result.page.total })}</span>
</DashboardTableSummary>
}
>
@@ -364,11 +370,11 @@ export default function DashboardRolesPage() {
<TableHeader className="bg-muted/40">
<TableRow>
<TableHead className="w-14"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead>{t("role.columnRole")}</TableHead>
<TableHead>{t("role.columnCode")}</TableHead>
<TableHead>{t("role.columnStatus")}</TableHead>
<TableHead>{t("role.columnSort")}</TableHead>
<TableHead className="text-right">{t("role.columnActions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -392,8 +398,8 @@ export default function DashboardRolesPage() {
<DashboardTableStateRow
colSpan={6}
loading={loading}
loadingText="正在加载角色数据..."
emptyText="暂无角色数据"
loadingText={t("role.loading")}
emptyText={t("role.empty")}
/>
) : null}
</TableBody>
+10 -5
View File
@@ -1,15 +1,20 @@
"use client"
import { DashboardPlaceholder } from "@/components/dashboard-placeholder"
import { useI18n } from "@/i18n/provider"
export default function DashboardSettingsPage() {
const t = useI18n()
return (
<DashboardPlaceholder
eyebrow="Settings"
title="系统设置骨架"
description="系统设置页将管理认证参数、上传配置、基础信息与运行策略。"
title={t("settings.title")}
description={t("settings.description")}
nextSteps={[
"补充系统配置读取与保存接口。",
"按安全、存储、登录策略拆分设置区块。",
"增加敏感配置的脱敏显示和二次确认。",
t("settings.step1"),
t("settings.step2"),
t("settings.step3"),
]}
/>
)
@@ -2,7 +2,7 @@
import { zodResolver } from "@hookform/resolvers/zod"
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 { LoaderCircleIcon, PlayIcon } from "lucide-react"
import { toast } from "sonner"
@@ -29,6 +29,7 @@ import {
type SkillDebugRunPayload,
type SkillDebugRunResult,
} from "@/lib/api/admin"
import { useI18n } from "@/i18n/provider"
type DebugDialogProps = {
open: boolean
@@ -37,19 +38,21 @@ type DebugDialogProps = {
onOpenChange: (open: boolean) => void
}
const debugFormSchema = z.object({
aiAgentId: z.string().trim().min(1, "请选择 AI Agent"),
type TFunction = (key: string, values?: Record<string, string | number>) => string
function createDebugFormSchema(t: TFunction) {
return z.object({
aiAgentId: z.string().trim().min(1, t("skillDefinition.agentRequired")),
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>
const debugFormResolver = zodResolver(debugFormSchema as never) as Resolver<
z.input<typeof debugFormSchema>,
undefined,
z.output<typeof debugFormSchema>
>
type DebugForm = {
aiAgentId: string
conversationId: string
userMessage: string
}
const emptyForm: DebugForm = {
aiAgentId: "",
@@ -57,15 +60,17 @@ const emptyForm: DebugForm = {
userMessage: "",
}
const quickResumeActions = [
{ label: "确认", value: "确认" },
{ label: "取消", value: "取消" },
]
function getQuickResumeActions(t: TFunction) {
return [
{ label: t("skillDefinition.confirm"), value: t("skillDefinition.confirm") },
{ label: t("skillDefinition.reject"), value: t("skillDefinition.reject") },
]
}
function ResultBlock({
title,
value,
emptyText = "暂无数据",
emptyText,
}: {
title: string
value?: string
@@ -116,6 +121,7 @@ function DebugDialogBody({
skillName,
onOpenChange,
}: DebugDialogProps) {
const t = useI18n()
const formId = `skill-debug-form-${skillCode}`
const [running, setRunning] = useState(false)
const [resuming, setResuming] = useState(false)
@@ -123,11 +129,12 @@ function DebugDialogBody({
const [result, setResult] = useState<SkillDebugRunResult | null>(null)
const [resumeResult, setResumeResult] = useState<SkillDebugRunResult | null>(null)
const [resumeMessage, setResumeMessage] = useState("")
const form = useForm<
z.input<typeof debugFormSchema>,
undefined,
z.output<typeof debugFormSchema>
>({
const debugFormSchema = useMemo(() => createDebugFormSchema(t), [t])
const debugFormResolver = useMemo(
() => zodResolver(debugFormSchema) as Resolver<DebugForm>,
[debugFormSchema],
)
const form = useForm<DebugForm>({
resolver: debugFormResolver,
defaultValues: emptyForm,
})
@@ -142,6 +149,7 @@ function DebugDialogBody({
} = form
const selectedAgentId = watch("aiAgentId")
const quickResumeActions = useMemo(() => getQuickResumeActions(t), [t])
useEffect(() => {
async function loadAIAgents() {
@@ -205,7 +213,7 @@ function DebugDialogBody({
setResumeResult(null)
setResumeMessage("")
} catch (error) {
toast.error(error instanceof Error ? error.message : "Skill 调试失败")
toast.error(error instanceof Error ? error.message : t("skillDefinition.debugFailed"))
setResult(null)
} finally {
setRunning(false)
@@ -218,7 +226,7 @@ function DebugDialogBody({
return
}
if (!nextMessage) {
toast.error("请输入恢复消息")
toast.error(t("skillDefinition.resumeMessageRequired"))
return
}
const payload: SkillDebugResumePayload = {
@@ -237,7 +245,7 @@ function DebugDialogBody({
setResumeResult(data)
setResumeMessage(nextMessage)
} catch (error) {
toast.error(error instanceof Error ? error.message : "恢复调试失败")
toast.error(error instanceof Error ? error.message : t("skillDefinition.resumeFailed"))
setResumeResult(null)
} finally {
setResuming(false)
@@ -248,8 +256,8 @@ function DebugDialogBody({
<ProjectDialog
open={open}
onOpenChange={onOpenChange}
title={`调试 Skill · ${skillName || skillCode}`}
description="强制指定当前 Skill,直接查看 route、tools、graph、HITL 和回复结果。"
title={t("skillDefinition.debugTitle", { name: skillName || skillCode })}
description={t("skillDefinition.debugDescription")}
size="xl"
allowFullscreen
footer={
@@ -260,11 +268,11 @@ function DebugDialogBody({
disabled={running}
onClick={() => onOpenChange(false)}
>
{t("skillDefinition.close")}
</Button>
<Button type="submit" form={formId} disabled={running}>
{running ? <LoaderCircleIcon className="animate-spin" /> : <PlayIcon />}
{running ? "调试中..." : "开始调试"}
{running ? t("skillDefinition.debugging") : t("skillDefinition.startDebug")}
</Button>
</>
}
@@ -272,7 +280,7 @@ function DebugDialogBody({
<div className="space-y-6">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm"></CardTitle>
<CardTitle className="text-sm">{t("skillDefinition.debugInput")}</CardTitle>
</CardHeader>
<CardContent>
<form id={formId} onSubmit={handleSubmit(onSubmit)} className="space-y-4">
@@ -283,9 +291,9 @@ function DebugDialogBody({
<OptionCombobox
value={selectedAgentId}
options={aiAgentOptions}
placeholder="选择 AI Agent"
searchPlaceholder="搜索 AI Agent"
emptyText="未找到 AI Agent"
placeholder={t("skillDefinition.selectAgent")}
searchPlaceholder={t("skillDefinition.searchAgent")}
emptyText={t("skillDefinition.emptyAgent")}
onChange={(value) =>
setValue("aiAgentId", value, { shouldValidate: true })
}
@@ -302,7 +310,7 @@ function DebugDialogBody({
id="skill-debug-conversation-id"
type="number"
min={0}
placeholder="可选,填已有会话 ID 以复用上下文"
placeholder={t("skillDefinition.conversationPlaceholder")}
aria-invalid={!!errors.conversationId}
{...register("conversationId")}
/>
@@ -318,22 +326,22 @@ function DebugDialogBody({
</FieldContent>
</Field>
<Field>
<FieldLabel> Agent</FieldLabel>
<FieldLabel>{t("skillDefinition.matchedAgent")}</FieldLabel>
<FieldContent>
<Input
value={selectedAgent?.name || "未选择"}
value={selectedAgent?.name || t("skillDefinition.noAgentSelected")}
disabled
/>
</FieldContent>
</Field>
</div>
<Field data-invalid={!!errors.userMessage}>
<FieldLabel htmlFor="skill-debug-user-message"></FieldLabel>
<FieldLabel htmlFor="skill-debug-user-message">{t("skillDefinition.userMessage")}</FieldLabel>
<FieldContent>
<Textarea
id="skill-debug-user-message"
rows={5}
placeholder="输入一段用户消息,调试当前 Skill 的路由、工具和回复。"
placeholder={t("skillDefinition.userMessagePlaceholder")}
aria-invalid={!!errors.userMessage}
{...register("userMessage")}
/>
@@ -347,7 +355,7 @@ function DebugDialogBody({
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm"></CardTitle>
<CardTitle className="text-sm">{t("skillDefinition.debugSummary")}</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="flex flex-wrap gap-2">
@@ -359,38 +367,38 @@ function DebugDialogBody({
<Badge variant="secondary">{result.interruptType}</Badge>
) : null}
{result?.interrupted ? (
<Badge></Badge>
<Badge>{t("skillDefinition.interrupted")}</Badge>
) : (
<Badge variant="outline"></Badge>
<Badge variant="outline">{t("skillDefinition.notInterrupted")}</Badge>
)}
</div>
<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>
<div className="rounded-lg bg-muted/50 p-3">
<div className="text-xs text-muted-foreground">Plan Reason</div>
<div className="mt-1 whitespace-pre-wrap break-words">
{result?.planReason || "暂无"}
{result?.planReason || t("skillDefinition.none")}
</div>
</div>
<div className="rounded-lg bg-muted/50 p-3">
<div className="text-xs text-muted-foreground">Reply</div>
<div className="mt-1 whitespace-pre-wrap break-words">
{result?.replyText || "暂无"}
{result?.replyText || t("skillDefinition.none")}
</div>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="rounded-lg bg-muted/50 p-3">
<div className="text-xs text-muted-foreground">Checkpoint</div>
<div className="mt-1 break-all">
{result?.checkPointId || "暂无"}
{result?.checkPointId || t("skillDefinition.none")}
</div>
</div>
<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">
{result?.errorMessage || "暂无"}
{result?.errorMessage || t("skillDefinition.none")}
</div>
</div>
</div>
@@ -399,11 +407,11 @@ function DebugDialogBody({
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm"></CardTitle>
<CardTitle className="text-sm">{t("skillDefinition.toolView")}</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<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">
{(result?.toolWhitelist ?? []).length > 0 ? (
result?.toolWhitelist.map((toolCode) => (
@@ -412,12 +420,12 @@ function DebugDialogBody({
</Badge>
))
) : (
<span className="text-muted-foreground"></span>
<span className="text-muted-foreground">{t("skillDefinition.none")}</span>
)}
</div>
</div>
<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">
{(result?.exposedToolCodes ?? []).length > 0 ? (
result?.exposedToolCodes.map((toolCode) => (
@@ -426,12 +434,12 @@ function DebugDialogBody({
</Badge>
))
) : (
<span className="text-muted-foreground"></span>
<span className="text-muted-foreground">{t("skillDefinition.none")}</span>
)}
</div>
</div>
<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">
{(result?.invokedToolCodes ?? []).length > 0 ? (
result?.invokedToolCodes.map((toolCode) => (
@@ -440,7 +448,7 @@ function DebugDialogBody({
</Badge>
))
) : (
<span className="text-muted-foreground"></span>
<span className="text-muted-foreground">{t("skillDefinition.none")}</span>
)}
</div>
</div>
@@ -449,29 +457,29 @@ function DebugDialogBody({
</div>
<div className="grid grid-cols-1 gap-4">
<ResultBlock title="Skill Route Trace" value={result?.skillRouteTrace} />
<ResultBlock title="Tool Search Trace" value={result?.toolSearchTrace} />
<ResultBlock title="Graph Tool Trace" value={result?.graphToolTrace} />
<ResultBlock title="Trace Data" value={result?.traceData} />
<ResultBlock title="Skill Route Trace" value={result?.skillRouteTrace} emptyText={t("skillDefinition.emptyData")} />
<ResultBlock title="Tool Search Trace" value={result?.toolSearchTrace} emptyText={t("skillDefinition.emptyData")} />
<ResultBlock title="Graph Tool Trace" value={result?.graphToolTrace} emptyText={t("skillDefinition.emptyData")} />
<ResultBlock title="Trace Data" value={result?.traceData} emptyText={t("skillDefinition.emptyData")} />
</div>
{result?.interrupted && result.checkPointId ? (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm"></CardTitle>
<CardTitle className="text-sm">{t("skillDefinition.resumeDebug")}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<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>
<Field>
<FieldLabel htmlFor="skill-debug-resume-message"></FieldLabel>
<FieldLabel htmlFor="skill-debug-resume-message">{t("skillDefinition.resumeMessage")}</FieldLabel>
<FieldContent>
<Textarea
id="skill-debug-resume-message"
rows={3}
placeholder="输入确认、取消或其他恢复消息"
placeholder={t("skillDefinition.resumePlaceholder")}
value={resumeMessage}
onChange={(event) => setResumeMessage(event.target.value)}
/>
@@ -499,7 +507,7 @@ function DebugDialogBody({
) : (
<PlayIcon />
)}
{resuming ? "恢复中..." : "恢复调试"}
{resuming ? t("skillDefinition.resuming") : t("skillDefinition.resumeDebugAction")}
</Button>
</div>
</CardContent>
@@ -510,7 +518,7 @@ function DebugDialogBody({
<div className="space-y-4">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm"></CardTitle>
<CardTitle className="text-sm">{t("skillDefinition.resumeResult")}</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="flex flex-wrap gap-2">
@@ -522,34 +530,34 @@ function DebugDialogBody({
<Badge variant="secondary">{resumeResult.interruptType}</Badge>
) : null}
{resumeResult.interrupted ? (
<Badge></Badge>
<Badge>{t("skillDefinition.stillWaiting")}</Badge>
) : (
<Badge variant="outline"></Badge>
<Badge variant="outline">{t("skillDefinition.resumeCompleted")}</Badge>
)}
</div>
<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">
{resumeMessage || "暂无"}
{resumeMessage || t("skillDefinition.none")}
</div>
</div>
<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">
{resumeResult.replyText || "暂无"}
{resumeResult.replyText || t("skillDefinition.none")}
</div>
</div>
<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">
{resumeResult.planReason || "暂无"}
{resumeResult.planReason || t("skillDefinition.none")}
</div>
</div>
</CardContent>
</Card>
<ResultBlock title="恢复 Tool Search Trace" value={resumeResult.toolSearchTrace} />
<ResultBlock title="恢复 Graph Tool Trace" value={resumeResult.graphToolTrace} />
<ResultBlock title="恢复 Trace Data" value={resumeResult.traceData} />
<ResultBlock title="Resume Tool Search Trace" value={resumeResult.toolSearchTrace} emptyText={t("skillDefinition.emptyData")} />
<ResultBlock title="Resume Graph Tool Trace" value={resumeResult.graphToolTrace} emptyText={t("skillDefinition.emptyData")} />
<ResultBlock title="Resume Trace Data" value={resumeResult.traceData} emptyText={t("skillDefinition.emptyData")} />
</div>
) : null}
</div>
@@ -2,7 +2,7 @@
import { zodResolver } from "@hookform/resolvers/zod";
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 { OptionCombobox } from "@/components/option-combobox";
@@ -23,7 +23,9 @@ import {
type MCPToolCatalogItem,
type SkillDefinition,
} from "@/lib/api/admin";
import { useI18n } from "@/i18n/provider";
type TFunction = (key: string, values?: Record<string, string | number>) => string;
type SkillEditDialogProps = {
open: boolean;
@@ -42,25 +44,29 @@ const emptyForm: EditForm = {
remark: "",
};
const skillFormSchema = z.object({
function createSkillFormSchema(t: TFunction) {
return z.object({
code: z
.string()
.trim()
.min(1, "Skill 编码不能为空")
.regex(/^[a-zA-Z0-9_-]+$/, "Skill 编码仅支持字母、数字、下划线和中划线"),
name: z.string().trim().min(1, "Skill 名称不能为空"),
.min(1, t("skillDefinition.codeRequired"))
.regex(/^[a-zA-Z0-9_-]+$/, t("skillDefinition.codeInvalid")),
name: z.string().trim().min(1, t("skillDefinition.nameRequired")),
description: z.string().trim(),
instruction: z.string().trim().min(1, "技能说明不能为空"),
instruction: z.string().trim().min(1, t("skillDefinition.instructionRequired")),
examplesText: z.string().trim(),
remark: z.string().trim(),
});
});
}
type EditForm = z.infer<typeof skillFormSchema>;
const editFormResolver = zodResolver(skillFormSchema as never) as Resolver<
z.input<typeof skillFormSchema>,
undefined,
z.output<typeof skillFormSchema>
>;
type EditForm = {
code: string;
name: string;
description: string;
instruction: string;
examplesText: string;
remark: string;
};
function buildForm(item: SkillDefinition | null): EditForm {
if (!item) {
@@ -127,6 +133,7 @@ function SkillEditDialogBody({
onOpenChange,
onSubmit,
}: SkillEditDialogBodyProps) {
const t = useI18n();
const formId = "skill-definition-edit-form";
const [loading, setLoading] = useState(false);
const [toolCatalog, setToolCatalog] = useState<MCPToolCatalogItem[]>([]);
@@ -134,11 +141,12 @@ function SkillEditDialogBody({
string[]
>([]);
const [toolCodeToAdd, setToolCodeToAdd] = useState("");
const form = useForm<
z.input<typeof skillFormSchema>,
undefined,
z.output<typeof skillFormSchema>
>({
const skillFormSchema = useMemo(() => createSkillFormSchema(t), [t]);
const editFormResolver = useMemo(
() => zodResolver(skillFormSchema) as Resolver<EditForm>,
[skillFormSchema],
);
const form = useForm<EditForm>({
resolver: editFormResolver,
defaultValues: emptyForm,
});
@@ -237,7 +245,7 @@ function SkillEditDialogBody({
<ProjectDialog
open={open}
onOpenChange={onOpenChange}
title={itemId ? "编辑" : "新建"}
title={itemId ? t("skillDefinition.editTitle") : t("skillDefinition.createTitle")}
size="xl"
allowFullscreen
footer={
@@ -248,17 +256,17 @@ function SkillEditDialogBody({
onClick={() => onOpenChange(false)}
disabled={saving}
>
{t("skillDefinition.cancel")}
</Button>
<Button type="submit" form={formId} disabled={saving || loading}>
{saving ? "保存中..." : itemId ? "保存" : "创建"}
{saving ? t("skillDefinition.saving") : itemId ? t("skillDefinition.save") : t("skillDefinition.create")}
</Button>
</>
}
>
{loading ? (
<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>
) : (
<form
@@ -268,11 +276,11 @@ function SkillEditDialogBody({
>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field data-invalid={!!errors.code}>
<FieldLabel htmlFor="skill-code"></FieldLabel>
<FieldLabel htmlFor="skill-code">{t("skillDefinition.code")}</FieldLabel>
<FieldContent>
<Input
id="skill-code"
placeholder="例如:refund_skill"
placeholder={t("skillDefinition.codePlaceholder")}
aria-invalid={!!errors.code}
{...register("code")}
/>
@@ -280,11 +288,11 @@ function SkillEditDialogBody({
</FieldContent>
</Field>
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="skill-name"></FieldLabel>
<FieldLabel htmlFor="skill-name">{t("skillDefinition.name")}</FieldLabel>
<FieldContent>
<Input
id="skill-name"
placeholder="例如:退款处理"
placeholder={t("skillDefinition.namePlaceholder")}
aria-invalid={!!errors.name}
{...register("name")}
/>
@@ -294,12 +302,12 @@ function SkillEditDialogBody({
</div>
<Field data-invalid={!!errors.description}>
<FieldLabel htmlFor="skill-description"></FieldLabel>
<FieldLabel htmlFor="skill-description">{t("skillDefinition.description")}</FieldLabel>
<FieldContent>
<Textarea
id="skill-description"
rows={3}
placeholder="描述这个 Skill 的用途、边界和适用场景"
placeholder={t("skillDefinition.descriptionPlaceholder")}
aria-invalid={!!errors.description}
{...register("description")}
/>
@@ -308,12 +316,12 @@ function SkillEditDialogBody({
</Field>
<Field data-invalid={!!errors.instruction}>
<FieldLabel htmlFor="skill-instruction"></FieldLabel>
<FieldLabel htmlFor="skill-instruction">{t("skillDefinition.instruction")}</FieldLabel>
<FieldContent>
<Textarea
id="skill-instruction"
rows={12}
placeholder="请输入 Skill 文档内容,描述目标、步骤、工具使用规则和边界。"
placeholder={t("skillDefinition.instructionPlaceholder")}
aria-invalid={!!errors.instruction}
{...register("instruction")}
/>
@@ -322,12 +330,12 @@ function SkillEditDialogBody({
</Field>
<Field data-invalid={!!errors.examplesText}>
<FieldLabel htmlFor="skill-examples"></FieldLabel>
<FieldLabel htmlFor="skill-examples">{t("skillDefinition.examples")}</FieldLabel>
<FieldContent>
<Textarea
id="skill-examples"
rows={5}
placeholder={"每行一个典型用户问法,例如:\n我要申请退款\n帮我查下订单"}
placeholder={t("skillDefinition.examplesPlaceholder")}
aria-invalid={!!errors.examplesText}
{...register("examplesText")}
/>
@@ -336,16 +344,16 @@ function SkillEditDialogBody({
</Field>
<Field>
<FieldLabel></FieldLabel>
<FieldLabel>{t("skillDefinition.toolWhitelist")}</FieldLabel>
<FieldContent className="space-y-3">
<div className="flex items-center gap-2">
<div className="flex-1">
<OptionCombobox
value={toolCodeToAdd}
options={addableToolOptions}
placeholder="选择该 Skill 允许使用的工具"
searchPlaceholder="搜索 toolCode 或工具名"
emptyText="没有可添加的工具"
placeholder={t("skillDefinition.selectTool")}
searchPlaceholder={t("skillDefinition.searchTool")}
emptyText={t("skillDefinition.emptyTool")}
onChange={handleAddToolWhitelist}
/>
</div>
@@ -355,13 +363,13 @@ function SkillEditDialogBody({
disabled={!toolCodeToAdd}
onClick={() => handleAddToolWhitelist(toolCodeToAdd)}
>
{t("skillDefinition.add")}
</Button>
</div>
<div className="flex flex-wrap gap-2">
{selectedToolOptions.length === 0 ? (
<span className="text-sm text-muted-foreground">
Skill Agent
{t("skillDefinition.inheritAgentTools")}
</span>
) : (
selectedToolOptions.map((option) => (
@@ -382,12 +390,12 @@ function SkillEditDialogBody({
</Field>
<Field data-invalid={!!errors.remark}>
<FieldLabel htmlFor="skill-remark"></FieldLabel>
<FieldLabel htmlFor="skill-remark">{t("skillDefinition.remark")}</FieldLabel>
<FieldContent>
<Textarea
id="skill-remark"
rows={3}
placeholder="记录内部备注或维护说明"
placeholder={t("skillDefinition.remarkPlaceholder")}
aria-invalid={!!errors.remark}
{...register("remark")}
/>
+65 -44
View File
@@ -1,6 +1,6 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import { useCallback, useEffect, useMemo, useState } from "react"
import {
BrainCircuitIcon,
BugIcon,
@@ -51,19 +51,35 @@ import {
type PageResult,
type SkillDefinition,
} from "@/lib/api/admin"
import { Status, StatusLabels } from "@/lib/generated/enums"
import { getEnumLabel, getEnumOptions } from "@/lib/enums"
import { useI18n } from "@/i18n/provider"
import { Status } from "@/lib/generated/enums"
import { formatDateTime } from "@/lib/utils"
import { EditDialog } from "./_components/edit"
import { DebugDialog } from "./_components/debug-dialog"
const statusFilterOptions = [
{ value: "all", label: "全部状态" },
...getEnumOptions(StatusLabels).map((option) => ({
value: String(option.value),
label: option.label,
})),
]
type TFunction = (key: string, values?: Record<string, string | number>) => string
function statusLabel(status: number, t: TFunction) {
if (status === Status.Ok) {
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 = {
item: SkillDefinition
@@ -73,6 +89,7 @@ type SkillRowProps = {
handleToggleStatus: (item: SkillDefinition) => void
handleDelete: (item: SkillDefinition) => void
handleRestore: (item: SkillDefinition) => void
t: TFunction
}
function SkillRow({
@@ -83,6 +100,7 @@ function SkillRow({
handleToggleStatus,
handleDelete,
handleRestore,
t,
}: SkillRowProps) {
const isDeleted = item.status === Status.Deleted
const statusBadgeVariant = isDeleted
@@ -102,12 +120,12 @@ function SkillRow({
<div className="flex flex-wrap items-center gap-2">
<div className="font-medium">{item.name}</div>
<Badge variant="outline">{item.code}</Badge>
<Badge variant="secondary"> {item.toolWhitelist.length}</Badge>
<Badge variant="secondary"> {item.examples.length}</Badge>
<Badge variant="secondary">{t("skillDefinition.whitelistCount", { count: item.toolWhitelist.length })}</Badge>
<Badge variant="secondary">{t("skillDefinition.exampleCount", { count: item.examples.length })}</Badge>
</div>
<div className="mt-2 space-y-2">
<div className="line-clamp-2 text-sm leading-6 text-muted-foreground">
{item.description || "暂无描述"}
{item.description || t("skillDefinition.noDescription")}
</div>
</div>
{item.toolWhitelist.length > 0 ? (
@@ -131,10 +149,10 @@ function SkillRow({
checked={item.status === Status.Ok}
disabled={actionLoadingId === item.id || isDeleted}
onCheckedChange={() => void handleToggleStatus(item)}
aria-label={`${item.name} 状态切换`}
aria-label={t("skillDefinition.toggleStatus", { name: item.name })}
/>
<Badge variant={statusBadgeVariant}>
{getEnumLabel(StatusLabels, item.status as keyof typeof StatusLabels)}
{statusLabel(item.status, t)}
</Badge>
</div>
</TableCell>
@@ -150,15 +168,15 @@ function SkillRow({
<ButtonGroup className="ml-auto">
<Button variant="outline" size="sm" onClick={() => openDebugDialog(item)}>
<BugIcon />
{t("skillDefinition.debug")}
</Button>
<Button variant="outline" size="sm" onClick={() => openEditDialog(item)}>
{t("skillDefinition.edit")}
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={<Button variant="outline" size="icon-sm" />}
aria-label={`更多操作 ${item.name}`}
aria-label={t("skillDefinition.moreActions", { name: item.name })}
>
<MoreHorizontalIcon />
</DropdownMenuTrigger>
@@ -169,7 +187,7 @@ function SkillRow({
onClick={() => void handleRestore(item)}
>
<RotateCcwIcon />
{actionLoadingId === item.id ? "恢复中..." : "恢复"}
{actionLoadingId === item.id ? t("skillDefinition.restoring") : t("skillDefinition.restore")}
</DropdownMenuItem>
) : (
<DropdownMenuItem
@@ -178,7 +196,7 @@ function SkillRow({
className="text-destructive focus:text-destructive"
>
<Trash2Icon />
{actionLoadingId === item.id ? "删除中..." : "删除"}
{actionLoadingId === item.id ? t("skillDefinition.deleting") : t("skillDefinition.delete")}
</DropdownMenuItem>
)}
</DropdownMenuContent>
@@ -190,6 +208,7 @@ function SkillRow({
}
export default function DashboardSkillsPage() {
const t = useI18n()
const [nameInput, setNameInput] = useState("")
const [codeInput, setCodeInput] = useState("")
const [statusFilterInput, setStatusFilterInput] = useState("all")
@@ -209,6 +228,7 @@ export default function DashboardSkillsPage() {
results: [],
page: { page: 1, limit: 20, total: 0 },
})
const statusFilterOptions = useMemo(() => getStatusFilterOptions(t), [t])
const loadData = useCallback(async () => {
setLoading(true)
@@ -222,11 +242,11 @@ export default function DashboardSkillsPage() {
})
setResult(data)
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载 Skills 失败")
toast.error(error instanceof Error ? error.message : t("skillDefinition.loadFailed"))
} finally {
setLoading(false)
}
}, [name, code, statusFilter, page, limit])
}, [name, code, statusFilter, page, limit, t])
useEffect(() => {
void loadData()
@@ -298,16 +318,16 @@ export default function DashboardSkillsPage() {
id: editingItem.id,
...payload,
})
toast.success(`已更新 Skill${editingItem.name}`)
toast.success(t("skillDefinition.updated", { name: editingItem.name }))
} else {
await createSkillDefinition(payload)
toast.success(`已创建 Skill${payload.name}`)
toast.success(t("skillDefinition.created", { name: payload.name }))
}
setDialogOpen(false)
setEditingItem(null)
await loadData()
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存 Skill 失败")
toast.error(error instanceof Error ? error.message : t("skillDefinition.saveFailed"))
} finally {
setSaving(false)
}
@@ -323,10 +343,10 @@ export default function DashboardSkillsPage() {
setActionLoadingId(item.id)
try {
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()
} catch (error) {
toast.error(error instanceof Error ? error.message : "更新状态失败")
toast.error(error instanceof Error ? error.message : t("skillDefinition.statusUpdateFailed"))
} finally {
setActionLoadingId(null)
}
@@ -340,10 +360,10 @@ export default function DashboardSkillsPage() {
setActionLoadingId(item.id)
try {
await deleteSkillDefinition(item.id)
toast.success(`已删除 Skill${item.name}`)
toast.success(t("skillDefinition.deleted", { name: item.name }))
await loadData()
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除 Skill 失败")
toast.error(error instanceof Error ? error.message : t("skillDefinition.deleteFailed"))
} finally {
setActionLoadingId(null)
}
@@ -357,10 +377,10 @@ export default function DashboardSkillsPage() {
setActionLoadingId(item.id)
try {
await restoreSkillDefinition(item.id)
toast.success(`已恢复 Skill${item.name}`)
toast.success(t("skillDefinition.restored", { name: item.name }))
await loadData()
} catch (error) {
toast.error(error instanceof Error ? error.message : "恢复 Skill 失败")
toast.error(error instanceof Error ? error.message : t("skillDefinition.restoreFailed"))
} finally {
setActionLoadingId(null)
}
@@ -374,11 +394,11 @@ export default function DashboardSkillsPage() {
<>
<Button variant="outline" onClick={() => void loadData()} disabled={loading}>
<RefreshCwIcon className={loading ? "animate-spin" : ""} />
{t("skillDefinition.refresh")}
</Button>
<Button onClick={openCreateDialog}>
<PlusIcon />
{t("skillDefinition.new")}
</Button>
</>
}
@@ -389,7 +409,7 @@ export default function DashboardSkillsPage() {
value={nameInput}
onChange={(event) => setNameInput(event.target.value)}
onKeyDown={handleFilterKeyDown}
placeholder="按名称筛选"
placeholder={t("skillDefinition.filterName")}
className="pl-9"
/>
</div>
@@ -397,22 +417,22 @@ export default function DashboardSkillsPage() {
value={codeInput}
onChange={(event) => setCodeInput(event.target.value)}
onKeyDown={handleFilterKeyDown}
placeholder="按编码筛选"
placeholder={t("skillDefinition.filterCode")}
className="w-full sm:w-56"
/>
<div className="w-full sm:w-36">
<OptionCombobox
value={statusFilterInput}
options={statusFilterOptions}
placeholder="全部状态"
searchPlaceholder="搜索状态"
emptyText="未找到状态"
placeholder={t("skillDefinition.allStatus")}
searchPlaceholder={t("skillDefinition.searchStatus")}
emptyText={t("skillDefinition.emptyStatus")}
onChange={setStatusFilterInput}
/>
</div>
<Button variant="outline" onClick={applyFilters} disabled={loading}>
<SearchIcon />
{t("skillDefinition.query")}
</Button>
</DashboardToolbar>
@@ -435,9 +455,9 @@ export default function DashboardSkillsPage() {
<TableHeader className="bg-muted/40">
<TableRow>
<TableHead>Skill</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-[168px] text-right"></TableHead>
<TableHead>{t("skillDefinition.status")}</TableHead>
<TableHead>{t("skillDefinition.updatedAt")}</TableHead>
<TableHead className="w-[168px] text-right">{t("skillDefinition.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -445,8 +465,8 @@ export default function DashboardSkillsPage() {
<DashboardTableStateRow
colSpan={4}
loading={loading}
loadingText="正在加载 Skill..."
emptyText="没有匹配的 Skill"
loadingText={t("skillDefinition.loadingRows")}
emptyText={t("skillDefinition.emptyRows")}
/>
) : null}
{result.results.map((item) => (
@@ -459,6 +479,7 @@ export default function DashboardSkillsPage() {
handleToggleStatus={handleToggleStatus}
handleDelete={handleDelete}
handleRestore={handleRestore}
t={t}
/>
))}
</TableBody>
+52 -40
View File
@@ -1,10 +1,11 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { Controller, Resolver, useForm } from "react-hook-form";
import { z } from "zod/v4";
import { OptionCombobox } from "@/components/option-combobox";
import { ProjectDialog } from "@/components/project-dialog";
import { Button } from "@/components/ui/button";
import {
@@ -22,6 +23,7 @@ import {
type Tag,
type TagTree,
} from "@/lib/api/admin";
import { useI18n } from "@/i18n/provider";
type TagFormDialogProps = {
open: boolean;
@@ -37,18 +39,11 @@ const emptyForm: EditForm = {
remark: "",
};
const tagFormSchema = z.object({
parentId: z.string(),
name: z.string().trim().min(1, "标签名称不能为空"),
remark: z.string(),
});
type EditForm = z.infer<typeof tagFormSchema>;
const editFormResolver = zodResolver(tagFormSchema as never) as Resolver<
z.input<typeof tagFormSchema>,
undefined,
z.output<typeof tagFormSchema>
>;
type EditForm = {
parentId: string;
name: string;
remark: string;
};
function buildForm(item: Tag | null): EditForm {
if (!item) {
@@ -142,16 +137,38 @@ function TagFormDialogBody({
onOpenChange,
onSubmit,
}: TagFormDialogBodyProps) {
const t = useI18n();
const formId = "tag-edit-form";
const [loading, setLoading] = useState(false);
const [parentTags, setParentTags] = useState<
{ id: number; name: string; depth: number }[]
>([]);
const form = useForm<
z.input<typeof tagFormSchema>,
undefined,
z.output<typeof tagFormSchema>
>({
const tagFormSchema = useMemo(
() =>
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,
defaultValues: emptyForm,
});
@@ -205,7 +222,7 @@ function TagFormDialogBody({
<ProjectDialog
open={open}
onOpenChange={onOpenChange}
title={itemId ? "编辑标签" : "新建标签"}
title={itemId ? t("tag.editTitle") : t("tag.createTitle")}
size="md"
allowFullscreen
footer={
@@ -216,17 +233,17 @@ function TagFormDialogBody({
onClick={() => onOpenChange(false)}
disabled={saving}
>
{t("tag.cancel")}
</Button>
<Button type="submit" form={formId} disabled={saving || loading}>
{saving ? "保存中..." : itemId ? "保存" : "创建"}
{saving ? t("tag.saving") : itemId ? t("tag.save") : t("tag.create")}
</Button>
</>
}
>
{loading ? (
<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>
) : (
<form
@@ -235,37 +252,32 @@ function TagFormDialogBody({
className="space-y-4"
>
<Field data-invalid={!!errors.parentId}>
<FieldLabel htmlFor="tag-parent-id"></FieldLabel>
<FieldLabel htmlFor="tag-parent-id">{t("tag.parent")}</FieldLabel>
<FieldContent>
<Controller
control={control}
name="parentId"
render={({ field }) => (
<select
id="tag-parent-id"
<OptionCombobox
value={field.value}
onChange={(e) => field.onChange(e.target.value)}
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"
>
<option value="0"></option>
{parentTags.map((tag) => (
<option key={tag.id} value={String(tag.id)}>
{" ".repeat(tag.depth)}
{tag.name}
</option>
))}
</select>
options={parentOptions}
placeholder={t("tag.rootParent")}
searchPlaceholder={t("tag.searchParent")}
emptyText={t("tag.emptyParent")}
disabled={saving}
onChange={field.onChange}
/>
)}
/>
<FieldError errors={[errors.parentId]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="tag-name"></FieldLabel>
<FieldLabel htmlFor="tag-name">{t("tag.name")}</FieldLabel>
<FieldContent>
<Input
id="tag-name"
placeholder="请输入标签名称"
placeholder={t("tag.namePlaceholder")}
aria-invalid={!!errors.name}
{...register("name")}
/>
@@ -273,11 +285,11 @@ function TagFormDialogBody({
</FieldContent>
</Field>
<Field data-invalid={!!errors.remark}>
<FieldLabel htmlFor="tag-remark"></FieldLabel>
<FieldLabel htmlFor="tag-remark">{t("tag.remark")}</FieldLabel>
<FieldContent>
<Textarea
id="tag-remark"
placeholder="请输入备注(可选)"
placeholder={t("tag.remarkPlaceholder")}
rows={3}
aria-invalid={!!errors.remark}
{...register("remark")}
+53 -48
View File
@@ -50,6 +50,7 @@ import {
DashboardTableStateRow,
DashboardToolbar,
} from "@/components/dashboard-page"
import { OptionCombobox } from "@/components/option-combobox"
import { EditDialog } from "./_components/edit"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
@@ -71,6 +72,7 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table"
import { useI18n } from "@/i18n/provider"
type TagNode = TagTree & {
children: TagNode[]
@@ -151,12 +153,6 @@ function filterTree(nodes: TagNode[], keyword: string, status?: number): TagNode
return result
}
const listStatusOptions = [
{ value: "all", label: "全部状态" },
{ value: "0", label: "启用" },
{ value: "1", label: "禁用" },
] as const
type SortableRowProps = {
item: TagNode & { hasChildren: boolean }
disabled: boolean
@@ -178,6 +174,7 @@ function SortableRow({
onDelete,
actionLoadingId,
}: SortableRowProps) {
const t = useI18n()
const {
attributes,
listeners,
@@ -211,7 +208,7 @@ function SortableRow({
size="icon"
className="size-8 cursor-grab active:cursor-grabbing"
disabled={disabled}
aria-label={`拖拽排序 ${item.name}`}
aria-label={t("tag.dragSort", { name: item.name })}
{...attributes}
{...listeners}
>
@@ -250,10 +247,10 @@ function SortableRow({
checked={item.status === 0}
disabled={actionLoadingId === item.id}
onCheckedChange={() => void onToggleStatus(item)}
aria-label={`${item.name} 状态切换`}
aria-label={t("tag.toggleStatus", { name: item.name })}
/>
<Badge variant={item.status === 0 ? "default" : "outline"}>
{item.status === 0 ? "启用" : "禁用"}
{item.status === 0 ? t("status.ok") : t("status.disabled")}
</Badge>
</div>
</TableCell>
@@ -272,12 +269,12 @@ function SortableRow({
size="sm"
onClick={() => onEdit(item)}
>
{t("tag.edit")}
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={<Button variant="outline" size="icon-sm" />}
aria-label={`更多操作 ${item.name}`}
aria-label={t("tag.moreActions", { name: item.name })}
>
<MoreHorizontalIcon />
</DropdownMenuTrigger>
@@ -287,7 +284,7 @@ function SortableRow({
className="text-destructive focus:text-destructive"
>
<Trash2Icon />
{actionLoadingId === item.id ? "删除中..." : "删除"}
{actionLoadingId === item.id ? t("tag.deleting") : t("tag.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -298,6 +295,7 @@ function SortableRow({
}
export default function DashboardTagsPage() {
const t = useI18n()
const [keywordInput, setKeywordInput] = useState("")
const [statusFilterInput, setStatusFilterInput] = useState("all")
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 () => {
setLoading(true)
try {
@@ -336,11 +343,11 @@ export default function DashboardTagsPage() {
setAllTags(Array.isArray(listData.results) ? listData.results : [])
setExpandedIds(collectParentIds(nextTree))
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载标签失败")
toast.error(error instanceof Error ? error.message : t("tag.loadFailed"))
} finally {
setLoading(false)
}
}, [])
}, [t])
useEffect(() => {
void loadData()
@@ -415,16 +422,16 @@ export default function DashboardTagsPage() {
id: editingItem.id,
...payload,
})
toast.success(`已更新标签:${editingItem.name}`)
toast.success(t("tag.updated", { name: editingItem.name }))
} else {
await createTag(payload)
toast.success(`已创建标签:${payload.name}`)
toast.success(t("tag.created", { name: payload.name }))
}
setDialogOpen(false)
setEditingItem(null)
await loadData()
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存标签失败")
toast.error(error instanceof Error ? error.message : t("tag.saveFailed"))
} finally {
setSaving(false)
}
@@ -435,10 +442,10 @@ export default function DashboardTagsPage() {
try {
const nextStatus = item.status === 0 ? 1 : 0
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()
} catch (error) {
toast.error(error instanceof Error ? error.message : "更新状态失败")
toast.error(error instanceof Error ? error.message : t("tag.statusUpdateFailed"))
} finally {
setActionLoadingId(null)
}
@@ -448,10 +455,10 @@ export default function DashboardTagsPage() {
setActionLoadingId(item.id)
try {
await deleteTag(item.id)
toast.success(`已删除标签:${item.name}`)
toast.success(t("tag.deleted", { name: item.name }))
await loadData()
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除标签失败")
toast.error(error instanceof Error ? error.message : t("tag.deleteFailed"))
} finally {
setActionLoadingId(null)
}
@@ -498,7 +505,7 @@ export default function DashboardTagsPage() {
}
if (activeItem.parentId !== overItem.parentId) {
toast.error("只能在同一父标签下拖拽排序")
toast.error(t("tag.sameParentOnly"))
return
}
@@ -521,15 +528,15 @@ export default function DashboardTagsPage() {
const movedIndex = siblingIds.indexOf(movedId)
const targetIndex = siblingIds.indexOf(targetId)
if (movedIndex < 0 || targetIndex < 0) {
throw new Error("找不到标签")
throw new Error(t("tag.notFound"))
}
const newSiblingIds = arrayMove(siblingIds, movedIndex, targetIndex)
await updateTagSort(newSiblingIds)
toast.success("标签排序已更新")
toast.success(t("tag.sortUpdated"))
await loadData()
} catch (error) {
setFlatList(previousList)
toast.error(error instanceof Error ? error.message : "更新标签排序失败")
toast.error(error instanceof Error ? error.message : t("tag.sortUpdateFailed"))
} finally {
setSorting(false)
}
@@ -543,17 +550,17 @@ export default function DashboardTagsPage() {
<>
<Button variant="outline" onClick={() => void loadData()} disabled={loading}>
<RefreshCwIcon className={loading ? "animate-spin" : ""} />
{t("tag.refresh")}
</Button>
<Button variant="outline" onClick={expandAll} disabled={loading}>
{t("tag.expandAll")}
</Button>
<Button variant="outline" onClick={collapseAll} disabled={loading}>
{t("tag.collapseAll")}
</Button>
<Button onClick={openCreateDialog}>
<PlusIcon />
{t("tag.new")}
</Button>
</>
}
@@ -564,24 +571,22 @@ export default function DashboardTagsPage() {
value={keywordInput}
onChange={(event) => setKeywordInput(event.target.value)}
onKeyDown={handleFilterKeyDown}
placeholder="按名称筛选"
placeholder={t("tag.filterName")}
className="pl-9"
/>
</div>
<select
<OptionCombobox
value={statusFilterInput}
onChange={(e) => handleStatusFilterChange(e.target.value)}
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"
>
{listStatusOptions.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
options={listStatusOptions}
placeholder={t("status.all")}
searchPlaceholder={t("tag.searchStatus")}
emptyText={t("tag.emptyStatus")}
disabled={loading}
onChange={handleStatusFilterChange}
/>
<Button variant="outline" onClick={applyFilters} disabled={loading}>
<SearchIcon />
{t("tag.query")}
</Button>
</DashboardToolbar>
<DashboardTableShell>
@@ -594,11 +599,11 @@ export default function DashboardTagsPage() {
<TableHeader className="bg-muted/40">
<TableRow>
<TableHead className="w-14" />
<TableHead className="min-w-[260px]"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-[92px] text-right"></TableHead>
<TableHead className="min-w-[260px]">{t("tag.columnName")}</TableHead>
<TableHead>{t("tag.columnStatus")}</TableHead>
<TableHead>{t("tag.columnRemark")}</TableHead>
<TableHead>{t("tag.columnCreatedAt")}</TableHead>
<TableHead className="w-[92px] text-right">{t("tag.columnActions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -624,8 +629,8 @@ export default function DashboardTagsPage() {
<DashboardTableStateRow
colSpan={6}
loading={loading}
loadingText="正在加载标签..."
emptyText="没有匹配的标签"
loadingText={t("tag.loading")}
emptyText={t("tag.empty")}
/>
) : null}
</TableBody>
@@ -2,6 +2,7 @@
import { toast } from "sonner"
import { useI18n } from "@/i18n/provider"
import { createTicketFromConversation } from "@/lib/api/ticket"
import { EditDialog } from "./edit"
@@ -26,6 +27,7 @@ export function CreateTicketFromConversationDialog({
onOpenChange,
onSuccess,
}: CreateTicketFromConversationDialogProps) {
const t = useI18n()
const initialValues = conversation
? {
title: conversation.customerName || "",
@@ -43,11 +45,11 @@ export function CreateTicketFromConversationDialog({
fixedConversationId={conversation?.id}
fixedCustomerId={conversation?.customerId}
initialValues={initialValues}
titleOverride="会话转工单"
descriptionOverride="从当前会话上下文创建正式工单"
titleOverride={t("ticket.conversationToTicket")}
descriptionOverride={t("ticket.conversationToTicketDescription")}
onSubmit={async (payload) => {
if (!conversation?.id) {
throw new Error("会话不存在")
throw new Error(t("ticket.conversationMissing"))
}
await createTicketFromConversation({
conversationId: conversation.id,
@@ -56,7 +58,7 @@ export function CreateTicketFromConversationDialog({
currentAssigneeId: payload.currentAssigneeId,
tagIds: payload.tagIds,
})
toast.success("工单创建成功")
toast.success(t("ticket.createSuccess"))
onSuccess?.()
}}
/>
+43 -35
View File
@@ -42,6 +42,9 @@ import {
type TicketItem,
type UpdateTicketPayload,
} from "@/lib/api/ticket"
import { useI18n } from "@/i18n/provider"
type TFunction = (key: string, values?: Record<string, string | number>) => string
type EditDialogProps = {
open: boolean
@@ -56,20 +59,21 @@ type EditDialogProps = {
onSubmit: (payload: CreateTicketPayload | UpdateTicketPayload) => Promise<void>
}
const schema = z.object({
title: z.string().trim().min(1, "请输入工单标题"),
description: z.string().refine((value) => !isRichTextEmpty(value), "请输入问题描述"),
function createSchema(t: TFunction) {
return z.object({
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(),
tagIds: z.array(z.number().int().positive()).default([]),
})
})
}
type EditForm = z.infer<typeof schema>
const editFormResolver = zodResolver(schema as never) as Resolver<
z.input<typeof schema>,
undefined,
z.output<typeof schema>
>
type EditForm = {
title: string
description: string
currentAssigneeId?: number
tagIds: number[]
}
const emptyForm: EditForm = {
title: "",
@@ -130,9 +134,10 @@ type TicketTagSelectorProps = {
value?: number[]
onChange: (value: number[]) => void
availableTags: TagTree[]
t: TFunction
}
function TicketTagSelector({ value, onChange, availableTags }: TicketTagSelectorProps) {
function TicketTagSelector({ value, onChange, availableTags, t }: TicketTagSelectorProps) {
const selectedValues = useMemo(() => value ?? [], [value])
const flatTags = useMemo(() => flattenTagTree(availableTags), [availableTags])
const selectedTagIDs = useMemo(() => new Set(selectedValues), [selectedValues])
@@ -158,14 +163,14 @@ function TicketTagSelector({ value, onChange, availableTags }: TicketTagSelector
}
>
<TagIcon className="size-4" />
{selectedTags.length > 0 ? `已选择 ${selectedTags.length} 个标签` : "请选择工单标签"}
{selectedTags.length > 0 ? t("ticket.selectedTags", { count: selectedTags.length }) : t("ticket.selectTags")}
</PopoverTrigger>
<PopoverContent align="start" className="w-[320px] p-0">
<Command>
<CommandInput placeholder="搜索标签" />
<CommandInput placeholder={t("ticket.searchTags")} />
<CommandList>
<CommandEmpty></CommandEmpty>
<CommandGroup heading="标签">
<CommandEmpty>{t("ticket.emptyTags")}</CommandEmpty>
<CommandGroup heading={t("ticket.tags")}>
{flatTags.map((tag) => {
const checked = selectedTagIDs.has(tag.id)
return (
@@ -245,15 +250,17 @@ function TicketEditDialogBody({
onOpenChange,
onSubmit,
}: TicketEditDialogBodyProps) {
const t = useI18n()
const formId = "ticket-edit-form"
const [loading, setLoading] = useState(false)
const [tags, setTags] = useState<TagTree[]>([])
const [agents, setAgents] = useState<AdminAgentProfile[]>([])
const form = useForm<
z.input<typeof schema>,
undefined,
z.output<typeof schema>
>({
const schema = useMemo(() => createSchema(t), [t])
const editFormResolver = useMemo(
() => zodResolver(schema) as Resolver<EditForm>,
[schema],
)
const form = useForm<EditForm>({
resolver: editFormResolver,
defaultValues: emptyForm,
})
@@ -296,14 +303,14 @@ function TicketEditDialogBody({
})()
}, [open])
const agentOptions = [{ value: "0", label: "不指定处理人" }].concat(
const agentOptions = [{ value: "0", label: t("ticket.noAssignee") }].concat(
agents.map((agent) => ({
value: String(agent.userId),
label:
agent.displayName ||
agent.nickname ||
agent.username ||
`客服#${agent.userId}`,
t("ticket.agentFallback", { id: agent.userId }),
})),
)
@@ -328,8 +335,8 @@ function TicketEditDialogBody({
<ProjectDialog
open={open}
onOpenChange={onOpenChange}
title={titleOverride || (itemId ? "编辑工单" : "新建工单")}
description={descriptionOverride || "填写工单基础信息"}
title={titleOverride || (itemId ? t("ticket.editTitle") : t("ticket.createTitle"))}
description={descriptionOverride || t("ticket.dialogDescription")}
size="lg"
allowFullscreen
footer={
@@ -340,27 +347,27 @@ function TicketEditDialogBody({
onClick={() => onOpenChange(false)}
disabled={saving}
>
{t("ticket.cancel")}
</Button>
<Button type="submit" form={formId} disabled={saving || loading}>
{saving ? "保存中..." : itemId ? "保存" : "创建"}
{saving ? t("ticket.saving") : itemId ? t("ticket.save") : t("ticket.create")}
</Button>
</>
}
>
{loading ? (
<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>
) : (
<form id={formId} onSubmit={handleSubmit(onFormSubmit)} className="space-y-4">
<FieldGroup>
<Field data-invalid={!!errors.title}>
<FieldLabel htmlFor="ticket-title"></FieldLabel>
<FieldLabel htmlFor="ticket-title">{t("ticket.title")}</FieldLabel>
<FieldContent>
<Input
id="ticket-title"
placeholder="请输入工单标题"
placeholder={t("ticket.titlePlaceholder")}
aria-invalid={!!errors.title}
{...register("title")}
/>
@@ -369,7 +376,7 @@ function TicketEditDialogBody({
</Field>
<Field data-invalid={!!errors.description}>
<FieldLabel></FieldLabel>
<FieldLabel>{t("ticket.description")}</FieldLabel>
<FieldContent>
<Controller
control={control}
@@ -378,7 +385,7 @@ function TicketEditDialogBody({
<ContentEditor
value={{ mode: "html", raw: field.value ?? "" }}
onChange={(next) => field.onChange(next.raw)}
placeholder="请输入问题描述"
placeholder={t("ticket.descriptionRequired")}
disabled={saving || loading}
allowedModes={["html"]}
height={260}
@@ -390,7 +397,7 @@ function TicketEditDialogBody({
</Field>
<Field>
<FieldLabel></FieldLabel>
<FieldLabel>{t("ticket.assignee")}</FieldLabel>
<FieldContent>
<Controller
control={control}
@@ -399,7 +406,7 @@ function TicketEditDialogBody({
<OptionCombobox
value={String(field.value ?? 0)}
onChange={(value) => field.onChange(Number(value))}
placeholder="请选择处理人"
placeholder={t("ticket.selectAssignee")}
options={agentOptions}
/>
)}
@@ -408,7 +415,7 @@ function TicketEditDialogBody({
</Field>
<Field>
<FieldLabel></FieldLabel>
<FieldLabel>{t("ticket.ticketTags")}</FieldLabel>
<FieldContent>
<Controller
control={control}
@@ -418,6 +425,7 @@ function TicketEditDialogBody({
value={field.value}
onChange={field.onChange}
availableTags={tags}
t={t}
/>
)}
/>
@@ -1,8 +1,8 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { useEffect, useMemo, useRef, useState } from "react"
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 { z } from "zod/v4"
@@ -21,20 +21,22 @@ import {
fetchAgentProfilesAll,
type AdminAgentProfile,
} from "@/lib/api/admin"
import { useI18n } from "@/i18n/provider"
import { assignTicket } from "@/lib/api/ticket"
const schema = z.object({
toUserId: z.string().trim().min(1, "请选择处理人"),
type TFunction = (key: string, values?: Record<string, string | number>) => string
function createSchema(t: TFunction) {
return z.object({
toUserId: z.string().trim().min(1, t("ticket.assigneeRequired")),
reason: z.string().trim(),
})
})
}
type FormValues = z.infer<typeof schema>
const resolver = zodResolver(schema as never) as Resolver<
z.input<typeof schema>,
undefined,
z.output<typeof schema>
>
type FormValues = {
toUserId: string
reason: string
}
const emptyForm: FormValues = {
toUserId: "",
@@ -77,16 +79,18 @@ function TicketAssignDialogBody({
onOpenChange,
onSuccess,
}: Omit<TicketAssignDialogProps, "open">) {
const t = useI18n()
const [saving, setSaving] = useState(false)
const [loading, setLoading] = useState(false)
const [agents, setAgents] = useState<AdminAgentProfile[]>([])
const activeRef = useRef(false)
const form = useForm<
z.input<typeof schema>,
undefined,
z.output<typeof schema>
>({
const schema = useMemo(() => createSchema(t), [t])
const resolver = useMemo(
() => zodResolver(schema) as Resolver<FormValues>,
[schema],
)
const form = useForm<FormValues>({
resolver,
defaultValues: emptyForm,
})
@@ -126,18 +130,18 @@ function TicketAssignDialogBody({
if (!activeRef.current) {
return
}
toast.error(error instanceof Error ? error.message : "加载处理人失败")
toast.error(error instanceof Error ? error.message : t("ticket.loadAssigneesFailed"))
})
.finally(() => {
if (activeRef.current) {
setLoading(false)
}
})
}, [])
}, [t])
async function onFormSubmit(values: FormValues) {
if (!ticketId) {
toast.error("请选择工单")
toast.error(t("ticket.selectTicket"))
return
}
setSaving(true)
@@ -150,7 +154,7 @@ function TicketAssignDialogBody({
if (!activeRef.current) {
return
}
toast.success("处理人已更新")
toast.success(t("ticket.assigneeUpdated"))
if (!activeRef.current) {
return
}
@@ -160,7 +164,7 @@ function TicketAssignDialogBody({
if (!activeRef.current) {
return
}
toast.error(error instanceof Error ? error.message : "指派工单失败")
toast.error(error instanceof Error ? error.message : t("ticket.assignFailed"))
} finally {
if (activeRef.current) {
setSaving(false)
@@ -171,12 +175,12 @@ function TicketAssignDialogBody({
return (
<DialogContent className="max-w-lg gap-0 p-0 sm:max-w-lg">
<DialogHeader className="px-6 pt-6">
<DialogTitle></DialogTitle>
<DialogTitle>{t("ticket.assignTitle")}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(onFormSubmit)}>
<div className="space-y-4 p-6">
<Field data-invalid={!!errors.toUserId}>
<FieldLabel></FieldLabel>
<FieldLabel>{t("ticket.assignee")}</FieldLabel>
<FieldContent>
<Controller
control={control}
@@ -185,14 +189,14 @@ function TicketAssignDialogBody({
<OptionCombobox
value={field.value}
onChange={field.onChange}
placeholder={loading ? "加载中..." : "选择处理人"}
placeholder={loading ? t("ticket.loading") : t("ticket.selectHandler")}
options={agents.map((agent) => ({
value: String(agent.userId),
label:
agent.displayName ||
agent.nickname ||
agent.username ||
`客服#${agent.userId}`,
t("ticket.agentFallback", { id: agent.userId }),
}))}
/>
)}
@@ -201,19 +205,19 @@ function TicketAssignDialogBody({
</FieldContent>
</Field>
<Field data-invalid={!!errors.reason}>
<FieldLabel></FieldLabel>
<FieldLabel>{t("ticket.assignReason")}</FieldLabel>
<FieldContent>
<Textarea rows={4} placeholder="填写指派说明" {...register("reason")} />
<Textarea rows={4} placeholder={t("ticket.assignReasonPlaceholder")} {...register("reason")} />
<FieldError errors={[errors.reason]} />
</FieldContent>
</Field>
</div>
<DialogFooter className="mx-0 mb-0 px-6 py-4">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
{t("ticket.cancel")}
</Button>
<Button type="submit" disabled={saving}>
{saving ? "提交中..." : "确认指派"}
{saving ? t("ticket.submitting") : t("ticket.confirmAssign")}
</Button>
</DialogFooter>
</form>
@@ -31,6 +31,7 @@ import {
type UpdateTicketPayload,
updateTicket,
} from "@/lib/api/ticket"
import { useI18n } from "@/i18n/provider"
import { cn, formatDateTime } from "@/lib/utils"
import { EditDialog } from "./edit"
import { TicketAssignDialog } from "./ticket-assign-dialog"
@@ -43,18 +44,22 @@ type TicketDetailDialogProps = {
onChanged: () => void
}
const statusOptions: Array<{ value: TicketStatus; label: string }> = [
{ value: "pending", label: "待处理" },
{ value: "in_progress", label: "处理中" },
{ value: "done", label: "已处理" },
]
type TFunction = (key: string, values?: Record<string, string | number>) => string
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) {
case "manual":
return "手动创建"
return t("ticket.manualCreated")
case "conversation":
return "会话生成"
return t("ticket.conversationGenerated")
default:
return source || "-"
}
@@ -77,6 +82,7 @@ export function TicketDetailDialog({
onOpenChange,
onChanged,
}: TicketDetailDialogProps) {
const t = useI18n()
const [detail, setDetail] = useState<TicketDetail | null>(null)
const [loading, setLoading] = useState(false)
const [statusSaving, setStatusSaving] = useState<TicketStatus | null>(null)
@@ -120,13 +126,13 @@ export function TicketDetailDialog({
if (loadSeqRef.current !== seq || !isCurrentOperation(targetTicketId, dialogSeq)) {
return
}
toast.error(error instanceof Error ? error.message : "加载工单详情失败")
toast.error(error instanceof Error ? error.message : t("ticket.loadDetailFailed"))
} finally {
if (loadSeqRef.current === seq) {
setLoading(false)
}
}
}, [open, ticketId])
}, [open, t, ticketId])
useEffect(() => {
dialogSeqRef.current += 1
@@ -160,7 +166,7 @@ export function TicketDetailDialog({
if (!isCurrentOperation(activeTicketId, activeDialogSeq)) {
return
}
toast.success("工单状态已更新")
toast.success(t("ticket.statusUpdated"))
await loadDetail(activeTicketId, activeDialogSeq)
if (!isCurrentOperation(activeTicketId, activeDialogSeq)) {
return
@@ -170,7 +176,7 @@ export function TicketDetailDialog({
if (!isCurrentOperation(activeTicketId, activeDialogSeq)) {
return
}
toast.error(error instanceof Error ? error.message : "更新工单状态失败")
toast.error(error instanceof Error ? error.message : t("ticket.statusUpdateFailed"))
} finally {
if (isCurrentOperation(activeTicketId, activeDialogSeq)) {
setStatusSaving(null)
@@ -186,7 +192,7 @@ export function TicketDetailDialog({
const activeDialogSeq = dialogSeqRef.current
const content = progressContent.trim()
if (isRichTextEmpty(content)) {
toast.error("请填写处理进展")
toast.error(t("ticket.progressRequired"))
return
}
setProgressSaving(true)
@@ -198,7 +204,7 @@ export function TicketDetailDialog({
if (!isCurrentOperation(activeTicketId, activeDialogSeq)) {
return
}
toast.success("处理进展已记录")
toast.success(t("ticket.progressRecorded"))
setProgressContent("")
setProgressOpen(false)
await loadDetail(activeTicketId, activeDialogSeq)
@@ -210,7 +216,7 @@ export function TicketDetailDialog({
if (!isCurrentOperation(activeTicketId, activeDialogSeq)) {
return
}
toast.error(error instanceof Error ? error.message : "记录处理进展失败")
toast.error(error instanceof Error ? error.message : t("ticket.progressCreateFailed"))
} finally {
if (isCurrentOperation(activeTicketId, activeDialogSeq)) {
setProgressSaving(false)
@@ -233,7 +239,7 @@ export function TicketDetailDialog({
async function handleUpdateTicket(payload: CreateTicketPayload | UpdateTicketPayload) {
if (!("ticketId" in payload) || payload.ticketId <= 0) {
toast.error("请选择工单")
toast.error(t("ticket.selectTicket"))
return
}
const activeDialogSeq = dialogSeqRef.current
@@ -243,7 +249,7 @@ export function TicketDetailDialog({
if (!isCurrentOperation(payload.ticketId, activeDialogSeq)) {
return
}
toast.success("工单已更新")
toast.success(t("ticket.updated"))
setEditOpen(false)
await loadDetail(payload.ticketId, activeDialogSeq)
if (!isCurrentOperation(payload.ticketId, activeDialogSeq)) {
@@ -254,7 +260,7 @@ export function TicketDetailDialog({
if (!isCurrentOperation(payload.ticketId, activeDialogSeq)) {
return
}
toast.error(error instanceof Error ? error.message : "更新工单失败")
toast.error(error instanceof Error ? error.message : t("ticket.updateFailed"))
} finally {
if (isCurrentOperation(payload.ticketId, activeDialogSeq)) {
setEditSaving(false)
@@ -265,7 +271,7 @@ export function TicketDetailDialog({
async function handleUpdateCustomer(payload: CustomerFormSavePayload) {
const activeCustomerId = getTicketCustomerId(ticket)
if (!ticket?.id || activeCustomerId <= 0) {
toast.error("当前工单未关联客户")
toast.error(t("ticket.noLinkedCustomer"))
return
}
if (customerEditSaving) {
@@ -279,7 +285,7 @@ export function TicketDetailDialog({
if (!isCurrentOperation(activeTicketId, activeDialogSeq)) {
return
}
toast.success("已保存")
toast.success(t("ticket.saved"))
setCustomerEditOpen(false)
await loadDetail(activeTicketId, activeDialogSeq)
if (!isCurrentOperation(activeTicketId, activeDialogSeq)) {
@@ -290,7 +296,7 @@ export function TicketDetailDialog({
if (!isCurrentOperation(activeTicketId, activeDialogSeq)) {
return
}
toast.error(error instanceof Error ? error.message : "保存失败")
toast.error(error instanceof Error ? error.message : t("ticket.saveFailed"))
} finally {
if (isCurrentOperation(activeTicketId, activeDialogSeq)) {
setCustomerEditSaving(false)
@@ -313,6 +319,7 @@ export function TicketDetailDialog({
const ticket = detail?.ticket
const customerId = getTicketCustomerId(ticket)
const statusOptions = getStatusOptions(t)
return (
<>
@@ -321,7 +328,7 @@ export function TicketDetailDialog({
onOpenChange={onOpenChange}
title={
<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}
</div>
}
@@ -329,8 +336,8 @@ export function TicketDetailDialog({
ticket ? (
<span className="flex flex-wrap items-center gap-2 text-sm">
<span className="font-mono">{ticket.ticketNo}</span>
<span>{sourceLabel(ticket.source)}</span>
<span>{metadataValue(ticket.createdByName || ticket.createdBy)}</span>
<span>{sourceLabel(ticket.source, t)}</span>
<span>{t("ticket.creator", { name: metadataValue(ticket.createdByName || ticket.createdBy) })}</span>
</span>
) : undefined
}
@@ -342,21 +349,21 @@ export function TicketDetailDialog({
{loading && !ticket ? (
<div className="flex h-130 items-center justify-center gap-2 text-sm text-muted-foreground">
<RefreshCcwIcon className="size-4 animate-spin" />
...
{t("ticket.loading")}
</div>
) : ticket ? (
<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">
<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">
<SafeRichHTML html={ticket.description} fallback="暂无描述" />
<SafeRichHTML html={ticket.description} fallback={t("ticket.noDescription")} />
</div>
</section>
<section className="grid gap-4 md:grid-cols-[minmax(0,1fr)_220px]">
<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">
{statusOptions.map((option) => (
<Button
@@ -367,20 +374,20 @@ export function TicketDetailDialog({
disabled={!!statusSaving}
onClick={() => void handleStatusChange(option.value)}
>
{statusSaving === option.value ? "更新中..." : option.label}
{statusSaving === option.value ? t("ticket.updating") : option.label}
</Button>
))}
</div>
</div>
<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 min-w-0 items-center gap-2 text-sm">
<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>
<Button type="button" size="sm" variant="outline" onClick={() => setAssignOpen(true)}>
{t("ticket.assign")}
</Button>
</div>
</div>
@@ -388,9 +395,9 @@ export function TicketDetailDialog({
<section className="space-y-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)}>
{t("ticket.edit")}
</Button>
</div>
{ticket.tags && ticket.tags.length > 0 ? (
@@ -402,13 +409,13 @@ export function TicketDetailDialog({
))}
</div>
) : (
<div className="text-sm text-muted-foreground"></div>
<div className="text-sm text-muted-foreground">{t("ticket.emptyTags")}</div>
)}
</section>
<section className="space-y-3 rounded-md border p-3 text-sm">
<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
type="button"
variant="ghost"
@@ -423,22 +430,22 @@ export function TicketDetailDialog({
}}
>
<PencilIcon className="size-3.5" />
{customerId > 0 ? "编辑" : "关联或创建"}
{customerId > 0 ? t("ticket.edit") : t("ticket.linkOrCreate")}
</Button>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<MetadataItem label="客户" value={ticket.customer?.name || ticket.customerId} />
<MetadataItem label="联系方式" value={ticket.customer?.primaryMobile || ticket.customer?.primaryEmail} />
<MetadataItem label={t("ticket.customer")} value={ticket.customer?.name || ticket.customerId} />
<MetadataItem label={t("ticket.contact")} value={ticket.customer?.primaryMobile || ticket.customer?.primaryEmail} />
</div>
</section>
<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">
<MetadataItem label="来源" value={sourceLabel(ticket.source)} />
<MetadataItem label="渠道" value={ticket.channel} />
<MetadataItem label="会话 ID" value={ticket.conversationId || undefined} />
<MetadataItem label="最后更新" value={ticket.updatedAt ? formatDateTime(ticket.updatedAt) : undefined} />
<MetadataItem label={t("ticket.source")} value={sourceLabel(ticket.source, t)} />
<MetadataItem label={t("ticket.channel")} value={ticket.channel} />
<MetadataItem label={t("ticket.conversationId")} value={ticket.conversationId || undefined} />
<MetadataItem label={t("ticket.columnUpdated")} value={ticket.updatedAt ? formatDateTime(ticket.updatedAt) : undefined} />
</div>
</section>
</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 gap-2 text-sm font-medium">
<MessageSquareTextIcon className="size-4 text-muted-foreground" />
{t("ticket.progress")}
</div>
<Button type="button" size="sm" onClick={() => setProgressOpen(true)}>
<PlusIcon className="size-3.5" />
{t("ticket.addProgress")}
</Button>
</div>
<Separator />
@@ -471,7 +478,7 @@ export function TicketDetailDialog({
</div>
<div className="min-w-0 flex-1 pb-3">
<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>
</div>
<SafeRichHTML html={progress.content} className="mt-1" />
@@ -481,14 +488,14 @@ export function TicketDetailDialog({
</div>
) : (
<div className="rounded-md border border-dashed px-3 py-6 text-center text-sm text-muted-foreground">
{t("ticket.noProgress")}
</div>
)}
</div>
</aside>
</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>
@@ -533,13 +540,13 @@ export function TicketDetailDialog({
>
<DialogContent className="max-w-2xl gap-0 p-0 sm:max-w-3xl">
<DialogHeader className="px-6 pt-6">
<DialogTitle></DialogTitle>
<DialogTitle>{t("ticket.addProgress")}</DialogTitle>
</DialogHeader>
<div className="px-6 py-4">
<ContentEditor
value={{ mode: "html", raw: progressContent }}
onChange={(next) => setProgressContent(next.raw)}
placeholder="记录本次处理进展"
placeholder={t("ticket.progressPlaceholder")}
disabled={progressSaving}
allowedModes={["html"]}
height={260}
@@ -555,11 +562,11 @@ export function TicketDetailDialog({
setProgressContent("")
}}
>
{t("ticket.cancel")}
</Button>
<Button type="button" disabled={progressSaving} onClick={() => void handleCreateProgress()}>
<SendIcon className="size-3.5" />
{progressSaving ? "提交中..." : "提交"}
{progressSaving ? t("ticket.submitting") : t("ticket.submit")}
</Button>
</DialogFooter>
</DialogContent>
@@ -1,24 +1,26 @@
"use client"
import { Badge } from "@/components/ui/badge"
import { useI18n } from "@/i18n/provider"
import type { TicketStatus } from "@/lib/api/ticket"
const statusMap = {
pending: { label: "待处理", className: "border-amber-200 bg-amber-50 text-amber-700" },
in_progress: { label: "处理中", className: "border-blue-200 bg-blue-50 text-blue-700" },
done: { label: "已处理", className: "border-emerald-200 bg-emerald-50 text-emerald-700" },
pending: { labelKey: "ticket.statusPending", className: "border-amber-200 bg-amber-50 text-amber-700" },
in_progress: { labelKey: "ticket.statusInProgress", className: "border-blue-200 bg-blue-50 text-blue-700" },
done: { labelKey: "ticket.statusDone", className: "border-emerald-200 bg-emerald-50 text-emerald-700" },
} as const
export function ticketStatusLabel(status: string) {
return statusMap[status as TicketStatus]?.label ?? status
return status
}
export function TicketStatusBadge({ status }: { status: string }) {
const t = useI18n()
const option = statusMap[status as TicketStatus]
return (
<Badge variant="outline" className={option?.className ?? "border-border bg-muted text-muted-foreground"}>
{option?.label ?? status}
{option ? t(option.labelKey) : status}
</Badge>
)
}

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