refactor: support i18n
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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)))
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 = "路径参数错误"
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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-0;lg 起有 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)}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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、answer;similarQuestions 使用 | 或换行分隔。"
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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?.()
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user