Merge pull request #10 from huabeitech/feature/lightweight-ticket-redesign
Feature/lightweight ticket redesign
This commit is contained in:
@@ -49,13 +49,9 @@ func main() {
|
|||||||
codegen.GetGenerateStruct(&models.ConversationEventLog{}),
|
codegen.GetGenerateStruct(&models.ConversationEventLog{}),
|
||||||
codegen.GetGenerateStruct(&models.Ticket{}),
|
codegen.GetGenerateStruct(&models.Ticket{}),
|
||||||
codegen.GetGenerateStruct(&models.TicketTag{}),
|
codegen.GetGenerateStruct(&models.TicketTag{}),
|
||||||
codegen.GetGenerateStruct(&models.TicketResolutionCode{}),
|
codegen.GetGenerateStruct(&models.TicketProgress{}),
|
||||||
codegen.GetGenerateStruct(&models.TicketPriorityConfig{}),
|
codegen.GetGenerateStruct(&models.TicketView{}),
|
||||||
codegen.GetGenerateStruct(&models.TicketComment{}),
|
codegen.GetGenerateStruct(&models.TicketNoSequence{}),
|
||||||
codegen.GetGenerateStruct(&models.TicketWatcher{}),
|
|
||||||
codegen.GetGenerateStruct(&models.TicketEventLog{}),
|
|
||||||
codegen.GetGenerateStruct(&models.TicketSLARecord{}),
|
|
||||||
codegen.GetGenerateStruct(&models.TicketRelation{}),
|
|
||||||
codegen.GetGenerateStruct(&models.AgentProfile{}),
|
codegen.GetGenerateStruct(&models.AgentProfile{}),
|
||||||
codegen.GetGenerateStruct(&models.AgentTeam{}),
|
codegen.GetGenerateStruct(&models.AgentTeam{}),
|
||||||
codegen.GetGenerateStruct(&models.AgentTeamSchedule{}),
|
codegen.GetGenerateStruct(&models.AgentTeamSchedule{}),
|
||||||
|
|||||||
@@ -27,8 +27,6 @@ type CreateTicketGraphInterruptInfo struct {
|
|||||||
type createTicketGraphArgs struct {
|
type createTicketGraphArgs struct {
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Priority int64 `json:"priority"`
|
|
||||||
Severity int `json:"severity"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -100,8 +98,7 @@ func (g *CreateTicketGraph) Run(ctx context.Context, argumentsInJSON string) (st
|
|||||||
|
|
||||||
func (g *CreateTicketGraph) buildCreateRequest(argumentsInJSON string) (request.CreateTicketFromConversationRequest, error) {
|
func (g *CreateTicketGraph) buildCreateRequest(argumentsInJSON string) (request.CreateTicketFromConversationRequest, error) {
|
||||||
req := request.CreateTicketFromConversationRequest{
|
req := request.CreateTicketFromConversationRequest{
|
||||||
ConversationID: g.conversation.ID,
|
ConversationID: g.conversation.ID,
|
||||||
SyncToConversation: true,
|
|
||||||
}
|
}
|
||||||
var args createTicketGraphArgs
|
var args createTicketGraphArgs
|
||||||
if strings.TrimSpace(argumentsInJSON) != "" {
|
if strings.TrimSpace(argumentsInJSON) != "" {
|
||||||
@@ -111,8 +108,6 @@ func (g *CreateTicketGraph) buildCreateRequest(argumentsInJSON string) (request.
|
|||||||
}
|
}
|
||||||
req.Title = strings.TrimSpace(args.Title)
|
req.Title = strings.TrimSpace(args.Title)
|
||||||
req.Description = strings.TrimSpace(args.Description)
|
req.Description = strings.TrimSpace(args.Description)
|
||||||
req.Priority = args.Priority
|
|
||||||
req.Severity = args.Severity
|
|
||||||
if req.Title == "" {
|
if req.Title == "" {
|
||||||
req.Title = strings.TrimSpace(g.conversation.LastMessageSummary)
|
req.Title = strings.TrimSpace(g.conversation.LastMessageSummary)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,16 +18,12 @@ type PrepareTicketDraftInput struct {
|
|||||||
Impact string `json:"impact"`
|
Impact string `json:"impact"`
|
||||||
ExpectedOutcome string `json:"expectedOutcome"`
|
ExpectedOutcome string `json:"expectedOutcome"`
|
||||||
CurrentAttempt string `json:"currentAttempt"`
|
CurrentAttempt string `json:"currentAttempt"`
|
||||||
Priority int64 `json:"priority"`
|
|
||||||
Severity int `json:"severity"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type PrepareTicketDraftResult struct {
|
type PrepareTicketDraftResult struct {
|
||||||
Ready bool `json:"ready"`
|
Ready bool `json:"ready"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Priority int64 `json:"priority,omitempty"`
|
|
||||||
Severity int `json:"severity,omitempty"`
|
|
||||||
MissingFields []string `json:"missingFields,omitempty"`
|
MissingFields []string `json:"missingFields,omitempty"`
|
||||||
FollowUpQuestions []string `json:"followUpQuestions,omitempty"`
|
FollowUpQuestions []string `json:"followUpQuestions,omitempty"`
|
||||||
ConversationFacts []string `json:"conversationFacts,omitempty"`
|
ConversationFacts []string `json:"conversationFacts,omitempty"`
|
||||||
@@ -74,8 +70,6 @@ func (g *PrepareTicketDraftGraph) parseInput(argumentsInJSON string) (PrepareTic
|
|||||||
|
|
||||||
func buildPrepareTicketDraftResult(conversation models.Conversation, messages []models.Message, input PrepareTicketDraftInput) PrepareTicketDraftResult {
|
func buildPrepareTicketDraftResult(conversation models.Conversation, messages []models.Message, input PrepareTicketDraftInput) PrepareTicketDraftResult {
|
||||||
result := PrepareTicketDraftResult{
|
result := PrepareTicketDraftResult{
|
||||||
Priority: input.Priority,
|
|
||||||
Severity: input.Severity,
|
|
||||||
MissingFields: make([]string, 0, 2),
|
MissingFields: make([]string, 0, 2),
|
||||||
FollowUpQuestions: make([]string, 0, 2),
|
FollowUpQuestions: make([]string, 0, 2),
|
||||||
ConversationFacts: buildConversationFacts(conversation, messages),
|
ConversationFacts: buildConversationFacts(conversation, messages),
|
||||||
|
|||||||
@@ -75,20 +75,6 @@ func (t *CreateTicketGraphTool) Info(ctx context.Context) (*schema.ToolInfo, err
|
|||||||
Description: "工单描述,清晰整理用户问题、现象和诉求。",
|
Description: "工单描述,清晰整理用户问题、现象和诉求。",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
orderedmap.Pair[string, *einojsonschema.Schema]{
|
|
||||||
Key: "priority",
|
|
||||||
Value: &einojsonschema.Schema{
|
|
||||||
Type: "integer",
|
|
||||||
Description: "工单优先级,可选;未知时可不传。",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
orderedmap.Pair[string, *einojsonschema.Schema]{
|
|
||||||
Key: "severity",
|
|
||||||
Value: &einojsonschema.Schema{
|
|
||||||
Type: "integer",
|
|
||||||
Description: "严重度,可选;1=轻微,2=严重,3=致命。",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)),
|
)),
|
||||||
}),
|
}),
|
||||||
Extra: map[string]any{
|
Extra: map[string]any{
|
||||||
|
|||||||
@@ -95,20 +95,6 @@ func (t *PrepareTicketDraftTool) Info(ctx context.Context) (*schema.ToolInfo, er
|
|||||||
Description: "当前已尝试过的处理步骤,可选。",
|
Description: "当前已尝试过的处理步骤,可选。",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
orderedmap.Pair[string, *einojsonschema.Schema]{
|
|
||||||
Key: "priority",
|
|
||||||
Value: &einojsonschema.Schema{
|
|
||||||
Type: "integer",
|
|
||||||
Description: "建议工单优先级,可选。",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
orderedmap.Pair[string, *einojsonschema.Schema]{
|
|
||||||
Key: "severity",
|
|
||||||
Value: &einojsonschema.Schema{
|
|
||||||
Type: "integer",
|
|
||||||
Description: "建议严重度,可选;1=轻微,2=严重,3=致命。",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)),
|
)),
|
||||||
}),
|
}),
|
||||||
Extra: map[string]any{
|
Extra: map[string]any{
|
||||||
|
|||||||
@@ -119,8 +119,6 @@ func addRouter(app *iris.Application) {
|
|||||||
m.Party("/conversation").Handle(new(dashboard.ConversationController))
|
m.Party("/conversation").Handle(new(dashboard.ConversationController))
|
||||||
m.Party("/ticket").Handle(new(dashboard.TicketController))
|
m.Party("/ticket").Handle(new(dashboard.TicketController))
|
||||||
m.Party("/notification").Handle(new(dashboard.NotificationController))
|
m.Party("/notification").Handle(new(dashboard.NotificationController))
|
||||||
m.Party("/ticket-resolution-code").Handle(new(dashboard.TicketResolutionCodeController))
|
|
||||||
m.Party("/ticket-priority-config").Handle(new(dashboard.TicketPriorityConfigController))
|
|
||||||
m.Party("/quick-reply").Handle(new(dashboard.QuickReplyController))
|
m.Party("/quick-reply").Handle(new(dashboard.QuickReplyController))
|
||||||
m.Party("/channel").Handle(new(dashboard.ChannelController))
|
m.Party("/channel").Handle(new(dashboard.ChannelController))
|
||||||
m.Party("/agent").Handle(new(dashboard.AgentController))
|
m.Party("/agent").Handle(new(dashboard.AgentController))
|
||||||
|
|||||||
@@ -1,35 +1,23 @@
|
|||||||
package builders
|
package builders
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"cs-agent/internal/models"
|
|
||||||
"cs-agent/internal/pkg/dto/response"
|
|
||||||
"cs-agent/internal/pkg/enums"
|
|
||||||
"cs-agent/internal/pkg/utils"
|
|
||||||
"cs-agent/internal/services"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/mlogclub/simple/sqls"
|
"cs-agent/internal/models"
|
||||||
|
"cs-agent/internal/pkg/dto/response"
|
||||||
|
"cs-agent/internal/pkg/utils"
|
||||||
|
"cs-agent/internal/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
type TicketBuildContext struct {
|
type TicketBuildContext struct {
|
||||||
TagsByTicketID map[int64][]models.Tag
|
TagsByTicketID map[int64][]models.Tag
|
||||||
Priorities map[int64]*models.TicketPriorityConfig
|
Users map[int64]*models.User
|
||||||
ResolutionCodes map[string]*models.TicketResolutionCode
|
Customers map[int64]*models.Customer
|
||||||
Users map[int64]*models.User
|
|
||||||
Teams map[int64]*models.AgentTeam
|
|
||||||
Customers map[int64]*models.Customer
|
|
||||||
SLAByTicketID map[int64][]models.TicketSLARecord
|
|
||||||
WatchedTicketIDs map[int64]struct{}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type TicketDetailBuildContext struct {
|
type TicketDetailBuildContext struct {
|
||||||
Users map[int64]*models.User
|
Users map[int64]*models.User
|
||||||
Teams map[int64]*models.AgentTeam
|
|
||||||
AgentProfiles map[int64]*models.AgentProfile
|
|
||||||
RelatedTickets map[int64]*models.Ticket
|
|
||||||
Customers map[int64]*models.Customer
|
|
||||||
AIAgents map[int64]*models.AIAgent
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func BuildTicket(item *models.Ticket) *response.TicketResponse {
|
func BuildTicket(item *models.Ticket) *response.TicketResponse {
|
||||||
@@ -41,99 +29,34 @@ func BuildTicketWithContext(item *models.Ticket, ctx *TicketBuildContext) *respo
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
ret := &response.TicketResponse{
|
ret := &response.TicketResponse{
|
||||||
ID: item.ID,
|
ID: item.ID,
|
||||||
TicketNo: item.TicketNo,
|
TicketNo: item.TicketNo,
|
||||||
Title: item.Title,
|
Title: item.Title,
|
||||||
Description: item.Description,
|
Description: item.Description,
|
||||||
Source: item.Source,
|
Source: item.Source,
|
||||||
Channel: item.Channel,
|
Channel: item.Channel,
|
||||||
CustomerID: item.CustomerID,
|
CustomerID: item.CustomerID,
|
||||||
ConversationID: item.ConversationID,
|
ConversationID: item.ConversationID,
|
||||||
Type: item.Type,
|
Status: item.Status,
|
||||||
Priority: item.Priority,
|
CurrentAssigneeID: item.CurrentAssigneeID,
|
||||||
Severity: item.Severity,
|
CreatedBy: item.CreateUserID,
|
||||||
Status: item.Status,
|
CreatedByName: item.CreateUserName,
|
||||||
CurrentTeamID: item.CurrentTeamID,
|
HandledAt: utils.FormatTimePtr(item.HandledAt),
|
||||||
CurrentAssigneeID: item.CurrentAssigneeID,
|
CreatedAt: utils.FormatTime(item.CreatedAt),
|
||||||
PendingReason: item.PendingReason,
|
UpdatedAt: utils.FormatTime(item.UpdatedAt),
|
||||||
CloseReason: item.CloseReason,
|
|
||||||
ResolutionCode: item.ResolutionCode,
|
|
||||||
ResolutionSummary: item.ResolutionSummary,
|
|
||||||
FirstResponseAt: utils.FormatTimePtr(item.FirstResponseAt),
|
|
||||||
ResolvedAt: utils.FormatTimePtr(item.ResolvedAt),
|
|
||||||
ClosedAt: utils.FormatTimePtr(item.ClosedAt),
|
|
||||||
DueAt: utils.FormatTimePtr(item.DueAt),
|
|
||||||
NextReplyDeadlineAt: utils.FormatTimePtr(item.NextReplyDeadlineAt),
|
|
||||||
ResolveDeadlineAt: utils.FormatTimePtr(item.ResolveDeadlineAt),
|
|
||||||
ReopenedCount: item.ReopenedCount,
|
|
||||||
CreatedAt: utils.FormatTime(item.CreatedAt),
|
|
||||||
UpdatedAt: utils.FormatTime(item.UpdatedAt),
|
|
||||||
}
|
|
||||||
if ctx != nil {
|
|
||||||
if _, ok := ctx.WatchedTicketIDs[item.ID]; ok {
|
|
||||||
ret.WatchedByMe = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if ctx != nil && ctx.TagsByTicketID != nil {
|
if ctx != nil && ctx.TagsByTicketID != nil {
|
||||||
ret.Tags = BuildTagResponses(ctx.TagsByTicketID[item.ID])
|
ret.Tags = BuildTagResponses(ctx.TagsByTicketID[item.ID])
|
||||||
} else {
|
|
||||||
ret.Tags = BuildTagResponses(services.TicketService.GetTags(item.ID))
|
|
||||||
}
|
}
|
||||||
if item.Priority > 0 && ctx != nil && ctx.Priorities != nil {
|
if item.CurrentAssigneeID > 0 {
|
||||||
if priority := ctx.Priorities[item.Priority]; priority != nil {
|
if ctx != nil && ctx.Users != nil {
|
||||||
ret.PriorityName = priority.Name
|
ret.CurrentAssigneeName = buildTicketUserDisplayName(ctx.Users[item.CurrentAssigneeID])
|
||||||
}
|
|
||||||
} else if item.Priority > 0 {
|
|
||||||
if priority := services.TicketPriorityConfigService.Get(item.Priority); priority != nil {
|
|
||||||
ret.PriorityName = priority.Name
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if item.ResolutionCode != "" && ctx != nil && ctx.ResolutionCodes != nil {
|
if item.CustomerID > 0 {
|
||||||
if code := ctx.ResolutionCodes[item.ResolutionCode]; code != nil {
|
if ctx != nil && ctx.Customers != nil {
|
||||||
ret.ResolutionCodeName = code.Name
|
ret.Customer = BuildCustomer(ctx.Customers[item.CustomerID])
|
||||||
}
|
}
|
||||||
} else if item.ResolutionCode != "" {
|
|
||||||
if code := services.TicketResolutionCodeService.Take("code = ? AND status <> ?", item.ResolutionCode, enums.StatusDeleted); code != nil {
|
|
||||||
ret.ResolutionCodeName = code.Name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if item.CurrentAssigneeID > 0 && ctx != nil && ctx.Users != nil {
|
|
||||||
if user := ctx.Users[item.CurrentAssigneeID]; user != nil {
|
|
||||||
ret.CurrentAssigneeName = user.Nickname
|
|
||||||
if ret.CurrentAssigneeName == "" {
|
|
||||||
ret.CurrentAssigneeName = user.Username
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if item.CurrentAssigneeID > 0 {
|
|
||||||
if user := services.UserService.Get(item.CurrentAssigneeID); user != nil {
|
|
||||||
ret.CurrentAssigneeName = user.Nickname
|
|
||||||
if ret.CurrentAssigneeName == "" {
|
|
||||||
ret.CurrentAssigneeName = user.Username
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if item.CurrentTeamID > 0 && ctx != nil && ctx.Teams != nil {
|
|
||||||
if team := ctx.Teams[item.CurrentTeamID]; team != nil {
|
|
||||||
ret.CurrentTeamName = team.Name
|
|
||||||
}
|
|
||||||
} else if item.CurrentTeamID > 0 {
|
|
||||||
if team := services.AgentTeamService.Get(item.CurrentTeamID); team != nil {
|
|
||||||
ret.CurrentTeamName = team.Name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if item.CustomerID > 0 && ctx != nil && ctx.Customers != nil {
|
|
||||||
ret.Customer = BuildCustomer(ctx.Customers[item.CustomerID])
|
|
||||||
} else if item.CustomerID > 0 {
|
|
||||||
ret.Customer = BuildCustomer(services.CustomerService.Get(item.CustomerID))
|
|
||||||
}
|
|
||||||
if ctx != nil && ctx.SLAByTicketID != nil {
|
|
||||||
ret.SLA = BuildTicketSLAList(ctx.SLAByTicketID[item.ID])
|
|
||||||
} else {
|
|
||||||
ret.SLA = BuildTicketSLAList(
|
|
||||||
services.TicketSLARecordService.Find(
|
|
||||||
sqls.NewCnd().Eq("ticket_id", item.ID).Asc("id"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
return ret
|
return ret
|
||||||
}
|
}
|
||||||
@@ -155,343 +78,80 @@ func BuildTicketListWithContext(list []models.Ticket, ctx *TicketBuildContext) [
|
|||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
func BuildTicketComment(item *models.TicketComment) *response.TicketCommentResponse {
|
func BuildTicketProgress(item *models.TicketProgress) *response.TicketProgressResponse {
|
||||||
return BuildTicketCommentWithContext(item, nil)
|
return BuildTicketProgressWithContext(item, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func BuildTicketCommentWithContext(item *models.TicketComment, ctx *TicketDetailBuildContext) *response.TicketCommentResponse {
|
func BuildTicketProgressWithContext(item *models.TicketProgress, ctx *TicketDetailBuildContext) *response.TicketProgressResponse {
|
||||||
if item == nil {
|
if item == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
ret := &response.TicketCommentResponse{
|
ret := &response.TicketProgressResponse{
|
||||||
ID: item.ID,
|
ID: item.ID,
|
||||||
TicketID: item.TicketID,
|
TicketID: item.TicketID,
|
||||||
CommentType: item.CommentType,
|
Content: item.Content,
|
||||||
AuthorType: item.AuthorType,
|
AuthorID: item.AuthorID,
|
||||||
AuthorID: item.AuthorID,
|
CreatedAt: utils.FormatTime(item.CreatedAt),
|
||||||
ContentType: item.ContentType,
|
}
|
||||||
Content: item.Content,
|
if item.AuthorID > 0 {
|
||||||
Payload: item.Payload,
|
if ctx != nil && ctx.Users != nil {
|
||||||
CreatedAt: utils.FormatTime(item.CreatedAt),
|
ret.AuthorName = buildTicketUserDisplayName(ctx.Users[item.AuthorID])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
ret.AuthorName = buildTicketOperatorName(item.AuthorType, item.AuthorID, ctx)
|
|
||||||
return ret
|
return ret
|
||||||
}
|
}
|
||||||
|
|
||||||
func BuildTicketCommentList(list []models.TicketComment) []response.TicketCommentResponse {
|
func BuildTicketProgressList(list []models.TicketProgress) []response.TicketProgressResponse {
|
||||||
return BuildTicketCommentListWithContext(list, nil)
|
return BuildTicketProgressListWithContext(list, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func BuildTicketCommentListWithContext(list []models.TicketComment, ctx *TicketDetailBuildContext) []response.TicketCommentResponse {
|
func BuildTicketProgressListWithContext(list []models.TicketProgress, ctx *TicketDetailBuildContext) []response.TicketProgressResponse {
|
||||||
if len(list) == 0 {
|
if len(list) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
results := make([]response.TicketCommentResponse, 0, len(list))
|
results := make([]response.TicketProgressResponse, 0, len(list))
|
||||||
for i := range list {
|
for i := range list {
|
||||||
if item := BuildTicketCommentWithContext(&list[i], ctx); item != nil {
|
if item := BuildTicketProgressWithContext(&list[i], ctx); item != nil {
|
||||||
results = append(results, *item)
|
results = append(results, *item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
func BuildTicketEventLog(item *models.TicketEventLog) *response.TicketEventLogResponse {
|
|
||||||
return BuildTicketEventLogWithContext(item, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func BuildTicketEventLogWithContext(item *models.TicketEventLog, ctx *TicketDetailBuildContext) *response.TicketEventLogResponse {
|
|
||||||
if item == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &response.TicketEventLogResponse{
|
|
||||||
ID: item.ID,
|
|
||||||
TicketID: item.TicketID,
|
|
||||||
EventType: item.EventType,
|
|
||||||
OperatorType: item.OperatorType,
|
|
||||||
OperatorID: item.OperatorID,
|
|
||||||
OperatorName: buildTicketOperatorName(item.OperatorType, item.OperatorID, ctx),
|
|
||||||
OldValue: item.OldValue,
|
|
||||||
NewValue: item.NewValue,
|
|
||||||
Content: item.Content,
|
|
||||||
Payload: item.Payload,
|
|
||||||
CreatedAt: utils.FormatTime(item.CreatedAt),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func BuildTicketEventLogList(list []models.TicketEventLog) []response.TicketEventLogResponse {
|
|
||||||
return BuildTicketEventLogListWithContext(list, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func BuildTicketEventLogListWithContext(list []models.TicketEventLog, ctx *TicketDetailBuildContext) []response.TicketEventLogResponse {
|
|
||||||
if len(list) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
results := make([]response.TicketEventLogResponse, 0, len(list))
|
|
||||||
for i := range list {
|
|
||||||
if item := BuildTicketEventLogWithContext(&list[i], ctx); item != nil {
|
|
||||||
results = append(results, *item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return results
|
|
||||||
}
|
|
||||||
|
|
||||||
func BuildTicketSLAList(list []models.TicketSLARecord) []response.TicketSLAResponse {
|
|
||||||
if len(list) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
results := make([]response.TicketSLAResponse, 0, len(list))
|
|
||||||
for i := range list {
|
|
||||||
item := &list[i]
|
|
||||||
results = append(results, response.TicketSLAResponse{
|
|
||||||
SLAType: item.SLAType,
|
|
||||||
TargetMinutes: item.TargetMinutes,
|
|
||||||
Status: item.Status,
|
|
||||||
StartedAt: utils.FormatTimePtr(item.StartedAt),
|
|
||||||
PausedAt: utils.FormatTimePtr(item.PausedAt),
|
|
||||||
StoppedAt: utils.FormatTimePtr(item.StoppedAt),
|
|
||||||
BreachedAt: utils.FormatTimePtr(item.BreachedAt),
|
|
||||||
ElapsedMin: item.ElapsedMin,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return results
|
|
||||||
}
|
|
||||||
|
|
||||||
func BuildTicketDetail(aggregate *services.TicketDetailAggregate) *response.TicketDetailResponse {
|
func BuildTicketDetail(aggregate *services.TicketDetailAggregate) *response.TicketDetailResponse {
|
||||||
return BuildTicketDetailWithContext(aggregate, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func BuildTicketDetailWithContext(aggregate *services.TicketDetailAggregate, ctx *TicketDetailBuildContext) *response.TicketDetailResponse {
|
|
||||||
if aggregate == nil || aggregate.Ticket == nil {
|
if aggregate == nil || aggregate.Ticket == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if ctx == nil {
|
ctx := &TicketBuildContext{
|
||||||
users := make(map[int64]*models.User, len(aggregate.Users)+len(aggregate.OperatorUsers))
|
TagsByTicketID: map[int64][]models.Tag{aggregate.Ticket.ID: aggregate.Tags},
|
||||||
for id, item := range aggregate.Users {
|
Users: aggregate.Users,
|
||||||
users[id] = item
|
Customers: map[int64]*models.Customer{},
|
||||||
}
|
|
||||||
for id, item := range aggregate.OperatorUsers {
|
|
||||||
users[id] = item
|
|
||||||
}
|
|
||||||
ctx = &TicketDetailBuildContext{
|
|
||||||
Users: users,
|
|
||||||
Teams: aggregate.Teams,
|
|
||||||
AgentProfiles: aggregate.AgentProfiles,
|
|
||||||
RelatedTickets: aggregate.RelatedMap,
|
|
||||||
Customers: aggregate.OperatorCustomers,
|
|
||||||
AIAgents: aggregate.OperatorAIAgents,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ret := &response.TicketDetailResponse{
|
|
||||||
Ticket: *BuildTicket(aggregate.Ticket),
|
|
||||||
Watchers: BuildTicketWatcherListWithContext(aggregate.Watchers, ctx),
|
|
||||||
Collaborators: BuildTicketCollaboratorListWithContext(aggregate.Collaborators, ctx),
|
|
||||||
RelatedTickets: BuildTicketRelationListWithContext(aggregate.RelatedTickets, ctx),
|
|
||||||
}
|
|
||||||
if len(aggregate.Comments) > 0 {
|
|
||||||
ret.Comments = make([]response.TicketCommentResponse, 0, len(aggregate.Comments))
|
|
||||||
for i := range aggregate.Comments {
|
|
||||||
if item := BuildTicketCommentWithContext(&aggregate.Comments[i], ctx); item != nil {
|
|
||||||
ret.Comments = append(ret.Comments, *item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(aggregate.Events) > 0 {
|
|
||||||
ret.Events = make([]response.TicketEventLogResponse, 0, len(aggregate.Events))
|
|
||||||
for i := range aggregate.Events {
|
|
||||||
if item := BuildTicketEventLogWithContext(&aggregate.Events[i], ctx); item != nil {
|
|
||||||
ret.Events = append(ret.Events, *item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if aggregate.Customer != nil {
|
if aggregate.Customer != nil {
|
||||||
ret.Ticket.Customer = BuildCustomer(aggregate.Customer)
|
ctx.Customers[aggregate.Customer.ID] = aggregate.Customer
|
||||||
}
|
}
|
||||||
ret.Ticket.Tags = BuildTagResponses(aggregate.Tags)
|
ret := &response.TicketDetailResponse{
|
||||||
ret.Ticket.SLA = BuildTicketSLAList(aggregate.SLAs)
|
Ticket: *BuildTicketWithContext(aggregate.Ticket, ctx),
|
||||||
for i := range ret.Comments {
|
|
||||||
if ret.Comments[i].AuthorName == "" {
|
|
||||||
ret.Comments[i].AuthorName = buildTicketOperatorName(ret.Comments[i].AuthorType, ret.Comments[i].AuthorID, ctx)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for i := range ret.Events {
|
|
||||||
if ret.Events[i].OperatorName == "" {
|
|
||||||
ret.Events[i].OperatorName = buildTicketOperatorName(ret.Events[i].OperatorType, ret.Events[i].OperatorID, ctx)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
ret.Progresses = BuildTicketProgressListWithContext(aggregate.Progresses, &TicketDetailBuildContext{Users: aggregate.Users})
|
||||||
return ret
|
return ret
|
||||||
}
|
}
|
||||||
|
|
||||||
func BuildTicketRelation(item *models.TicketRelation) *response.TicketRelationResponse {
|
|
||||||
return BuildTicketRelationWithContext(item, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func BuildTicketRelationWithContext(item *models.TicketRelation, ctx *TicketDetailBuildContext) *response.TicketRelationResponse {
|
|
||||||
if item == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
ret := &response.TicketRelationResponse{
|
|
||||||
ID: item.ID,
|
|
||||||
TicketID: item.TicketID,
|
|
||||||
RelatedTicketID: item.RelatedTicketID,
|
|
||||||
RelationType: item.RelationType,
|
|
||||||
}
|
|
||||||
related := (*models.Ticket)(nil)
|
|
||||||
if ctx != nil && ctx.RelatedTickets != nil {
|
|
||||||
related = ctx.RelatedTickets[item.RelatedTicketID]
|
|
||||||
}
|
|
||||||
if related == nil {
|
|
||||||
related = services.TicketService.Get(item.RelatedTicketID)
|
|
||||||
}
|
|
||||||
if related != nil {
|
|
||||||
ret.RelatedTicketNo = related.TicketNo
|
|
||||||
ret.RelatedTicketTitle = related.Title
|
|
||||||
ret.RelatedTicketStatus = related.Status
|
|
||||||
ret.UpdatedAt = utils.FormatTime(related.UpdatedAt)
|
|
||||||
if related.CurrentTeamID > 0 && ctx != nil && ctx.Teams != nil {
|
|
||||||
if team := ctx.Teams[related.CurrentTeamID]; team != nil {
|
|
||||||
ret.CurrentTeamName = team.Name
|
|
||||||
}
|
|
||||||
} else if related.CurrentTeamID > 0 {
|
|
||||||
if team := services.AgentTeamService.Get(related.CurrentTeamID); team != nil {
|
|
||||||
ret.CurrentTeamName = team.Name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if related.CurrentAssigneeID > 0 && ctx != nil && ctx.Users != nil {
|
|
||||||
ret.CurrentAssigneeName = buildTicketUserDisplayName(ctx.Users[related.CurrentAssigneeID])
|
|
||||||
} else if related.CurrentAssigneeID > 0 {
|
|
||||||
if user := services.UserService.Get(related.CurrentAssigneeID); user != nil {
|
|
||||||
ret.CurrentAssigneeName = user.Nickname
|
|
||||||
if ret.CurrentAssigneeName == "" {
|
|
||||||
ret.CurrentAssigneeName = user.Username
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ret
|
|
||||||
}
|
|
||||||
|
|
||||||
func BuildTicketRelationList(list []models.TicketRelation) []response.TicketRelationResponse {
|
|
||||||
return BuildTicketRelationListWithContext(list, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func BuildTicketRelationListWithContext(list []models.TicketRelation, ctx *TicketDetailBuildContext) []response.TicketRelationResponse {
|
|
||||||
if len(list) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
results := make([]response.TicketRelationResponse, 0, len(list))
|
|
||||||
for i := range list {
|
|
||||||
if item := BuildTicketRelationWithContext(&list[i], ctx); item != nil {
|
|
||||||
results = append(results, *item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return results
|
|
||||||
}
|
|
||||||
|
|
||||||
func BuildTicketSummary(summary *services.TicketSummaryAggregate) *response.TicketSummaryResponse {
|
func BuildTicketSummary(summary *services.TicketSummaryAggregate) *response.TicketSummaryResponse {
|
||||||
if summary == nil {
|
if summary == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return &response.TicketSummaryResponse{
|
return &response.TicketSummaryResponse{
|
||||||
All: summary.All,
|
All: summary.All,
|
||||||
Mine: summary.Mine,
|
Pending: summary.Pending,
|
||||||
Watching: summary.Watching,
|
InProgress: summary.InProgress,
|
||||||
Collaboration: summary.Collaboration,
|
Done: summary.Done,
|
||||||
Participating: summary.Participating,
|
Unassigned: summary.Unassigned,
|
||||||
Mentioned: summary.Mentioned,
|
Mine: summary.Mine,
|
||||||
Unassigned: summary.Unassigned,
|
Stale: summary.Stale,
|
||||||
PendingCustomer: summary.PendingCustomer,
|
|
||||||
PendingInternal: summary.PendingInternal,
|
|
||||||
Overdue: summary.Overdue,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func BuildTicketRiskOverview(overview *services.TicketRiskOverviewAggregate) *response.TicketRiskOverviewResponse {
|
|
||||||
if overview == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
ret := &response.TicketRiskOverviewResponse{
|
|
||||||
Overdue: overview.Overdue,
|
|
||||||
HighRisk: overview.HighRisk,
|
|
||||||
Unassigned: overview.Unassigned,
|
|
||||||
PendingInternal: overview.PendingInternal,
|
|
||||||
PendingCustomer: overview.PendingCustomer,
|
|
||||||
RiskWindowMins: overview.RiskWindowMins,
|
|
||||||
}
|
|
||||||
if len(overview.Reasons) > 0 {
|
|
||||||
ret.Reasons = make([]response.TicketRiskReasonResponse, 0, len(overview.Reasons))
|
|
||||||
for _, item := range overview.Reasons {
|
|
||||||
ret.Reasons = append(ret.Reasons, response.TicketRiskReasonResponse{
|
|
||||||
Code: item.Code,
|
|
||||||
Title: item.Title,
|
|
||||||
Description: item.Description,
|
|
||||||
Count: item.Count,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ret
|
|
||||||
}
|
|
||||||
|
|
||||||
func BuildTicketWatcherList(list []models.TicketWatcher) []response.TicketWatcherResponse {
|
|
||||||
return BuildTicketWatcherListWithContext(list, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func BuildTicketWatcherListWithContext(list []models.TicketWatcher, ctx *TicketDetailBuildContext) []response.TicketWatcherResponse {
|
|
||||||
if len(list) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
results := make([]response.TicketWatcherResponse, 0, len(list))
|
|
||||||
for i := range list {
|
|
||||||
item := &list[i]
|
|
||||||
out := response.TicketWatcherResponse{
|
|
||||||
ID: item.ID,
|
|
||||||
UserID: item.UserID,
|
|
||||||
}
|
|
||||||
if ctx != nil && ctx.Users != nil {
|
|
||||||
out.UserName = buildTicketUserDisplayName(ctx.Users[item.UserID])
|
|
||||||
} else if user := services.UserService.Get(item.UserID); user != nil {
|
|
||||||
out.UserName = buildTicketUserDisplayName(user)
|
|
||||||
}
|
|
||||||
results = append(results, out)
|
|
||||||
}
|
|
||||||
return results
|
|
||||||
}
|
|
||||||
|
|
||||||
func BuildTicketCollaboratorList(list []models.TicketCollaborator) []response.TicketCollaboratorResponse {
|
|
||||||
return BuildTicketCollaboratorListWithContext(list, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func BuildTicketCollaboratorListWithContext(list []models.TicketCollaborator, ctx *TicketDetailBuildContext) []response.TicketCollaboratorResponse {
|
|
||||||
if len(list) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
results := make([]response.TicketCollaboratorResponse, 0, len(list))
|
|
||||||
for i := range list {
|
|
||||||
item := &list[i]
|
|
||||||
out := response.TicketCollaboratorResponse{
|
|
||||||
ID: item.ID,
|
|
||||||
UserID: item.UserID,
|
|
||||||
}
|
|
||||||
if ctx != nil && ctx.Users != nil {
|
|
||||||
out.UserName = buildTicketUserDisplayName(ctx.Users[item.UserID])
|
|
||||||
} else if user := services.UserService.Get(item.UserID); user != nil {
|
|
||||||
out.UserName = buildTicketUserDisplayName(user)
|
|
||||||
}
|
|
||||||
if ctx != nil && ctx.AgentProfiles != nil {
|
|
||||||
if profile := ctx.AgentProfiles[item.UserID]; profile != nil && profile.TeamID > 0 {
|
|
||||||
if team := ctx.Teams[profile.TeamID]; team != nil {
|
|
||||||
out.TeamName = team.Name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if profile := services.AgentProfileService.GetByUserID(item.UserID); profile != nil && profile.TeamID > 0 {
|
|
||||||
if team := services.AgentTeamService.Get(profile.TeamID); team != nil {
|
|
||||||
out.TeamName = team.Name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
results = append(results, out)
|
|
||||||
}
|
|
||||||
return results
|
|
||||||
}
|
|
||||||
|
|
||||||
func BuildTicketView(item *models.TicketView) *response.TicketViewResponse {
|
func BuildTicketView(item *models.TicketView) *response.TicketViewResponse {
|
||||||
if item == nil {
|
if item == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -529,39 +189,3 @@ func buildTicketUserDisplayName(user *models.User) string {
|
|||||||
}
|
}
|
||||||
return user.Username
|
return user.Username
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildTicketOperatorName(senderType enums.IMSenderType, senderID int64, ctx *TicketDetailBuildContext) string {
|
|
||||||
if senderID <= 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
switch senderType {
|
|
||||||
case enums.IMSenderTypeAgent:
|
|
||||||
if ctx != nil && ctx.Users != nil {
|
|
||||||
if user := ctx.Users[senderID]; user != nil {
|
|
||||||
return buildTicketUserDisplayName(user)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if user := services.UserService.Get(senderID); user != nil {
|
|
||||||
return buildTicketUserDisplayName(user)
|
|
||||||
}
|
|
||||||
case enums.IMSenderTypeCustomer:
|
|
||||||
if ctx != nil && ctx.Customers != nil {
|
|
||||||
if customer := ctx.Customers[senderID]; customer != nil {
|
|
||||||
return customer.Name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if customer := services.CustomerService.Get(senderID); customer != nil {
|
|
||||||
return customer.Name
|
|
||||||
}
|
|
||||||
case enums.IMSenderTypeAI:
|
|
||||||
if ctx != nil && ctx.AIAgents != nil {
|
|
||||||
if ai := ctx.AIAgents[senderID]; ai != nil {
|
|
||||||
return ai.Name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ai := services.AIAgentService.Get(senderID); ai != nil {
|
|
||||||
return ai.Name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package builders
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"cs-agent/internal/models"
|
||||||
|
"cs-agent/internal/pkg/enums"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildLightweightTicket(t *testing.T) {
|
||||||
|
now := time.Date(2026, 5, 2, 12, 30, 0, 0, time.Local)
|
||||||
|
ticket := &models.Ticket{
|
||||||
|
ID: 12,
|
||||||
|
TicketNo: "TK202605020001",
|
||||||
|
Title: "登录失败",
|
||||||
|
Description: "客户反馈无法登录",
|
||||||
|
Source: enums.TicketSourceManual,
|
||||||
|
Channel: "web",
|
||||||
|
CustomerID: 3,
|
||||||
|
ConversationID: 4,
|
||||||
|
Status: enums.TicketStatusPending,
|
||||||
|
CurrentAssigneeID: 5,
|
||||||
|
AuditFields: models.AuditFields{
|
||||||
|
CreateUserID: 1,
|
||||||
|
CreateUserName: "admin",
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ctx := &TicketBuildContext{
|
||||||
|
TagsByTicketID: map[int64][]models.Tag{
|
||||||
|
12: {{ID: 8, Name: "登录", Status: enums.StatusOk}},
|
||||||
|
},
|
||||||
|
Users: map[int64]*models.User{
|
||||||
|
5: {ID: 5, Username: "agent", Nickname: "客服"},
|
||||||
|
},
|
||||||
|
Customers: map[int64]*models.Customer{
|
||||||
|
3: {ID: 3, Name: "客户"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
out := BuildTicketWithContext(ticket, ctx)
|
||||||
|
if out == nil {
|
||||||
|
t.Fatalf("expected ticket response")
|
||||||
|
}
|
||||||
|
if out.ID != ticket.ID || out.TicketNo != ticket.TicketNo || out.Status != ticket.Status {
|
||||||
|
t.Fatalf("unexpected ticket response: %+v", out)
|
||||||
|
}
|
||||||
|
if out.CurrentAssigneeName != "客服" {
|
||||||
|
t.Fatalf("expected assignee name, got %q", out.CurrentAssigneeName)
|
||||||
|
}
|
||||||
|
if len(out.Tags) != 1 || out.Tags[0].ID != 8 {
|
||||||
|
t.Fatalf("expected tag response, got %+v", out.Tags)
|
||||||
|
}
|
||||||
|
if out.Customer == nil || out.Customer.ID != 3 {
|
||||||
|
t.Fatalf("expected customer response, got %+v", out.Customer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildTicketWithoutContextLeavesOptionalLookupsEmpty(t *testing.T) {
|
||||||
|
now := time.Date(2026, 5, 2, 12, 30, 0, 0, time.Local)
|
||||||
|
ticket := &models.Ticket{
|
||||||
|
ID: 12,
|
||||||
|
TicketNo: "TK202605020001",
|
||||||
|
Title: "登录失败",
|
||||||
|
Description: "客户反馈无法登录",
|
||||||
|
CustomerID: 3,
|
||||||
|
CurrentAssigneeID: 5,
|
||||||
|
Status: enums.TicketStatusPending,
|
||||||
|
AuditFields: models.AuditFields{
|
||||||
|
CreateUserID: 1,
|
||||||
|
CreateUserName: "admin",
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
out := BuildTicket(ticket)
|
||||||
|
if out == nil {
|
||||||
|
t.Fatalf("expected ticket response")
|
||||||
|
}
|
||||||
|
if out.Tags != nil {
|
||||||
|
t.Fatalf("expected tags to stay empty without context, got %+v", out.Tags)
|
||||||
|
}
|
||||||
|
if out.Customer != nil {
|
||||||
|
t.Fatalf("expected customer to stay empty without context, got %+v", out.Customer)
|
||||||
|
}
|
||||||
|
if out.CurrentAssigneeName != "" {
|
||||||
|
t.Fatalf("expected assignee name to stay empty without context, got %q", out.CurrentAssigneeName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildTicketProgress(t *testing.T) {
|
||||||
|
now := time.Date(2026, 5, 2, 12, 30, 0, 0, time.Local)
|
||||||
|
progress := &models.TicketProgress{
|
||||||
|
ID: 1,
|
||||||
|
TicketID: 2,
|
||||||
|
Content: "已联系客户",
|
||||||
|
AuthorID: 3,
|
||||||
|
CreatedAt: now,
|
||||||
|
}
|
||||||
|
ctx := &TicketDetailBuildContext{
|
||||||
|
Users: map[int64]*models.User{
|
||||||
|
3: {ID: 3, Username: "agent", Nickname: "客服"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
out := BuildTicketProgressWithContext(progress, ctx)
|
||||||
|
if out == nil {
|
||||||
|
t.Fatalf("expected progress response")
|
||||||
|
}
|
||||||
|
if out.ID != progress.ID || out.TicketID != progress.TicketID || out.Content != progress.Content {
|
||||||
|
t.Fatalf("unexpected progress response: %+v", out)
|
||||||
|
}
|
||||||
|
if out.AuthorName != "客服" {
|
||||||
|
t.Fatalf("expected author name, got %q", out.AuthorName)
|
||||||
|
}
|
||||||
|
if out.CreatedAt == "" {
|
||||||
|
t.Fatalf("expected createdAt to be formatted")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
package builders
|
|
||||||
|
|
||||||
import (
|
|
||||||
"cs-agent/internal/models"
|
|
||||||
"cs-agent/internal/pkg/dto/response"
|
|
||||||
)
|
|
||||||
|
|
||||||
func BuildTicketResolutionCode(item *models.TicketResolutionCode) *response.TicketResolutionCodeResponse {
|
|
||||||
if item == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &response.TicketResolutionCodeResponse{
|
|
||||||
ID: item.ID,
|
|
||||||
Name: item.Name,
|
|
||||||
Code: item.Code,
|
|
||||||
SortNo: item.SortNo,
|
|
||||||
Status: item.Status,
|
|
||||||
Remark: item.Remark,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func BuildTicketResolutionCodeList(list []models.TicketResolutionCode) []response.TicketResolutionCodeResponse {
|
|
||||||
if len(list) == 0 {
|
|
||||||
return make([]response.TicketResolutionCodeResponse, 0)
|
|
||||||
}
|
|
||||||
ret := make([]response.TicketResolutionCodeResponse, 0, len(list))
|
|
||||||
for i := range list {
|
|
||||||
if item := BuildTicketResolutionCode(&list[i]); item != nil {
|
|
||||||
ret = append(ret, *item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ret
|
|
||||||
}
|
|
||||||
|
|
||||||
func BuildTicketPriorityConfig(item *models.TicketPriorityConfig) *response.TicketPriorityConfigResponse {
|
|
||||||
if item == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &response.TicketPriorityConfigResponse{
|
|
||||||
ID: item.ID,
|
|
||||||
Name: item.Name,
|
|
||||||
SortNo: item.SortNo,
|
|
||||||
FirstResponseMinutes: item.FirstResponseMinutes,
|
|
||||||
ResolutionMinutes: item.ResolutionMinutes,
|
|
||||||
Status: item.Status,
|
|
||||||
Remark: item.Remark,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func BuildTicketPriorityConfigList(list []models.TicketPriorityConfig) []response.TicketPriorityConfigResponse {
|
|
||||||
if len(list) == 0 {
|
|
||||||
return make([]response.TicketPriorityConfigResponse, 0)
|
|
||||||
}
|
|
||||||
ret := make([]response.TicketPriorityConfigResponse, 0, len(list))
|
|
||||||
for i := range list {
|
|
||||||
if item := BuildTicketPriorityConfig(&list[i]); item != nil {
|
|
||||||
ret = append(ret, *item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ret
|
|
||||||
}
|
|
||||||
@@ -2,15 +2,14 @@ package dashboard
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"cs-agent/internal/builders"
|
"cs-agent/internal/builders"
|
||||||
"cs-agent/internal/pkg/constants"
|
"cs-agent/internal/pkg/constants"
|
||||||
"cs-agent/internal/pkg/dto/request"
|
"cs-agent/internal/pkg/dto/request"
|
||||||
"cs-agent/internal/pkg/enums"
|
|
||||||
"cs-agent/internal/services"
|
"cs-agent/internal/services"
|
||||||
|
|
||||||
"github.com/kataras/iris/v12"
|
"github.com/kataras/iris/v12"
|
||||||
|
"github.com/mlogclub/simple/sqls"
|
||||||
"github.com/mlogclub/simple/web"
|
"github.com/mlogclub/simple/web"
|
||||||
"github.com/mlogclub/simple/web/params"
|
"github.com/mlogclub/simple/web/params"
|
||||||
)
|
)
|
||||||
@@ -26,13 +25,11 @@ func (c *TicketController) AnyList() *web.JsonResult {
|
|||||||
}
|
}
|
||||||
cnd := params.NewPagedSqlCnd(c.Ctx,
|
cnd := params.NewPagedSqlCnd(c.Ctx,
|
||||||
params.QueryFilter{ParamName: "status"},
|
params.QueryFilter{ParamName: "status"},
|
||||||
params.QueryFilter{ParamName: "priority"},
|
|
||||||
params.QueryFilter{ParamName: "severity"},
|
|
||||||
params.QueryFilter{ParamName: "currentTeamId"},
|
|
||||||
params.QueryFilter{ParamName: "currentAssigneeId"},
|
params.QueryFilter{ParamName: "currentAssigneeId"},
|
||||||
params.QueryFilter{ParamName: "customerId"},
|
params.QueryFilter{ParamName: "customerId"},
|
||||||
params.QueryFilter{ParamName: "conversationId"},
|
params.QueryFilter{ParamName: "conversationId"},
|
||||||
params.QueryFilter{ParamName: "source"},
|
params.QueryFilter{ParamName: "source"},
|
||||||
|
params.QueryFilter{ParamName: "channel"},
|
||||||
).Desc("updated_at").Desc("id")
|
).Desc("updated_at").Desc("id")
|
||||||
if keyword, _ := params.Get(c.Ctx, "keyword"); strings.TrimSpace(keyword) != "" {
|
if keyword, _ := params.Get(c.Ctx, "keyword"); strings.TrimSpace(keyword) != "" {
|
||||||
keyword = "%" + strings.TrimSpace(keyword) + "%"
|
keyword = "%" + strings.TrimSpace(keyword) + "%"
|
||||||
@@ -41,32 +38,15 @@ func (c *TicketController) AnyList() *web.JsonResult {
|
|||||||
if tagID, _ := params.GetInt64(c.Ctx, "tagId"); tagID > 0 {
|
if tagID, _ := params.GetInt64(c.Ctx, "tagId"); tagID > 0 {
|
||||||
cnd.Where("id IN (SELECT ticket_id FROM t_ticket_tag WHERE tag_id = ?)", tagID)
|
cnd.Where("id IN (SELECT ticket_id FROM t_ticket_tag WHERE tag_id = ?)", tagID)
|
||||||
}
|
}
|
||||||
if watching, _ := params.Get(c.Ctx, "watching"); watching == "1" || strings.EqualFold(watching, "true") {
|
|
||||||
cnd.Where("id IN (SELECT ticket_id FROM t_ticket_watcher WHERE user_id = ?)", operator.UserID)
|
|
||||||
}
|
|
||||||
if collaborating, _ := params.Get(c.Ctx, "collaborating"); collaborating == "1" || strings.EqualFold(collaborating, "true") {
|
|
||||||
cnd.Where("id IN (SELECT ticket_id FROM t_ticket_collaborator WHERE user_id = ?)", operator.UserID)
|
|
||||||
}
|
|
||||||
if collaboration, _ := params.Get(c.Ctx, "collaboration"); collaboration == "1" || strings.EqualFold(collaboration, "true") {
|
|
||||||
cnd.Where(
|
|
||||||
"(id IN (SELECT ticket_id FROM t_ticket_collaborator WHERE user_id = ?) OR id IN (SELECT ticket_id FROM t_ticket_mention WHERE mentioned_user_id = ?))",
|
|
||||||
operator.UserID,
|
|
||||||
operator.UserID,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if mentioned, _ := params.Get(c.Ctx, "mentioned"); mentioned == "1" || strings.EqualFold(mentioned, "true") {
|
|
||||||
cnd.Where("id IN (SELECT ticket_id FROM t_ticket_mention WHERE mentioned_user_id = ?)", operator.UserID)
|
|
||||||
}
|
|
||||||
if mine, _ := params.Get(c.Ctx, "mine"); mine == "1" || strings.EqualFold(mine, "true") {
|
if mine, _ := params.Get(c.Ctx, "mine"); mine == "1" || strings.EqualFold(mine, "true") {
|
||||||
cnd.Eq("current_assignee_id", operator.UserID)
|
cnd.Eq("current_assignee_id", operator.UserID)
|
||||||
}
|
}
|
||||||
if unassigned, _ := params.Get(c.Ctx, "unassigned"); unassigned == "1" || strings.EqualFold(unassigned, "true") {
|
if unassigned, _ := params.Get(c.Ctx, "unassigned"); unassigned == "1" || strings.EqualFold(unassigned, "true") {
|
||||||
cnd.Eq("current_assignee_id", 0)
|
cnd.Eq("current_assignee_id", 0)
|
||||||
}
|
}
|
||||||
if overdue, _ := params.Get(c.Ctx, "overdue"); overdue == "1" || strings.EqualFold(overdue, "true") {
|
if staleHoursValue, _ := params.Get(c.Ctx, "staleHours"); strings.TrimSpace(staleHoursValue) != "" {
|
||||||
cnd.In("status", []string{"new", "open", "pending_customer", "pending_internal"})
|
staleHours, _ := params.GetInt(c.Ctx, "staleHours")
|
||||||
cnd.Where("resolve_deadline_at IS NOT NULL")
|
services.TicketService.ApplyStaleFilter(cnd, staleHours)
|
||||||
cnd.Where("resolve_deadline_at < ?", time.Now())
|
|
||||||
}
|
}
|
||||||
aggregate, err := services.TicketService.FindPageAggregateByCnd(cnd, operator.UserID)
|
aggregate, err := services.TicketService.FindPageAggregateByCnd(cnd, operator.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -74,14 +54,9 @@ func (c *TicketController) AnyList() *web.JsonResult {
|
|||||||
}
|
}
|
||||||
return web.JsonData(&web.PageResult{
|
return web.JsonData(&web.PageResult{
|
||||||
Results: builders.BuildTicketListWithContext(aggregate.List, &builders.TicketBuildContext{
|
Results: builders.BuildTicketListWithContext(aggregate.List, &builders.TicketBuildContext{
|
||||||
TagsByTicketID: aggregate.TagsByTicketID,
|
TagsByTicketID: aggregate.TagsByTicketID,
|
||||||
Priorities: aggregate.Priorities,
|
Users: aggregate.Users,
|
||||||
ResolutionCodes: aggregate.ResolutionCodes,
|
Customers: aggregate.Customers,
|
||||||
Users: aggregate.Users,
|
|
||||||
Teams: aggregate.Teams,
|
|
||||||
Customers: aggregate.Customers,
|
|
||||||
SLAByTicketID: aggregate.SLAByTicketID,
|
|
||||||
WatchedTicketIDs: aggregate.WatchedTicketIDs,
|
|
||||||
}),
|
}),
|
||||||
Page: aggregate.Paging,
|
Page: aggregate.Paging,
|
||||||
})
|
})
|
||||||
@@ -92,45 +67,8 @@ func (c *TicketController) AnySummary() *web.JsonResult {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return web.JsonError(err)
|
return web.JsonError(err)
|
||||||
}
|
}
|
||||||
return web.JsonData(builders.BuildTicketSummary(services.TicketService.GetSummary(operator)))
|
staleHours, _ := params.GetInt(c.Ctx, "staleHours")
|
||||||
}
|
return web.JsonData(builders.BuildTicketSummary(services.TicketService.GetSummary(operator, staleHours)))
|
||||||
|
|
||||||
func (c *TicketController) AnyRisk_overview() *web.JsonResult {
|
|
||||||
if _, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionTicketView); err != nil {
|
|
||||||
return web.JsonError(err)
|
|
||||||
}
|
|
||||||
teamID, _ := params.GetInt64(c.Ctx, "currentTeamId")
|
|
||||||
riskWindowMins, _ := params.GetInt(c.Ctx, "riskWindowMins")
|
|
||||||
return web.JsonData(builders.BuildTicketRiskOverview(services.TicketService.GetRiskOverview(teamID, riskWindowMins)))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *TicketController) AnyRisk_list() *web.JsonResult {
|
|
||||||
operator, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionTicketView)
|
|
||||||
if err != nil {
|
|
||||||
return web.JsonError(err)
|
|
||||||
}
|
|
||||||
riskType, _ := params.Get(c.Ctx, "riskType")
|
|
||||||
teamID, _ := params.GetInt64(c.Ctx, "currentTeamId")
|
|
||||||
riskWindowMins, _ := params.GetInt(c.Ctx, "riskWindowMins")
|
|
||||||
page, _ := params.GetInt(c.Ctx, "page")
|
|
||||||
limit, _ := params.GetInt(c.Ctx, "limit")
|
|
||||||
aggregate, err := services.TicketService.GetRiskPageAggregate(riskType, teamID, riskWindowMins, page, limit, operator.UserID)
|
|
||||||
if err != nil {
|
|
||||||
return web.JsonError(err)
|
|
||||||
}
|
|
||||||
return web.JsonData(&web.PageResult{
|
|
||||||
Results: builders.BuildTicketListWithContext(aggregate.List, &builders.TicketBuildContext{
|
|
||||||
TagsByTicketID: aggregate.TagsByTicketID,
|
|
||||||
Priorities: aggregate.Priorities,
|
|
||||||
ResolutionCodes: aggregate.ResolutionCodes,
|
|
||||||
Users: aggregate.Users,
|
|
||||||
Teams: aggregate.Teams,
|
|
||||||
Customers: aggregate.Customers,
|
|
||||||
SLAByTicketID: aggregate.SLAByTicketID,
|
|
||||||
WatchedTicketIDs: aggregate.WatchedTicketIDs,
|
|
||||||
}),
|
|
||||||
Page: aggregate.Paging,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *TicketController) AnyView_list() *web.JsonResult {
|
func (c *TicketController) AnyView_list() *web.JsonResult {
|
||||||
@@ -230,21 +168,6 @@ func (c *TicketController) PostUpdate() *web.JsonResult {
|
|||||||
return web.JsonSuccess()
|
return web.JsonSuccess()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *TicketController) PostLink_customer() *web.JsonResult {
|
|
||||||
operator, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionTicketUpdate)
|
|
||||||
if err != nil {
|
|
||||||
return web.JsonError(err)
|
|
||||||
}
|
|
||||||
req := request.LinkTicketCustomerRequest{}
|
|
||||||
if err := params.ReadJSON(c.Ctx, &req); err != nil {
|
|
||||||
return web.JsonError(err)
|
|
||||||
}
|
|
||||||
if err := services.TicketService.LinkTicketCustomer(req.TicketID, req.CustomerID, operator); err != nil {
|
|
||||||
return web.JsonError(err)
|
|
||||||
}
|
|
||||||
return web.JsonSuccess()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *TicketController) PostAssign() *web.JsonResult {
|
func (c *TicketController) PostAssign() *web.JsonResult {
|
||||||
operator, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionTicketAssign)
|
operator, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionTicketAssign)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -290,212 +213,37 @@ func (c *TicketController) PostChange_status() *web.JsonResult {
|
|||||||
return web.JsonSuccess()
|
return web.JsonSuccess()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *TicketController) PostBatch_change_status() *web.JsonResult {
|
func (c *TicketController) AnyProgressList() *web.JsonResult {
|
||||||
operator, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionTicketChangeStatus)
|
|
||||||
if err != nil {
|
|
||||||
return web.JsonError(err)
|
|
||||||
}
|
|
||||||
req := request.BatchChangeTicketStatusRequest{}
|
|
||||||
if err := params.ReadJSON(c.Ctx, &req); err != nil {
|
|
||||||
return web.JsonError(err)
|
|
||||||
}
|
|
||||||
if err := services.TicketService.BatchChangeStatus(req, operator); err != nil {
|
|
||||||
return web.JsonError(err)
|
|
||||||
}
|
|
||||||
return web.JsonSuccess()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *TicketController) PostReply() *web.JsonResult {
|
|
||||||
operator, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionTicketReply)
|
|
||||||
if err != nil {
|
|
||||||
return web.JsonError(err)
|
|
||||||
}
|
|
||||||
req := request.ReplyTicketRequest{}
|
|
||||||
if err := params.ReadJSON(c.Ctx, &req); err != nil {
|
|
||||||
return web.JsonError(err)
|
|
||||||
}
|
|
||||||
item, err := services.TicketService.ReplyTicket(req, operator)
|
|
||||||
if err != nil {
|
|
||||||
return web.JsonError(err)
|
|
||||||
}
|
|
||||||
return web.JsonData(builders.BuildTicketComment(item))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *TicketController) PostInternal_note() *web.JsonResult {
|
|
||||||
operator, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionTicketReply)
|
|
||||||
if err != nil {
|
|
||||||
return web.JsonError(err)
|
|
||||||
}
|
|
||||||
req := request.InternalNoteRequest{}
|
|
||||||
if err := params.ReadJSON(c.Ctx, &req); err != nil {
|
|
||||||
return web.JsonError(err)
|
|
||||||
}
|
|
||||||
item, err := services.TicketService.AddInternalNote(req, operator)
|
|
||||||
if err != nil {
|
|
||||||
return web.JsonError(err)
|
|
||||||
}
|
|
||||||
return web.JsonData(builders.BuildTicketComment(item))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *TicketController) PostClose() *web.JsonResult {
|
|
||||||
operator, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionTicketClose)
|
|
||||||
if err != nil {
|
|
||||||
return web.JsonError(err)
|
|
||||||
}
|
|
||||||
req := request.CloseTicketRequest{}
|
|
||||||
if err := params.ReadJSON(c.Ctx, &req); err != nil {
|
|
||||||
return web.JsonError(err)
|
|
||||||
}
|
|
||||||
if err := services.TicketService.CloseTicket(req, operator); err != nil {
|
|
||||||
return web.JsonError(err)
|
|
||||||
}
|
|
||||||
return web.JsonSuccess()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *TicketController) PostReopen() *web.JsonResult {
|
|
||||||
operator, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionTicketReopen)
|
|
||||||
if err != nil {
|
|
||||||
return web.JsonError(err)
|
|
||||||
}
|
|
||||||
req := request.ReopenTicketRequest{}
|
|
||||||
if err := params.ReadJSON(c.Ctx, &req); err != nil {
|
|
||||||
return web.JsonError(err)
|
|
||||||
}
|
|
||||||
if err := services.TicketService.ReopenTicket(req, operator); err != nil {
|
|
||||||
return web.JsonError(err)
|
|
||||||
}
|
|
||||||
return web.JsonSuccess()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *TicketController) PostWatch() *web.JsonResult {
|
|
||||||
operator, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionTicketView)
|
|
||||||
if err != nil {
|
|
||||||
return web.JsonError(err)
|
|
||||||
}
|
|
||||||
req := request.WatchTicketRequest{}
|
|
||||||
if err := params.ReadJSON(c.Ctx, &req); err != nil {
|
|
||||||
return web.JsonError(err)
|
|
||||||
}
|
|
||||||
if err := services.TicketService.WatchTicket(req.TicketID, operator); err != nil {
|
|
||||||
return web.JsonError(err)
|
|
||||||
}
|
|
||||||
return web.JsonSuccess()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *TicketController) PostUnwatch() *web.JsonResult {
|
|
||||||
operator, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionTicketView)
|
|
||||||
if err != nil {
|
|
||||||
return web.JsonError(err)
|
|
||||||
}
|
|
||||||
req := request.WatchTicketRequest{}
|
|
||||||
if err := params.ReadJSON(c.Ctx, &req); err != nil {
|
|
||||||
return web.JsonError(err)
|
|
||||||
}
|
|
||||||
if err := services.TicketService.UnwatchTicket(req.TicketID, operator); err != nil {
|
|
||||||
return web.JsonError(err)
|
|
||||||
}
|
|
||||||
return web.JsonSuccess()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *TicketController) PostBatch_watch() *web.JsonResult {
|
|
||||||
operator, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionTicketView)
|
|
||||||
if err != nil {
|
|
||||||
return web.JsonError(err)
|
|
||||||
}
|
|
||||||
req := request.BatchWatchTicketRequest{}
|
|
||||||
if err := params.ReadJSON(c.Ctx, &req); err != nil {
|
|
||||||
return web.JsonError(err)
|
|
||||||
}
|
|
||||||
if err := services.TicketService.BatchWatchTickets(req, operator); err != nil {
|
|
||||||
return web.JsonError(err)
|
|
||||||
}
|
|
||||||
return web.JsonSuccess()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *TicketController) PostAdd_relation() *web.JsonResult {
|
|
||||||
operator, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionTicketUpdate)
|
|
||||||
if err != nil {
|
|
||||||
return web.JsonError(err)
|
|
||||||
}
|
|
||||||
req := request.AddTicketRelationRequest{}
|
|
||||||
if err := params.ReadJSON(c.Ctx, &req); err != nil {
|
|
||||||
return web.JsonError(err)
|
|
||||||
}
|
|
||||||
relatedTicketID := req.RelatedTicketID
|
|
||||||
if relatedTicketID <= 0 && strings.TrimSpace(req.RelatedTicketNo) != "" {
|
|
||||||
if relatedTicket := services.TicketService.Take("ticket_no = ?", strings.TrimSpace(req.RelatedTicketNo)); relatedTicket != nil {
|
|
||||||
relatedTicketID = relatedTicket.ID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := services.TicketRelationService.AddRelation(req.TicketID, relatedTicketID, enums.TicketRelationType(strings.TrimSpace(req.RelationType)), operator); err != nil {
|
|
||||||
return web.JsonError(err)
|
|
||||||
}
|
|
||||||
return web.JsonSuccess()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *TicketController) PostDelete_relation() *web.JsonResult {
|
|
||||||
operator, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionTicketUpdate)
|
|
||||||
if err != nil {
|
|
||||||
return web.JsonError(err)
|
|
||||||
}
|
|
||||||
req := request.DeleteTicketRelationRequest{}
|
|
||||||
if err := params.ReadJSON(c.Ctx, &req); err != nil {
|
|
||||||
return web.JsonError(err)
|
|
||||||
}
|
|
||||||
if err := services.TicketRelationService.DeleteRelation(req.TicketID, req.RelationID, operator); err != nil {
|
|
||||||
return web.JsonError(err)
|
|
||||||
}
|
|
||||||
return web.JsonSuccess()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *TicketController) PostAdd_collaborator() *web.JsonResult {
|
|
||||||
operator, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionTicketUpdate)
|
|
||||||
if err != nil {
|
|
||||||
return web.JsonError(err)
|
|
||||||
}
|
|
||||||
req := request.AddTicketCollaboratorRequest{}
|
|
||||||
if err := params.ReadJSON(c.Ctx, &req); err != nil {
|
|
||||||
return web.JsonError(err)
|
|
||||||
}
|
|
||||||
if err := services.TicketService.AddCollaborator(req.TicketID, req.UserID, operator); err != nil {
|
|
||||||
return web.JsonError(err)
|
|
||||||
}
|
|
||||||
return web.JsonSuccess()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *TicketController) PostDelete_collaborator() *web.JsonResult {
|
|
||||||
operator, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionTicketUpdate)
|
|
||||||
if err != nil {
|
|
||||||
return web.JsonError(err)
|
|
||||||
}
|
|
||||||
req := request.DeleteTicketCollaboratorRequest{}
|
|
||||||
if err := params.ReadJSON(c.Ctx, &req); err != nil {
|
|
||||||
return web.JsonError(err)
|
|
||||||
}
|
|
||||||
if err := services.TicketService.RemoveCollaborator(req.TicketID, req.CollaboratorID, operator); err != nil {
|
|
||||||
return web.JsonError(err)
|
|
||||||
}
|
|
||||||
return web.JsonSuccess()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *TicketController) AnyComment_list() *web.JsonResult {
|
|
||||||
if _, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionTicketView); err != nil {
|
if _, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionTicketView); err != nil {
|
||||||
return web.JsonError(err)
|
return web.JsonError(err)
|
||||||
}
|
}
|
||||||
ticketID, _ := params.GetInt64(c.Ctx, "ticketId")
|
ticketID, _ := params.GetInt64(c.Ctx, "ticketId")
|
||||||
if ticketID <= 0 {
|
if ticketID <= 0 {
|
||||||
return web.JsonData(&web.PageResult{Results: []any{}, Page: params.GetPaging(c.Ctx)})
|
return web.JsonErrorMsg("工单不存在")
|
||||||
}
|
}
|
||||||
cnd := params.NewPagedSqlCnd(c.Ctx, params.QueryFilter{ParamName: "ticketId"}).Asc("id")
|
if services.TicketService.Get(ticketID) == nil {
|
||||||
results, paging := services.TicketCommentService.FindPageByCnd(cnd)
|
return web.JsonErrorMsg("工单不存在")
|
||||||
return web.JsonData(&web.PageResult{Results: builders.BuildTicketCommentList(results), Page: paging})
|
}
|
||||||
|
progresses := services.TicketProgressService.Find(sqls.NewCnd().Eq("ticket_id", ticketID).Asc("id"))
|
||||||
|
return web.JsonData(builders.BuildTicketProgressList(progresses))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *TicketController) AnyEvent_list() *web.JsonResult {
|
func (c *TicketController) PostProgressCreate() *web.JsonResult {
|
||||||
if _, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionTicketView); err != nil {
|
return c.createProgress()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *TicketController) createProgress() *web.JsonResult {
|
||||||
|
operator, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionTicketProgress)
|
||||||
|
if err != nil {
|
||||||
return web.JsonError(err)
|
return web.JsonError(err)
|
||||||
}
|
}
|
||||||
cnd := params.NewPagedSqlCnd(c.Ctx, params.QueryFilter{ParamName: "ticketId"}).Desc("id")
|
req := request.CreateTicketProgressRequest{}
|
||||||
list, paging := services.TicketEventLogService.FindPageByCnd(cnd)
|
if err := params.ReadJSON(c.Ctx, &req); err != nil {
|
||||||
return web.JsonData(&web.PageResult{Results: builders.BuildTicketEventLogList(list), Page: paging})
|
return web.JsonError(err)
|
||||||
|
}
|
||||||
|
item, err := services.TicketService.AddProgress(req, operator)
|
||||||
|
if err != nil {
|
||||||
|
return web.JsonError(err)
|
||||||
|
}
|
||||||
|
return web.JsonData(builders.BuildTicketProgress(item))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
package dashboard
|
|
||||||
|
|
||||||
import (
|
|
||||||
"cs-agent/internal/builders"
|
|
||||||
"cs-agent/internal/pkg/constants"
|
|
||||||
"cs-agent/internal/pkg/dto/request"
|
|
||||||
"cs-agent/internal/pkg/enums"
|
|
||||||
"cs-agent/internal/services"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/kataras/iris/v12"
|
|
||||||
"github.com/mlogclub/simple/sqls"
|
|
||||||
"github.com/mlogclub/simple/web"
|
|
||||||
"github.com/mlogclub/simple/web/params"
|
|
||||||
)
|
|
||||||
|
|
||||||
type TicketPriorityConfigController struct{ Ctx iris.Context }
|
|
||||||
|
|
||||||
func (c *TicketPriorityConfigController) AnyList() *web.JsonResult {
|
|
||||||
if _, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionTicketPriorityConfigView); err != nil {
|
|
||||||
return web.JsonError(err)
|
|
||||||
}
|
|
||||||
cnd := sqls.NewCnd().Asc("sort_no").Asc("id")
|
|
||||||
if status, ok := params.Get(c.Ctx, "status"); ok && strings.TrimSpace(status) != "" {
|
|
||||||
cnd.Eq("status", status)
|
|
||||||
} else {
|
|
||||||
cnd.Where("status <> ?", enums.StatusDeleted)
|
|
||||||
}
|
|
||||||
if name, ok := params.Get(c.Ctx, "name"); ok && strings.TrimSpace(name) != "" {
|
|
||||||
cnd.Where("name LIKE ?", "%"+strings.TrimSpace(name)+"%")
|
|
||||||
}
|
|
||||||
list := services.TicketPriorityConfigService.Find(cnd)
|
|
||||||
return web.JsonData(builders.BuildTicketPriorityConfigList(list))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *TicketPriorityConfigController) GetList_all() *web.JsonResult {
|
|
||||||
if _, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionTicketPriorityConfigView); err != nil {
|
|
||||||
return web.JsonError(err)
|
|
||||||
}
|
|
||||||
list := services.TicketPriorityConfigService.Find(sqls.NewCnd().Eq("status", enums.StatusOk).Asc("sort_no").Asc("id"))
|
|
||||||
return web.JsonData(builders.BuildTicketPriorityConfigList(list))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *TicketPriorityConfigController) PostCreate() *web.JsonResult {
|
|
||||||
operator, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionTicketPriorityConfigCreate)
|
|
||||||
if err != nil {
|
|
||||||
return web.JsonError(err)
|
|
||||||
}
|
|
||||||
req := request.CreateTicketPriorityConfigRequest{}
|
|
||||||
if err := params.ReadJSON(c.Ctx, &req); err != nil {
|
|
||||||
return web.JsonError(err)
|
|
||||||
}
|
|
||||||
item, err := services.TicketPriorityConfigService.CreateTicketPriorityConfig(req, operator)
|
|
||||||
if err != nil {
|
|
||||||
return web.JsonError(err)
|
|
||||||
}
|
|
||||||
return web.JsonData(builders.BuildTicketPriorityConfig(item))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *TicketPriorityConfigController) PostUpdate() *web.JsonResult {
|
|
||||||
operator, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionTicketPriorityConfigUpdate)
|
|
||||||
if err != nil {
|
|
||||||
return web.JsonError(err)
|
|
||||||
}
|
|
||||||
req := request.UpdateTicketPriorityConfigRequest{}
|
|
||||||
if err := params.ReadJSON(c.Ctx, &req); err != nil {
|
|
||||||
return web.JsonError(err)
|
|
||||||
}
|
|
||||||
if err := services.TicketPriorityConfigService.UpdateTicketPriorityConfig(req, operator); err != nil {
|
|
||||||
return web.JsonError(err)
|
|
||||||
}
|
|
||||||
return web.JsonSuccess()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *TicketPriorityConfigController) PostUpdate_sort() *web.JsonResult {
|
|
||||||
if _, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionTicketPriorityConfigUpdate); err != nil {
|
|
||||||
return web.JsonError(err)
|
|
||||||
}
|
|
||||||
var ids []int64
|
|
||||||
if err := c.Ctx.ReadJSON(&ids); err != nil {
|
|
||||||
return web.JsonError(err)
|
|
||||||
}
|
|
||||||
if err := services.TicketPriorityConfigService.UpdateSort(ids); err != nil {
|
|
||||||
return web.JsonError(err)
|
|
||||||
}
|
|
||||||
return web.JsonSuccess()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *TicketPriorityConfigController) PostDelete() *web.JsonResult {
|
|
||||||
operator, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionTicketPriorityConfigDelete)
|
|
||||||
if err != nil {
|
|
||||||
return web.JsonError(err)
|
|
||||||
}
|
|
||||||
req := request.DeleteTicketPriorityConfigRequest{}
|
|
||||||
if err := params.ReadJSON(c.Ctx, &req); err != nil {
|
|
||||||
return web.JsonError(err)
|
|
||||||
}
|
|
||||||
if err := services.TicketPriorityConfigService.DeleteTicketPriorityConfig(req.ID, operator); err != nil {
|
|
||||||
return web.JsonError(err)
|
|
||||||
}
|
|
||||||
return web.JsonSuccess()
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
package dashboard
|
|
||||||
|
|
||||||
import (
|
|
||||||
"cs-agent/internal/builders"
|
|
||||||
"cs-agent/internal/pkg/constants"
|
|
||||||
"cs-agent/internal/pkg/dto/request"
|
|
||||||
"cs-agent/internal/pkg/enums"
|
|
||||||
"cs-agent/internal/services"
|
|
||||||
|
|
||||||
"github.com/kataras/iris/v12"
|
|
||||||
"github.com/mlogclub/simple/sqls"
|
|
||||||
"github.com/mlogclub/simple/web"
|
|
||||||
"github.com/mlogclub/simple/web/params"
|
|
||||||
)
|
|
||||||
|
|
||||||
type TicketResolutionCodeController struct{ Ctx iris.Context }
|
|
||||||
|
|
||||||
func (c *TicketResolutionCodeController) AnyList() *web.JsonResult {
|
|
||||||
if _, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionTicketResolutionCodeView); err != nil {
|
|
||||||
return web.JsonError(err)
|
|
||||||
}
|
|
||||||
cnd := params.NewPagedSqlCnd(c.Ctx,
|
|
||||||
params.QueryFilter{ParamName: "status"},
|
|
||||||
params.QueryFilter{ParamName: "name", Op: params.Like},
|
|
||||||
).Asc("sort_no").Desc("id")
|
|
||||||
if _, ok := params.Get(c.Ctx, "status"); !ok {
|
|
||||||
cnd.Where("status <> ?", enums.StatusDeleted)
|
|
||||||
}
|
|
||||||
list, paging := services.TicketResolutionCodeService.FindPageByCnd(cnd)
|
|
||||||
return web.JsonData(&web.PageResult{Results: builders.BuildTicketResolutionCodeList(list), Page: paging})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *TicketResolutionCodeController) GetList_all() *web.JsonResult {
|
|
||||||
if _, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionTicketResolutionCodeView); err != nil {
|
|
||||||
return web.JsonError(err)
|
|
||||||
}
|
|
||||||
list := services.TicketResolutionCodeService.Find(sqls.NewCnd().Eq("status", enums.StatusOk).Asc("sort_no").Desc("id"))
|
|
||||||
return web.JsonData(builders.BuildTicketResolutionCodeList(list))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *TicketResolutionCodeController) PostCreate() *web.JsonResult {
|
|
||||||
operator, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionTicketResolutionCodeCreate)
|
|
||||||
if err != nil {
|
|
||||||
return web.JsonError(err)
|
|
||||||
}
|
|
||||||
req := request.CreateTicketResolutionCodeRequest{}
|
|
||||||
if err := params.ReadJSON(c.Ctx, &req); err != nil {
|
|
||||||
return web.JsonError(err)
|
|
||||||
}
|
|
||||||
item, err := services.TicketResolutionCodeService.CreateTicketResolutionCode(req, operator)
|
|
||||||
if err != nil {
|
|
||||||
return web.JsonError(err)
|
|
||||||
}
|
|
||||||
return web.JsonData(builders.BuildTicketResolutionCode(item))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *TicketResolutionCodeController) PostUpdate() *web.JsonResult {
|
|
||||||
operator, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionTicketResolutionCodeUpdate)
|
|
||||||
if err != nil {
|
|
||||||
return web.JsonError(err)
|
|
||||||
}
|
|
||||||
req := request.UpdateTicketResolutionCodeRequest{}
|
|
||||||
if err := params.ReadJSON(c.Ctx, &req); err != nil {
|
|
||||||
return web.JsonError(err)
|
|
||||||
}
|
|
||||||
if err := services.TicketResolutionCodeService.UpdateTicketResolutionCode(req, operator); err != nil {
|
|
||||||
return web.JsonError(err)
|
|
||||||
}
|
|
||||||
return web.JsonSuccess()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *TicketResolutionCodeController) PostDelete() *web.JsonResult {
|
|
||||||
operator, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionTicketResolutionCodeDelete)
|
|
||||||
if err != nil {
|
|
||||||
return web.JsonError(err)
|
|
||||||
}
|
|
||||||
req := request.DeleteTicketResolutionCodeRequest{}
|
|
||||||
if err := params.ReadJSON(c.Ctx, &req); err != nil {
|
|
||||||
return web.JsonError(err)
|
|
||||||
}
|
|
||||||
if err := services.TicketResolutionCodeService.DeleteTicketResolutionCode(req.ID, operator); err != nil {
|
|
||||||
return web.JsonError(err)
|
|
||||||
}
|
|
||||||
return web.JsonSuccess()
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
package migration
|
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"cs-agent/internal/models"
|
|
||||||
"cs-agent/internal/pkg/constants"
|
|
||||||
"cs-agent/internal/pkg/enums"
|
|
||||||
|
|
||||||
"github.com/mlogclub/simple/sqls"
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
register(3, "init ticket priority configs", func() error {
|
|
||||||
return sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
|
||||||
var total int64
|
|
||||||
if err := ctx.Tx.Model(&models.TicketPriorityConfig{}).Where("status <> ?", enums.StatusDeleted).Count(&total).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if total == 0 {
|
|
||||||
now := time.Now()
|
|
||||||
items := []*models.TicketPriorityConfig{
|
|
||||||
{Name: "普通", SortNo: 10, FirstResponseMinutes: 30, ResolutionMinutes: 1440, Status: enums.StatusOk},
|
|
||||||
{Name: "高", SortNo: 20, FirstResponseMinutes: 10, ResolutionMinutes: 240, Status: enums.StatusOk},
|
|
||||||
{Name: "紧急", SortNo: 30, FirstResponseMinutes: 5, ResolutionMinutes: 120, Status: enums.StatusOk},
|
|
||||||
}
|
|
||||||
for _, item := range items {
|
|
||||||
item.AuditFields = models.AuditFields{
|
|
||||||
CreatedAt: now,
|
|
||||||
CreateUserID: constants.SystemAuditUserID,
|
|
||||||
CreateUserName: constants.SystemAuditUserName,
|
|
||||||
UpdatedAt: now,
|
|
||||||
UpdateUserID: constants.SystemAuditUserID,
|
|
||||||
UpdateUserName: constants.SystemAuditUserName,
|
|
||||||
}
|
|
||||||
if err := ctx.Tx.Create(item).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package migration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cs-agent/internal/models"
|
||||||
|
|
||||||
|
"github.com/mlogclub/simple/sqls"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
register(8, "sync lightweight ticket permissions and reset ticket data", func() error {
|
||||||
|
return sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||||
|
if err := resetLightweightTicketData(ctx.Tx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := deleteObsoleteTicketPermissions(ctx.Tx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
permissions, err := ensurePermissions(ctx.Tx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
roles, err := ensureRoles(ctx.Tx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return ensureRolePermissions(ctx.Tx, roles, permissions)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteObsoleteTicketPermissions(tx *gorm.DB) error {
|
||||||
|
codes := obsoleteTicketPermissionCodes()
|
||||||
|
if len(codes) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
permissionIDs := tx.Model(&models.Permission{}).Select("id").Where("code IN ?", codes)
|
||||||
|
if err := tx.Where("permission_id IN (?)", permissionIDs).Delete(&models.RolePermission{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
permissionIDs = tx.Model(&models.Permission{}).Select("id").Where("code IN ?", codes)
|
||||||
|
if err := tx.Where("permission_id IN (?)", permissionIDs).Delete(&models.UserPermission{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Where("code IN ?", codes).Delete(&models.Permission{}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func obsoleteTicketPermissionCodes() []string {
|
||||||
|
return []string{
|
||||||
|
"ticket.reply",
|
||||||
|
"ticket.close",
|
||||||
|
"ticket.reopen",
|
||||||
|
"ticketResolutionCode.view",
|
||||||
|
"ticketResolutionCode.create",
|
||||||
|
"ticketResolutionCode.update",
|
||||||
|
"ticketResolutionCode.delete",
|
||||||
|
"ticketPriorityConfig.view",
|
||||||
|
"ticketPriorityConfig.create",
|
||||||
|
"ticketPriorityConfig.update",
|
||||||
|
"ticketPriorityConfig.delete",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetLightweightTicketData(tx *gorm.DB) error {
|
||||||
|
for _, table := range lightweightTicketResetTables(tx) {
|
||||||
|
if !tx.Migrator().HasTable(table) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := tx.Exec("DELETE FROM " + table).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func lightweightTicketResetTables(tx *gorm.DB) []string {
|
||||||
|
return []string{
|
||||||
|
"t_ticket_sla_record",
|
||||||
|
"t_ticket_resolution_code",
|
||||||
|
"t_ticket_priority_config",
|
||||||
|
"t_ticket_watcher",
|
||||||
|
"t_ticket_collaborator",
|
||||||
|
"t_ticket_mention",
|
||||||
|
"t_ticket_event_log",
|
||||||
|
"t_ticket_relation",
|
||||||
|
"t_ticket_comment",
|
||||||
|
tableName(tx, &models.TicketProgress{}, "t_ticket_progress"),
|
||||||
|
tableName(tx, &models.TicketTag{}, "t_ticket_tag"),
|
||||||
|
tableName(tx, &models.Ticket{}, "t_ticket"),
|
||||||
|
tableName(tx, &models.TicketNoSequence{}, "t_ticket_no_sequence"),
|
||||||
|
tableName(tx, &models.TicketView{}, "t_ticket_view"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func tableName(tx *gorm.DB, model any, fallback string) string {
|
||||||
|
stmt := &gorm.Statement{DB: tx}
|
||||||
|
if err := stmt.Parse(model); err != nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return stmt.Schema.Table
|
||||||
|
}
|
||||||
@@ -1,6 +1,15 @@
|
|||||||
package migration
|
package migration
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"cs-agent/internal/models"
|
||||||
|
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/schema"
|
||||||
|
)
|
||||||
|
|
||||||
func TestNotificationPermissionMigrationRegistered(t *testing.T) {
|
func TestNotificationPermissionMigrationRegistered(t *testing.T) {
|
||||||
migration, ok := migrationFuncs[7]
|
migration, ok := migrationFuncs[7]
|
||||||
@@ -11,3 +20,165 @@ func TestNotificationPermissionMigrationRegistered(t *testing.T) {
|
|||||||
t.Fatalf("unexpected migration remark: %q", migration.Remark)
|
t.Fatalf("unexpected migration remark: %q", migration.Remark)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLightweightTicketPermissionMigrationRegistered(t *testing.T) {
|
||||||
|
migration, ok := migrationFuncs[8]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected migration version 8 to be registered")
|
||||||
|
}
|
||||||
|
if migration.Remark != "sync lightweight ticket permissions and reset ticket data" {
|
||||||
|
t.Fatalf("unexpected migration remark: %q", migration.Remark)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLightweightTicketMigrationResetDeletesTicketData(t *testing.T) {
|
||||||
|
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{
|
||||||
|
NamingStrategy: schema.NamingStrategy{
|
||||||
|
TablePrefix: "t_",
|
||||||
|
SingularTable: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open sqlite db: %v", err)
|
||||||
|
}
|
||||||
|
sqlDB, err := db.DB()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get sqlite db: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = sqlDB.Close()
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := db.AutoMigrate(&models.Ticket{}, &models.TicketTag{}, &models.TicketProgress{}, &models.TicketNoSequence{}); err != nil {
|
||||||
|
t.Fatalf("AutoMigrate() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
ticket := &models.Ticket{
|
||||||
|
TicketNo: "TK2026050200001",
|
||||||
|
Title: "legacy ticket",
|
||||||
|
Description: "legacy ticket description",
|
||||||
|
AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now},
|
||||||
|
}
|
||||||
|
if err := db.Create(ticket).Error; err != nil {
|
||||||
|
t.Fatalf("create ticket error = %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&models.TicketTag{TicketID: ticket.ID, TagID: 1, AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now}}).Error; err != nil {
|
||||||
|
t.Fatalf("create ticket tag error = %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&models.TicketProgress{TicketID: ticket.ID, Content: "legacy progress", CreatedAt: now}).Error; err != nil {
|
||||||
|
t.Fatalf("create ticket progress error = %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&models.TicketNoSequence{DateKey: "20260502", NextSeq: 2, CreatedAt: now, UpdatedAt: now}).Error; err != nil {
|
||||||
|
t.Fatalf("create ticket no sequence error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := resetLightweightTicketData(db); err != nil {
|
||||||
|
t.Fatalf("resetLightweightTicketData() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertTableCount(t, db, &models.Ticket{}, 0)
|
||||||
|
assertTableCount(t, db, &models.TicketTag{}, 0)
|
||||||
|
assertTableCount(t, db, &models.TicketProgress{}, 0)
|
||||||
|
assertTableCount(t, db, &models.TicketNoSequence{}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLightweightTicketMigrationDeletesObsoletePermissions(t *testing.T) {
|
||||||
|
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{
|
||||||
|
NamingStrategy: schema.NamingStrategy{
|
||||||
|
TablePrefix: "t_",
|
||||||
|
SingularTable: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open sqlite db: %v", err)
|
||||||
|
}
|
||||||
|
sqlDB, err := db.DB()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get sqlite db: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = sqlDB.Close()
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := db.AutoMigrate(&models.Permission{}, &models.RolePermission{}, &models.UserPermission{}); err != nil {
|
||||||
|
t.Fatalf("AutoMigrate() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
legacyPermission := &models.Permission{
|
||||||
|
Name: "回复工单",
|
||||||
|
Code: "ticket.reply",
|
||||||
|
Type: "api",
|
||||||
|
GroupName: "ticket",
|
||||||
|
AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now},
|
||||||
|
}
|
||||||
|
currentPermission := &models.Permission{
|
||||||
|
Name: "查看工单",
|
||||||
|
Code: "ticket.view",
|
||||||
|
Type: "api",
|
||||||
|
GroupName: "ticket",
|
||||||
|
AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now},
|
||||||
|
}
|
||||||
|
if err := db.Create(legacyPermission).Error; err != nil {
|
||||||
|
t.Fatalf("create legacy permission error = %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Create(currentPermission).Error; err != nil {
|
||||||
|
t.Fatalf("create current permission error = %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&models.RolePermission{RoleID: 1, PermissionID: legacyPermission.ID, AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now}}).Error; err != nil {
|
||||||
|
t.Fatalf("create legacy role permission error = %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&models.UserPermission{UserID: 1, PermissionID: legacyPermission.ID, Effect: 1, AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now}}).Error; err != nil {
|
||||||
|
t.Fatalf("create legacy user permission error = %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&models.RolePermission{RoleID: 1, PermissionID: currentPermission.ID, AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now}}).Error; err != nil {
|
||||||
|
t.Fatalf("create current role permission error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := deleteObsoleteTicketPermissions(db); err != nil {
|
||||||
|
t.Fatalf("deleteObsoleteTicketPermissions() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertPermissionCodeCount(t, db, "ticket.reply", 0)
|
||||||
|
assertPermissionCodeCount(t, db, "ticket.view", 1)
|
||||||
|
assertPermissionRelationCount(t, db, &models.RolePermission{}, legacyPermission.ID, 0)
|
||||||
|
assertPermissionRelationCount(t, db, &models.UserPermission{}, legacyPermission.ID, 0)
|
||||||
|
assertPermissionRelationCount(t, db, &models.RolePermission{}, currentPermission.ID, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertTableCount(t *testing.T, db *gorm.DB, model any, expected int64) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
if err := db.Model(model).Count(&count).Error; err != nil {
|
||||||
|
t.Fatalf("count %T error = %v", model, err)
|
||||||
|
}
|
||||||
|
if count != expected {
|
||||||
|
t.Fatalf("expected %T count %d, got %d", model, expected, count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertPermissionCodeCount(t *testing.T, db *gorm.DB, code string, expected int64) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
if err := db.Model(&models.Permission{}).Where("code = ?", code).Count(&count).Error; err != nil {
|
||||||
|
t.Fatalf("count permission %s error = %v", code, err)
|
||||||
|
}
|
||||||
|
if count != expected {
|
||||||
|
t.Fatalf("expected permission %s count %d, got %d", code, expected, count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertPermissionRelationCount(t *testing.T, db *gorm.DB, model any, permissionID int64, expected int64) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
if err := db.Model(model).Where("permission_id = ?", permissionID).Count(&count).Error; err != nil {
|
||||||
|
t.Fatalf("count %T permission %d error = %v", model, permissionID, err)
|
||||||
|
}
|
||||||
|
if count != expected {
|
||||||
|
t.Fatalf("expected %T permission %d count %d, got %d", model, permissionID, expected, count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+20
-135
@@ -8,8 +8,6 @@ import (
|
|||||||
// Models 注册所有需要迁移和代码生成的模型。
|
// Models 注册所有需要迁移和代码生成的模型。
|
||||||
var Models = []any{
|
var Models = []any{
|
||||||
&Migration{},
|
&Migration{},
|
||||||
&TicketNoSequence{},
|
|
||||||
&TicketView{},
|
|
||||||
&User{},
|
&User{},
|
||||||
&UserIdentity{},
|
&UserIdentity{},
|
||||||
&Company{},
|
&Company{},
|
||||||
@@ -39,15 +37,9 @@ var Models = []any{
|
|||||||
&ConversationEventLog{},
|
&ConversationEventLog{},
|
||||||
&Ticket{},
|
&Ticket{},
|
||||||
&TicketTag{},
|
&TicketTag{},
|
||||||
&TicketResolutionCode{},
|
&TicketProgress{},
|
||||||
&TicketPriorityConfig{},
|
&TicketView{},
|
||||||
&TicketComment{},
|
&TicketNoSequence{},
|
||||||
&TicketWatcher{},
|
|
||||||
&TicketCollaborator{},
|
|
||||||
&TicketMention{},
|
|
||||||
&TicketEventLog{},
|
|
||||||
&TicketSLARecord{},
|
|
||||||
&TicketRelation{},
|
|
||||||
&Notification{},
|
&Notification{},
|
||||||
&AIAgent{},
|
&AIAgent{},
|
||||||
&Channel{},
|
&Channel{},
|
||||||
@@ -568,35 +560,19 @@ type ConversationEventLog struct {
|
|||||||
CreatedAt time.Time `gorm:"type:datetime;not null;index"`
|
CreatedAt time.Time `gorm:"type:datetime;not null;index"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ticket 客服工单主档。
|
// Ticket 客服问题记录。
|
||||||
type Ticket struct {
|
type Ticket struct {
|
||||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||||
TicketNo string `gorm:"type:varchar(64);not null;default:'';uniqueIndex"`
|
TicketNo string `gorm:"type:varchar(64);not null;default:'';uniqueIndex"`
|
||||||
Title string `gorm:"type:varchar(255);not null;default:'';index"`
|
Title string `gorm:"type:varchar(255);not null;default:'';index"`
|
||||||
Description string `gorm:"type:text"`
|
Description string `gorm:"type:text"`
|
||||||
Source enums.TicketSource `gorm:"type:varchar(50);not null;default:'';index"`
|
Source enums.TicketSource `gorm:"type:varchar(50);not null;default:'';index"`
|
||||||
Channel string `gorm:"type:varchar(50);not null;default:'';index"`
|
Channel string `gorm:"type:varchar(50);not null;default:'';index"`
|
||||||
CustomerID int64 `gorm:"type:bigint;not null;default:0;index"`
|
CustomerID int64 `gorm:"type:bigint;not null;default:0;index"`
|
||||||
ConversationID int64 `gorm:"type:bigint;not null;default:0;index"`
|
ConversationID int64 `gorm:"type:bigint;not null;default:0;index"`
|
||||||
Type string `gorm:"type:varchar(50);not null;default:'';index"`
|
Status enums.TicketStatus `gorm:"type:varchar(50);not null;default:'pending';index"`
|
||||||
Priority int64 `gorm:"type:bigint;not null;default:0;index"`
|
CurrentAssigneeID int64 `gorm:"type:bigint;not null;default:0;index"`
|
||||||
Severity enums.TicketSeverity `gorm:"type:int;not null;default:1;index"`
|
HandledAt *time.Time `gorm:"type:datetime;index"`
|
||||||
Status enums.TicketStatus `gorm:"type:varchar(50);not null;default:'new';index"`
|
|
||||||
CurrentTeamID int64 `gorm:"type:bigint;not null;default:0;index"`
|
|
||||||
CurrentAssigneeID int64 `gorm:"type:bigint;not null;default:0;index"`
|
|
||||||
PendingReason string `gorm:"type:varchar(255);not null;default:''"`
|
|
||||||
CloseReason string `gorm:"type:varchar(255);not null;default:''"`
|
|
||||||
ResolutionCode string `gorm:"type:varchar(100);not null;default:''"`
|
|
||||||
ResolutionSummary string `gorm:"type:text"`
|
|
||||||
FirstResponseAt *time.Time `gorm:"type:datetime;index"`
|
|
||||||
ResolvedAt *time.Time `gorm:"type:datetime;index"`
|
|
||||||
ClosedAt *time.Time `gorm:"type:datetime;index"`
|
|
||||||
DueAt *time.Time `gorm:"type:datetime;index"`
|
|
||||||
NextReplyDeadlineAt *time.Time `gorm:"type:datetime;index"`
|
|
||||||
ResolveDeadlineAt *time.Time `gorm:"type:datetime;index"`
|
|
||||||
ReopenedCount int `gorm:"type:int;not null;default:0"`
|
|
||||||
CustomFieldsJSON string `gorm:"type:text"`
|
|
||||||
ExtraJSON string `gorm:"type:text"`
|
|
||||||
AuditFields
|
AuditFields
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -608,106 +584,15 @@ type TicketTag struct {
|
|||||||
AuditFields
|
AuditFields
|
||||||
}
|
}
|
||||||
|
|
||||||
// TicketResolutionCode 工单解决码。
|
// TicketProgress 工单处理进展。
|
||||||
type TicketResolutionCode struct {
|
type TicketProgress struct {
|
||||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
|
||||||
Name string `gorm:"type:varchar(100);not null;default:'';index"`
|
|
||||||
Code string `gorm:"type:varchar(100);not null;default:'';uniqueIndex"`
|
|
||||||
SortNo int `gorm:"type:int;not null;default:0;index"`
|
|
||||||
Status enums.Status `gorm:"type:int;not null;default:0;index"`
|
|
||||||
Remark string `gorm:"type:text"`
|
|
||||||
AuditFields
|
|
||||||
}
|
|
||||||
|
|
||||||
// TicketPriorityConfig 工单优先级配置。
|
|
||||||
type TicketPriorityConfig struct {
|
|
||||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
|
||||||
Name string `gorm:"type:varchar(100);not null;default:'';uniqueIndex"`
|
|
||||||
SortNo int `gorm:"type:int;not null;default:0;index"`
|
|
||||||
FirstResponseMinutes int `gorm:"type:int;not null;default:0"`
|
|
||||||
ResolutionMinutes int `gorm:"type:int;not null;default:0"`
|
|
||||||
Status enums.Status `gorm:"type:int;not null;default:0;index"`
|
|
||||||
Remark string `gorm:"type:text"`
|
|
||||||
AuditFields
|
|
||||||
}
|
|
||||||
|
|
||||||
// TicketComment 工单评论。
|
|
||||||
type TicketComment struct {
|
|
||||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
|
||||||
TicketID int64 `gorm:"type:bigint;not null;index"`
|
|
||||||
CommentType enums.TicketCommentType `gorm:"type:varchar(50);not null;default:'';index"`
|
|
||||||
AuthorType enums.IMSenderType `gorm:"type:varchar(30);not null;default:'';index"`
|
|
||||||
AuthorID int64 `gorm:"type:bigint;not null;default:0;index"`
|
|
||||||
ContentType string `gorm:"type:varchar(30);not null;default:''"`
|
|
||||||
Content string `gorm:"type:text"`
|
|
||||||
Payload string `gorm:"type:text"`
|
|
||||||
CreatedAt time.Time `gorm:"type:datetime;not null;index"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// TicketWatcher 工单关注人。
|
|
||||||
type TicketWatcher struct {
|
|
||||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||||
TicketID int64 `gorm:"type:bigint;not null;index;uniqueIndex:uk_ticket_watcher"`
|
TicketID int64 `gorm:"type:bigint;not null;index"`
|
||||||
UserID int64 `gorm:"type:bigint;not null;index;uniqueIndex:uk_ticket_watcher"`
|
Content string `gorm:"type:text"`
|
||||||
|
AuthorID int64 `gorm:"type:bigint;not null;default:0;index"`
|
||||||
CreatedAt time.Time `gorm:"type:datetime;not null;index"`
|
CreatedAt time.Time `gorm:"type:datetime;not null;index"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TicketCollaborator 工单协作人。
|
|
||||||
type TicketCollaborator struct {
|
|
||||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
|
||||||
TicketID int64 `gorm:"type:bigint;not null;index;uniqueIndex:uk_ticket_collaborator"`
|
|
||||||
UserID int64 `gorm:"type:bigint;not null;index;uniqueIndex:uk_ticket_collaborator"`
|
|
||||||
CreatedAt time.Time `gorm:"type:datetime;not null;index"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// TicketMention 工单提及记录。
|
|
||||||
type TicketMention struct {
|
|
||||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
|
||||||
TicketID int64 `gorm:"type:bigint;not null;index;uniqueIndex:uk_ticket_mention"`
|
|
||||||
CommentID int64 `gorm:"type:bigint;not null;index;uniqueIndex:uk_ticket_mention"`
|
|
||||||
MentionedUserID int64 `gorm:"type:bigint;not null;index;uniqueIndex:uk_ticket_mention"`
|
|
||||||
CreatedAt time.Time `gorm:"type:datetime;not null;index"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// TicketEventLog 工单事件日志。
|
|
||||||
type TicketEventLog struct {
|
|
||||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
|
||||||
TicketID int64 `gorm:"type:bigint;not null;index"`
|
|
||||||
EventType enums.TicketEventType `gorm:"type:varchar(50);not null;default:'';index"`
|
|
||||||
OperatorType enums.IMSenderType `gorm:"type:varchar(30);not null;default:'';index"`
|
|
||||||
OperatorID int64 `gorm:"type:bigint;not null;default:0;index"`
|
|
||||||
OldValue string `gorm:"type:text"`
|
|
||||||
NewValue string `gorm:"type:text"`
|
|
||||||
Content string `gorm:"type:text"`
|
|
||||||
Payload string `gorm:"type:text"`
|
|
||||||
CreatedAt time.Time `gorm:"type:datetime;not null;index"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// TicketSLARecord 工单 SLA 记录。
|
|
||||||
type TicketSLARecord struct {
|
|
||||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
|
||||||
TicketID int64 `gorm:"type:bigint;not null;index"`
|
|
||||||
SLAType enums.TicketSLAType `gorm:"type:varchar(50);not null;default:'';index"`
|
|
||||||
TargetMinutes int `gorm:"type:int;not null;default:0"`
|
|
||||||
Status enums.TicketSLAStatus `gorm:"type:varchar(30);not null;default:'';index"`
|
|
||||||
StartedAt *time.Time `gorm:"type:datetime;index"`
|
|
||||||
PausedAt *time.Time `gorm:"type:datetime;index"`
|
|
||||||
StoppedAt *time.Time `gorm:"type:datetime;index"`
|
|
||||||
BreachedAt *time.Time `gorm:"type:datetime;index"`
|
|
||||||
ElapsedMin int `gorm:"type:int;not null;default:0"`
|
|
||||||
CreatedAt time.Time `gorm:"type:datetime;not null;index"`
|
|
||||||
UpdatedAt time.Time `gorm:"type:datetime;not null;index"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// TicketRelation 工单关联关系。
|
|
||||||
type TicketRelation struct {
|
|
||||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
|
||||||
TicketID int64 `gorm:"type:bigint;not null;index"`
|
|
||||||
RelatedTicketID int64 `gorm:"type:bigint;not null;index"`
|
|
||||||
RelationType enums.TicketRelationType `gorm:"type:varchar(30);not null;default:'';index"`
|
|
||||||
CreatedAt time.Time `gorm:"type:datetime;not null;index"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// AgentProfile 客服档案。
|
// AgentProfile 客服档案。
|
||||||
type AgentProfile struct {
|
type AgentProfile struct {
|
||||||
ID int64 `gorm:"primaryKey;autoIncrement"` // ID 为客服档案主键。
|
ID int64 `gorm:"primaryKey;autoIncrement"` // ID 为客服档案主键。
|
||||||
|
|||||||
@@ -68,22 +68,12 @@ var (
|
|||||||
PermissionConversationLinkCustomer = Permission{Name: "关联会话客户", Code: "conversation.linkCustomer", Type: "api", GroupName: "conversation", Method: "POST", APIPath: "/api/dashboard/conversation/link_customer", SortNo: 495}
|
PermissionConversationLinkCustomer = Permission{Name: "关联会话客户", Code: "conversation.linkCustomer", Type: "api", GroupName: "conversation", Method: "POST", APIPath: "/api/dashboard/conversation/link_customer", SortNo: 495}
|
||||||
|
|
||||||
// 工单相关权限
|
// 工单相关权限
|
||||||
PermissionTicketView = Permission{Name: "查看工单", Code: "ticket.view", Type: "api", GroupName: "ticket", Method: "ANY", APIPath: "/api/dashboard/ticket/list", SortNo: 500}
|
PermissionTicketView = Permission{Name: "查看工单", Code: "ticket.view", Type: "api", GroupName: "ticket", Method: "ANY", APIPath: "/api/dashboard/ticket/list", SortNo: 500}
|
||||||
PermissionTicketCreate = Permission{Name: "创建工单", Code: "ticket.create", Type: "api", GroupName: "ticket", Method: "POST", APIPath: "/api/dashboard/ticket/create", SortNo: 510}
|
PermissionTicketCreate = Permission{Name: "创建工单", Code: "ticket.create", Type: "api", GroupName: "ticket", Method: "POST", APIPath: "/api/dashboard/ticket/create", SortNo: 510}
|
||||||
PermissionTicketUpdate = Permission{Name: "更新工单", Code: "ticket.update", Type: "api", GroupName: "ticket", Method: "POST", APIPath: "/api/dashboard/ticket/update", SortNo: 520}
|
PermissionTicketUpdate = Permission{Name: "更新工单", Code: "ticket.update", Type: "api", GroupName: "ticket", Method: "POST", APIPath: "/api/dashboard/ticket/update", SortNo: 520}
|
||||||
PermissionTicketAssign = Permission{Name: "指派工单", Code: "ticket.assign", Type: "api", GroupName: "ticket", Method: "POST", APIPath: "/api/dashboard/ticket/assign", SortNo: 530}
|
PermissionTicketAssign = Permission{Name: "指派工单", Code: "ticket.assign", Type: "api", GroupName: "ticket", Method: "POST", APIPath: "/api/dashboard/ticket/assign", SortNo: 530}
|
||||||
PermissionTicketChangeStatus = Permission{Name: "变更工单状态", Code: "ticket.changeStatus", Type: "api", GroupName: "ticket", Method: "POST", APIPath: "/api/dashboard/ticket/change_status", SortNo: 540}
|
PermissionTicketChangeStatus = Permission{Name: "变更工单状态", Code: "ticket.changeStatus", Type: "api", GroupName: "ticket", Method: "POST", APIPath: "/api/dashboard/ticket/change_status", SortNo: 540}
|
||||||
PermissionTicketReply = Permission{Name: "回复工单", Code: "ticket.reply", Type: "api", GroupName: "ticket", Method: "POST", APIPath: "/api/dashboard/ticket/reply", SortNo: 550}
|
PermissionTicketProgress = Permission{Name: "更新工单进展", Code: "ticket.progress", Type: "api", GroupName: "ticket", Method: "POST", APIPath: "/api/dashboard/ticket/progress/create", SortNo: 550}
|
||||||
PermissionTicketClose = Permission{Name: "关闭工单", Code: "ticket.close", Type: "api", GroupName: "ticket", Method: "POST", APIPath: "/api/dashboard/ticket/close", SortNo: 560}
|
|
||||||
PermissionTicketReopen = Permission{Name: "重开工单", Code: "ticket.reopen", Type: "api", GroupName: "ticket", Method: "POST", APIPath: "/api/dashboard/ticket/reopen", SortNo: 570}
|
|
||||||
PermissionTicketResolutionCodeView = Permission{Name: "查看工单解决码", Code: "ticketResolutionCode.view", Type: "api", GroupName: "ticketResolutionCode", Method: "ANY", APIPath: "/api/dashboard/ticket-resolution-code/list", SortNo: 580}
|
|
||||||
PermissionTicketResolutionCodeCreate = Permission{Name: "创建工单解决码", Code: "ticketResolutionCode.create", Type: "api", GroupName: "ticketResolutionCode", Method: "POST", APIPath: "/api/dashboard/ticket-resolution-code/create", SortNo: 590}
|
|
||||||
PermissionTicketResolutionCodeUpdate = Permission{Name: "更新工单解决码", Code: "ticketResolutionCode.update", Type: "api", GroupName: "ticketResolutionCode", Method: "POST", APIPath: "/api/dashboard/ticket-resolution-code/update", SortNo: 600}
|
|
||||||
PermissionTicketResolutionCodeDelete = Permission{Name: "删除工单解决码", Code: "ticketResolutionCode.delete", Type: "api", GroupName: "ticketResolutionCode", Method: "POST", APIPath: "/api/dashboard/ticket-resolution-code/delete", SortNo: 610}
|
|
||||||
PermissionTicketPriorityConfigView = Permission{Name: "查看工单优先级", Code: "ticketPriorityConfig.view", Type: "api", GroupName: "ticketPriorityConfig", Method: "ANY", APIPath: "/api/dashboard/ticket-priority-config/list", SortNo: 620}
|
|
||||||
PermissionTicketPriorityConfigCreate = Permission{Name: "创建工单优先级", Code: "ticketPriorityConfig.create", Type: "api", GroupName: "ticketPriorityConfig", Method: "POST", APIPath: "/api/dashboard/ticket-priority-config/create", SortNo: 630}
|
|
||||||
PermissionTicketPriorityConfigUpdate = Permission{Name: "更新工单优先级", Code: "ticketPriorityConfig.update", Type: "api", GroupName: "ticketPriorityConfig", Method: "POST", APIPath: "/api/dashboard/ticket-priority-config/update", SortNo: 640}
|
|
||||||
PermissionTicketPriorityConfigDelete = Permission{Name: "删除工单优先级", Code: "ticketPriorityConfig.delete", Type: "api", GroupName: "ticketPriorityConfig", Method: "POST", APIPath: "/api/dashboard/ticket-priority-config/delete", SortNo: 650}
|
|
||||||
|
|
||||||
// 通知相关权限
|
// 通知相关权限
|
||||||
PermissionNotificationView = Permission{Name: "查看通知", Code: "notification.view", Type: "api", GroupName: "notification", Method: "ANY", APIPath: "/api/dashboard/notification/list", SortNo: 680}
|
PermissionNotificationView = Permission{Name: "查看通知", Code: "notification.view", Type: "api", GroupName: "notification", Method: "ANY", APIPath: "/api/dashboard/notification/list", SortNo: 680}
|
||||||
@@ -214,17 +204,7 @@ var Permissions = []Permission{
|
|||||||
PermissionTicketUpdate,
|
PermissionTicketUpdate,
|
||||||
PermissionTicketAssign,
|
PermissionTicketAssign,
|
||||||
PermissionTicketChangeStatus,
|
PermissionTicketChangeStatus,
|
||||||
PermissionTicketReply,
|
PermissionTicketProgress,
|
||||||
PermissionTicketClose,
|
|
||||||
PermissionTicketReopen,
|
|
||||||
PermissionTicketResolutionCodeView,
|
|
||||||
PermissionTicketResolutionCodeCreate,
|
|
||||||
PermissionTicketResolutionCodeUpdate,
|
|
||||||
PermissionTicketResolutionCodeDelete,
|
|
||||||
PermissionTicketPriorityConfigView,
|
|
||||||
PermissionTicketPriorityConfigCreate,
|
|
||||||
PermissionTicketPriorityConfigUpdate,
|
|
||||||
PermissionTicketPriorityConfigDelete,
|
|
||||||
PermissionNotificationView,
|
PermissionNotificationView,
|
||||||
PermissionNotificationUpdate,
|
PermissionNotificationUpdate,
|
||||||
PermissionQuickReplyView,
|
PermissionQuickReplyView,
|
||||||
@@ -324,9 +304,7 @@ var RolePermissions = map[string][]Permission{
|
|||||||
PermissionPermissionView, PermissionPermissionSync,
|
PermissionPermissionView, PermissionPermissionSync,
|
||||||
PermissionSessionView, PermissionSessionRevoke,
|
PermissionSessionView, PermissionSessionRevoke,
|
||||||
PermissionConversationView, PermissionConversationAssign, PermissionConversationTransfer, PermissionConversationClose, PermissionConversationSend, PermissionConversationTag, PermissionConversationHandover, PermissionConversationRecycle, PermissionConversationLinkCustomer,
|
PermissionConversationView, PermissionConversationAssign, PermissionConversationTransfer, PermissionConversationClose, PermissionConversationSend, PermissionConversationTag, PermissionConversationHandover, PermissionConversationRecycle, PermissionConversationLinkCustomer,
|
||||||
PermissionTicketView, PermissionTicketCreate, PermissionTicketUpdate, PermissionTicketAssign, PermissionTicketChangeStatus, PermissionTicketReply, PermissionTicketClose, PermissionTicketReopen,
|
PermissionTicketView, PermissionTicketCreate, PermissionTicketUpdate, PermissionTicketAssign, PermissionTicketChangeStatus, PermissionTicketProgress,
|
||||||
PermissionTicketResolutionCodeView, PermissionTicketResolutionCodeCreate, PermissionTicketResolutionCodeUpdate, PermissionTicketResolutionCodeDelete,
|
|
||||||
PermissionTicketPriorityConfigView, PermissionTicketPriorityConfigCreate, PermissionTicketPriorityConfigUpdate, PermissionTicketPriorityConfigDelete,
|
|
||||||
PermissionNotificationView, PermissionNotificationUpdate,
|
PermissionNotificationView, PermissionNotificationUpdate,
|
||||||
PermissionQuickReplyView, PermissionQuickReplyCreate, PermissionQuickReplyUpdate, PermissionQuickReplyDelete,
|
PermissionQuickReplyView, PermissionQuickReplyCreate, PermissionQuickReplyUpdate, PermissionQuickReplyDelete,
|
||||||
PermissionTagView, PermissionTagCreate, PermissionTagUpdate, PermissionTagDelete,
|
PermissionTagView, PermissionTagCreate, PermissionTagUpdate, PermissionTagDelete,
|
||||||
@@ -347,9 +325,7 @@ var RolePermissions = map[string][]Permission{
|
|||||||
PermissionPermissionView,
|
PermissionPermissionView,
|
||||||
PermissionSessionView,
|
PermissionSessionView,
|
||||||
PermissionConversationView, PermissionConversationClose, PermissionConversationSend, PermissionConversationTag, PermissionConversationHandover, PermissionConversationRecycle, PermissionConversationLinkCustomer,
|
PermissionConversationView, PermissionConversationClose, PermissionConversationSend, PermissionConversationTag, PermissionConversationHandover, PermissionConversationRecycle, PermissionConversationLinkCustomer,
|
||||||
PermissionTicketView, PermissionTicketCreate, PermissionTicketUpdate, PermissionTicketAssign, PermissionTicketChangeStatus, PermissionTicketReply, PermissionTicketClose, PermissionTicketReopen,
|
PermissionTicketView, PermissionTicketCreate, PermissionTicketUpdate, PermissionTicketAssign, PermissionTicketChangeStatus, PermissionTicketProgress,
|
||||||
PermissionTicketResolutionCodeView, PermissionTicketResolutionCodeCreate, PermissionTicketResolutionCodeUpdate, PermissionTicketResolutionCodeDelete,
|
|
||||||
PermissionTicketPriorityConfigView, PermissionTicketPriorityConfigCreate, PermissionTicketPriorityConfigUpdate, PermissionTicketPriorityConfigDelete,
|
|
||||||
PermissionNotificationView, PermissionNotificationUpdate,
|
PermissionNotificationView, PermissionNotificationUpdate,
|
||||||
PermissionQuickReplyView, PermissionQuickReplyCreate, PermissionQuickReplyUpdate, PermissionQuickReplyDelete,
|
PermissionQuickReplyView, PermissionQuickReplyCreate, PermissionQuickReplyUpdate, PermissionQuickReplyDelete,
|
||||||
PermissionTagView, PermissionTagCreate, PermissionTagUpdate, PermissionTagDelete,
|
PermissionTagView, PermissionTagCreate, PermissionTagUpdate, PermissionTagDelete,
|
||||||
@@ -369,8 +345,7 @@ var RolePermissions = map[string][]Permission{
|
|||||||
PermissionRoleView,
|
PermissionRoleView,
|
||||||
PermissionPermissionView,
|
PermissionPermissionView,
|
||||||
PermissionConversationView,
|
PermissionConversationView,
|
||||||
PermissionTicketView, PermissionTicketCreate, PermissionTicketReply,
|
PermissionTicketView, PermissionTicketCreate, PermissionTicketAssign, PermissionTicketChangeStatus, PermissionTicketProgress,
|
||||||
PermissionTicketResolutionCodeView, PermissionTicketPriorityConfigView,
|
|
||||||
PermissionNotificationView, PermissionNotificationUpdate,
|
PermissionNotificationView, PermissionNotificationUpdate,
|
||||||
PermissionQuickReplyView,
|
PermissionQuickReplyView,
|
||||||
PermissionTagView,
|
PermissionTagView,
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
package request
|
|
||||||
|
|
||||||
import "cs-agent/internal/pkg/enums"
|
|
||||||
|
|
||||||
type CreateTicketResolutionCodeRequest struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Code string `json:"code"`
|
|
||||||
SortNo int `json:"sortNo"`
|
|
||||||
Status enums.Status `json:"status"`
|
|
||||||
Remark string `json:"remark"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type UpdateTicketResolutionCodeRequest struct {
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
CreateTicketResolutionCodeRequest
|
|
||||||
}
|
|
||||||
|
|
||||||
type DeleteTicketResolutionCodeRequest struct {
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type CreateTicketPriorityConfigRequest struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
FirstResponseMinutes int `json:"firstResponseMinutes"`
|
|
||||||
ResolutionMinutes int `json:"resolutionMinutes"`
|
|
||||||
Status enums.Status `json:"status"`
|
|
||||||
Remark string `json:"remark"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type UpdateTicketPriorityConfigRequest struct {
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
CreateTicketPriorityConfigRequest
|
|
||||||
}
|
|
||||||
|
|
||||||
type DeleteTicketPriorityConfigRequest struct {
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
}
|
|
||||||
@@ -1,143 +1,54 @@
|
|||||||
package request
|
package request
|
||||||
|
|
||||||
type CreateTicketRequest struct {
|
type CreateTicketRequest struct {
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Source string `json:"source"`
|
Source string `json:"source"`
|
||||||
Channel string `json:"channel"`
|
Channel string `json:"channel"`
|
||||||
CustomerID int64 `json:"customerId"`
|
CustomerID int64 `json:"customerId"`
|
||||||
ConversationID int64 `json:"conversationId"`
|
ConversationID int64 `json:"conversationId"`
|
||||||
TagIDs []int64 `json:"tagIds"`
|
TagIDs []int64 `json:"tagIds"`
|
||||||
Type string `json:"type"`
|
CurrentAssigneeID int64 `json:"currentAssigneeId"`
|
||||||
Priority int64 `json:"priority"`
|
|
||||||
Severity int `json:"severity"`
|
|
||||||
CurrentTeamID int64 `json:"currentTeamId"`
|
|
||||||
CurrentAssigneeID int64 `json:"currentAssigneeId"`
|
|
||||||
DueAt string `json:"dueAt"`
|
|
||||||
CustomFields map[string]any `json:"customFields"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateTicketFromConversationRequest struct {
|
type CreateTicketFromConversationRequest struct {
|
||||||
ConversationID int64 `json:"conversationId"`
|
ConversationID int64 `json:"conversationId"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
TagIDs []int64 `json:"tagIds"`
|
TagIDs []int64 `json:"tagIds"`
|
||||||
Priority int64 `json:"priority"`
|
CurrentAssigneeID int64 `json:"currentAssigneeId"`
|
||||||
Severity int `json:"severity"`
|
|
||||||
CurrentTeamID int64 `json:"currentTeamId"`
|
|
||||||
CurrentAssigneeID int64 `json:"currentAssigneeId"`
|
|
||||||
SyncToConversation bool `json:"syncToConversation"`
|
|
||||||
CustomFields map[string]any `json:"customFields"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateTicketRequest struct {
|
type UpdateTicketRequest struct {
|
||||||
TicketID int64 `json:"ticketId"`
|
TicketID int64 `json:"ticketId"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
TagIDs []int64 `json:"tagIds"`
|
TagIDs []int64 `json:"tagIds"`
|
||||||
Type string `json:"type"`
|
CurrentAssigneeID int64 `json:"currentAssigneeId"`
|
||||||
Priority int64 `json:"priority"`
|
|
||||||
Severity int `json:"severity"`
|
|
||||||
CurrentTeamID int64 `json:"currentTeamId"`
|
|
||||||
CurrentAssigneeID int64 `json:"currentAssigneeId"`
|
|
||||||
DueAt string `json:"dueAt"`
|
|
||||||
CustomFields map[string]any `json:"customFields"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type AssignTicketRequest struct {
|
type AssignTicketRequest struct {
|
||||||
TicketID int64 `json:"ticketId"`
|
TicketID int64 `json:"ticketId"`
|
||||||
ToUserID int64 `json:"toUserId"`
|
ToUserID int64 `json:"toUserId"`
|
||||||
ToTeamID int64 `json:"toTeamId"`
|
|
||||||
Reason string `json:"reason"`
|
Reason string `json:"reason"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChangeTicketStatusRequest struct {
|
type ChangeTicketStatusRequest struct {
|
||||||
TicketID int64 `json:"ticketId"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
PendingReason string `json:"pendingReason"`
|
|
||||||
CloseReason string `json:"closeReason"`
|
|
||||||
ResolutionCode string `json:"resolutionCode"`
|
|
||||||
ResolutionSummary string `json:"resolutionSummary"`
|
|
||||||
Reason string `json:"reason"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ReplyTicketRequest struct {
|
|
||||||
TicketID int64 `json:"ticketId"`
|
|
||||||
ContentType string `json:"contentType"`
|
|
||||||
Content string `json:"content"`
|
|
||||||
Payload string `json:"payload"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type InternalNoteRequest struct {
|
|
||||||
TicketID int64 `json:"ticketId"`
|
|
||||||
ContentType string `json:"contentType"`
|
|
||||||
Content string `json:"content"`
|
|
||||||
Payload string `json:"payload"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type CloseTicketRequest struct {
|
|
||||||
TicketID int64 `json:"ticketId"`
|
|
||||||
CloseReason string `json:"closeReason"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ReopenTicketRequest struct {
|
|
||||||
TicketID int64 `json:"ticketId"`
|
TicketID int64 `json:"ticketId"`
|
||||||
Reason string `json:"reason"`
|
Status string `json:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type WatchTicketRequest struct {
|
type CreateTicketProgressRequest struct {
|
||||||
TicketID int64 `json:"ticketId"`
|
TicketID int64 `json:"ticketId"`
|
||||||
|
Content string `json:"content"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type BatchAssignTicketRequest struct {
|
type BatchAssignTicketRequest struct {
|
||||||
TicketIDs []int64 `json:"ticketIds"`
|
TicketIDs []int64 `json:"ticketIds"`
|
||||||
ToUserID int64 `json:"toUserId"`
|
ToUserID int64 `json:"toUserId"`
|
||||||
ToTeamID int64 `json:"toTeamId"`
|
|
||||||
Reason string `json:"reason"`
|
Reason string `json:"reason"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type BatchChangeTicketStatusRequest struct {
|
|
||||||
TicketIDs []int64 `json:"ticketIds"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
PendingReason string `json:"pendingReason"`
|
|
||||||
CloseReason string `json:"closeReason"`
|
|
||||||
ResolutionCode string `json:"resolutionCode"`
|
|
||||||
ResolutionSummary string `json:"resolutionSummary"`
|
|
||||||
Reason string `json:"reason"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type BatchWatchTicketRequest struct {
|
|
||||||
TicketIDs []int64 `json:"ticketIds"`
|
|
||||||
Watched bool `json:"watched"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type AddTicketRelationRequest struct {
|
|
||||||
TicketID int64 `json:"ticketId"`
|
|
||||||
RelatedTicketID int64 `json:"relatedTicketId"`
|
|
||||||
RelatedTicketNo string `json:"relatedTicketNo"`
|
|
||||||
RelationType string `json:"relationType"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type DeleteTicketRelationRequest struct {
|
|
||||||
TicketID int64 `json:"ticketId"`
|
|
||||||
RelationID int64 `json:"relationId"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type AddTicketCollaboratorRequest struct {
|
|
||||||
TicketID int64 `json:"ticketId"`
|
|
||||||
UserID int64 `json:"userId"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type DeleteTicketCollaboratorRequest struct {
|
|
||||||
TicketID int64 `json:"ticketId"`
|
|
||||||
CollaboratorID int64 `json:"collaboratorId"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type LinkTicketCustomerRequest struct {
|
|
||||||
TicketID int64 `json:"ticketId"`
|
|
||||||
CustomerID int64 `json:"customerId"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type SaveTicketViewRequest struct {
|
type SaveTicketViewRequest struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
package response
|
|
||||||
|
|
||||||
import "cs-agent/internal/pkg/enums"
|
|
||||||
|
|
||||||
type TicketResolutionCodeResponse struct {
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Code string `json:"code"`
|
|
||||||
SortNo int `json:"sortNo"`
|
|
||||||
Status enums.Status `json:"status"`
|
|
||||||
Remark string `json:"remark"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type TicketPriorityConfigResponse struct {
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
SortNo int `json:"sortNo"`
|
|
||||||
FirstResponseMinutes int `json:"firstResponseMinutes"`
|
|
||||||
ResolutionMinutes int `json:"resolutionMinutes"`
|
|
||||||
Status enums.Status `json:"status"`
|
|
||||||
Remark string `json:"remark"`
|
|
||||||
}
|
|
||||||
@@ -2,145 +2,49 @@ package response
|
|||||||
|
|
||||||
import "cs-agent/internal/pkg/enums"
|
import "cs-agent/internal/pkg/enums"
|
||||||
|
|
||||||
type TicketSLAResponse struct {
|
type TicketProgressResponse struct {
|
||||||
SLAType enums.TicketSLAType `json:"slaType"`
|
ID int64 `json:"id"`
|
||||||
TargetMinutes int `json:"targetMinutes"`
|
TicketID int64 `json:"ticketId"`
|
||||||
Status enums.TicketSLAStatus `json:"status"`
|
Content string `json:"content"`
|
||||||
StartedAt string `json:"startedAt,omitempty"`
|
AuthorID int64 `json:"authorId"`
|
||||||
PausedAt string `json:"pausedAt,omitempty"`
|
AuthorName string `json:"authorName,omitempty"`
|
||||||
StoppedAt string `json:"stoppedAt,omitempty"`
|
CreatedAt string `json:"createdAt,omitempty"`
|
||||||
BreachedAt string `json:"breachedAt,omitempty"`
|
|
||||||
ElapsedMin int `json:"elapsedMin"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type TicketWatcherResponse struct {
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
UserID int64 `json:"userId"`
|
|
||||||
UserName string `json:"userName,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type TicketCommentResponse struct {
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
TicketID int64 `json:"ticketId"`
|
|
||||||
CommentType enums.TicketCommentType `json:"commentType"`
|
|
||||||
AuthorType enums.IMSenderType `json:"authorType"`
|
|
||||||
AuthorID int64 `json:"authorId"`
|
|
||||||
AuthorName string `json:"authorName,omitempty"`
|
|
||||||
ContentType string `json:"contentType"`
|
|
||||||
Content string `json:"content"`
|
|
||||||
Payload string `json:"payload,omitempty"`
|
|
||||||
CreatedAt string `json:"createdAt,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type TicketEventLogResponse struct {
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
TicketID int64 `json:"ticketId"`
|
|
||||||
EventType enums.TicketEventType `json:"eventType"`
|
|
||||||
OperatorType enums.IMSenderType `json:"operatorType"`
|
|
||||||
OperatorID int64 `json:"operatorId"`
|
|
||||||
OperatorName string `json:"operatorName,omitempty"`
|
|
||||||
OldValue string `json:"oldValue,omitempty"`
|
|
||||||
NewValue string `json:"newValue,omitempty"`
|
|
||||||
Content string `json:"content,omitempty"`
|
|
||||||
Payload string `json:"payload,omitempty"`
|
|
||||||
CreatedAt string `json:"createdAt,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type TicketResponse struct {
|
type TicketResponse struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
TicketNo string `json:"ticketNo"`
|
TicketNo string `json:"ticketNo"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Source enums.TicketSource `json:"source"`
|
Source enums.TicketSource `json:"source"`
|
||||||
Channel string `json:"channel"`
|
Channel string `json:"channel"`
|
||||||
CustomerID int64 `json:"customerId"`
|
CustomerID int64 `json:"customerId"`
|
||||||
ConversationID int64 `json:"conversationId"`
|
ConversationID int64 `json:"conversationId"`
|
||||||
Tags []TagResponse `json:"tags,omitempty"`
|
Tags []TagResponse `json:"tags,omitempty"`
|
||||||
Type string `json:"type"`
|
Status enums.TicketStatus `json:"status"`
|
||||||
Priority int64 `json:"priority"`
|
CurrentAssigneeID int64 `json:"currentAssigneeId"`
|
||||||
PriorityName string `json:"priorityName,omitempty"`
|
CurrentAssigneeName string `json:"currentAssigneeName,omitempty"`
|
||||||
Severity enums.TicketSeverity `json:"severity"`
|
CreatedBy int64 `json:"createdBy"`
|
||||||
Status enums.TicketStatus `json:"status"`
|
CreatedByName string `json:"createdByName,omitempty"`
|
||||||
CurrentTeamID int64 `json:"currentTeamId"`
|
HandledAt string `json:"handledAt,omitempty"`
|
||||||
CurrentTeamName string `json:"currentTeamName,omitempty"`
|
CreatedAt string `json:"createdAt,omitempty"`
|
||||||
CurrentAssigneeID int64 `json:"currentAssigneeId"`
|
UpdatedAt string `json:"updatedAt,omitempty"`
|
||||||
CurrentAssigneeName string `json:"currentAssigneeName,omitempty"`
|
Customer *CustomerResponse `json:"customer,omitempty"`
|
||||||
WatchedByMe bool `json:"watchedByMe"`
|
|
||||||
PendingReason string `json:"pendingReason,omitempty"`
|
|
||||||
CloseReason string `json:"closeReason,omitempty"`
|
|
||||||
ResolutionCode string `json:"resolutionCode,omitempty"`
|
|
||||||
ResolutionCodeName string `json:"resolutionCodeName,omitempty"`
|
|
||||||
ResolutionSummary string `json:"resolutionSummary,omitempty"`
|
|
||||||
FirstResponseAt string `json:"firstResponseAt,omitempty"`
|
|
||||||
ResolvedAt string `json:"resolvedAt,omitempty"`
|
|
||||||
ClosedAt string `json:"closedAt,omitempty"`
|
|
||||||
DueAt string `json:"dueAt,omitempty"`
|
|
||||||
NextReplyDeadlineAt string `json:"nextReplyDeadlineAt,omitempty"`
|
|
||||||
ResolveDeadlineAt string `json:"resolveDeadlineAt,omitempty"`
|
|
||||||
ReopenedCount int `json:"reopenedCount"`
|
|
||||||
CreatedAt string `json:"createdAt,omitempty"`
|
|
||||||
UpdatedAt string `json:"updatedAt,omitempty"`
|
|
||||||
Customer *CustomerResponse `json:"customer,omitempty"`
|
|
||||||
SLA []TicketSLAResponse `json:"sla,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type TicketDetailResponse struct {
|
type TicketDetailResponse struct {
|
||||||
Ticket TicketResponse `json:"ticket"`
|
Ticket TicketResponse `json:"ticket"`
|
||||||
Watchers []TicketWatcherResponse `json:"watchers,omitempty"`
|
Progresses []TicketProgressResponse `json:"progresses,omitempty"`
|
||||||
Collaborators []TicketCollaboratorResponse `json:"collaborators,omitempty"`
|
|
||||||
Comments []TicketCommentResponse `json:"comments,omitempty"`
|
|
||||||
Events []TicketEventLogResponse `json:"events,omitempty"`
|
|
||||||
RelatedTickets []TicketRelationResponse `json:"relatedTickets,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type TicketSummaryResponse struct {
|
type TicketSummaryResponse struct {
|
||||||
All int64 `json:"all"`
|
All int64 `json:"all"`
|
||||||
Mine int64 `json:"mine"`
|
Pending int64 `json:"pending"`
|
||||||
Watching int64 `json:"watching"`
|
InProgress int64 `json:"inProgress"`
|
||||||
Collaboration int64 `json:"collaboration"`
|
Done int64 `json:"done"`
|
||||||
Participating int64 `json:"participating"`
|
Unassigned int64 `json:"unassigned"`
|
||||||
Mentioned int64 `json:"mentioned"`
|
Mine int64 `json:"mine"`
|
||||||
Unassigned int64 `json:"unassigned"`
|
Stale int64 `json:"stale"`
|
||||||
PendingCustomer int64 `json:"pendingCustomer"`
|
|
||||||
PendingInternal int64 `json:"pendingInternal"`
|
|
||||||
Overdue int64 `json:"overdue"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type TicketRiskReasonResponse struct {
|
|
||||||
Code string `json:"code"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
Count int64 `json:"count"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type TicketRiskOverviewResponse struct {
|
|
||||||
Overdue int64 `json:"overdue"`
|
|
||||||
HighRisk int64 `json:"highRisk"`
|
|
||||||
Unassigned int64 `json:"unassigned"`
|
|
||||||
PendingInternal int64 `json:"pendingInternal"`
|
|
||||||
PendingCustomer int64 `json:"pendingCustomer"`
|
|
||||||
RiskWindowMins int `json:"riskWindowMins"`
|
|
||||||
Reasons []TicketRiskReasonResponse `json:"reasons,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type TicketRelationResponse struct {
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
TicketID int64 `json:"ticketId"`
|
|
||||||
RelatedTicketID int64 `json:"relatedTicketId"`
|
|
||||||
RelationType enums.TicketRelationType `json:"relationType"`
|
|
||||||
RelatedTicketNo string `json:"relatedTicketNo,omitempty"`
|
|
||||||
RelatedTicketTitle string `json:"relatedTicketTitle,omitempty"`
|
|
||||||
RelatedTicketStatus enums.TicketStatus `json:"relatedTicketStatus,omitempty"`
|
|
||||||
CurrentTeamName string `json:"currentTeamName,omitempty"`
|
|
||||||
CurrentAssigneeName string `json:"currentAssigneeName,omitempty"`
|
|
||||||
UpdatedAt string `json:"updatedAt,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type TicketCollaboratorResponse struct {
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
UserID int64 `json:"userId"`
|
|
||||||
UserName string `json:"userName,omitempty"`
|
|
||||||
TeamName string `json:"teamName,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type TicketViewResponse struct {
|
type TicketViewResponse struct {
|
||||||
|
|||||||
@@ -3,33 +3,21 @@ package enums
|
|||||||
type TicketStatus string
|
type TicketStatus string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
TicketStatusNew TicketStatus = "new"
|
TicketStatusPending TicketStatus = "pending"
|
||||||
TicketStatusOpen TicketStatus = "open"
|
TicketStatusInProgress TicketStatus = "in_progress"
|
||||||
TicketStatusPendingCustomer TicketStatus = "pending_customer"
|
TicketStatusDone TicketStatus = "done"
|
||||||
TicketStatusPendingInternal TicketStatus = "pending_internal"
|
|
||||||
TicketStatusResolved TicketStatus = "resolved"
|
|
||||||
TicketStatusClosed TicketStatus = "closed"
|
|
||||||
TicketStatusCancelled TicketStatus = "cancelled"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var TicketStatusValues = []TicketStatus{
|
var TicketStatusValues = []TicketStatus{
|
||||||
TicketStatusNew,
|
TicketStatusPending,
|
||||||
TicketStatusOpen,
|
TicketStatusInProgress,
|
||||||
TicketStatusPendingCustomer,
|
TicketStatusDone,
|
||||||
TicketStatusPendingInternal,
|
|
||||||
TicketStatusResolved,
|
|
||||||
TicketStatusClosed,
|
|
||||||
TicketStatusCancelled,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var ticketStatusLabelMap = map[TicketStatus]string{
|
var ticketStatusLabelMap = map[TicketStatus]string{
|
||||||
TicketStatusNew: "新建",
|
TicketStatusPending: "待处理",
|
||||||
TicketStatusOpen: "处理中",
|
TicketStatusInProgress: "处理中",
|
||||||
TicketStatusPendingCustomer: "待客户反馈",
|
TicketStatusDone: "已处理",
|
||||||
TicketStatusPendingInternal: "待内部处理",
|
|
||||||
TicketStatusResolved: "已解决",
|
|
||||||
TicketStatusClosed: "已关闭",
|
|
||||||
TicketStatusCancelled: "已取消",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetTicketStatusLabel(status TicketStatus) string {
|
func GetTicketStatusLabel(status TicketStatus) string {
|
||||||
@@ -45,55 +33,16 @@ func IsValidTicketStatus(status string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
type TicketSeverity int
|
|
||||||
|
|
||||||
const (
|
|
||||||
TicketSeverityMinor TicketSeverity = 1
|
|
||||||
TicketSeverityMajor TicketSeverity = 2
|
|
||||||
TicketSeverityCritical TicketSeverity = 3
|
|
||||||
)
|
|
||||||
|
|
||||||
var TicketSeverityValues = []TicketSeverity{
|
|
||||||
TicketSeverityMinor,
|
|
||||||
TicketSeverityMajor,
|
|
||||||
TicketSeverityCritical,
|
|
||||||
}
|
|
||||||
|
|
||||||
var ticketSeverityLabelMap = map[TicketSeverity]string{
|
|
||||||
TicketSeverityMinor: "轻微",
|
|
||||||
TicketSeverityMajor: "严重",
|
|
||||||
TicketSeverityCritical: "致命",
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetTicketSeverityLabel(severity TicketSeverity) string {
|
|
||||||
return ticketSeverityLabelMap[severity]
|
|
||||||
}
|
|
||||||
|
|
||||||
func IsValidTicketSeverity(severity int) bool {
|
|
||||||
for _, item := range TicketSeverityValues {
|
|
||||||
if int(item) == severity {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
type TicketSource string
|
type TicketSource string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
TicketSourceManual TicketSource = "manual"
|
TicketSourceManual TicketSource = "manual"
|
||||||
TicketSourceConversation TicketSource = "conversation"
|
TicketSourceConversation TicketSource = "conversation"
|
||||||
TicketSourcePortal TicketSource = "portal"
|
|
||||||
TicketSourceAPI TicketSource = "api"
|
|
||||||
TicketSourceRule TicketSource = "rule"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var TicketSourceValues = []TicketSource{
|
var TicketSourceValues = []TicketSource{
|
||||||
TicketSourceManual,
|
TicketSourceManual,
|
||||||
TicketSourceConversation,
|
TicketSourceConversation,
|
||||||
TicketSourcePortal,
|
|
||||||
TicketSourceAPI,
|
|
||||||
TicketSourceRule,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func IsValidTicketSource(source string) bool {
|
func IsValidTicketSource(source string) bool {
|
||||||
@@ -104,53 +53,3 @@ func IsValidTicketSource(source string) bool {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
type TicketCommentType string
|
|
||||||
|
|
||||||
const (
|
|
||||||
TicketCommentTypePublicReply TicketCommentType = "public_reply"
|
|
||||||
TicketCommentTypeInternalNote TicketCommentType = "internal_note"
|
|
||||||
TicketCommentTypeSystemLog TicketCommentType = "system_log"
|
|
||||||
)
|
|
||||||
|
|
||||||
type TicketEventType string
|
|
||||||
|
|
||||||
const (
|
|
||||||
TicketEventTypeCreated TicketEventType = "created"
|
|
||||||
TicketEventTypeUpdated TicketEventType = "updated"
|
|
||||||
TicketEventTypeAssigned TicketEventType = "assigned"
|
|
||||||
TicketEventTypeTransferred TicketEventType = "transferred"
|
|
||||||
TicketEventTypeStatusChanged TicketEventType = "status_changed"
|
|
||||||
TicketEventTypeReplied TicketEventType = "replied"
|
|
||||||
TicketEventTypeInternalNoted TicketEventType = "internal_noted"
|
|
||||||
TicketEventTypeClosed TicketEventType = "closed"
|
|
||||||
TicketEventTypeReopened TicketEventType = "reopened"
|
|
||||||
TicketEventTypeSLABreached TicketEventType = "sla_breached"
|
|
||||||
TicketEventTypeLinkedConversation TicketEventType = "linked_conversation"
|
|
||||||
TicketEventTypeMentioned TicketEventType = "mentioned"
|
|
||||||
)
|
|
||||||
|
|
||||||
type TicketSLAType string
|
|
||||||
|
|
||||||
const (
|
|
||||||
TicketSLATypeFirstResponse TicketSLAType = "first_response"
|
|
||||||
TicketSLATypeResolution TicketSLAType = "resolution"
|
|
||||||
)
|
|
||||||
|
|
||||||
type TicketSLAStatus string
|
|
||||||
|
|
||||||
const (
|
|
||||||
TicketSLAStatusRunning TicketSLAStatus = "running"
|
|
||||||
TicketSLAStatusPaused TicketSLAStatus = "paused"
|
|
||||||
TicketSLAStatusCompleted TicketSLAStatus = "completed"
|
|
||||||
TicketSLAStatusBreached TicketSLAStatus = "breached"
|
|
||||||
)
|
|
||||||
|
|
||||||
type TicketRelationType string
|
|
||||||
|
|
||||||
const (
|
|
||||||
TicketRelationTypeDuplicate TicketRelationType = "duplicate"
|
|
||||||
TicketRelationTypeRelated TicketRelationType = "related"
|
|
||||||
TicketRelationTypeParent TicketRelationType = "parent"
|
|
||||||
TicketRelationTypeChild TicketRelationType = "child"
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
package repositories
|
|
||||||
|
|
||||||
import (
|
|
||||||
"cs-agent/internal/models"
|
|
||||||
|
|
||||||
"github.com/mlogclub/simple/sqls"
|
|
||||||
"github.com/mlogclub/simple/web/params"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
var TicketCollaboratorRepository = newTicketCollaboratorRepository()
|
|
||||||
|
|
||||||
func newTicketCollaboratorRepository() *ticketCollaboratorRepository {
|
|
||||||
return &ticketCollaboratorRepository{}
|
|
||||||
}
|
|
||||||
|
|
||||||
type ticketCollaboratorRepository struct {
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketCollaboratorRepository) TakeByTicketIDAndUserID(db *gorm.DB, ticketID, userID int64) *models.TicketCollaborator {
|
|
||||||
ret := &models.TicketCollaborator{}
|
|
||||||
if err := db.Take(ret, "ticket_id = ? AND user_id = ?", ticketID, userID).Error; err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return ret
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketCollaboratorRepository) Get(db *gorm.DB, id int64) *models.TicketCollaborator {
|
|
||||||
ret := &models.TicketCollaborator{}
|
|
||||||
if err := db.First(ret, "id = ?", id).Error; err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return ret
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketCollaboratorRepository) Take(db *gorm.DB, where ...interface{}) *models.TicketCollaborator {
|
|
||||||
ret := &models.TicketCollaborator{}
|
|
||||||
if err := db.Take(ret, where...).Error; err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return ret
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketCollaboratorRepository) Find(db *gorm.DB, cnd *sqls.Cnd) (list []models.TicketCollaborator) {
|
|
||||||
cnd.Find(db, &list)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketCollaboratorRepository) FindOne(db *gorm.DB, cnd *sqls.Cnd) *models.TicketCollaborator {
|
|
||||||
ret := &models.TicketCollaborator{}
|
|
||||||
if err := cnd.FindOne(db, &ret); err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return ret
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketCollaboratorRepository) FindPageByParams(db *gorm.DB, params *params.QueryParams) (list []models.TicketCollaborator, paging *sqls.Paging) {
|
|
||||||
return r.FindPageByCnd(db, ¶ms.Cnd)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketCollaboratorRepository) FindPageByCnd(db *gorm.DB, cnd *sqls.Cnd) (list []models.TicketCollaborator, paging *sqls.Paging) {
|
|
||||||
cnd.Find(db, &list)
|
|
||||||
count := cnd.Count(db, &models.TicketCollaborator{})
|
|
||||||
|
|
||||||
paging = &sqls.Paging{
|
|
||||||
Page: cnd.Paging.Page,
|
|
||||||
Limit: cnd.Paging.Limit,
|
|
||||||
Total: count,
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketCollaboratorRepository) Count(db *gorm.DB, cnd *sqls.Cnd) int64 {
|
|
||||||
return cnd.Count(db, &models.TicketCollaborator{})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketCollaboratorRepository) Create(db *gorm.DB, t *models.TicketCollaborator) error {
|
|
||||||
return db.Create(t).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketCollaboratorRepository) Delete(db *gorm.DB, id int64) {
|
|
||||||
db.Delete(&models.TicketCollaborator{}, "id = ?", id)
|
|
||||||
}
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
package repositories
|
|
||||||
|
|
||||||
import (
|
|
||||||
"cs-agent/internal/models"
|
|
||||||
|
|
||||||
"github.com/mlogclub/simple/sqls"
|
|
||||||
"github.com/mlogclub/simple/web/params"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
var TicketCommentRepository = newTicketCommentRepository()
|
|
||||||
|
|
||||||
func newTicketCommentRepository() *ticketCommentRepository {
|
|
||||||
return &ticketCommentRepository{}
|
|
||||||
}
|
|
||||||
|
|
||||||
type ticketCommentRepository struct {
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketCommentRepository) Get(db *gorm.DB, id int64) *models.TicketComment {
|
|
||||||
ret := &models.TicketComment{}
|
|
||||||
if err := db.First(ret, "id = ?", id).Error; err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return ret
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketCommentRepository) Take(db *gorm.DB, where ...interface{}) *models.TicketComment {
|
|
||||||
ret := &models.TicketComment{}
|
|
||||||
if err := db.Take(ret, where...).Error; err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return ret
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketCommentRepository) Find(db *gorm.DB, cnd *sqls.Cnd) (list []models.TicketComment) {
|
|
||||||
cnd.Find(db, &list)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketCommentRepository) FindOne(db *gorm.DB, cnd *sqls.Cnd) *models.TicketComment {
|
|
||||||
ret := &models.TicketComment{}
|
|
||||||
if err := cnd.FindOne(db, &ret); err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return ret
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketCommentRepository) FindPageByParams(db *gorm.DB, params *params.QueryParams) (list []models.TicketComment, paging *sqls.Paging) {
|
|
||||||
return r.FindPageByCnd(db, ¶ms.Cnd)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketCommentRepository) FindPageByCnd(db *gorm.DB, cnd *sqls.Cnd) (list []models.TicketComment, paging *sqls.Paging) {
|
|
||||||
cnd.Find(db, &list)
|
|
||||||
count := cnd.Count(db, &models.TicketComment{})
|
|
||||||
|
|
||||||
paging = &sqls.Paging{
|
|
||||||
Page: cnd.Paging.Page,
|
|
||||||
Limit: cnd.Paging.Limit,
|
|
||||||
Total: count,
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketCommentRepository) FindBySql(db *gorm.DB, sqlStr string, paramArr... interface{}) (list []models.TicketComment) {
|
|
||||||
db.Raw(sqlStr, paramArr...).Scan(&list)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketCommentRepository) CountBySql(db *gorm.DB, sqlStr string, paramArr... interface{}) (count int64) {
|
|
||||||
db.Raw(sqlStr, paramArr...).Count(&count)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketCommentRepository) Count(db *gorm.DB, cnd *sqls.Cnd) int64 {
|
|
||||||
return cnd.Count(db, &models.TicketComment{})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketCommentRepository) Create(db *gorm.DB, t *models.TicketComment) (err error) {
|
|
||||||
err = db.Create(t).Error
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketCommentRepository) Update(db *gorm.DB, t *models.TicketComment) (err error) {
|
|
||||||
err = db.Save(t).Error
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketCommentRepository) Updates(db *gorm.DB, id int64, columns map[string]interface{}) (err error) {
|
|
||||||
err = db.Model(&models.TicketComment{}).Where("id = ?", id).Updates(columns).Error
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketCommentRepository) UpdateColumn(db *gorm.DB, id int64, name string, value interface{}) (err error) {
|
|
||||||
err = db.Model(&models.TicketComment{}).Where("id = ?", id).UpdateColumn(name, value).Error
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketCommentRepository) Delete(db *gorm.DB, id int64) {
|
|
||||||
db.Delete(&models.TicketComment{}, "id = ?", id)
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
package repositories
|
|
||||||
|
|
||||||
import (
|
|
||||||
"cs-agent/internal/models"
|
|
||||||
|
|
||||||
"github.com/mlogclub/simple/sqls"
|
|
||||||
"github.com/mlogclub/simple/web/params"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
var TicketEventLogRepository = newTicketEventLogRepository()
|
|
||||||
|
|
||||||
func newTicketEventLogRepository() *ticketEventLogRepository {
|
|
||||||
return &ticketEventLogRepository{}
|
|
||||||
}
|
|
||||||
|
|
||||||
type ticketEventLogRepository struct {
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketEventLogRepository) Get(db *gorm.DB, id int64) *models.TicketEventLog {
|
|
||||||
ret := &models.TicketEventLog{}
|
|
||||||
if err := db.First(ret, "id = ?", id).Error; err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return ret
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketEventLogRepository) Take(db *gorm.DB, where ...interface{}) *models.TicketEventLog {
|
|
||||||
ret := &models.TicketEventLog{}
|
|
||||||
if err := db.Take(ret, where...).Error; err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return ret
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketEventLogRepository) Find(db *gorm.DB, cnd *sqls.Cnd) (list []models.TicketEventLog) {
|
|
||||||
cnd.Find(db, &list)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketEventLogRepository) FindOne(db *gorm.DB, cnd *sqls.Cnd) *models.TicketEventLog {
|
|
||||||
ret := &models.TicketEventLog{}
|
|
||||||
if err := cnd.FindOne(db, &ret); err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return ret
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketEventLogRepository) FindPageByParams(db *gorm.DB, params *params.QueryParams) (list []models.TicketEventLog, paging *sqls.Paging) {
|
|
||||||
return r.FindPageByCnd(db, ¶ms.Cnd)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketEventLogRepository) FindPageByCnd(db *gorm.DB, cnd *sqls.Cnd) (list []models.TicketEventLog, paging *sqls.Paging) {
|
|
||||||
cnd.Find(db, &list)
|
|
||||||
count := cnd.Count(db, &models.TicketEventLog{})
|
|
||||||
|
|
||||||
paging = &sqls.Paging{
|
|
||||||
Page: cnd.Paging.Page,
|
|
||||||
Limit: cnd.Paging.Limit,
|
|
||||||
Total: count,
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketEventLogRepository) FindBySql(db *gorm.DB, sqlStr string, paramArr... interface{}) (list []models.TicketEventLog) {
|
|
||||||
db.Raw(sqlStr, paramArr...).Scan(&list)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketEventLogRepository) CountBySql(db *gorm.DB, sqlStr string, paramArr... interface{}) (count int64) {
|
|
||||||
db.Raw(sqlStr, paramArr...).Count(&count)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketEventLogRepository) Count(db *gorm.DB, cnd *sqls.Cnd) int64 {
|
|
||||||
return cnd.Count(db, &models.TicketEventLog{})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketEventLogRepository) Create(db *gorm.DB, t *models.TicketEventLog) (err error) {
|
|
||||||
err = db.Create(t).Error
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketEventLogRepository) Update(db *gorm.DB, t *models.TicketEventLog) (err error) {
|
|
||||||
err = db.Save(t).Error
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketEventLogRepository) Updates(db *gorm.DB, id int64, columns map[string]interface{}) (err error) {
|
|
||||||
err = db.Model(&models.TicketEventLog{}).Where("id = ?", id).Updates(columns).Error
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketEventLogRepository) UpdateColumn(db *gorm.DB, id int64, name string, value interface{}) (err error) {
|
|
||||||
err = db.Model(&models.TicketEventLog{}).Where("id = ?", id).UpdateColumn(name, value).Error
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketEventLogRepository) Delete(db *gorm.DB, id int64) {
|
|
||||||
db.Delete(&models.TicketEventLog{}, "id = ?", id)
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
package repositories
|
|
||||||
|
|
||||||
import (
|
|
||||||
"cs-agent/internal/models"
|
|
||||||
|
|
||||||
"github.com/mlogclub/simple/sqls"
|
|
||||||
"github.com/mlogclub/simple/web/params"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
var TicketMentionRepository = newTicketMentionRepository()
|
|
||||||
|
|
||||||
func newTicketMentionRepository() *ticketMentionRepository {
|
|
||||||
return &ticketMentionRepository{}
|
|
||||||
}
|
|
||||||
|
|
||||||
type ticketMentionRepository struct {
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketMentionRepository) TakeByCommentAndUserID(db *gorm.DB, ticketID, commentID, userID int64) *models.TicketMention {
|
|
||||||
ret := &models.TicketMention{}
|
|
||||||
if err := db.Take(ret, "ticket_id = ? AND comment_id = ? AND mentioned_user_id = ?", ticketID, commentID, userID).Error; err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return ret
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketMentionRepository) Get(db *gorm.DB, id int64) *models.TicketMention {
|
|
||||||
ret := &models.TicketMention{}
|
|
||||||
if err := db.First(ret, "id = ?", id).Error; err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return ret
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketMentionRepository) Take(db *gorm.DB, where ...interface{}) *models.TicketMention {
|
|
||||||
ret := &models.TicketMention{}
|
|
||||||
if err := db.Take(ret, where...).Error; err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return ret
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketMentionRepository) Find(db *gorm.DB, cnd *sqls.Cnd) (list []models.TicketMention) {
|
|
||||||
cnd.Find(db, &list)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketMentionRepository) FindOne(db *gorm.DB, cnd *sqls.Cnd) *models.TicketMention {
|
|
||||||
ret := &models.TicketMention{}
|
|
||||||
if err := cnd.FindOne(db, &ret); err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return ret
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketMentionRepository) FindPageByParams(db *gorm.DB, params *params.QueryParams) (list []models.TicketMention, paging *sqls.Paging) {
|
|
||||||
return r.FindPageByCnd(db, ¶ms.Cnd)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketMentionRepository) FindPageByCnd(db *gorm.DB, cnd *sqls.Cnd) (list []models.TicketMention, paging *sqls.Paging) {
|
|
||||||
cnd.Find(db, &list)
|
|
||||||
count := cnd.Count(db, &models.TicketMention{})
|
|
||||||
paging = &sqls.Paging{Page: cnd.Paging.Page, Limit: cnd.Paging.Limit, Total: count}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketMentionRepository) Count(db *gorm.DB, cnd *sqls.Cnd) int64 {
|
|
||||||
return cnd.Count(db, &models.TicketMention{})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketMentionRepository) Create(db *gorm.DB, t *models.TicketMention) error {
|
|
||||||
return db.Create(t).Error
|
|
||||||
}
|
|
||||||
@@ -2,9 +2,13 @@ package repositories
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"cs-agent/internal/models"
|
"cs-agent/internal/models"
|
||||||
|
"errors"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/mlogclub/simple/sqls"
|
||||||
|
"github.com/mlogclub/simple/web/params"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
|
|
||||||
var TicketNoSequenceRepository = newTicketNoSequenceRepository()
|
var TicketNoSequenceRepository = newTicketNoSequenceRepository()
|
||||||
@@ -15,6 +19,55 @@ func newTicketNoSequenceRepository() *ticketNoSequenceRepository {
|
|||||||
|
|
||||||
type ticketNoSequenceRepository struct{}
|
type ticketNoSequenceRepository struct{}
|
||||||
|
|
||||||
|
func (r *ticketNoSequenceRepository) Get(db *gorm.DB, id int64) *models.TicketNoSequence {
|
||||||
|
ret := &models.TicketNoSequence{}
|
||||||
|
if err := db.First(ret, "id = ?", id).Error; err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ticketNoSequenceRepository) Take(db *gorm.DB, where ...any) *models.TicketNoSequence {
|
||||||
|
ret := &models.TicketNoSequence{}
|
||||||
|
if err := db.Take(ret, where...).Error; err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ticketNoSequenceRepository) Find(db *gorm.DB, cnd *sqls.Cnd) (list []models.TicketNoSequence) {
|
||||||
|
cnd.Find(db, &list)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ticketNoSequenceRepository) FindOne(db *gorm.DB, cnd *sqls.Cnd) *models.TicketNoSequence {
|
||||||
|
ret := &models.TicketNoSequence{}
|
||||||
|
if err := cnd.FindOne(db, &ret); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ticketNoSequenceRepository) FindPageByParams(db *gorm.DB, params *params.QueryParams) (list []models.TicketNoSequence, paging *sqls.Paging) {
|
||||||
|
return r.FindPageByCnd(db, ¶ms.Cnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ticketNoSequenceRepository) FindPageByCnd(db *gorm.DB, cnd *sqls.Cnd) (list []models.TicketNoSequence, paging *sqls.Paging) {
|
||||||
|
cnd.Find(db, &list)
|
||||||
|
count := cnd.Count(db, &models.TicketNoSequence{})
|
||||||
|
|
||||||
|
paging = &sqls.Paging{
|
||||||
|
Page: cnd.Paging.Page,
|
||||||
|
Limit: cnd.Paging.Limit,
|
||||||
|
Total: count,
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ticketNoSequenceRepository) Count(db *gorm.DB, cnd *sqls.Cnd) int64 {
|
||||||
|
return cnd.Count(db, &models.TicketNoSequence{})
|
||||||
|
}
|
||||||
|
|
||||||
func (r *ticketNoSequenceRepository) GetByDateKey(db *gorm.DB, dateKey string) *models.TicketNoSequence {
|
func (r *ticketNoSequenceRepository) GetByDateKey(db *gorm.DB, dateKey string) *models.TicketNoSequence {
|
||||||
ret := &models.TicketNoSequence{}
|
ret := &models.TicketNoSequence{}
|
||||||
if err := db.Take(ret, "date_key = ?", dateKey).Error; err != nil {
|
if err := db.Take(ret, "date_key = ?", dateKey).Error; err != nil {
|
||||||
@@ -23,10 +76,38 @@ func (r *ticketNoSequenceRepository) GetByDateKey(db *gorm.DB, dateKey string) *
|
|||||||
return ret
|
return ret
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *ticketNoSequenceRepository) GetByDateKeyForUpdate(db *gorm.DB, dateKey string) (*models.TicketNoSequence, error) {
|
||||||
|
ret := &models.TicketNoSequence{}
|
||||||
|
err := db.Clauses(clause.Locking{Strength: "UPDATE"}).Take(ret, "date_key = ?", dateKey).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return ret, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *ticketNoSequenceRepository) Create(db *gorm.DB, t *models.TicketNoSequence) error {
|
func (r *ticketNoSequenceRepository) Create(db *gorm.DB, t *models.TicketNoSequence) error {
|
||||||
return db.Create(t).Error
|
return db.Create(t).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *ticketNoSequenceRepository) Update(db *gorm.DB, t *models.TicketNoSequence) error {
|
||||||
|
return db.Save(t).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ticketNoSequenceRepository) Updates(db *gorm.DB, id int64, columns map[string]any) error {
|
||||||
|
return db.Model(&models.TicketNoSequence{}).Where("id = ?", id).Updates(columns).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ticketNoSequenceRepository) UpdateColumn(db *gorm.DB, id int64, name string, value any) error {
|
||||||
|
return db.Model(&models.TicketNoSequence{}).Where("id = ?", id).UpdateColumn(name, value).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ticketNoSequenceRepository) Delete(db *gorm.DB, id int64) {
|
||||||
|
db.Delete(&models.TicketNoSequence{}, "id = ?", id)
|
||||||
|
}
|
||||||
|
|
||||||
func (r *ticketNoSequenceRepository) UpdateNextSeq(db *gorm.DB, id int64, currentSeq, nextSeq int64, updatedAt time.Time) (bool, error) {
|
func (r *ticketNoSequenceRepository) UpdateNextSeq(db *gorm.DB, id int64, currentSeq, nextSeq int64, updatedAt time.Time) (bool, error) {
|
||||||
result := db.Model(&models.TicketNoSequence{}).
|
result := db.Model(&models.TicketNoSequence{}).
|
||||||
Where("id = ? AND next_seq = ?", id, currentSeq).
|
Where("id = ? AND next_seq = ?", id, currentSeq).
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
package repositories
|
|
||||||
|
|
||||||
import (
|
|
||||||
"cs-agent/internal/models"
|
|
||||||
|
|
||||||
"github.com/mlogclub/simple/sqls"
|
|
||||||
"github.com/mlogclub/simple/web/params"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
var TicketPriorityConfigRepository = newTicketPriorityConfigRepository()
|
|
||||||
|
|
||||||
func newTicketPriorityConfigRepository() *ticketPriorityConfigRepository {
|
|
||||||
return &ticketPriorityConfigRepository{}
|
|
||||||
}
|
|
||||||
|
|
||||||
type ticketPriorityConfigRepository struct{}
|
|
||||||
|
|
||||||
func (r *ticketPriorityConfigRepository) Get(db *gorm.DB, id int64) *models.TicketPriorityConfig {
|
|
||||||
ret := &models.TicketPriorityConfig{}
|
|
||||||
if err := db.First(ret, id).Error; err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return ret
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketPriorityConfigRepository) Take(db *gorm.DB, where ...interface{}) *models.TicketPriorityConfig {
|
|
||||||
ret := &models.TicketPriorityConfig{}
|
|
||||||
if err := db.Take(ret, where...).Error; err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return ret
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketPriorityConfigRepository) Find(db *gorm.DB, cnd *sqls.Cnd) (list []models.TicketPriorityConfig) {
|
|
||||||
cnd.Find(db, &list)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketPriorityConfigRepository) FindOne(db *gorm.DB, cnd *sqls.Cnd) *models.TicketPriorityConfig {
|
|
||||||
ret := &models.TicketPriorityConfig{}
|
|
||||||
if err := cnd.FindOne(db, ret); err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return ret
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketPriorityConfigRepository) FindPageByParams(db *gorm.DB, queryParams *params.QueryParams) (list []models.TicketPriorityConfig, paging *sqls.Paging) {
|
|
||||||
return r.FindPageByCnd(db, &queryParams.Cnd)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketPriorityConfigRepository) FindPageByCnd(db *gorm.DB, cnd *sqls.Cnd) (list []models.TicketPriorityConfig, paging *sqls.Paging) {
|
|
||||||
cnd.Find(db, &list)
|
|
||||||
count := cnd.Count(db, &models.TicketPriorityConfig{})
|
|
||||||
paging = &sqls.Paging{Page: cnd.Paging.Page, Limit: cnd.Paging.Limit, Total: count}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketPriorityConfigRepository) Count(db *gorm.DB, cnd *sqls.Cnd) int64 {
|
|
||||||
return cnd.Count(db, &models.TicketPriorityConfig{})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketPriorityConfigRepository) Create(db *gorm.DB, t *models.TicketPriorityConfig) error {
|
|
||||||
return db.Create(t).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketPriorityConfigRepository) Update(db *gorm.DB, t *models.TicketPriorityConfig) error {
|
|
||||||
return db.Save(t).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketPriorityConfigRepository) Updates(db *gorm.DB, id int64, columns map[string]any) error {
|
|
||||||
return db.Model(&models.TicketPriorityConfig{}).Where("id = ?", id).Updates(columns).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketPriorityConfigRepository) UpdateColumn(db *gorm.DB, id int64, name string, value any) error {
|
|
||||||
return db.Model(&models.TicketPriorityConfig{}).Where("id = ?", id).UpdateColumn(name, value).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketPriorityConfigRepository) Delete(db *gorm.DB, id int64) error {
|
|
||||||
return db.Delete(&models.TicketPriorityConfig{}, "id = ?", id).Error
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package repositories
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cs-agent/internal/models"
|
||||||
|
|
||||||
|
"github.com/mlogclub/simple/sqls"
|
||||||
|
"github.com/mlogclub/simple/web/params"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
var TicketProgressRepository = newTicketProgressRepository()
|
||||||
|
|
||||||
|
func newTicketProgressRepository() *ticketProgressRepository {
|
||||||
|
return &ticketProgressRepository{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ticketProgressRepository struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ticketProgressRepository) Get(db *gorm.DB, id int64) *models.TicketProgress {
|
||||||
|
ret := &models.TicketProgress{}
|
||||||
|
if err := db.First(ret, "id = ?", id).Error; err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ticketProgressRepository) Take(db *gorm.DB, where ...any) *models.TicketProgress {
|
||||||
|
ret := &models.TicketProgress{}
|
||||||
|
if err := db.Take(ret, where...).Error; err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ticketProgressRepository) Find(db *gorm.DB, cnd *sqls.Cnd) (list []models.TicketProgress) {
|
||||||
|
cnd.Find(db, &list)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ticketProgressRepository) FindOne(db *gorm.DB, cnd *sqls.Cnd) *models.TicketProgress {
|
||||||
|
ret := &models.TicketProgress{}
|
||||||
|
if err := cnd.FindOne(db, &ret); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ticketProgressRepository) FindPageByParams(db *gorm.DB, params *params.QueryParams) (list []models.TicketProgress, paging *sqls.Paging) {
|
||||||
|
return r.FindPageByCnd(db, ¶ms.Cnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ticketProgressRepository) FindPageByCnd(db *gorm.DB, cnd *sqls.Cnd) (list []models.TicketProgress, paging *sqls.Paging) {
|
||||||
|
cnd.Find(db, &list)
|
||||||
|
count := cnd.Count(db, &models.TicketProgress{})
|
||||||
|
|
||||||
|
paging = &sqls.Paging{
|
||||||
|
Page: cnd.Paging.Page,
|
||||||
|
Limit: cnd.Paging.Limit,
|
||||||
|
Total: count,
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ticketProgressRepository) FindBySql(db *gorm.DB, sqlStr string, paramArr ...any) (list []models.TicketProgress) {
|
||||||
|
db.Raw(sqlStr, paramArr...).Scan(&list)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ticketProgressRepository) CountBySql(db *gorm.DB, sqlStr string, paramArr ...any) (count int64) {
|
||||||
|
db.Raw(sqlStr, paramArr...).Count(&count)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ticketProgressRepository) Count(db *gorm.DB, cnd *sqls.Cnd) int64 {
|
||||||
|
return cnd.Count(db, &models.TicketProgress{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ticketProgressRepository) Create(db *gorm.DB, t *models.TicketProgress) (err error) {
|
||||||
|
err = db.Create(t).Error
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ticketProgressRepository) Update(db *gorm.DB, t *models.TicketProgress) (err error) {
|
||||||
|
err = db.Save(t).Error
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ticketProgressRepository) Updates(db *gorm.DB, id int64, columns map[string]any) (err error) {
|
||||||
|
err = db.Model(&models.TicketProgress{}).Where("id = ?", id).Updates(columns).Error
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ticketProgressRepository) UpdateColumn(db *gorm.DB, id int64, name string, value any) (err error) {
|
||||||
|
err = db.Model(&models.TicketProgress{}).Where("id = ?", id).UpdateColumn(name, value).Error
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ticketProgressRepository) Delete(db *gorm.DB, id int64) {
|
||||||
|
db.Delete(&models.TicketProgress{}, "id = ?", id)
|
||||||
|
}
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
package repositories
|
|
||||||
|
|
||||||
import (
|
|
||||||
"cs-agent/internal/models"
|
|
||||||
|
|
||||||
"github.com/mlogclub/simple/sqls"
|
|
||||||
"github.com/mlogclub/simple/web/params"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
var TicketRelationRepository = newTicketRelationRepository()
|
|
||||||
|
|
||||||
func newTicketRelationRepository() *ticketRelationRepository {
|
|
||||||
return &ticketRelationRepository{}
|
|
||||||
}
|
|
||||||
|
|
||||||
type ticketRelationRepository struct {
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketRelationRepository) Get(db *gorm.DB, id int64) *models.TicketRelation {
|
|
||||||
ret := &models.TicketRelation{}
|
|
||||||
if err := db.First(ret, "id = ?", id).Error; err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return ret
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketRelationRepository) Take(db *gorm.DB, where ...interface{}) *models.TicketRelation {
|
|
||||||
ret := &models.TicketRelation{}
|
|
||||||
if err := db.Take(ret, where...).Error; err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return ret
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketRelationRepository) Find(db *gorm.DB, cnd *sqls.Cnd) (list []models.TicketRelation) {
|
|
||||||
cnd.Find(db, &list)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketRelationRepository) FindOne(db *gorm.DB, cnd *sqls.Cnd) *models.TicketRelation {
|
|
||||||
ret := &models.TicketRelation{}
|
|
||||||
if err := cnd.FindOne(db, &ret); err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return ret
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketRelationRepository) FindPageByParams(db *gorm.DB, params *params.QueryParams) (list []models.TicketRelation, paging *sqls.Paging) {
|
|
||||||
return r.FindPageByCnd(db, ¶ms.Cnd)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketRelationRepository) FindPageByCnd(db *gorm.DB, cnd *sqls.Cnd) (list []models.TicketRelation, paging *sqls.Paging) {
|
|
||||||
cnd.Find(db, &list)
|
|
||||||
count := cnd.Count(db, &models.TicketRelation{})
|
|
||||||
|
|
||||||
paging = &sqls.Paging{
|
|
||||||
Page: cnd.Paging.Page,
|
|
||||||
Limit: cnd.Paging.Limit,
|
|
||||||
Total: count,
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketRelationRepository) FindBySql(db *gorm.DB, sqlStr string, paramArr ...interface{}) (list []models.TicketRelation) {
|
|
||||||
db.Raw(sqlStr, paramArr...).Scan(&list)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketRelationRepository) CountBySql(db *gorm.DB, sqlStr string, paramArr ...interface{}) (count int64) {
|
|
||||||
db.Raw(sqlStr, paramArr...).Count(&count)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketRelationRepository) Count(db *gorm.DB, cnd *sqls.Cnd) int64 {
|
|
||||||
return cnd.Count(db, &models.TicketRelation{})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketRelationRepository) Create(db *gorm.DB, t *models.TicketRelation) (err error) {
|
|
||||||
err = db.Create(t).Error
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketRelationRepository) Update(db *gorm.DB, t *models.TicketRelation) (err error) {
|
|
||||||
err = db.Save(t).Error
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketRelationRepository) Updates(db *gorm.DB, id int64, columns map[string]interface{}) (err error) {
|
|
||||||
err = db.Model(&models.TicketRelation{}).Where("id = ?", id).Updates(columns).Error
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketRelationRepository) UpdateColumn(db *gorm.DB, id int64, name string, value interface{}) (err error) {
|
|
||||||
err = db.Model(&models.TicketRelation{}).Where("id = ?", id).UpdateColumn(name, value).Error
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketRelationRepository) Delete(db *gorm.DB, id int64) {
|
|
||||||
db.Delete(&models.TicketRelation{}, "id = ?", id)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketRelationRepository) DeleteByTicketRelation(db *gorm.DB, ticketID, relatedTicketID int64, relationType string) error {
|
|
||||||
return db.Where("ticket_id = ? AND related_ticket_id = ? AND relation_type = ?", ticketID, relatedTicketID, relationType).
|
|
||||||
Delete(&models.TicketRelation{}).Error
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
package repositories
|
|
||||||
|
|
||||||
import (
|
|
||||||
"cs-agent/internal/models"
|
|
||||||
|
|
||||||
"github.com/mlogclub/simple/sqls"
|
|
||||||
"github.com/mlogclub/simple/web/params"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
var TicketResolutionCodeRepository = newTicketResolutionCodeRepository()
|
|
||||||
|
|
||||||
func newTicketResolutionCodeRepository() *ticketResolutionCodeRepository {
|
|
||||||
return &ticketResolutionCodeRepository{}
|
|
||||||
}
|
|
||||||
|
|
||||||
type ticketResolutionCodeRepository struct{}
|
|
||||||
|
|
||||||
func (r *ticketResolutionCodeRepository) Get(db *gorm.DB, id int64) *models.TicketResolutionCode {
|
|
||||||
ret := &models.TicketResolutionCode{}
|
|
||||||
if err := db.First(ret, "id = ?", id).Error; err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return ret
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketResolutionCodeRepository) Take(db *gorm.DB, where ...interface{}) *models.TicketResolutionCode {
|
|
||||||
ret := &models.TicketResolutionCode{}
|
|
||||||
if err := db.Take(ret, where...).Error; err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return ret
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketResolutionCodeRepository) Find(db *gorm.DB, cnd *sqls.Cnd) (list []models.TicketResolutionCode) {
|
|
||||||
cnd.Find(db, &list)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketResolutionCodeRepository) FindOne(db *gorm.DB, cnd *sqls.Cnd) *models.TicketResolutionCode {
|
|
||||||
ret := &models.TicketResolutionCode{}
|
|
||||||
if err := cnd.FindOne(db, &ret); err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return ret
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketResolutionCodeRepository) FindPageByParams(db *gorm.DB, params *params.QueryParams) (list []models.TicketResolutionCode, paging *sqls.Paging) {
|
|
||||||
return r.FindPageByCnd(db, ¶ms.Cnd)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketResolutionCodeRepository) FindPageByCnd(db *gorm.DB, cnd *sqls.Cnd) (list []models.TicketResolutionCode, paging *sqls.Paging) {
|
|
||||||
cnd.Find(db, &list)
|
|
||||||
count := cnd.Count(db, &models.TicketResolutionCode{})
|
|
||||||
paging = &sqls.Paging{Page: cnd.Paging.Page, Limit: cnd.Paging.Limit, Total: count}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketResolutionCodeRepository) Count(db *gorm.DB, cnd *sqls.Cnd) int64 {
|
|
||||||
return cnd.Count(db, &models.TicketResolutionCode{})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketResolutionCodeRepository) Create(db *gorm.DB, t *models.TicketResolutionCode) error {
|
|
||||||
return db.Create(t).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketResolutionCodeRepository) Update(db *gorm.DB, t *models.TicketResolutionCode) error {
|
|
||||||
return db.Save(t).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketResolutionCodeRepository) Updates(db *gorm.DB, id int64, columns map[string]interface{}) error {
|
|
||||||
return db.Model(&models.TicketResolutionCode{}).Where("id = ?", id).Updates(columns).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketResolutionCodeRepository) UpdateColumn(db *gorm.DB, id int64, name string, value interface{}) error {
|
|
||||||
return db.Model(&models.TicketResolutionCode{}).Where("id = ?", id).UpdateColumn(name, value).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketResolutionCodeRepository) Delete(db *gorm.DB, id int64) {
|
|
||||||
db.Delete(&models.TicketResolutionCode{}, "id = ?", id)
|
|
||||||
}
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
package repositories
|
|
||||||
|
|
||||||
import (
|
|
||||||
"cs-agent/internal/models"
|
|
||||||
|
|
||||||
"github.com/mlogclub/simple/sqls"
|
|
||||||
"github.com/mlogclub/simple/web/params"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
var TicketSLARecordRepository = newTicketSLARecordRepository()
|
|
||||||
|
|
||||||
func newTicketSLARecordRepository() *ticketSLARecordRepository {
|
|
||||||
return &ticketSLARecordRepository{}
|
|
||||||
}
|
|
||||||
|
|
||||||
type ticketSLARecordRepository struct {
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketSLARecordRepository) TakeByTicketIDAndType(db *gorm.DB, ticketID int64, slaType string) *models.TicketSLARecord {
|
|
||||||
ret := &models.TicketSLARecord{}
|
|
||||||
if err := db.Take(ret, "ticket_id = ? AND sla_type = ?", ticketID, slaType).Error; err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return ret
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketSLARecordRepository) Get(db *gorm.DB, id int64) *models.TicketSLARecord {
|
|
||||||
ret := &models.TicketSLARecord{}
|
|
||||||
if err := db.First(ret, "id = ?", id).Error; err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return ret
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketSLARecordRepository) Take(db *gorm.DB, where ...interface{}) *models.TicketSLARecord {
|
|
||||||
ret := &models.TicketSLARecord{}
|
|
||||||
if err := db.Take(ret, where...).Error; err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return ret
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketSLARecordRepository) Find(db *gorm.DB, cnd *sqls.Cnd) (list []models.TicketSLARecord) {
|
|
||||||
cnd.Find(db, &list)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketSLARecordRepository) FindOne(db *gorm.DB, cnd *sqls.Cnd) *models.TicketSLARecord {
|
|
||||||
ret := &models.TicketSLARecord{}
|
|
||||||
if err := cnd.FindOne(db, &ret); err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return ret
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketSLARecordRepository) FindPageByParams(db *gorm.DB, params *params.QueryParams) (list []models.TicketSLARecord, paging *sqls.Paging) {
|
|
||||||
return r.FindPageByCnd(db, ¶ms.Cnd)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketSLARecordRepository) FindPageByCnd(db *gorm.DB, cnd *sqls.Cnd) (list []models.TicketSLARecord, paging *sqls.Paging) {
|
|
||||||
cnd.Find(db, &list)
|
|
||||||
count := cnd.Count(db, &models.TicketSLARecord{})
|
|
||||||
|
|
||||||
paging = &sqls.Paging{
|
|
||||||
Page: cnd.Paging.Page,
|
|
||||||
Limit: cnd.Paging.Limit,
|
|
||||||
Total: count,
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketSLARecordRepository) FindBySql(db *gorm.DB, sqlStr string, paramArr ...interface{}) (list []models.TicketSLARecord) {
|
|
||||||
db.Raw(sqlStr, paramArr...).Scan(&list)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketSLARecordRepository) CountBySql(db *gorm.DB, sqlStr string, paramArr ...interface{}) (count int64) {
|
|
||||||
db.Raw(sqlStr, paramArr...).Count(&count)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketSLARecordRepository) Count(db *gorm.DB, cnd *sqls.Cnd) int64 {
|
|
||||||
return cnd.Count(db, &models.TicketSLARecord{})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketSLARecordRepository) Create(db *gorm.DB, t *models.TicketSLARecord) (err error) {
|
|
||||||
err = db.Create(t).Error
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketSLARecordRepository) Update(db *gorm.DB, t *models.TicketSLARecord) (err error) {
|
|
||||||
err = db.Save(t).Error
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketSLARecordRepository) Updates(db *gorm.DB, id int64, columns map[string]interface{}) (err error) {
|
|
||||||
err = db.Model(&models.TicketSLARecord{}).Where("id = ?", id).Updates(columns).Error
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketSLARecordRepository) UpdateColumn(db *gorm.DB, id int64, name string, value interface{}) (err error) {
|
|
||||||
err = db.Model(&models.TicketSLARecord{}).Where("id = ?", id).UpdateColumn(name, value).Error
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketSLARecordRepository) Delete(db *gorm.DB, id int64) {
|
|
||||||
db.Delete(&models.TicketSLARecord{}, "id = ?", id)
|
|
||||||
}
|
|
||||||
@@ -25,7 +25,7 @@ func (r *ticketViewRepository) Get(db *gorm.DB, id int64) *models.TicketView {
|
|||||||
return ret
|
return ret
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ticketViewRepository) Take(db *gorm.DB, where ...interface{}) *models.TicketView {
|
func (r *ticketViewRepository) Take(db *gorm.DB, where ...any) *models.TicketView {
|
||||||
ret := &models.TicketView{}
|
ret := &models.TicketView{}
|
||||||
if err := db.Take(ret, where...).Error; err != nil {
|
if err := db.Take(ret, where...).Error; err != nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -74,10 +74,10 @@ func (r *ticketViewRepository) Update(db *gorm.DB, t *models.TicketView) error {
|
|||||||
return db.Save(t).Error
|
return db.Save(t).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ticketViewRepository) Updates(db *gorm.DB, id int64, columns map[string]interface{}) error {
|
func (r *ticketViewRepository) Updates(db *gorm.DB, id int64, columns map[string]any) error {
|
||||||
return db.Model(&models.TicketView{}).Where("id = ?", id).Updates(columns).Error
|
return db.Model(&models.TicketView{}).Where("id = ?", id).Updates(columns).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ticketViewRepository) Delete(db *gorm.DB, id int64) {
|
func (r *ticketViewRepository) Delete(db *gorm.DB, id int64) error {
|
||||||
db.Delete(&models.TicketView{}, "id = ?", id)
|
return db.Delete(&models.TicketView{}, "id = ?", id).Error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,109 +0,0 @@
|
|||||||
package repositories
|
|
||||||
|
|
||||||
import (
|
|
||||||
"cs-agent/internal/models"
|
|
||||||
|
|
||||||
"github.com/mlogclub/simple/sqls"
|
|
||||||
"github.com/mlogclub/simple/web/params"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
var TicketWatcherRepository = newTicketWatcherRepository()
|
|
||||||
|
|
||||||
func newTicketWatcherRepository() *ticketWatcherRepository {
|
|
||||||
return &ticketWatcherRepository{}
|
|
||||||
}
|
|
||||||
|
|
||||||
type ticketWatcherRepository struct {
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketWatcherRepository) TakeByTicketIDAndUserID(db *gorm.DB, ticketID, userID int64) *models.TicketWatcher {
|
|
||||||
ret := &models.TicketWatcher{}
|
|
||||||
if err := db.Take(ret, "ticket_id = ? AND user_id = ?", ticketID, userID).Error; err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return ret
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketWatcherRepository) Get(db *gorm.DB, id int64) *models.TicketWatcher {
|
|
||||||
ret := &models.TicketWatcher{}
|
|
||||||
if err := db.First(ret, "id = ?", id).Error; err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return ret
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketWatcherRepository) Take(db *gorm.DB, where ...interface{}) *models.TicketWatcher {
|
|
||||||
ret := &models.TicketWatcher{}
|
|
||||||
if err := db.Take(ret, where...).Error; err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return ret
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketWatcherRepository) Find(db *gorm.DB, cnd *sqls.Cnd) (list []models.TicketWatcher) {
|
|
||||||
cnd.Find(db, &list)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketWatcherRepository) FindOne(db *gorm.DB, cnd *sqls.Cnd) *models.TicketWatcher {
|
|
||||||
ret := &models.TicketWatcher{}
|
|
||||||
if err := cnd.FindOne(db, &ret); err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return ret
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketWatcherRepository) FindPageByParams(db *gorm.DB, params *params.QueryParams) (list []models.TicketWatcher, paging *sqls.Paging) {
|
|
||||||
return r.FindPageByCnd(db, ¶ms.Cnd)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketWatcherRepository) FindPageByCnd(db *gorm.DB, cnd *sqls.Cnd) (list []models.TicketWatcher, paging *sqls.Paging) {
|
|
||||||
cnd.Find(db, &list)
|
|
||||||
count := cnd.Count(db, &models.TicketWatcher{})
|
|
||||||
|
|
||||||
paging = &sqls.Paging{
|
|
||||||
Page: cnd.Paging.Page,
|
|
||||||
Limit: cnd.Paging.Limit,
|
|
||||||
Total: count,
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketWatcherRepository) FindBySql(db *gorm.DB, sqlStr string, paramArr ...interface{}) (list []models.TicketWatcher) {
|
|
||||||
db.Raw(sqlStr, paramArr...).Scan(&list)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketWatcherRepository) CountBySql(db *gorm.DB, sqlStr string, paramArr ...interface{}) (count int64) {
|
|
||||||
db.Raw(sqlStr, paramArr...).Count(&count)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketWatcherRepository) Count(db *gorm.DB, cnd *sqls.Cnd) int64 {
|
|
||||||
return cnd.Count(db, &models.TicketWatcher{})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketWatcherRepository) Create(db *gorm.DB, t *models.TicketWatcher) (err error) {
|
|
||||||
err = db.Create(t).Error
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketWatcherRepository) Update(db *gorm.DB, t *models.TicketWatcher) (err error) {
|
|
||||||
err = db.Save(t).Error
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketWatcherRepository) Updates(db *gorm.DB, id int64, columns map[string]interface{}) (err error) {
|
|
||||||
err = db.Model(&models.TicketWatcher{}).Where("id = ?", id).Updates(columns).Error
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketWatcherRepository) UpdateColumn(db *gorm.DB, id int64, name string, value interface{}) (err error) {
|
|
||||||
err = db.Model(&models.TicketWatcher{}).Where("id = ?", id).UpdateColumn(name, value).Error
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ticketWatcherRepository) Delete(db *gorm.DB, id int64) {
|
|
||||||
db.Delete(&models.TicketWatcher{}, "id = ?", id)
|
|
||||||
}
|
|
||||||
@@ -28,17 +28,6 @@ func Init() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
addFunc(c, "@every 1m", func() {
|
|
||||||
count, err := services.TicketService.ScanAndMarkBreachedSLAs(200)
|
|
||||||
if err != nil {
|
|
||||||
slog.Warn("scan breached ticket slas failed", "error", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if count > 0 {
|
|
||||||
slog.Info("ticket sla breached scan completed", "breachedCount", count)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
c.Start()
|
c.Start()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ func handleTicketAssignedInAppNotification(ctx context.Context, event events.Tic
|
|||||||
NotificationType: "ticket_assigned",
|
NotificationType: "ticket_assigned",
|
||||||
BizType: "ticket",
|
BizType: "ticket",
|
||||||
BizID: ticket.ID,
|
BizID: ticket.ID,
|
||||||
ActionURL: fmt.Sprintf("/dashboard/tickets/%d", ticket.ID),
|
ActionURL: fmt.Sprintf("/dashboard/tickets?ticketId=%d", ticket.ID),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("create ticket assigned in-app notification failed", "error", err, "ticketId", event.TicketID, "toUserId", event.ToUserID)
|
slog.Error("create ticket assigned in-app notification failed", "error", err, "ticketId", event.TicketID, "toUserId", event.ToUserID)
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ func TestTicketAssignedInAppNotification(t *testing.T) {
|
|||||||
TicketNo: "TK202604280001",
|
TicketNo: "TK202604280001",
|
||||||
Title: "退款处理",
|
Title: "退款处理",
|
||||||
Source: enums.TicketSourceManual,
|
Source: enums.TicketSourceManual,
|
||||||
Status: enums.TicketStatusOpen,
|
Status: enums.TicketStatusPending,
|
||||||
CurrentAssigneeID: 11,
|
CurrentAssigneeID: 11,
|
||||||
AuditFields: models.AuditFields{
|
AuditFields: models.AuditFields{
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
@@ -52,7 +52,7 @@ func TestTicketAssignedInAppNotification(t *testing.T) {
|
|||||||
if got.NotificationType != "ticket_assigned" || got.BizType != "ticket" || got.BizID != ticket.ID {
|
if got.NotificationType != "ticket_assigned" || got.BizType != "ticket" || got.BizID != ticket.ID {
|
||||||
t.Fatalf("unexpected notification: %+v", got)
|
t.Fatalf("unexpected notification: %+v", got)
|
||||||
}
|
}
|
||||||
if got.ActionURL != "/dashboard/tickets/1" {
|
if got.ActionURL != "/dashboard/tickets?ticketId=1" {
|
||||||
t.Fatalf("unexpected action url: %q", got.ActionURL)
|
t.Fatalf("unexpected action url: %q", got.ActionURL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
package services
|
|
||||||
|
|
||||||
import (
|
|
||||||
"cs-agent/internal/models"
|
|
||||||
"cs-agent/internal/repositories"
|
|
||||||
|
|
||||||
"github.com/mlogclub/simple/sqls"
|
|
||||||
"github.com/mlogclub/simple/web/params"
|
|
||||||
)
|
|
||||||
|
|
||||||
var TicketCollaboratorService = newTicketCollaboratorService()
|
|
||||||
|
|
||||||
func newTicketCollaboratorService() *ticketCollaboratorService {
|
|
||||||
return &ticketCollaboratorService{}
|
|
||||||
}
|
|
||||||
|
|
||||||
type ticketCollaboratorService struct {
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketCollaboratorService) Get(id int64) *models.TicketCollaborator {
|
|
||||||
return repositories.TicketCollaboratorRepository.Get(sqls.DB(), id)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketCollaboratorService) Take(where ...interface{}) *models.TicketCollaborator {
|
|
||||||
return repositories.TicketCollaboratorRepository.Take(sqls.DB(), where...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketCollaboratorService) Find(cnd *sqls.Cnd) []models.TicketCollaborator {
|
|
||||||
return repositories.TicketCollaboratorRepository.Find(sqls.DB(), cnd)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketCollaboratorService) FindOne(cnd *sqls.Cnd) *models.TicketCollaborator {
|
|
||||||
return repositories.TicketCollaboratorRepository.FindOne(sqls.DB(), cnd)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketCollaboratorService) FindPageByParams(params *params.QueryParams) (list []models.TicketCollaborator, paging *sqls.Paging) {
|
|
||||||
return repositories.TicketCollaboratorRepository.FindPageByParams(sqls.DB(), params)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketCollaboratorService) FindPageByCnd(cnd *sqls.Cnd) (list []models.TicketCollaborator, paging *sqls.Paging) {
|
|
||||||
return repositories.TicketCollaboratorRepository.FindPageByCnd(sqls.DB(), cnd)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketCollaboratorService) Count(cnd *sqls.Cnd) int64 {
|
|
||||||
return repositories.TicketCollaboratorRepository.Count(sqls.DB(), cnd)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketCollaboratorService) Create(t *models.TicketCollaborator) error {
|
|
||||||
return repositories.TicketCollaboratorRepository.Create(sqls.DB(), t)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketCollaboratorService) Delete(id int64) {
|
|
||||||
repositories.TicketCollaboratorRepository.Delete(sqls.DB(), id)
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
package services
|
|
||||||
|
|
||||||
import (
|
|
||||||
"cs-agent/internal/models"
|
|
||||||
"cs-agent/internal/repositories"
|
|
||||||
|
|
||||||
"github.com/mlogclub/simple/sqls"
|
|
||||||
"github.com/mlogclub/simple/web/params"
|
|
||||||
)
|
|
||||||
|
|
||||||
var TicketCommentService = newTicketCommentService()
|
|
||||||
|
|
||||||
func newTicketCommentService() *ticketCommentService {
|
|
||||||
return &ticketCommentService{}
|
|
||||||
}
|
|
||||||
|
|
||||||
type ticketCommentService struct {
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketCommentService) Get(id int64) *models.TicketComment {
|
|
||||||
return repositories.TicketCommentRepository.Get(sqls.DB(), id)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketCommentService) Take(where ...interface{}) *models.TicketComment {
|
|
||||||
return repositories.TicketCommentRepository.Take(sqls.DB(), where...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketCommentService) Find(cnd *sqls.Cnd) []models.TicketComment {
|
|
||||||
return repositories.TicketCommentRepository.Find(sqls.DB(), cnd)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketCommentService) FindOne(cnd *sqls.Cnd) *models.TicketComment {
|
|
||||||
return repositories.TicketCommentRepository.FindOne(sqls.DB(), cnd)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketCommentService) FindPageByParams(params *params.QueryParams) (list []models.TicketComment, paging *sqls.Paging) {
|
|
||||||
return repositories.TicketCommentRepository.FindPageByParams(sqls.DB(), params)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketCommentService) FindPageByCnd(cnd *sqls.Cnd) (list []models.TicketComment, paging *sqls.Paging) {
|
|
||||||
return repositories.TicketCommentRepository.FindPageByCnd(sqls.DB(), cnd)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketCommentService) Count(cnd *sqls.Cnd) int64 {
|
|
||||||
return repositories.TicketCommentRepository.Count(sqls.DB(), cnd)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketCommentService) Create(t *models.TicketComment) error {
|
|
||||||
return repositories.TicketCommentRepository.Create(sqls.DB(), t)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketCommentService) Update(t *models.TicketComment) error {
|
|
||||||
return repositories.TicketCommentRepository.Update(sqls.DB(), t)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketCommentService) Updates(id int64, columns map[string]interface{}) error {
|
|
||||||
return repositories.TicketCommentRepository.Updates(sqls.DB(), id, columns)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketCommentService) UpdateColumn(id int64, name string, value interface{}) error {
|
|
||||||
return repositories.TicketCommentRepository.UpdateColumn(sqls.DB(), id, name, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketCommentService) Delete(id int64) {
|
|
||||||
repositories.TicketCommentRepository.Delete(sqls.DB(), id)
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
package services
|
|
||||||
|
|
||||||
import (
|
|
||||||
"cs-agent/internal/models"
|
|
||||||
"cs-agent/internal/repositories"
|
|
||||||
|
|
||||||
"github.com/mlogclub/simple/sqls"
|
|
||||||
"github.com/mlogclub/simple/web/params"
|
|
||||||
)
|
|
||||||
|
|
||||||
var TicketEventLogService = newTicketEventLogService()
|
|
||||||
|
|
||||||
func newTicketEventLogService() *ticketEventLogService {
|
|
||||||
return &ticketEventLogService{}
|
|
||||||
}
|
|
||||||
|
|
||||||
type ticketEventLogService struct {
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketEventLogService) Get(id int64) *models.TicketEventLog {
|
|
||||||
return repositories.TicketEventLogRepository.Get(sqls.DB(), id)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketEventLogService) Take(where ...interface{}) *models.TicketEventLog {
|
|
||||||
return repositories.TicketEventLogRepository.Take(sqls.DB(), where...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketEventLogService) Find(cnd *sqls.Cnd) []models.TicketEventLog {
|
|
||||||
return repositories.TicketEventLogRepository.Find(sqls.DB(), cnd)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketEventLogService) FindOne(cnd *sqls.Cnd) *models.TicketEventLog {
|
|
||||||
return repositories.TicketEventLogRepository.FindOne(sqls.DB(), cnd)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketEventLogService) FindPageByParams(params *params.QueryParams) (list []models.TicketEventLog, paging *sqls.Paging) {
|
|
||||||
return repositories.TicketEventLogRepository.FindPageByParams(sqls.DB(), params)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketEventLogService) FindPageByCnd(cnd *sqls.Cnd) (list []models.TicketEventLog, paging *sqls.Paging) {
|
|
||||||
return repositories.TicketEventLogRepository.FindPageByCnd(sqls.DB(), cnd)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketEventLogService) Count(cnd *sqls.Cnd) int64 {
|
|
||||||
return repositories.TicketEventLogRepository.Count(sqls.DB(), cnd)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketEventLogService) Create(t *models.TicketEventLog) error {
|
|
||||||
return repositories.TicketEventLogRepository.Create(sqls.DB(), t)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketEventLogService) Update(t *models.TicketEventLog) error {
|
|
||||||
return repositories.TicketEventLogRepository.Update(sqls.DB(), t)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketEventLogService) Updates(id int64, columns map[string]interface{}) error {
|
|
||||||
return repositories.TicketEventLogRepository.Updates(sqls.DB(), id, columns)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketEventLogService) UpdateColumn(id int64, name string, value interface{}) error {
|
|
||||||
return repositories.TicketEventLogRepository.UpdateColumn(sqls.DB(), id, name, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketEventLogService) Delete(id int64) {
|
|
||||||
repositories.TicketEventLogRepository.Delete(sqls.DB(), id)
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
package services
|
|
||||||
|
|
||||||
import (
|
|
||||||
"cs-agent/internal/models"
|
|
||||||
"cs-agent/internal/repositories"
|
|
||||||
|
|
||||||
"github.com/mlogclub/simple/sqls"
|
|
||||||
"github.com/mlogclub/simple/web/params"
|
|
||||||
)
|
|
||||||
|
|
||||||
var TicketMentionService = newTicketMentionService()
|
|
||||||
|
|
||||||
func newTicketMentionService() *ticketMentionService {
|
|
||||||
return &ticketMentionService{}
|
|
||||||
}
|
|
||||||
|
|
||||||
type ticketMentionService struct {
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketMentionService) Get(id int64) *models.TicketMention {
|
|
||||||
return repositories.TicketMentionRepository.Get(sqls.DB(), id)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketMentionService) Take(where ...interface{}) *models.TicketMention {
|
|
||||||
return repositories.TicketMentionRepository.Take(sqls.DB(), where...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketMentionService) Find(cnd *sqls.Cnd) []models.TicketMention {
|
|
||||||
return repositories.TicketMentionRepository.Find(sqls.DB(), cnd)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketMentionService) FindOne(cnd *sqls.Cnd) *models.TicketMention {
|
|
||||||
return repositories.TicketMentionRepository.FindOne(sqls.DB(), cnd)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketMentionService) FindPageByParams(params *params.QueryParams) (list []models.TicketMention, paging *sqls.Paging) {
|
|
||||||
return repositories.TicketMentionRepository.FindPageByParams(sqls.DB(), params)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketMentionService) FindPageByCnd(cnd *sqls.Cnd) (list []models.TicketMention, paging *sqls.Paging) {
|
|
||||||
return repositories.TicketMentionRepository.FindPageByCnd(sqls.DB(), cnd)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketMentionService) Count(cnd *sqls.Cnd) int64 {
|
|
||||||
return repositories.TicketMentionRepository.Count(sqls.DB(), cnd)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"cs-agent/internal/models"
|
||||||
|
"cs-agent/internal/repositories"
|
||||||
|
|
||||||
|
"github.com/mlogclub/simple/sqls"
|
||||||
|
"github.com/mlogclub/simple/web/params"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
var TicketNoSequenceService = newTicketNoSequenceService()
|
||||||
|
|
||||||
|
func newTicketNoSequenceService() *ticketNoSequenceService {
|
||||||
|
return &ticketNoSequenceService{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ticketNoSequenceService struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ticketNoSequenceService) Get(id int64) *models.TicketNoSequence {
|
||||||
|
return repositories.TicketNoSequenceRepository.Get(sqls.DB(), id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ticketNoSequenceService) Take(where ...any) *models.TicketNoSequence {
|
||||||
|
return repositories.TicketNoSequenceRepository.Take(sqls.DB(), where...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ticketNoSequenceService) Find(cnd *sqls.Cnd) []models.TicketNoSequence {
|
||||||
|
return repositories.TicketNoSequenceRepository.Find(sqls.DB(), cnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ticketNoSequenceService) FindOne(cnd *sqls.Cnd) *models.TicketNoSequence {
|
||||||
|
return repositories.TicketNoSequenceRepository.FindOne(sqls.DB(), cnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ticketNoSequenceService) FindPageByParams(params *params.QueryParams) (list []models.TicketNoSequence, paging *sqls.Paging) {
|
||||||
|
return repositories.TicketNoSequenceRepository.FindPageByParams(sqls.DB(), params)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ticketNoSequenceService) FindPageByCnd(cnd *sqls.Cnd) (list []models.TicketNoSequence, paging *sqls.Paging) {
|
||||||
|
return repositories.TicketNoSequenceRepository.FindPageByCnd(sqls.DB(), cnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ticketNoSequenceService) Count(cnd *sqls.Cnd) int64 {
|
||||||
|
return repositories.TicketNoSequenceRepository.Count(sqls.DB(), cnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ticketNoSequenceService) Create(t *models.TicketNoSequence) error {
|
||||||
|
return repositories.TicketNoSequenceRepository.Create(sqls.DB(), t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ticketNoSequenceService) Update(t *models.TicketNoSequence) error {
|
||||||
|
return repositories.TicketNoSequenceRepository.Update(sqls.DB(), t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ticketNoSequenceService) Updates(id int64, columns map[string]any) error {
|
||||||
|
return repositories.TicketNoSequenceRepository.Updates(sqls.DB(), id, columns)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ticketNoSequenceService) UpdateColumn(id int64, name string, value any) error {
|
||||||
|
return repositories.TicketNoSequenceRepository.UpdateColumn(sqls.DB(), id, name, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ticketNoSequenceService) Delete(id int64) {
|
||||||
|
repositories.TicketNoSequenceRepository.Delete(sqls.DB(), id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ticketNoSequenceService) Next(db *gorm.DB, now time.Time) (string, error) {
|
||||||
|
return TicketNoService.Next(db, now)
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"cs-agent/internal/repositories"
|
"cs-agent/internal/repositories"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -12,6 +13,8 @@ import (
|
|||||||
|
|
||||||
var TicketNoService = newTicketNoService()
|
var TicketNoService = newTicketNoService()
|
||||||
|
|
||||||
|
var ticketNoSQLiteMu sync.Mutex
|
||||||
|
|
||||||
func newTicketNoService() *ticketNoService {
|
func newTicketNoService() *ticketNoService {
|
||||||
return &ticketNoService{}
|
return &ticketNoService{}
|
||||||
}
|
}
|
||||||
@@ -22,9 +25,25 @@ func (s *ticketNoService) Next(tx *gorm.DB, now time.Time) (string, error) {
|
|||||||
if tx == nil {
|
if tx == nil {
|
||||||
return "", fmt.Errorf("ticket number transaction is required")
|
return "", fmt.Errorf("ticket number transaction is required")
|
||||||
}
|
}
|
||||||
|
if tx.Dialector.Name() == "sqlite" {
|
||||||
|
ticketNoSQLiteMu.Lock()
|
||||||
|
defer ticketNoSQLiteMu.Unlock()
|
||||||
|
return s.nextWithRetry(tx, now)
|
||||||
|
}
|
||||||
|
return s.nextWithRetry(tx, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ticketNoService) nextWithRetry(tx *gorm.DB, now time.Time) (string, error) {
|
||||||
dateKey := now.Format("20060102")
|
dateKey := now.Format("20060102")
|
||||||
for attempt := 0; attempt < 20; attempt++ {
|
for attempt := 0; attempt < 100; attempt++ {
|
||||||
current := repositories.TicketNoSequenceRepository.GetByDateKey(tx, dateKey)
|
current, err := repositories.TicketNoSequenceRepository.GetByDateKeyForUpdate(tx, dateKey)
|
||||||
|
if err != nil {
|
||||||
|
if isRetriableTicketNoError(tx, err) {
|
||||||
|
sleepTicketNoRetry(attempt)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
if current == nil {
|
if current == nil {
|
||||||
item := &models.TicketNoSequence{
|
item := &models.TicketNoSequence{
|
||||||
DateKey: dateKey,
|
DateKey: dateKey,
|
||||||
@@ -36,17 +55,28 @@ func (s *ticketNoService) Next(tx *gorm.DB, now time.Time) (string, error) {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
return formatTicketNo(dateKey, 1), nil
|
return formatTicketNo(dateKey, 1), nil
|
||||||
}
|
}
|
||||||
if !isRetriableTicketNoError(err) {
|
if !isRetriableTicketNoError(tx, err) {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
time.Sleep(time.Duration(attempt+1) * 10 * time.Millisecond)
|
|
||||||
continue
|
current, err = repositories.TicketNoSequenceRepository.GetByDateKeyForUpdate(tx, dateKey)
|
||||||
|
if err != nil {
|
||||||
|
if isRetriableTicketNoError(tx, err) {
|
||||||
|
sleepTicketNoRetry(attempt)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if current == nil {
|
||||||
|
sleepTicketNoRetry(attempt)
|
||||||
|
continue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
allocated := current.NextSeq
|
allocated := current.NextSeq
|
||||||
ok, err := repositories.TicketNoSequenceRepository.UpdateNextSeq(tx, current.ID, current.NextSeq, current.NextSeq+1, now)
|
ok, err := repositories.TicketNoSequenceRepository.UpdateNextSeq(tx, current.ID, allocated, allocated+1, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if isRetriableTicketNoError(err) {
|
if isRetriableTicketNoError(tx, err) {
|
||||||
time.Sleep(time.Duration(attempt+1) * 10 * time.Millisecond)
|
sleepTicketNoRetry(attempt)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
return "", err
|
return "", err
|
||||||
@@ -54,11 +84,19 @@ func (s *ticketNoService) Next(tx *gorm.DB, now time.Time) (string, error) {
|
|||||||
if ok {
|
if ok {
|
||||||
return formatTicketNo(dateKey, allocated), nil
|
return formatTicketNo(dateKey, allocated), nil
|
||||||
}
|
}
|
||||||
time.Sleep(time.Duration(attempt+1) * 10 * time.Millisecond)
|
sleepTicketNoRetry(attempt)
|
||||||
}
|
}
|
||||||
return "", fmt.Errorf("failed to allocate ticket number")
|
return "", fmt.Errorf("failed to allocate ticket number")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sleepTicketNoRetry(attempt int) {
|
||||||
|
delay := time.Duration(attempt+1) * 10 * time.Millisecond
|
||||||
|
if delay > 200*time.Millisecond {
|
||||||
|
delay = 200 * time.Millisecond
|
||||||
|
}
|
||||||
|
time.Sleep(delay)
|
||||||
|
}
|
||||||
|
|
||||||
func formatTicketNo(dateKey string, seq int64) string {
|
func formatTicketNo(dateKey string, seq int64) string {
|
||||||
return fmt.Sprintf("TK%s%05d", dateKey, seq)
|
return fmt.Sprintf("TK%s%05d", dateKey, seq)
|
||||||
}
|
}
|
||||||
@@ -71,14 +109,19 @@ func isDuplicateKeyError(err error) bool {
|
|||||||
return strings.Contains(message, "duplicate") || strings.Contains(message, "unique") || strings.Contains(message, "constraint failed")
|
return strings.Contains(message, "duplicate") || strings.Contains(message, "unique") || strings.Contains(message, "constraint failed")
|
||||||
}
|
}
|
||||||
|
|
||||||
func isRetriableTicketNoError(err error) bool {
|
func isRetriableTicketNoError(tx *gorm.DB, err error) bool {
|
||||||
return isDuplicateKeyError(err) || isDatabaseLockedError(err)
|
if isDuplicateKeyError(err) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return tx != nil && tx.Dialector.Name() == "sqlite" && isSQLiteDatabaseLockedError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func isDatabaseLockedError(err error) bool {
|
func isSQLiteDatabaseLockedError(err error) bool {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
message := strings.ToLower(err.Error())
|
message := strings.ToLower(err.Error())
|
||||||
return strings.Contains(message, "database is locked") || strings.Contains(message, "database table is locked")
|
return strings.Contains(message, "database is locked") ||
|
||||||
|
strings.Contains(message, "database table is locked") ||
|
||||||
|
strings.Contains(message, "database is busy")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,162 +0,0 @@
|
|||||||
package services
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"cs-agent/internal/models"
|
|
||||||
"cs-agent/internal/pkg/dto"
|
|
||||||
"cs-agent/internal/pkg/dto/request"
|
|
||||||
"cs-agent/internal/pkg/enums"
|
|
||||||
"cs-agent/internal/pkg/errorsx"
|
|
||||||
"cs-agent/internal/pkg/utils"
|
|
||||||
"cs-agent/internal/repositories"
|
|
||||||
|
|
||||||
"github.com/mlogclub/simple/sqls"
|
|
||||||
"github.com/mlogclub/simple/web/params"
|
|
||||||
)
|
|
||||||
|
|
||||||
var TicketPriorityConfigService = newTicketPriorityConfigService()
|
|
||||||
|
|
||||||
func newTicketPriorityConfigService() *ticketPriorityConfigService {
|
|
||||||
return &ticketPriorityConfigService{}
|
|
||||||
}
|
|
||||||
|
|
||||||
type ticketPriorityConfigService struct{}
|
|
||||||
|
|
||||||
func (s *ticketPriorityConfigService) Get(id int64) *models.TicketPriorityConfig {
|
|
||||||
return repositories.TicketPriorityConfigRepository.Get(sqls.DB(), id)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketPriorityConfigService) Take(where ...interface{}) *models.TicketPriorityConfig {
|
|
||||||
return repositories.TicketPriorityConfigRepository.Take(sqls.DB(), where...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketPriorityConfigService) Find(cnd *sqls.Cnd) []models.TicketPriorityConfig {
|
|
||||||
return repositories.TicketPriorityConfigRepository.Find(sqls.DB(), cnd)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketPriorityConfigService) FindOne(cnd *sqls.Cnd) *models.TicketPriorityConfig {
|
|
||||||
return repositories.TicketPriorityConfigRepository.FindOne(sqls.DB(), cnd)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketPriorityConfigService) FindPageByParams(queryParams *params.QueryParams) (list []models.TicketPriorityConfig, paging *sqls.Paging) {
|
|
||||||
return repositories.TicketPriorityConfigRepository.FindPageByParams(sqls.DB(), queryParams)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketPriorityConfigService) FindPageByCnd(cnd *sqls.Cnd) (list []models.TicketPriorityConfig, paging *sqls.Paging) {
|
|
||||||
return repositories.TicketPriorityConfigRepository.FindPageByCnd(sqls.DB(), cnd)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketPriorityConfigService) Create(t *models.TicketPriorityConfig) error {
|
|
||||||
return repositories.TicketPriorityConfigRepository.Create(sqls.DB(), t)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketPriorityConfigService) Updates(id int64, columns map[string]any) error {
|
|
||||||
return repositories.TicketPriorityConfigRepository.Updates(sqls.DB(), id, columns)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketPriorityConfigService) GetDefaultActive() *models.TicketPriorityConfig {
|
|
||||||
return s.FindOne(sqls.NewCnd().Eq("status", enums.StatusOk).Asc("sort_no").Asc("id"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketPriorityConfigService) CreateTicketPriorityConfig(req request.CreateTicketPriorityConfigRequest, operator *dto.AuthPrincipal) (*models.TicketPriorityConfig, error) {
|
|
||||||
if operator == nil {
|
|
||||||
return nil, errorsx.Unauthorized("未登录或登录已过期")
|
|
||||||
}
|
|
||||||
item, err := s.buildPriorityConfigModel(0, req.Name, req.FirstResponseMinutes, req.ResolutionMinutes, int(req.Status), req.Remark)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
item.SortNo = s.nextSortNo()
|
|
||||||
item.AuditFields = utils.BuildAuditFields(operator)
|
|
||||||
if err := s.Create(item); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return item, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketPriorityConfigService) UpdateTicketPriorityConfig(req request.UpdateTicketPriorityConfigRequest, operator *dto.AuthPrincipal) error {
|
|
||||||
if operator == nil {
|
|
||||||
return errorsx.Unauthorized("未登录或登录已过期")
|
|
||||||
}
|
|
||||||
current := s.Get(req.ID)
|
|
||||||
if current == nil || current.Status == enums.StatusDeleted {
|
|
||||||
return errorsx.InvalidParam("工单优先级配置不存在")
|
|
||||||
}
|
|
||||||
item, err := s.buildPriorityConfigModel(req.ID, req.Name, req.FirstResponseMinutes, req.ResolutionMinutes, int(req.Status), req.Remark)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return s.Updates(req.ID, map[string]any{
|
|
||||||
"name": item.Name,
|
|
||||||
"first_response_minutes": item.FirstResponseMinutes,
|
|
||||||
"resolution_minutes": item.ResolutionMinutes,
|
|
||||||
"status": item.Status,
|
|
||||||
"remark": item.Remark,
|
|
||||||
"update_user_id": operator.UserID,
|
|
||||||
"update_user_name": operator.Username,
|
|
||||||
"updated_at": time.Now(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketPriorityConfigService) UpdateSort(ids []int64) error {
|
|
||||||
return sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
|
||||||
for i, id := range ids {
|
|
||||||
if err := repositories.TicketPriorityConfigRepository.UpdateColumn(ctx.Tx, id, "sort_no", i+1); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketPriorityConfigService) DeleteTicketPriorityConfig(id int64, operator *dto.AuthPrincipal) error {
|
|
||||||
if operator == nil {
|
|
||||||
return errorsx.Unauthorized("未登录或登录已过期")
|
|
||||||
}
|
|
||||||
current := s.Get(id)
|
|
||||||
if current == nil || current.Status == enums.StatusDeleted {
|
|
||||||
return errorsx.InvalidParam("工单优先级配置不存在")
|
|
||||||
}
|
|
||||||
if TicketService.Take("priority = ?", id) != nil {
|
|
||||||
return errorsx.Forbidden("该优先级仍有关联工单,无法删除")
|
|
||||||
}
|
|
||||||
return s.Updates(id, map[string]any{
|
|
||||||
"status": enums.StatusDeleted,
|
|
||||||
"update_user_id": operator.UserID,
|
|
||||||
"update_user_name": operator.Username,
|
|
||||||
"updated_at": time.Now(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketPriorityConfigService) buildPriorityConfigModel(id int64, name string, firstResponseMinutes, resolutionMinutes, status int, remark string) (*models.TicketPriorityConfig, error) {
|
|
||||||
name = strings.TrimSpace(name)
|
|
||||||
if name == "" {
|
|
||||||
return nil, errorsx.InvalidParam("工单优先级名称不能为空")
|
|
||||||
}
|
|
||||||
if firstResponseMinutes <= 0 || resolutionMinutes <= 0 {
|
|
||||||
return nil, errorsx.InvalidParam("SLA 时长必须大于 0")
|
|
||||||
}
|
|
||||||
if !enums.IsValidStatus(status) || status == int(enums.StatusDeleted) {
|
|
||||||
return nil, errorsx.InvalidParam("工单优先级状态不合法")
|
|
||||||
}
|
|
||||||
if exists := s.Take("name = ? AND status <> ? AND id <> ?", name, enums.StatusDeleted, id); exists != nil {
|
|
||||||
return nil, errorsx.InvalidParam("工单优先级名称已存在")
|
|
||||||
}
|
|
||||||
return &models.TicketPriorityConfig{
|
|
||||||
Name: name,
|
|
||||||
FirstResponseMinutes: firstResponseMinutes,
|
|
||||||
ResolutionMinutes: resolutionMinutes,
|
|
||||||
Status: enums.Status(status),
|
|
||||||
Remark: strings.TrimSpace(remark),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketPriorityConfigService) nextSortNo() int {
|
|
||||||
list := s.Find(sqls.NewCnd().NotEq("status", enums.StatusDeleted).Desc("sort_no").Desc("id").Limit(1))
|
|
||||||
if len(list) == 0 {
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
return list[0].SortNo + 1
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cs-agent/internal/models"
|
||||||
|
"cs-agent/internal/repositories"
|
||||||
|
|
||||||
|
"github.com/mlogclub/simple/sqls"
|
||||||
|
"github.com/mlogclub/simple/web/params"
|
||||||
|
)
|
||||||
|
|
||||||
|
var TicketProgressService = newTicketProgressService()
|
||||||
|
|
||||||
|
func newTicketProgressService() *ticketProgressService {
|
||||||
|
return &ticketProgressService{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ticketProgressService struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ticketProgressService) Get(id int64) *models.TicketProgress {
|
||||||
|
return repositories.TicketProgressRepository.Get(sqls.DB(), id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ticketProgressService) Take(where ...any) *models.TicketProgress {
|
||||||
|
return repositories.TicketProgressRepository.Take(sqls.DB(), where...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ticketProgressService) Find(cnd *sqls.Cnd) []models.TicketProgress {
|
||||||
|
return repositories.TicketProgressRepository.Find(sqls.DB(), cnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ticketProgressService) FindOne(cnd *sqls.Cnd) *models.TicketProgress {
|
||||||
|
return repositories.TicketProgressRepository.FindOne(sqls.DB(), cnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ticketProgressService) FindPageByParams(params *params.QueryParams) (list []models.TicketProgress, paging *sqls.Paging) {
|
||||||
|
return repositories.TicketProgressRepository.FindPageByParams(sqls.DB(), params)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ticketProgressService) FindPageByCnd(cnd *sqls.Cnd) (list []models.TicketProgress, paging *sqls.Paging) {
|
||||||
|
return repositories.TicketProgressRepository.FindPageByCnd(sqls.DB(), cnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ticketProgressService) Count(cnd *sqls.Cnd) int64 {
|
||||||
|
return repositories.TicketProgressRepository.Count(sqls.DB(), cnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ticketProgressService) Create(t *models.TicketProgress) error {
|
||||||
|
return repositories.TicketProgressRepository.Create(sqls.DB(), t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ticketProgressService) Update(t *models.TicketProgress) error {
|
||||||
|
return repositories.TicketProgressRepository.Update(sqls.DB(), t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ticketProgressService) Updates(id int64, columns map[string]any) error {
|
||||||
|
return repositories.TicketProgressRepository.Updates(sqls.DB(), id, columns)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ticketProgressService) UpdateColumn(id int64, name string, value any) error {
|
||||||
|
return repositories.TicketProgressRepository.UpdateColumn(sqls.DB(), id, name, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ticketProgressService) Delete(id int64) {
|
||||||
|
repositories.TicketProgressRepository.Delete(sqls.DB(), id)
|
||||||
|
}
|
||||||
@@ -1,212 +0,0 @@
|
|||||||
package services
|
|
||||||
|
|
||||||
import (
|
|
||||||
"cs-agent/internal/models"
|
|
||||||
"cs-agent/internal/pkg/dto"
|
|
||||||
"cs-agent/internal/pkg/enums"
|
|
||||||
"cs-agent/internal/pkg/errorsx"
|
|
||||||
"cs-agent/internal/repositories"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/mlogclub/simple/sqls"
|
|
||||||
"github.com/mlogclub/simple/web/params"
|
|
||||||
)
|
|
||||||
|
|
||||||
var TicketRelationService = newTicketRelationService()
|
|
||||||
|
|
||||||
func newTicketRelationService() *ticketRelationService {
|
|
||||||
return &ticketRelationService{}
|
|
||||||
}
|
|
||||||
|
|
||||||
type ticketRelationService struct {
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketRelationService) Get(id int64) *models.TicketRelation {
|
|
||||||
return repositories.TicketRelationRepository.Get(sqls.DB(), id)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketRelationService) Take(where ...interface{}) *models.TicketRelation {
|
|
||||||
return repositories.TicketRelationRepository.Take(sqls.DB(), where...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketRelationService) Find(cnd *sqls.Cnd) []models.TicketRelation {
|
|
||||||
return repositories.TicketRelationRepository.Find(sqls.DB(), cnd)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketRelationService) FindOne(cnd *sqls.Cnd) *models.TicketRelation {
|
|
||||||
return repositories.TicketRelationRepository.FindOne(sqls.DB(), cnd)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketRelationService) FindPageByParams(params *params.QueryParams) (list []models.TicketRelation, paging *sqls.Paging) {
|
|
||||||
return repositories.TicketRelationRepository.FindPageByParams(sqls.DB(), params)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketRelationService) FindPageByCnd(cnd *sqls.Cnd) (list []models.TicketRelation, paging *sqls.Paging) {
|
|
||||||
return repositories.TicketRelationRepository.FindPageByCnd(sqls.DB(), cnd)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketRelationService) Count(cnd *sqls.Cnd) int64 {
|
|
||||||
return repositories.TicketRelationRepository.Count(sqls.DB(), cnd)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketRelationService) Create(t *models.TicketRelation) error {
|
|
||||||
return repositories.TicketRelationRepository.Create(sqls.DB(), t)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketRelationService) Update(t *models.TicketRelation) error {
|
|
||||||
return repositories.TicketRelationRepository.Update(sqls.DB(), t)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketRelationService) Updates(id int64, columns map[string]interface{}) error {
|
|
||||||
return repositories.TicketRelationRepository.Updates(sqls.DB(), id, columns)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketRelationService) UpdateColumn(id int64, name string, value interface{}) error {
|
|
||||||
return repositories.TicketRelationRepository.UpdateColumn(sqls.DB(), id, name, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketRelationService) Delete(id int64) {
|
|
||||||
repositories.TicketRelationRepository.Delete(sqls.DB(), id)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketRelationService) AddRelation(ticketID, relatedTicketID int64, relationType enums.TicketRelationType, operator *dto.AuthPrincipal) error {
|
|
||||||
if operator == nil {
|
|
||||||
return errorsx.Unauthorized("未登录或登录已过期")
|
|
||||||
}
|
|
||||||
if ticketID <= 0 || relatedTicketID <= 0 {
|
|
||||||
return errorsx.InvalidParam("工单不存在")
|
|
||||||
}
|
|
||||||
if ticketID == relatedTicketID {
|
|
||||||
return errorsx.InvalidParam("不能关联自己")
|
|
||||||
}
|
|
||||||
if !isValidTicketRelationType(relationType) {
|
|
||||||
return errorsx.InvalidParam("关联类型不合法")
|
|
||||||
}
|
|
||||||
ticket := TicketService.Get(ticketID)
|
|
||||||
if ticket == nil {
|
|
||||||
return errorsx.InvalidParam("工单不存在")
|
|
||||||
}
|
|
||||||
relatedTicket := TicketService.Get(relatedTicketID)
|
|
||||||
if relatedTicket == nil {
|
|
||||||
return errorsx.InvalidParam("关联工单不存在")
|
|
||||||
}
|
|
||||||
if repositories.TicketRelationRepository.Take(sqls.DB(), "ticket_id = ? AND related_ticket_id = ? AND relation_type = ?", ticketID, relatedTicketID, relationType) != nil {
|
|
||||||
return errorsx.InvalidParam("该关联已存在")
|
|
||||||
}
|
|
||||||
now := time.Now()
|
|
||||||
inverseType := inverseTicketRelationType(relationType)
|
|
||||||
return sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
|
||||||
if err := repositories.TicketRelationRepository.Create(ctx.Tx, &models.TicketRelation{
|
|
||||||
TicketID: ticketID,
|
|
||||||
RelatedTicketID: relatedTicketID,
|
|
||||||
RelationType: relationType,
|
|
||||||
CreatedAt: now,
|
|
||||||
}); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if repositories.TicketRelationRepository.Take(ctx.Tx, "ticket_id = ? AND related_ticket_id = ? AND relation_type = ?", relatedTicketID, ticketID, inverseType) == nil {
|
|
||||||
if err := repositories.TicketRelationRepository.Create(ctx.Tx, &models.TicketRelation{
|
|
||||||
TicketID: relatedTicketID,
|
|
||||||
RelatedTicketID: ticketID,
|
|
||||||
RelationType: inverseType,
|
|
||||||
CreatedAt: now,
|
|
||||||
}); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := repositories.TicketEventLogRepository.Create(ctx.Tx, &models.TicketEventLog{
|
|
||||||
TicketID: ticketID,
|
|
||||||
EventType: enums.TicketEventTypeUpdated,
|
|
||||||
OperatorType: enums.IMSenderTypeAgent,
|
|
||||||
OperatorID: operator.UserID,
|
|
||||||
Content: "新增关联工单",
|
|
||||||
Payload: strings.TrimSpace(string(relationType) + ":" + relatedTicket.TicketNo),
|
|
||||||
CreatedAt: now,
|
|
||||||
}); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return repositories.TicketEventLogRepository.Create(ctx.Tx, &models.TicketEventLog{
|
|
||||||
TicketID: relatedTicketID,
|
|
||||||
EventType: enums.TicketEventTypeUpdated,
|
|
||||||
OperatorType: enums.IMSenderTypeAgent,
|
|
||||||
OperatorID: operator.UserID,
|
|
||||||
Content: "新增关联工单",
|
|
||||||
Payload: strings.TrimSpace(string(inverseType) + ":" + ticket.TicketNo),
|
|
||||||
CreatedAt: now,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketRelationService) DeleteRelation(ticketID, relationID int64, operator *dto.AuthPrincipal) error {
|
|
||||||
if operator == nil {
|
|
||||||
return errorsx.Unauthorized("未登录或登录已过期")
|
|
||||||
}
|
|
||||||
relation := s.Get(relationID)
|
|
||||||
if relation == nil || relation.TicketID != ticketID {
|
|
||||||
return errorsx.InvalidParam("关联关系不存在")
|
|
||||||
}
|
|
||||||
ticket := TicketService.Get(relation.TicketID)
|
|
||||||
relatedTicket := TicketService.Get(relation.RelatedTicketID)
|
|
||||||
now := time.Now()
|
|
||||||
return sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
|
||||||
if err := repositories.TicketRelationRepository.DeleteByTicketRelation(ctx.Tx, relation.TicketID, relation.RelatedTicketID, string(relation.RelationType)); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := repositories.TicketRelationRepository.DeleteByTicketRelation(ctx.Tx, relation.RelatedTicketID, relation.TicketID, string(inverseTicketRelationType(relation.RelationType))); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if ticket != nil {
|
|
||||||
if err := repositories.TicketEventLogRepository.Create(ctx.Tx, &models.TicketEventLog{
|
|
||||||
TicketID: ticket.ID,
|
|
||||||
EventType: enums.TicketEventTypeUpdated,
|
|
||||||
OperatorType: enums.IMSenderTypeAgent,
|
|
||||||
OperatorID: operator.UserID,
|
|
||||||
Content: "移除关联工单",
|
|
||||||
Payload: strings.TrimSpace(string(relation.RelationType) + ":" + relationTicketNo(relatedTicket)),
|
|
||||||
CreatedAt: now,
|
|
||||||
}); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if relatedTicket != nil {
|
|
||||||
return repositories.TicketEventLogRepository.Create(ctx.Tx, &models.TicketEventLog{
|
|
||||||
TicketID: relatedTicket.ID,
|
|
||||||
EventType: enums.TicketEventTypeUpdated,
|
|
||||||
OperatorType: enums.IMSenderTypeAgent,
|
|
||||||
OperatorID: operator.UserID,
|
|
||||||
Content: "移除关联工单",
|
|
||||||
Payload: strings.TrimSpace(string(inverseTicketRelationType(relation.RelationType)) + ":" + relationTicketNo(ticket)),
|
|
||||||
CreatedAt: now,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func isValidTicketRelationType(relationType enums.TicketRelationType) bool {
|
|
||||||
switch relationType {
|
|
||||||
case enums.TicketRelationTypeDuplicate, enums.TicketRelationTypeRelated, enums.TicketRelationTypeParent, enums.TicketRelationTypeChild:
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func inverseTicketRelationType(relationType enums.TicketRelationType) enums.TicketRelationType {
|
|
||||||
switch relationType {
|
|
||||||
case enums.TicketRelationTypeParent:
|
|
||||||
return enums.TicketRelationTypeChild
|
|
||||||
case enums.TicketRelationTypeChild:
|
|
||||||
return enums.TicketRelationTypeParent
|
|
||||||
default:
|
|
||||||
return relationType
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func relationTicketNo(ticket *models.Ticket) string {
|
|
||||||
if ticket == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return ticket.TicketNo
|
|
||||||
}
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
package services
|
|
||||||
|
|
||||||
import (
|
|
||||||
"cs-agent/internal/models"
|
|
||||||
"cs-agent/internal/pkg/dto"
|
|
||||||
"cs-agent/internal/pkg/dto/request"
|
|
||||||
"cs-agent/internal/pkg/enums"
|
|
||||||
"cs-agent/internal/pkg/errorsx"
|
|
||||||
"cs-agent/internal/pkg/utils"
|
|
||||||
"cs-agent/internal/repositories"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/mlogclub/simple/sqls"
|
|
||||||
"github.com/mlogclub/simple/web/params"
|
|
||||||
)
|
|
||||||
|
|
||||||
var TicketResolutionCodeService = newTicketResolutionCodeService()
|
|
||||||
|
|
||||||
func newTicketResolutionCodeService() *ticketResolutionCodeService {
|
|
||||||
return &ticketResolutionCodeService{}
|
|
||||||
}
|
|
||||||
|
|
||||||
type ticketResolutionCodeService struct{}
|
|
||||||
|
|
||||||
func (s *ticketResolutionCodeService) Get(id int64) *models.TicketResolutionCode {
|
|
||||||
return repositories.TicketResolutionCodeRepository.Get(sqls.DB(), id)
|
|
||||||
}
|
|
||||||
func (s *ticketResolutionCodeService) Take(where ...interface{}) *models.TicketResolutionCode {
|
|
||||||
return repositories.TicketResolutionCodeRepository.Take(sqls.DB(), where...)
|
|
||||||
}
|
|
||||||
func (s *ticketResolutionCodeService) Find(cnd *sqls.Cnd) []models.TicketResolutionCode {
|
|
||||||
return repositories.TicketResolutionCodeRepository.Find(sqls.DB(), cnd)
|
|
||||||
}
|
|
||||||
func (s *ticketResolutionCodeService) FindOne(cnd *sqls.Cnd) *models.TicketResolutionCode {
|
|
||||||
return repositories.TicketResolutionCodeRepository.FindOne(sqls.DB(), cnd)
|
|
||||||
}
|
|
||||||
func (s *ticketResolutionCodeService) FindPageByParams(params *params.QueryParams) (list []models.TicketResolutionCode, paging *sqls.Paging) {
|
|
||||||
return repositories.TicketResolutionCodeRepository.FindPageByParams(sqls.DB(), params)
|
|
||||||
}
|
|
||||||
func (s *ticketResolutionCodeService) FindPageByCnd(cnd *sqls.Cnd) (list []models.TicketResolutionCode, paging *sqls.Paging) {
|
|
||||||
return repositories.TicketResolutionCodeRepository.FindPageByCnd(sqls.DB(), cnd)
|
|
||||||
}
|
|
||||||
func (s *ticketResolutionCodeService) Count(cnd *sqls.Cnd) int64 {
|
|
||||||
return repositories.TicketResolutionCodeRepository.Count(sqls.DB(), cnd)
|
|
||||||
}
|
|
||||||
func (s *ticketResolutionCodeService) Create(t *models.TicketResolutionCode) error {
|
|
||||||
return repositories.TicketResolutionCodeRepository.Create(sqls.DB(), t)
|
|
||||||
}
|
|
||||||
func (s *ticketResolutionCodeService) Updates(id int64, columns map[string]interface{}) error {
|
|
||||||
return repositories.TicketResolutionCodeRepository.Updates(sqls.DB(), id, columns)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketResolutionCodeService) CreateTicketResolutionCode(req request.CreateTicketResolutionCodeRequest, operator *dto.AuthPrincipal) (*models.TicketResolutionCode, error) {
|
|
||||||
if operator == nil {
|
|
||||||
return nil, errorsx.Unauthorized("未登录或登录已过期")
|
|
||||||
}
|
|
||||||
item, err := s.buildResolutionCodeModel(0, req.Name, req.Code, int(req.Status), req.SortNo, req.Remark)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
item.AuditFields = utils.BuildAuditFields(operator)
|
|
||||||
if err := s.Create(item); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return item, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketResolutionCodeService) UpdateTicketResolutionCode(req request.UpdateTicketResolutionCodeRequest, operator *dto.AuthPrincipal) error {
|
|
||||||
if operator == nil {
|
|
||||||
return errorsx.Unauthorized("未登录或登录已过期")
|
|
||||||
}
|
|
||||||
current := s.Get(req.ID)
|
|
||||||
if current == nil || current.Status == enums.StatusDeleted {
|
|
||||||
return errorsx.InvalidParam("工单解决码不存在")
|
|
||||||
}
|
|
||||||
item, err := s.buildResolutionCodeModel(req.ID, req.Name, req.Code, int(req.Status), req.SortNo, req.Remark)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return s.Updates(req.ID, map[string]any{
|
|
||||||
"name": item.Name,
|
|
||||||
"code": item.Code,
|
|
||||||
"status": item.Status,
|
|
||||||
"sort_no": item.SortNo,
|
|
||||||
"remark": item.Remark,
|
|
||||||
"update_user_id": operator.UserID,
|
|
||||||
"update_user_name": operator.Username,
|
|
||||||
"updated_at": time.Now(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketResolutionCodeService) DeleteTicketResolutionCode(id int64, operator *dto.AuthPrincipal) error {
|
|
||||||
if operator == nil {
|
|
||||||
return errorsx.Unauthorized("未登录或登录已过期")
|
|
||||||
}
|
|
||||||
current := s.Get(id)
|
|
||||||
if current == nil || current.Status == enums.StatusDeleted {
|
|
||||||
return errorsx.InvalidParam("工单解决码不存在")
|
|
||||||
}
|
|
||||||
return s.Updates(id, map[string]any{
|
|
||||||
"status": enums.StatusDeleted,
|
|
||||||
"update_user_id": operator.UserID,
|
|
||||||
"update_user_name": operator.Username,
|
|
||||||
"updated_at": time.Now(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketResolutionCodeService) buildResolutionCodeModel(id int64, name, code string, status, sortNo int, remark string) (*models.TicketResolutionCode, error) {
|
|
||||||
name = strings.TrimSpace(name)
|
|
||||||
code = strings.TrimSpace(code)
|
|
||||||
if name == "" {
|
|
||||||
return nil, errorsx.InvalidParam("工单解决码名称不能为空")
|
|
||||||
}
|
|
||||||
if code == "" {
|
|
||||||
return nil, errorsx.InvalidParam("工单解决码编码不能为空")
|
|
||||||
}
|
|
||||||
if !enums.IsValidStatus(status) || status == int(enums.StatusDeleted) {
|
|
||||||
return nil, errorsx.InvalidParam("工单解决码状态不合法")
|
|
||||||
}
|
|
||||||
if exists := s.Take("name = ? AND status <> ? AND id <> ?", name, enums.StatusDeleted, id); exists != nil {
|
|
||||||
return nil, errorsx.InvalidParam("工单解决码名称已存在")
|
|
||||||
}
|
|
||||||
if exists := s.Take("code = ? AND status <> ? AND id <> ?", code, enums.StatusDeleted, id); exists != nil {
|
|
||||||
return nil, errorsx.InvalidParam("工单解决码编码已存在")
|
|
||||||
}
|
|
||||||
return &models.TicketResolutionCode{
|
|
||||||
Name: name,
|
|
||||||
Code: code,
|
|
||||||
SortNo: sortNo,
|
|
||||||
Status: enums.Status(status),
|
|
||||||
Remark: strings.TrimSpace(remark),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
+387
-1621
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,67 +0,0 @@
|
|||||||
package services
|
|
||||||
|
|
||||||
import (
|
|
||||||
"cs-agent/internal/models"
|
|
||||||
"cs-agent/internal/repositories"
|
|
||||||
|
|
||||||
"github.com/mlogclub/simple/sqls"
|
|
||||||
"github.com/mlogclub/simple/web/params"
|
|
||||||
)
|
|
||||||
|
|
||||||
var TicketSLARecordService = newTicketSLARecordService()
|
|
||||||
|
|
||||||
func newTicketSLARecordService() *ticketSLARecordService {
|
|
||||||
return &ticketSLARecordService{}
|
|
||||||
}
|
|
||||||
|
|
||||||
type ticketSLARecordService struct {
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketSLARecordService) Get(id int64) *models.TicketSLARecord {
|
|
||||||
return repositories.TicketSLARecordRepository.Get(sqls.DB(), id)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketSLARecordService) Take(where ...interface{}) *models.TicketSLARecord {
|
|
||||||
return repositories.TicketSLARecordRepository.Take(sqls.DB(), where...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketSLARecordService) Find(cnd *sqls.Cnd) []models.TicketSLARecord {
|
|
||||||
return repositories.TicketSLARecordRepository.Find(sqls.DB(), cnd)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketSLARecordService) FindOne(cnd *sqls.Cnd) *models.TicketSLARecord {
|
|
||||||
return repositories.TicketSLARecordRepository.FindOne(sqls.DB(), cnd)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketSLARecordService) FindPageByParams(params *params.QueryParams) (list []models.TicketSLARecord, paging *sqls.Paging) {
|
|
||||||
return repositories.TicketSLARecordRepository.FindPageByParams(sqls.DB(), params)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketSLARecordService) FindPageByCnd(cnd *sqls.Cnd) (list []models.TicketSLARecord, paging *sqls.Paging) {
|
|
||||||
return repositories.TicketSLARecordRepository.FindPageByCnd(sqls.DB(), cnd)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketSLARecordService) Count(cnd *sqls.Cnd) int64 {
|
|
||||||
return repositories.TicketSLARecordRepository.Count(sqls.DB(), cnd)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketSLARecordService) Create(t *models.TicketSLARecord) error {
|
|
||||||
return repositories.TicketSLARecordRepository.Create(sqls.DB(), t)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketSLARecordService) Update(t *models.TicketSLARecord) error {
|
|
||||||
return repositories.TicketSLARecordRepository.Update(sqls.DB(), t)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketSLARecordService) Updates(id int64, columns map[string]interface{}) error {
|
|
||||||
return repositories.TicketSLARecordRepository.Updates(sqls.DB(), id, columns)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketSLARecordService) UpdateColumn(id int64, name string, value interface{}) error {
|
|
||||||
return repositories.TicketSLARecordRepository.UpdateColumn(sqls.DB(), id, name, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketSLARecordService) Delete(id int64) {
|
|
||||||
repositories.TicketSLARecordRepository.Delete(sqls.DB(), id)
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ func (s *ticketViewService) Save(req request.SaveTicketViewRequest, operator *dt
|
|||||||
if item == nil || item.UserID != operator.UserID {
|
if item == nil || item.UserID != operator.UserID {
|
||||||
return nil, errorsx.InvalidParam("视图不存在")
|
return nil, errorsx.InvalidParam("视图不存在")
|
||||||
}
|
}
|
||||||
if err := repositories.TicketViewRepository.Updates(sqls.DB(), req.ID, map[string]interface{}{
|
if err := repositories.TicketViewRepository.Updates(sqls.DB(), req.ID, map[string]any{
|
||||||
"name": name,
|
"name": name,
|
||||||
"filters_json": string(filtersJSON),
|
"filters_json": string(filtersJSON),
|
||||||
"update_user_id": operator.UserID,
|
"update_user_id": operator.UserID,
|
||||||
@@ -88,6 +88,5 @@ func (s *ticketViewService) Delete(id int64, operator *dto.AuthPrincipal) error
|
|||||||
if item == nil || item.UserID != operator.UserID {
|
if item == nil || item.UserID != operator.UserID {
|
||||||
return errorsx.InvalidParam("视图不存在")
|
return errorsx.InvalidParam("视图不存在")
|
||||||
}
|
}
|
||||||
repositories.TicketViewRepository.Delete(sqls.DB(), id)
|
return repositories.TicketViewRepository.Delete(sqls.DB(), id)
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
package services
|
|
||||||
|
|
||||||
import (
|
|
||||||
"cs-agent/internal/models"
|
|
||||||
"cs-agent/internal/repositories"
|
|
||||||
|
|
||||||
"github.com/mlogclub/simple/sqls"
|
|
||||||
"github.com/mlogclub/simple/web/params"
|
|
||||||
)
|
|
||||||
|
|
||||||
var TicketWatcherService = newTicketWatcherService()
|
|
||||||
|
|
||||||
func newTicketWatcherService() *ticketWatcherService {
|
|
||||||
return &ticketWatcherService{}
|
|
||||||
}
|
|
||||||
|
|
||||||
type ticketWatcherService struct {
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketWatcherService) Get(id int64) *models.TicketWatcher {
|
|
||||||
return repositories.TicketWatcherRepository.Get(sqls.DB(), id)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketWatcherService) Take(where ...interface{}) *models.TicketWatcher {
|
|
||||||
return repositories.TicketWatcherRepository.Take(sqls.DB(), where...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketWatcherService) Find(cnd *sqls.Cnd) []models.TicketWatcher {
|
|
||||||
return repositories.TicketWatcherRepository.Find(sqls.DB(), cnd)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketWatcherService) FindOne(cnd *sqls.Cnd) *models.TicketWatcher {
|
|
||||||
return repositories.TicketWatcherRepository.FindOne(sqls.DB(), cnd)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketWatcherService) FindPageByParams(params *params.QueryParams) (list []models.TicketWatcher, paging *sqls.Paging) {
|
|
||||||
return repositories.TicketWatcherRepository.FindPageByParams(sqls.DB(), params)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketWatcherService) FindPageByCnd(cnd *sqls.Cnd) (list []models.TicketWatcher, paging *sqls.Paging) {
|
|
||||||
return repositories.TicketWatcherRepository.FindPageByCnd(sqls.DB(), cnd)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketWatcherService) Count(cnd *sqls.Cnd) int64 {
|
|
||||||
return repositories.TicketWatcherRepository.Count(sqls.DB(), cnd)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketWatcherService) Create(t *models.TicketWatcher) error {
|
|
||||||
return repositories.TicketWatcherRepository.Create(sqls.DB(), t)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketWatcherService) Update(t *models.TicketWatcher) error {
|
|
||||||
return repositories.TicketWatcherRepository.Update(sqls.DB(), t)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketWatcherService) Updates(id int64, columns map[string]interface{}) error {
|
|
||||||
return repositories.TicketWatcherRepository.Updates(sqls.DB(), id, columns)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketWatcherService) UpdateColumn(id int64, name string, value interface{}) error {
|
|
||||||
return repositories.TicketWatcherRepository.UpdateColumn(sqls.DB(), id, name, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ticketWatcherService) Delete(id int64) {
|
|
||||||
repositories.TicketWatcherRepository.Delete(sqls.DB(), id)
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import {
|
import {
|
||||||
Building2Icon,
|
Building2Icon,
|
||||||
Link2Icon,
|
Link2Icon,
|
||||||
@@ -55,7 +53,6 @@ import {
|
|||||||
ConversationTagBadges,
|
ConversationTagBadges,
|
||||||
ConversationTagPicker,
|
ConversationTagPicker,
|
||||||
} from "./conversation-tag-picker";
|
} from "./conversation-tag-picker";
|
||||||
import { TicketPriorityBadge } from "../../tickets/_components/ticket-priority-badge";
|
|
||||||
import { TicketStatusBadge } from "../../tickets/_components/ticket-status-badge";
|
import { TicketStatusBadge } from "../../tickets/_components/ticket-status-badge";
|
||||||
|
|
||||||
function contactTypeLabel(contactType: ContactType | string) {
|
function contactTypeLabel(contactType: ContactType | string) {
|
||||||
@@ -627,12 +624,9 @@ function RelatedTicketsSection({ conversation }: { conversation: AgentConversati
|
|||||||
) : tickets.length > 0 ? (
|
) : tickets.length > 0 ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{tickets.map((ticket) => (
|
{tickets.map((ticket) => (
|
||||||
<Link
|
<div
|
||||||
key={ticket.id}
|
key={ticket.id}
|
||||||
href={`/dashboard/tickets/detail?id=${ticket.id}`}
|
className="rounded-lg border border-border bg-background px-3 py-2"
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="block rounded-lg border border-border bg-background px-3 py-2 transition-colors hover:bg-muted/40"
|
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
@@ -643,15 +637,14 @@ function RelatedTicketsSection({ conversation }: { conversation: AgentConversati
|
|||||||
{ticket.ticketNo}
|
{ticket.ticketNo}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<TicketPriorityBadge priority={ticket.priority} priorityName={ticket.priorityName} />
|
<TicketStatusBadge status={ticket.status} />
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 flex items-center justify-between gap-3">
|
<div className="mt-2 flex items-center justify-between gap-3">
|
||||||
<TicketStatusBadge status={ticket.status} />
|
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{ticket.updatedAt ? formatDateTime(ticket.updatedAt) : "—"}
|
{ticket.updatedAt ? formatDateTime(ticket.updatedAt) : "—"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,616 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
closestCenter,
|
|
||||||
DndContext,
|
|
||||||
KeyboardSensor,
|
|
||||||
MouseSensor,
|
|
||||||
TouchSensor,
|
|
||||||
useSensor,
|
|
||||||
useSensors,
|
|
||||||
type DragEndEvent,
|
|
||||||
} from "@dnd-kit/core";
|
|
||||||
import {
|
|
||||||
arrayMove,
|
|
||||||
SortableContext,
|
|
||||||
sortableKeyboardCoordinates,
|
|
||||||
useSortable,
|
|
||||||
verticalListSortingStrategy,
|
|
||||||
} from "@dnd-kit/sortable";
|
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import {
|
|
||||||
GripVerticalIcon,
|
|
||||||
PencilIcon,
|
|
||||||
PlusIcon,
|
|
||||||
RefreshCwIcon,
|
|
||||||
SearchIcon,
|
|
||||||
Trash2Icon,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useCallback, useEffect, useState, type CSSProperties } from "react";
|
|
||||||
import { Controller, useForm, type Resolver } from "react-hook-form";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { z } from "zod/v4";
|
|
||||||
|
|
||||||
import { useConfirm } from "@/components/confirm-provider";
|
|
||||||
import { OptionCombobox } from "@/components/option-combobox";
|
|
||||||
import { ProjectDialog } from "@/components/project-dialog";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { ButtonGroup } from "@/components/ui/button-group";
|
|
||||||
import {
|
|
||||||
Field,
|
|
||||||
FieldContent,
|
|
||||||
FieldError,
|
|
||||||
FieldLabel,
|
|
||||||
} from "@/components/ui/field";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import {
|
|
||||||
createTicketPriorityConfig,
|
|
||||||
deleteTicketPriorityConfig,
|
|
||||||
fetchTicketPriorityConfigs,
|
|
||||||
updateTicketPriorityConfig,
|
|
||||||
updateTicketPriorityConfigSort,
|
|
||||||
type CreateTicketPriorityConfigPayload,
|
|
||||||
type TicketPriorityConfig,
|
|
||||||
} from "@/lib/api/ticket-config";
|
|
||||||
import { getEnumOptions } from "@/lib/enums";
|
|
||||||
import { Status, StatusLabels } from "@/lib/generated/enums";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
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 formSchema = z.object({
|
|
||||||
name: z.string().trim().min(1, "优先级名称不能为空"),
|
|
||||||
firstResponseMinutes: z
|
|
||||||
.string()
|
|
||||||
.trim()
|
|
||||||
.min(1, "首响时长不能为空")
|
|
||||||
.regex(/^\d+$/, "请输入正整数"),
|
|
||||||
resolutionMinutes: z
|
|
||||||
.string()
|
|
||||||
.trim()
|
|
||||||
.min(1, "解决时长不能为空")
|
|
||||||
.regex(/^\d+$/, "请输入正整数"),
|
|
||||||
status: z.enum([String(Status.Ok), String(Status.Disabled)], {
|
|
||||||
message: "请选择状态",
|
|
||||||
}),
|
|
||||||
remark: z.string().trim(),
|
|
||||||
});
|
|
||||||
|
|
||||||
type EditForm = z.infer<typeof formSchema>;
|
|
||||||
|
|
||||||
const resolver = zodResolver(formSchema as never) as Resolver<
|
|
||||||
z.input<typeof formSchema>,
|
|
||||||
undefined,
|
|
||||||
z.output<typeof formSchema>
|
|
||||||
>;
|
|
||||||
|
|
||||||
const emptyForm: EditForm = {
|
|
||||||
name: "",
|
|
||||||
firstResponseMinutes: "30",
|
|
||||||
resolutionMinutes: "1440",
|
|
||||||
status: String(Status.Ok),
|
|
||||||
remark: "",
|
|
||||||
};
|
|
||||||
|
|
||||||
function buildForm(item: TicketPriorityConfig | null): EditForm {
|
|
||||||
if (!item) {
|
|
||||||
return emptyForm;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
name: item.name,
|
|
||||||
firstResponseMinutes: String(item.firstResponseMinutes),
|
|
||||||
resolutionMinutes: String(item.resolutionMinutes),
|
|
||||||
status: String(item.status) as EditForm["status"],
|
|
||||||
remark: item.remark || "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildPayload(form: EditForm): CreateTicketPriorityConfigPayload {
|
|
||||||
return {
|
|
||||||
name: form.name.trim(),
|
|
||||||
firstResponseMinutes: Number(form.firstResponseMinutes),
|
|
||||||
resolutionMinutes: Number(form.resolutionMinutes),
|
|
||||||
status: Number(form.status),
|
|
||||||
remark: form.remark.trim(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
type SortablePriorityRowProps = {
|
|
||||||
item: TicketPriorityConfig;
|
|
||||||
disabled: boolean;
|
|
||||||
onEdit: (item: TicketPriorityConfig) => void;
|
|
||||||
onDelete: (item: TicketPriorityConfig) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
function SortablePriorityRow({
|
|
||||||
item,
|
|
||||||
disabled,
|
|
||||||
onEdit,
|
|
||||||
onDelete,
|
|
||||||
}: SortablePriorityRowProps) {
|
|
||||||
const {
|
|
||||||
attributes,
|
|
||||||
listeners,
|
|
||||||
setNodeRef,
|
|
||||||
transform,
|
|
||||||
transition,
|
|
||||||
isDragging,
|
|
||||||
} = useSortable({
|
|
||||||
id: item.id,
|
|
||||||
disabled,
|
|
||||||
});
|
|
||||||
|
|
||||||
const style: CSSProperties = {
|
|
||||||
transform: CSS.Transform.toString(transform),
|
|
||||||
transition,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<tr
|
|
||||||
ref={setNodeRef}
|
|
||||||
style={style}
|
|
||||||
className={cn(
|
|
||||||
"border-t",
|
|
||||||
isDragging && "relative z-10 bg-muted/60 shadow-sm",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<td className="w-14 px-4 py-3">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="size-8 cursor-grab active:cursor-grabbing"
|
|
||||||
disabled={disabled}
|
|
||||||
aria-label={`拖拽排序 ${item.name}`}
|
|
||||||
{...attributes}
|
|
||||||
{...listeners}
|
|
||||||
>
|
|
||||||
<GripVerticalIcon className="size-4 text-muted-foreground" />
|
|
||||||
</Button>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">{item.name}</td>
|
|
||||||
<td className="px-4 py-3">{item.firstResponseMinutes} 分钟</td>
|
|
||||||
<td className="px-4 py-3">{item.resolutionMinutes} 分钟</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<Badge variant={item.status === Status.Ok ? "default" : "secondary"}>
|
|
||||||
{item.status === Status.Ok ? "启用" : "停用"}
|
|
||||||
</Badge>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-right">
|
|
||||||
<ButtonGroup className="ml-auto">
|
|
||||||
<Button variant="outline" size="sm" onClick={() => onEdit(item)}>
|
|
||||||
<PencilIcon className="size-3.5" />
|
|
||||||
编辑
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => void onDelete(item)}
|
|
||||||
>
|
|
||||||
<Trash2Icon className="size-3.5" />
|
|
||||||
删除
|
|
||||||
</Button>
|
|
||||||
</ButtonGroup>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TicketPrioritiesPage() {
|
|
||||||
const [keywordInput, setKeywordInput] = useState("");
|
|
||||||
const [statusFilterInput, setStatusFilterInput] = useState("all");
|
|
||||||
const [keyword, setKeyword] = useState("");
|
|
||||||
const [statusFilter, setStatusFilter] = useState("all");
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [sorting, setSorting] = useState(false);
|
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
|
||||||
const [editingItem, setEditingItem] = useState<TicketPriorityConfig | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const [deleting, setDeleting] = useState(false);
|
|
||||||
const [items, setItems] = useState<TicketPriorityConfig[]>([]);
|
|
||||||
const confirm = useConfirm();
|
|
||||||
|
|
||||||
const sensors = useSensors(
|
|
||||||
useSensor(MouseSensor, { activationConstraint: { distance: 6 } }),
|
|
||||||
useSensor(TouchSensor, {
|
|
||||||
activationConstraint: { delay: 120, tolerance: 8 },
|
|
||||||
}),
|
|
||||||
useSensor(KeyboardSensor, {
|
|
||||||
coordinateGetter: sortableKeyboardCoordinates,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const data = await fetchTicketPriorityConfigs({
|
|
||||||
name: keyword.trim() || undefined,
|
|
||||||
status: statusFilter === "all" ? undefined : statusFilter,
|
|
||||||
});
|
|
||||||
setItems(Array.isArray(data) ? data : []);
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(
|
|
||||||
error instanceof Error ? error.message : "加载工单优先级失败",
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [keyword, statusFilter]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void loadData();
|
|
||||||
}, [loadData]);
|
|
||||||
|
|
||||||
function applyFilters() {
|
|
||||||
setKeyword(keywordInput);
|
|
||||||
setStatusFilter(statusFilterInput);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSubmit(payload: CreateTicketPriorityConfigPayload) {
|
|
||||||
if (saving) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
if (editingItem) {
|
|
||||||
await updateTicketPriorityConfig({ id: editingItem.id, ...payload });
|
|
||||||
toast.success(`已更新工单优先级:${payload.name}`);
|
|
||||||
} else {
|
|
||||||
await createTicketPriorityConfig(payload);
|
|
||||||
toast.success(`已创建工单优先级:${payload.name}`);
|
|
||||||
}
|
|
||||||
setDialogOpen(false);
|
|
||||||
setEditingItem(null);
|
|
||||||
await loadData();
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(
|
|
||||||
error instanceof Error ? error.message : "保存工单优先级失败",
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDelete(item: TicketPriorityConfig) {
|
|
||||||
if (deleting) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const confirmed = await confirm({
|
|
||||||
title: "确认删除优先级",
|
|
||||||
description: `删除后将无法恢复。确定要删除工单优先级“${item.name}”吗?`,
|
|
||||||
confirmText: "确认删除",
|
|
||||||
cancelText: "取消",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
if (!confirmed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setDeleting(true);
|
|
||||||
try {
|
|
||||||
await deleteTicketPriorityConfig(item.id);
|
|
||||||
toast.success(`已删除工单优先级:${item.name}`);
|
|
||||||
await loadData();
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(
|
|
||||||
error instanceof Error ? error.message : "删除工单优先级失败",
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setDeleting(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDragEnd(event: DragEndEvent) {
|
|
||||||
const { active, over } = event;
|
|
||||||
if (!over || active.id === over.id || sorting || loading) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const previousResults = items;
|
|
||||||
const oldIndex = previousResults.findIndex((item) => item.id === active.id);
|
|
||||||
const newIndex = previousResults.findIndex((item) => item.id === over.id);
|
|
||||||
if (oldIndex < 0 || newIndex < 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const nextResults = arrayMove(previousResults, oldIndex, newIndex);
|
|
||||||
setItems(nextResults);
|
|
||||||
setSorting(true);
|
|
||||||
try {
|
|
||||||
await updateTicketPriorityConfigSort(nextResults.map((item) => item.id));
|
|
||||||
toast.success("工单优先级排序已更新");
|
|
||||||
await loadData();
|
|
||||||
} catch (error) {
|
|
||||||
setItems(previousResults);
|
|
||||||
toast.error(error instanceof Error ? error.message : "更新排序失败");
|
|
||||||
} finally {
|
|
||||||
setSorting(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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-center">
|
|
||||||
<div className="relative min-w-72">
|
|
||||||
<SearchIcon className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
value={keywordInput}
|
|
||||||
onChange={(event) => setKeywordInput(event.target.value)}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === "Enter") {
|
|
||||||
event.preventDefault();
|
|
||||||
applyFilters();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder="按优先级名称筛选"
|
|
||||||
className="pl-9"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="w-full xl:w-40">
|
|
||||||
<OptionCombobox
|
|
||||||
value={statusFilterInput}
|
|
||||||
onChange={setStatusFilterInput}
|
|
||||||
placeholder="全部状态"
|
|
||||||
options={listStatusOptions.map((item) => ({
|
|
||||||
value: item.value,
|
|
||||||
label: item.label,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" onClick={applyFilters} disabled={loading}>
|
|
||||||
<SearchIcon className="size-4" />
|
|
||||||
查询
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => void loadData()}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
<RefreshCwIcon className="size-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
setEditingItem(null);
|
|
||||||
setDialogOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PlusIcon className="size-4" />
|
|
||||||
新建优先级
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="overflow-hidden rounded-lg border bg-background">
|
|
||||||
<DndContext
|
|
||||||
sensors={sensors}
|
|
||||||
collisionDetection={closestCenter}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
>
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead className="bg-muted/35">
|
|
||||||
<tr>
|
|
||||||
<th className="w-14 px-4 py-3 text-left font-medium"></th>
|
|
||||||
<th className="px-4 py-3 text-left font-medium">名称</th>
|
|
||||||
<th className="px-4 py-3 text-left font-medium">首响时长</th>
|
|
||||||
<th className="px-4 py-3 text-left font-medium">解决时长</th>
|
|
||||||
<th className="px-4 py-3 text-left font-medium">状态</th>
|
|
||||||
<th className="px-4 py-3 text-right font-medium">操作</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{loading ? (
|
|
||||||
<tr>
|
|
||||||
<td
|
|
||||||
colSpan={6}
|
|
||||||
className="h-32 text-center text-muted-foreground"
|
|
||||||
>
|
|
||||||
加载中...
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : items.length > 0 ? (
|
|
||||||
<SortableContext
|
|
||||||
items={items.map((item) => item.id)}
|
|
||||||
strategy={verticalListSortingStrategy}
|
|
||||||
>
|
|
||||||
{items.map((item) => (
|
|
||||||
<SortablePriorityRow
|
|
||||||
key={item.id}
|
|
||||||
item={item}
|
|
||||||
disabled={sorting}
|
|
||||||
onEdit={(current) => {
|
|
||||||
setEditingItem(current);
|
|
||||||
setDialogOpen(true);
|
|
||||||
}}
|
|
||||||
onDelete={handleDelete}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</SortableContext>
|
|
||||||
) : (
|
|
||||||
<tr>
|
|
||||||
<td
|
|
||||||
colSpan={6}
|
|
||||||
className="h-32 text-center text-muted-foreground"
|
|
||||||
>
|
|
||||||
暂无工单优先级
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</DndContext>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TicketPriorityEditDialog
|
|
||||||
open={dialogOpen}
|
|
||||||
saving={saving}
|
|
||||||
item={editingItem}
|
|
||||||
onOpenChange={(nextOpen) => {
|
|
||||||
setDialogOpen(nextOpen);
|
|
||||||
if (!nextOpen) {
|
|
||||||
setEditingItem(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type TicketPriorityEditDialogProps = {
|
|
||||||
open: boolean;
|
|
||||||
saving: boolean;
|
|
||||||
item: TicketPriorityConfig | null;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
onSubmit: (payload: CreateTicketPriorityConfigPayload) => Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
function TicketPriorityEditDialog({
|
|
||||||
open,
|
|
||||||
saving,
|
|
||||||
item,
|
|
||||||
onOpenChange,
|
|
||||||
onSubmit,
|
|
||||||
}: TicketPriorityEditDialogProps) {
|
|
||||||
const formId = "ticket-priority-edit-form";
|
|
||||||
const form = useForm<
|
|
||||||
z.input<typeof formSchema>,
|
|
||||||
undefined,
|
|
||||||
z.output<typeof formSchema>
|
|
||||||
>({
|
|
||||||
resolver,
|
|
||||||
defaultValues: buildForm(item),
|
|
||||||
});
|
|
||||||
const {
|
|
||||||
register,
|
|
||||||
control,
|
|
||||||
handleSubmit,
|
|
||||||
reset,
|
|
||||||
formState: { errors },
|
|
||||||
} = form;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
reset(buildForm(item));
|
|
||||||
}, [item, reset]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ProjectDialog
|
|
||||||
open={open}
|
|
||||||
onOpenChange={onOpenChange}
|
|
||||||
title={item ? "编辑工单优先级" : "新建工单优先级"}
|
|
||||||
description="优先级同时承载首响与解决时长配置。排序请在列表中拖动调整。"
|
|
||||||
size="md"
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
disabled={saving}
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" form={formId} disabled={saving}>
|
|
||||||
{saving ? "保存中..." : item ? "保存" : "创建"}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<form
|
|
||||||
id={formId}
|
|
||||||
className="space-y-4"
|
|
||||||
onSubmit={handleSubmit(async (values) =>
|
|
||||||
onSubmit(buildPayload(values)),
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Field data-invalid={Boolean(errors.name)}>
|
|
||||||
<FieldLabel htmlFor="ticket-priority-name">名称</FieldLabel>
|
|
||||||
<FieldContent>
|
|
||||||
<Input
|
|
||||||
id="ticket-priority-name"
|
|
||||||
placeholder="请输入优先级名称"
|
|
||||||
{...register("name")}
|
|
||||||
/>
|
|
||||||
{errors.name ? <FieldError errors={[errors.name]} /> : null}
|
|
||||||
</FieldContent>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
|
||||||
<Field data-invalid={Boolean(errors.firstResponseMinutes)}>
|
|
||||||
<FieldLabel htmlFor="ticket-priority-first-response">
|
|
||||||
首响时长
|
|
||||||
</FieldLabel>
|
|
||||||
<FieldContent>
|
|
||||||
<Input
|
|
||||||
id="ticket-priority-first-response"
|
|
||||||
placeholder="分钟"
|
|
||||||
{...register("firstResponseMinutes")}
|
|
||||||
/>
|
|
||||||
{errors.firstResponseMinutes ? (
|
|
||||||
<FieldError errors={[errors.firstResponseMinutes]} />
|
|
||||||
) : null}
|
|
||||||
</FieldContent>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field data-invalid={Boolean(errors.resolutionMinutes)}>
|
|
||||||
<FieldLabel htmlFor="ticket-priority-resolution">
|
|
||||||
解决时长
|
|
||||||
</FieldLabel>
|
|
||||||
<FieldContent>
|
|
||||||
<Input
|
|
||||||
id="ticket-priority-resolution"
|
|
||||||
placeholder="分钟"
|
|
||||||
{...register("resolutionMinutes")}
|
|
||||||
/>
|
|
||||||
{errors.resolutionMinutes ? (
|
|
||||||
<FieldError errors={[errors.resolutionMinutes]} />
|
|
||||||
) : null}
|
|
||||||
</FieldContent>
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Field data-invalid={Boolean(errors.status)}>
|
|
||||||
<FieldLabel>状态</FieldLabel>
|
|
||||||
<FieldContent>
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="status"
|
|
||||||
render={({ field }) => (
|
|
||||||
<OptionCombobox
|
|
||||||
value={field.value}
|
|
||||||
onChange={field.onChange}
|
|
||||||
placeholder="请选择状态"
|
|
||||||
options={[
|
|
||||||
{ value: String(Status.Ok), label: "启用" },
|
|
||||||
{ value: String(Status.Disabled), label: "停用" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{errors.status ? <FieldError errors={[errors.status]} /> : null}
|
|
||||||
</FieldContent>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field data-invalid={Boolean(errors.remark)}>
|
|
||||||
<FieldLabel htmlFor="ticket-priority-remark">备注</FieldLabel>
|
|
||||||
<FieldContent>
|
|
||||||
<Textarea
|
|
||||||
id="ticket-priority-remark"
|
|
||||||
rows={4}
|
|
||||||
placeholder="可选"
|
|
||||||
{...register("remark")}
|
|
||||||
/>
|
|
||||||
{errors.remark ? <FieldError errors={[errors.remark]} /> : null}
|
|
||||||
</FieldContent>
|
|
||||||
</Field>
|
|
||||||
</form>
|
|
||||||
</ProjectDialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,477 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { PlusIcon, RefreshCwIcon, SearchIcon, Trash2Icon } from "lucide-react";
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
import { Controller, useForm, type Resolver } from "react-hook-form";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { z } from "zod/v4";
|
|
||||||
|
|
||||||
import { ListPagination } from "@/components/list-pagination";
|
|
||||||
import { OptionCombobox } from "@/components/option-combobox";
|
|
||||||
import { ProjectDialog } from "@/components/project-dialog";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import {
|
|
||||||
Field,
|
|
||||||
FieldContent,
|
|
||||||
FieldError,
|
|
||||||
FieldLabel,
|
|
||||||
} from "@/components/ui/field";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import {
|
|
||||||
createTicketResolutionCode,
|
|
||||||
deleteTicketResolutionCode,
|
|
||||||
fetchTicketResolutionCodes,
|
|
||||||
updateTicketResolutionCode,
|
|
||||||
type CreateTicketResolutionCodePayload,
|
|
||||||
type PageResult,
|
|
||||||
type TicketResolutionCode,
|
|
||||||
} from "@/lib/api/ticket-config";
|
|
||||||
import { getEnumLabel, getEnumOptions } from "@/lib/enums";
|
|
||||||
import { Status, StatusLabels } from "@/lib/generated/enums";
|
|
||||||
|
|
||||||
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 formSchema = z.object({
|
|
||||||
name: z.string().trim().min(1, "解决码名称不能为空"),
|
|
||||||
code: z.string().trim().min(1, "解决码编码不能为空"),
|
|
||||||
sortNo: z
|
|
||||||
.string()
|
|
||||||
.trim()
|
|
||||||
.min(1, "排序不能为空")
|
|
||||||
.regex(/^\d+$/, "排序值必须是大于等于 0 的整数"),
|
|
||||||
status: z.enum([String(Status.Ok), String(Status.Disabled)], {
|
|
||||||
message: "请选择状态",
|
|
||||||
}),
|
|
||||||
remark: z.string().trim(),
|
|
||||||
});
|
|
||||||
|
|
||||||
type EditForm = z.infer<typeof formSchema>;
|
|
||||||
|
|
||||||
const resolver = zodResolver(formSchema as never) as Resolver<
|
|
||||||
z.input<typeof formSchema>,
|
|
||||||
undefined,
|
|
||||||
z.output<typeof formSchema>
|
|
||||||
>;
|
|
||||||
|
|
||||||
const emptyForm: EditForm = {
|
|
||||||
name: "",
|
|
||||||
code: "",
|
|
||||||
sortNo: "0",
|
|
||||||
status: String(Status.Ok),
|
|
||||||
remark: "",
|
|
||||||
};
|
|
||||||
|
|
||||||
function buildForm(item: TicketResolutionCode | null): EditForm {
|
|
||||||
if (!item) {
|
|
||||||
return emptyForm;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
name: item.name,
|
|
||||||
code: item.code,
|
|
||||||
sortNo: String(item.sortNo),
|
|
||||||
status: String(item.status) as EditForm["status"],
|
|
||||||
remark: item.remark || "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildPayload(form: EditForm): CreateTicketResolutionCodePayload {
|
|
||||||
return {
|
|
||||||
name: form.name.trim(),
|
|
||||||
code: form.code.trim(),
|
|
||||||
sortNo: Number(form.sortNo),
|
|
||||||
status: Number(form.status),
|
|
||||||
remark: form.remark.trim(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TicketResolutionCodesPage() {
|
|
||||||
const [keywordInput, setKeywordInput] = useState("");
|
|
||||||
const [statusFilterInput, setStatusFilterInput] = useState("all");
|
|
||||||
const [keyword, setKeyword] = useState("");
|
|
||||||
const [statusFilter, setStatusFilter] = useState("all");
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const [limit, setLimit] = useState(20);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
|
||||||
const [editingItem, setEditingItem] = useState<TicketResolutionCode | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const [result, setResult] = useState<PageResult<TicketResolutionCode>>({
|
|
||||||
results: [],
|
|
||||||
page: { page: 1, limit: 20, total: 0 },
|
|
||||||
});
|
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const data = await fetchTicketResolutionCodes({
|
|
||||||
name: keyword.trim() || undefined,
|
|
||||||
status: statusFilter === "all" ? undefined : statusFilter,
|
|
||||||
page,
|
|
||||||
limit,
|
|
||||||
});
|
|
||||||
setResult(data);
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error instanceof Error ? error.message : "加载解决码失败");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [keyword, statusFilter, page, limit]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void loadData();
|
|
||||||
}, [loadData]);
|
|
||||||
|
|
||||||
function applyFilters() {
|
|
||||||
setKeyword(keywordInput);
|
|
||||||
setStatusFilter(statusFilterInput);
|
|
||||||
setPage(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSubmit(payload: CreateTicketResolutionCodePayload) {
|
|
||||||
if (saving) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
if (editingItem) {
|
|
||||||
await updateTicketResolutionCode({ id: editingItem.id, ...payload });
|
|
||||||
toast.success(`已更新解决码:${payload.name}`);
|
|
||||||
} else {
|
|
||||||
await createTicketResolutionCode(payload);
|
|
||||||
toast.success(`已创建解决码:${payload.name}`);
|
|
||||||
}
|
|
||||||
setDialogOpen(false);
|
|
||||||
setEditingItem(null);
|
|
||||||
await loadData();
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error instanceof Error ? error.message : "保存解决码失败");
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDelete(item: TicketResolutionCode) {
|
|
||||||
try {
|
|
||||||
await deleteTicketResolutionCode(item.id);
|
|
||||||
toast.success(`已删除解决码:${item.name}`);
|
|
||||||
await loadData();
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error instanceof Error ? error.message : "删除解决码失败");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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-center">
|
|
||||||
<div className="relative min-w-72">
|
|
||||||
<SearchIcon className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
value={keywordInput}
|
|
||||||
onChange={(event) => setKeywordInput(event.target.value)}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === "Enter") {
|
|
||||||
event.preventDefault();
|
|
||||||
applyFilters();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder="按解决码名称筛选"
|
|
||||||
className="pl-9"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="w-full xl:w-40">
|
|
||||||
<OptionCombobox
|
|
||||||
value={statusFilterInput}
|
|
||||||
onChange={setStatusFilterInput}
|
|
||||||
placeholder="全部状态"
|
|
||||||
options={listStatusOptions.map((item) => ({
|
|
||||||
value: item.value,
|
|
||||||
label: item.label,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" onClick={applyFilters} disabled={loading}>
|
|
||||||
<SearchIcon className="size-4" />
|
|
||||||
查询
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => void loadData()}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
<RefreshCwIcon className="size-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
setEditingItem(null);
|
|
||||||
setDialogOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PlusIcon className="size-4" />
|
|
||||||
新建解决码
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="overflow-hidden rounded-lg border bg-background">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead className="bg-muted/35">
|
|
||||||
<tr>
|
|
||||||
<th className="px-4 py-3 text-left font-medium">名称</th>
|
|
||||||
<th className="px-4 py-3 text-left font-medium">编码</th>
|
|
||||||
<th className="px-4 py-3 text-left font-medium">状态</th>
|
|
||||||
<th className="px-4 py-3 text-left font-medium">排序</th>
|
|
||||||
<th className="px-4 py-3 text-right font-medium">操作</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{loading ? (
|
|
||||||
<tr>
|
|
||||||
<td
|
|
||||||
colSpan={5}
|
|
||||||
className="h-32 text-center text-muted-foreground"
|
|
||||||
>
|
|
||||||
加载中...
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : result.results.length > 0 ? (
|
|
||||||
result.results.map((item) => (
|
|
||||||
<tr key={item.id} className="border-t">
|
|
||||||
<td className="px-4 py-3">{item.name}</td>
|
|
||||||
<td className="px-4 py-3 font-mono text-xs">{item.code}</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<Badge
|
|
||||||
variant={
|
|
||||||
item.status === Status.Ok ? "default" : "secondary"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{getEnumLabel(StatusLabels, item.status as Status)}
|
|
||||||
</Badge>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">{item.sortNo}</td>
|
|
||||||
<td className="px-4 py-3 text-right">
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
render={<Button variant="ghost" size="sm" />}
|
|
||||||
>
|
|
||||||
操作
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => {
|
|
||||||
setEditingItem(item);
|
|
||||||
setDialogOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
编辑
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="text-destructive"
|
|
||||||
onClick={() => void handleDelete(item)}
|
|
||||||
>
|
|
||||||
<Trash2Icon className="size-4" />
|
|
||||||
删除
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<tr>
|
|
||||||
<td
|
|
||||||
colSpan={5}
|
|
||||||
className="h-32 text-center text-muted-foreground"
|
|
||||||
>
|
|
||||||
暂无解决码
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ListPagination
|
|
||||||
page={result.page.page}
|
|
||||||
total={result.page.total}
|
|
||||||
limit={result.page.limit}
|
|
||||||
loading={loading}
|
|
||||||
onPageChange={setPage}
|
|
||||||
onLimitChange={(value) => {
|
|
||||||
setLimit(value);
|
|
||||||
setPage(1);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TicketResolutionCodeEditDialog
|
|
||||||
open={dialogOpen}
|
|
||||||
saving={saving}
|
|
||||||
item={editingItem}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!saving) {
|
|
||||||
setDialogOpen(open);
|
|
||||||
if (!open) {
|
|
||||||
setEditingItem(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type TicketResolutionCodeEditDialogProps = {
|
|
||||||
open: boolean;
|
|
||||||
saving: boolean;
|
|
||||||
item: TicketResolutionCode | null;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
onSubmit: (payload: CreateTicketResolutionCodePayload) => Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
function TicketResolutionCodeEditDialog({
|
|
||||||
open,
|
|
||||||
saving,
|
|
||||||
item,
|
|
||||||
onOpenChange,
|
|
||||||
onSubmit,
|
|
||||||
}: TicketResolutionCodeEditDialogProps) {
|
|
||||||
const formId = "ticket-resolution-code-edit-form";
|
|
||||||
const form = useForm<
|
|
||||||
z.input<typeof formSchema>,
|
|
||||||
undefined,
|
|
||||||
z.output<typeof formSchema>
|
|
||||||
>({
|
|
||||||
resolver,
|
|
||||||
defaultValues: emptyForm,
|
|
||||||
});
|
|
||||||
const {
|
|
||||||
control,
|
|
||||||
register,
|
|
||||||
handleSubmit,
|
|
||||||
reset,
|
|
||||||
formState: { errors },
|
|
||||||
} = form;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
reset(buildForm(item));
|
|
||||||
}, [item, reset]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ProjectDialog
|
|
||||||
open={open}
|
|
||||||
onOpenChange={onOpenChange}
|
|
||||||
title={item ? "编辑解决码" : "新建解决码"}
|
|
||||||
size="md"
|
|
||||||
allowFullscreen
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
disabled={saving}
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" form={formId} disabled={saving}>
|
|
||||||
{saving ? "保存中..." : item ? "保存" : "创建"}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<form
|
|
||||||
id={formId}
|
|
||||||
onSubmit={handleSubmit(async (values) =>
|
|
||||||
onSubmit(buildPayload(values)),
|
|
||||||
)}
|
|
||||||
className="space-y-4"
|
|
||||||
>
|
|
||||||
<Field data-invalid={!!errors.name}>
|
|
||||||
<FieldLabel htmlFor="ticket-resolution-code-name">
|
|
||||||
解决码名称
|
|
||||||
</FieldLabel>
|
|
||||||
<FieldContent>
|
|
||||||
<Input
|
|
||||||
id="ticket-resolution-code-name"
|
|
||||||
placeholder="请输入解决码名称"
|
|
||||||
{...register("name")}
|
|
||||||
/>
|
|
||||||
<FieldError errors={[errors.name]} />
|
|
||||||
</FieldContent>
|
|
||||||
</Field>
|
|
||||||
<Field data-invalid={!!errors.code}>
|
|
||||||
<FieldLabel htmlFor="ticket-resolution-code-code">
|
|
||||||
解决码编码
|
|
||||||
</FieldLabel>
|
|
||||||
<FieldContent>
|
|
||||||
<Input
|
|
||||||
id="ticket-resolution-code-code"
|
|
||||||
placeholder="请输入解决码编码"
|
|
||||||
{...register("code")}
|
|
||||||
/>
|
|
||||||
<FieldError errors={[errors.code]} />
|
|
||||||
</FieldContent>
|
|
||||||
</Field>
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
|
||||||
<Field data-invalid={!!errors.sortNo}>
|
|
||||||
<FieldLabel htmlFor="ticket-resolution-code-sort">排序</FieldLabel>
|
|
||||||
<FieldContent>
|
|
||||||
<Input id="ticket-resolution-code-sort" {...register("sortNo")} />
|
|
||||||
<FieldError errors={[errors.sortNo]} />
|
|
||||||
</FieldContent>
|
|
||||||
</Field>
|
|
||||||
<Field data-invalid={!!errors.status}>
|
|
||||||
<FieldLabel>状态</FieldLabel>
|
|
||||||
<FieldContent>
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="status"
|
|
||||||
render={({ field }) => (
|
|
||||||
<OptionCombobox
|
|
||||||
value={field.value}
|
|
||||||
onChange={field.onChange}
|
|
||||||
placeholder="请选择状态"
|
|
||||||
options={listStatusOptions
|
|
||||||
.filter((item) => item.value !== "all")
|
|
||||||
.map((item) => ({
|
|
||||||
value: item.value,
|
|
||||||
label: item.label,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FieldError errors={[errors.status]} />
|
|
||||||
</FieldContent>
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor="ticket-resolution-code-remark">备注</FieldLabel>
|
|
||||||
<FieldContent>
|
|
||||||
<Textarea
|
|
||||||
id="ticket-resolution-code-remark"
|
|
||||||
rows={4}
|
|
||||||
{...register("remark")}
|
|
||||||
/>
|
|
||||||
</FieldContent>
|
|
||||||
</Field>
|
|
||||||
</form>
|
|
||||||
</ProjectDialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,326 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import Link from "next/link"
|
|
||||||
import { AlertTriangleIcon, CircleDashedIcon, RefreshCcwIcon, TimerResetIcon, WrenchIcon } from "lucide-react"
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
|
||||||
import { toast } from "sonner"
|
|
||||||
|
|
||||||
import { OptionCombobox } from "@/components/option-combobox"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card"
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table"
|
|
||||||
import {
|
|
||||||
fetchAgentTeamsAll,
|
|
||||||
type AdminAgentTeam,
|
|
||||||
} from "@/lib/api/admin"
|
|
||||||
import {
|
|
||||||
fetchTicketRiskOverview,
|
|
||||||
fetchTicketRiskList,
|
|
||||||
type TicketItem,
|
|
||||||
type TicketRiskOverview,
|
|
||||||
} from "@/lib/api/ticket"
|
|
||||||
import { formatDateTime } from "@/lib/utils"
|
|
||||||
import { TicketPriorityBadge } from "../tickets/_components/ticket-priority-badge"
|
|
||||||
import { TicketSLABadge } from "../tickets/_components/ticket-sla-badge"
|
|
||||||
import { TicketStatusBadge } from "../tickets/_components/ticket-status-badge"
|
|
||||||
|
|
||||||
type RiskTableProps = {
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
items: TicketItem[]
|
|
||||||
emptyText: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function RiskTable({ title, description, items, emptyText }: RiskTableProps) {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-base">{title}</CardTitle>
|
|
||||||
<CardDescription>{description}</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>工单</TableHead>
|
|
||||||
<TableHead>分类</TableHead>
|
|
||||||
<TableHead>优先级</TableHead>
|
|
||||||
<TableHead>状态</TableHead>
|
|
||||||
<TableHead>SLA</TableHead>
|
|
||||||
<TableHead>处理人</TableHead>
|
|
||||||
<TableHead>更新时间</TableHead>
|
|
||||||
<TableHead className="text-right">操作</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{items.length > 0 ? (
|
|
||||||
items.map((item) => (
|
|
||||||
<TableRow key={item.id}>
|
|
||||||
<TableCell className="min-w-64">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="font-medium">{item.title}</div>
|
|
||||||
<div className="text-xs text-muted-foreground">{item.ticketNo}</div>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{item.tags && item.tags.length > 0
|
|
||||||
? item.tags.map((tag) => tag.name).join(" / ")
|
|
||||||
: "未打标签"}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<TicketPriorityBadge priority={item.priority} priorityName={item.priorityName} />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<TicketStatusBadge status={item.status} />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<TicketSLABadge ticket={item} />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{item.currentAssigneeName || "未指派"}</TableCell>
|
|
||||||
<TableCell>{item.updatedAt ? formatDateTime(item.updatedAt) : "—"}</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
<Link href={`/dashboard/tickets/detail?id=${item.id}`} target="_blank" rel="noreferrer">
|
|
||||||
<Button variant="outline" size="sm">
|
|
||||||
查看详情
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={8} className="h-24 text-center text-muted-foreground">
|
|
||||||
{emptyText}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TicketRiskPage() {
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [overview, setOverview] = useState<TicketRiskOverview | null>(null)
|
|
||||||
const [overdueTickets, setOverdueTickets] = useState<TicketItem[]>([])
|
|
||||||
const [highRiskTickets, setHighRiskTickets] = useState<TicketItem[]>([])
|
|
||||||
const [unassignedTickets, setUnassignedTickets] = useState<TicketItem[]>([])
|
|
||||||
const [pendingInternalTickets, setPendingInternalTickets] = useState<TicketItem[]>([])
|
|
||||||
const [teams, setTeams] = useState<AdminAgentTeam[]>([])
|
|
||||||
const [teamFilter, setTeamFilter] = useState("all")
|
|
||||||
const [riskWindow, setRiskWindow] = useState("240")
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
const data = await fetchAgentTeamsAll()
|
|
||||||
setTeams(Array.isArray(data) ? data : [])
|
|
||||||
} catch {
|
|
||||||
setTeams([])
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
|
||||||
setLoading(true)
|
|
||||||
try {
|
|
||||||
const currentTeamId = teamFilter === "all" ? undefined : Number(teamFilter)
|
|
||||||
const riskMinutes = Number(riskWindow)
|
|
||||||
const [overviewData, overdueData, highRiskData, unassignedData, pendingInternalData] =
|
|
||||||
await Promise.all([
|
|
||||||
fetchTicketRiskOverview({ currentTeamId, riskWindowMins: riskMinutes }),
|
|
||||||
fetchTicketRiskList({ riskType: "overdue", currentTeamId, riskWindowMins: riskMinutes, page: 1, limit: 10 }),
|
|
||||||
fetchTicketRiskList({ riskType: "high_risk", currentTeamId, riskWindowMins: riskMinutes, page: 1, limit: 10 }),
|
|
||||||
fetchTicketRiskList({ riskType: "unassigned", currentTeamId, riskWindowMins: riskMinutes, page: 1, limit: 10 }),
|
|
||||||
fetchTicketRiskList({ riskType: "pending_internal", currentTeamId, riskWindowMins: riskMinutes, page: 1, limit: 10 }),
|
|
||||||
])
|
|
||||||
|
|
||||||
setOverview(overviewData)
|
|
||||||
setOverdueTickets(Array.isArray(overdueData.results) ? overdueData.results : [])
|
|
||||||
setHighRiskTickets(Array.isArray(highRiskData.results) ? highRiskData.results : [])
|
|
||||||
setUnassignedTickets(Array.isArray(unassignedData.results) ? unassignedData.results : [])
|
|
||||||
setPendingInternalTickets(
|
|
||||||
Array.isArray(pendingInternalData.results) ? pendingInternalData.results : [],
|
|
||||||
)
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error instanceof Error ? error.message : "加载 SLA 风险页失败")
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}, [riskWindow, teamFilter])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void loadData()
|
|
||||||
}, [loadData])
|
|
||||||
|
|
||||||
const cards = useMemo(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
title: "已超时",
|
|
||||||
description: "解决 SLA 已经 breach 的工单",
|
|
||||||
value: overview?.overdue ?? 0,
|
|
||||||
icon: AlertTriangleIcon,
|
|
||||||
tone: "text-red-700 bg-red-500/10",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: `${Number(riskWindow) / 60} 小时内到期`,
|
|
||||||
description: "建议组长优先盯防的风险队列",
|
|
||||||
value: overview?.highRisk ?? 0,
|
|
||||||
icon: TimerResetIcon,
|
|
||||||
tone: "text-orange-700 bg-orange-500/10",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "待分配",
|
|
||||||
description: "目前还没有明确负责人的工单",
|
|
||||||
value: overview?.unassigned ?? 0,
|
|
||||||
icon: CircleDashedIcon,
|
|
||||||
tone: "text-amber-700 bg-amber-500/10",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "待内部处理",
|
|
||||||
description: "等待内部团队协作处理的工单",
|
|
||||||
value: overview?.pendingInternal ?? 0,
|
|
||||||
icon: WrenchIcon,
|
|
||||||
tone: "text-blue-700 bg-blue-500/10",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[overview, riskWindow],
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-0 flex-1 overflow-auto bg-muted/20 p-4 md:p-6">
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div className="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-xl font-semibold">SLA 风险运营</h1>
|
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
|
||||||
给主管和组长使用的风险盯防页,优先查看超时、临近超时和待分配工单
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<div className="w-44">
|
|
||||||
<OptionCombobox
|
|
||||||
value={teamFilter}
|
|
||||||
onChange={setTeamFilter}
|
|
||||||
placeholder="全部团队"
|
|
||||||
options={[
|
|
||||||
{ value: "all", label: "全部团队" },
|
|
||||||
...teams.map((team) => ({ value: String(team.id), label: team.name })),
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="w-44">
|
|
||||||
<OptionCombobox
|
|
||||||
value={riskWindow}
|
|
||||||
onChange={setRiskWindow}
|
|
||||||
placeholder="风险时间窗"
|
|
||||||
options={[
|
|
||||||
{ value: "60", label: "1 小时内" },
|
|
||||||
{ value: "240", label: "4 小时内" },
|
|
||||||
{ value: "1440", label: "24 小时内" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Link href="/dashboard/tickets">
|
|
||||||
<Button variant="outline">前往工单工作台</Button>
|
|
||||||
</Link>
|
|
||||||
<Button variant="outline" onClick={() => void loadData()} disabled={loading}>
|
|
||||||
<RefreshCcwIcon className="size-4" />
|
|
||||||
刷新
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
|
||||||
{cards.map((item) => {
|
|
||||||
const Icon = item.icon
|
|
||||||
return (
|
|
||||||
<Card key={item.title}>
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
<div className={`rounded-full p-2 ${item.tone}`}>
|
|
||||||
<Icon className="size-4" />
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-3xl font-semibold tracking-tight">
|
|
||||||
{loading ? "..." : item.value.toLocaleString()}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<RiskTable
|
|
||||||
title="已超时工单"
|
|
||||||
description="需要立即处理或升级的高风险工单"
|
|
||||||
items={overdueTickets}
|
|
||||||
emptyText="当前没有已超时工单"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<RiskTable
|
|
||||||
title="4 小时内到期"
|
|
||||||
description="建议优先处理,避免进入超时队列"
|
|
||||||
items={highRiskTickets}
|
|
||||||
emptyText="当前没有临近超时工单"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-base">滞留原因</CardTitle>
|
|
||||||
<CardDescription>帮助主管快速判断风险是由分配、协作还是 SLA 配置问题造成</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
|
||||||
{(overview?.reasons?.length ?? 0) > 0 ? (
|
|
||||||
overview?.reasons?.map((item) => (
|
|
||||||
<div key={item.code} className="rounded-lg border bg-muted/20 p-4">
|
|
||||||
<div className="text-sm font-medium">{item.title}</div>
|
|
||||||
<div className="mt-2 text-2xl font-semibold">{item.count.toLocaleString()}</div>
|
|
||||||
<div className="mt-2 text-xs leading-6 text-muted-foreground">{item.description}</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<div className="text-sm text-muted-foreground">暂无滞留原因数据</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className="grid gap-4 xl:grid-cols-2">
|
|
||||||
<RiskTable
|
|
||||||
title="待分配工单"
|
|
||||||
description="进入队列但尚未明确负责人的工单"
|
|
||||||
items={unassignedTickets}
|
|
||||||
emptyText="当前没有待分配工单"
|
|
||||||
/>
|
|
||||||
<RiskTable
|
|
||||||
title="待内部处理"
|
|
||||||
description="需要内部团队介入,容易长期滞留的工单"
|
|
||||||
items={pendingInternalTickets}
|
|
||||||
emptyText="当前没有待内部处理工单"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -30,8 +30,6 @@ export function CreateTicketFromConversationDialog({
|
|||||||
? {
|
? {
|
||||||
title: conversation.customerName || "",
|
title: conversation.customerName || "",
|
||||||
description: conversation.lastMessageSummary || "",
|
description: conversation.lastMessageSummary || "",
|
||||||
priority: 2,
|
|
||||||
severity: 1,
|
|
||||||
currentAssigneeId: conversation.currentAssigneeId || undefined,
|
currentAssigneeId: conversation.currentAssigneeId || undefined,
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
@@ -55,11 +53,8 @@ export function CreateTicketFromConversationDialog({
|
|||||||
conversationId: conversation.id,
|
conversationId: conversation.id,
|
||||||
title: payload.title,
|
title: payload.title,
|
||||||
description: payload.description,
|
description: payload.description,
|
||||||
priority: payload.priority,
|
|
||||||
severity: payload.severity,
|
|
||||||
currentTeamId: payload.currentTeamId,
|
|
||||||
currentAssigneeId: payload.currentAssigneeId,
|
currentAssigneeId: payload.currentAssigneeId,
|
||||||
syncToConversation: true,
|
tagIds: payload.tagIds,
|
||||||
})
|
})
|
||||||
toast.success("工单创建成功")
|
toast.success("工单创建成功")
|
||||||
onSuccess?.()
|
onSuccess?.()
|
||||||
|
|||||||
@@ -31,16 +31,10 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
|
|||||||
import { Textarea } from "@/components/ui/textarea"
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
import {
|
import {
|
||||||
fetchAgentProfilesAll,
|
fetchAgentProfilesAll,
|
||||||
fetchAgentTeamsAll,
|
|
||||||
fetchTagsAll,
|
fetchTagsAll,
|
||||||
type AdminAgentProfile,
|
type AdminAgentProfile,
|
||||||
type AdminAgentTeam,
|
|
||||||
type TagTree,
|
type TagTree,
|
||||||
} from "@/lib/api/admin"
|
} from "@/lib/api/admin"
|
||||||
import {
|
|
||||||
fetchTicketPriorityConfigsAll,
|
|
||||||
type TicketPriorityConfig,
|
|
||||||
} from "@/lib/api/ticket-config"
|
|
||||||
import {
|
import {
|
||||||
fetchTicketDetail,
|
fetchTicketDetail,
|
||||||
type CreateTicketPayload,
|
type CreateTicketPayload,
|
||||||
@@ -61,34 +55,26 @@ type EditDialogProps = {
|
|||||||
onSubmit: (payload: CreateTicketPayload | UpdateTicketPayload) => Promise<void>
|
onSubmit: (payload: CreateTicketPayload | UpdateTicketPayload) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
const ticketFormSchema = z.object({
|
const schema = z.object({
|
||||||
title: z.string().trim().min(1, "标题不能为空"),
|
title: z.string().trim().min(1, "请输入工单标题"),
|
||||||
description: z.string().trim(),
|
description: z.string().trim().min(1, "请输入问题描述"),
|
||||||
tagIds: z.array(z.string().trim()).default([]),
|
currentAssigneeId: z.coerce.number().int().min(0).optional(),
|
||||||
priority: z.string().trim().min(1, "请选择优先级"),
|
tagIds: z.array(z.number().int().positive()).default([]),
|
||||||
severity: z.enum(["1", "2", "3"], { message: "请选择严重度" }),
|
|
||||||
currentTeamId: z.string().trim(),
|
|
||||||
currentAssigneeId: z.string().trim(),
|
|
||||||
dueAt: z.string().trim(),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
type EditForm = z.infer<typeof ticketFormSchema>
|
type EditForm = z.infer<typeof schema>
|
||||||
|
|
||||||
const editFormResolver = zodResolver(ticketFormSchema as never) as Resolver<
|
const editFormResolver = zodResolver(schema as never) as Resolver<
|
||||||
z.input<typeof ticketFormSchema>,
|
z.input<typeof schema>,
|
||||||
undefined,
|
undefined,
|
||||||
z.output<typeof ticketFormSchema>
|
z.output<typeof schema>
|
||||||
>
|
>
|
||||||
|
|
||||||
const emptyForm: EditForm = {
|
const emptyForm: EditForm = {
|
||||||
title: "",
|
title: "",
|
||||||
description: "",
|
description: "",
|
||||||
|
currentAssigneeId: 0,
|
||||||
tagIds: [],
|
tagIds: [],
|
||||||
priority: "",
|
|
||||||
severity: "1",
|
|
||||||
currentTeamId: "",
|
|
||||||
currentAssigneeId: "",
|
|
||||||
dueAt: "",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildForm(item: TicketItem | null): EditForm {
|
function buildForm(item: TicketItem | null): EditForm {
|
||||||
@@ -98,12 +84,8 @@ function buildForm(item: TicketItem | null): EditForm {
|
|||||||
return {
|
return {
|
||||||
title: item.title ?? "",
|
title: item.title ?? "",
|
||||||
description: item.description ?? "",
|
description: item.description ?? "",
|
||||||
tagIds: (item.tags ?? []).map((tag) => String(tag.id)),
|
currentAssigneeId: item.currentAssigneeId ?? 0,
|
||||||
priority: item.priority ? String(item.priority) : "",
|
tagIds: (item.tags ?? []).map((tag) => tag.id),
|
||||||
severity: String(item.severity || 1) as EditForm["severity"],
|
|
||||||
currentTeamId: item.currentTeamId ? String(item.currentTeamId) : "",
|
|
||||||
currentAssigneeId: item.currentAssigneeId ? String(item.currentAssigneeId) : "",
|
|
||||||
dueAt: item.dueAt ? item.dueAt.replace(" ", "T").slice(0, 16) : "",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,27 +93,18 @@ function buildInitialForm(initialValues?: Partial<CreateTicketPayload>): EditFor
|
|||||||
return {
|
return {
|
||||||
title: initialValues?.title?.trim() ?? "",
|
title: initialValues?.title?.trim() ?? "",
|
||||||
description: initialValues?.description?.trim() ?? "",
|
description: initialValues?.description?.trim() ?? "",
|
||||||
tagIds: (initialValues?.tagIds ?? []).map((tagId) => String(tagId)),
|
currentAssigneeId: initialValues?.currentAssigneeId ?? 0,
|
||||||
priority: initialValues?.priority ? String(initialValues.priority) : "",
|
tagIds: initialValues?.tagIds ?? [],
|
||||||
severity: String(initialValues?.severity ?? 1) as EditForm["severity"],
|
|
||||||
currentTeamId: initialValues?.currentTeamId ? String(initialValues.currentTeamId) : "",
|
|
||||||
currentAssigneeId: initialValues?.currentAssigneeId
|
|
||||||
? String(initialValues.currentAssigneeId)
|
|
||||||
: "",
|
|
||||||
dueAt: initialValues?.dueAt ? initialValues.dueAt.replace(" ", "T").slice(0, 16) : "",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPayload(form: EditForm): CreateTicketPayload {
|
function buildPayload(form: EditForm): CreateTicketPayload {
|
||||||
|
const currentAssigneeId = form.currentAssigneeId ?? 0
|
||||||
return {
|
return {
|
||||||
title: form.title.trim(),
|
title: form.title.trim(),
|
||||||
description: form.description.trim(),
|
description: form.description.trim(),
|
||||||
tagIds: form.tagIds.length > 0 ? form.tagIds.map((tagId) => Number(tagId)) : undefined,
|
currentAssigneeId,
|
||||||
priority: Number(form.priority),
|
tagIds: form.tagIds,
|
||||||
severity: Number(form.severity),
|
|
||||||
currentTeamId: form.currentTeamId ? Number(form.currentTeamId) : undefined,
|
|
||||||
currentAssigneeId: form.currentAssigneeId ? Number(form.currentAssigneeId) : undefined,
|
|
||||||
dueAt: form.dueAt ? `${form.dueAt.replace("T", " ")}:00` : undefined,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,8 +126,8 @@ function flattenTagTree(nodes: TagTree[], depth = 0, parentPath = ""): FlatTagNo
|
|||||||
}
|
}
|
||||||
|
|
||||||
type TicketTagSelectorProps = {
|
type TicketTagSelectorProps = {
|
||||||
value?: string[]
|
value?: number[]
|
||||||
onChange: (value: string[]) => void
|
onChange: (value: number[]) => void
|
||||||
availableTags: TagTree[]
|
availableTags: TagTree[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,11 +136,11 @@ function TicketTagSelector({ value, onChange, availableTags }: TicketTagSelector
|
|||||||
const flatTags = useMemo(() => flattenTagTree(availableTags), [availableTags])
|
const flatTags = useMemo(() => flattenTagTree(availableTags), [availableTags])
|
||||||
const selectedTagIDs = useMemo(() => new Set(selectedValues), [selectedValues])
|
const selectedTagIDs = useMemo(() => new Set(selectedValues), [selectedValues])
|
||||||
const selectedTags = useMemo(
|
const selectedTags = useMemo(
|
||||||
() => flatTags.filter((tag) => selectedTagIDs.has(String(tag.id))),
|
() => flatTags.filter((tag) => selectedTagIDs.has(tag.id)),
|
||||||
[flatTags, selectedTagIDs],
|
[flatTags, selectedTagIDs],
|
||||||
)
|
)
|
||||||
|
|
||||||
function handleToggle(tagID: string) {
|
function handleToggle(tagID: number) {
|
||||||
if (selectedTagIDs.has(tagID)) {
|
if (selectedTagIDs.has(tagID)) {
|
||||||
onChange(selectedValues.filter((item) => item !== tagID))
|
onChange(selectedValues.filter((item) => item !== tagID))
|
||||||
return
|
return
|
||||||
@@ -183,8 +156,8 @@ function TicketTagSelector({ value, onChange, availableTags }: TicketTagSelector
|
|||||||
<Button type="button" variant="outline" className="w-full justify-start" />
|
<Button type="button" variant="outline" className="w-full justify-start" />
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<TagIcon className="size-4" />
|
<TagIcon className="size-4" />
|
||||||
{selectedTags.length > 0 ? `已选择 ${selectedTags.length} 个标签` : "请选择工单标签"}
|
{selectedTags.length > 0 ? `已选择 ${selectedTags.length} 个标签` : "请选择工单标签"}
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent align="start" className="w-[320px] p-0">
|
<PopoverContent align="start" className="w-[320px] p-0">
|
||||||
<Command>
|
<Command>
|
||||||
@@ -193,12 +166,12 @@ function TicketTagSelector({ value, onChange, availableTags }: TicketTagSelector
|
|||||||
<CommandEmpty>暂无可用标签</CommandEmpty>
|
<CommandEmpty>暂无可用标签</CommandEmpty>
|
||||||
<CommandGroup heading="标签">
|
<CommandGroup heading="标签">
|
||||||
{flatTags.map((tag) => {
|
{flatTags.map((tag) => {
|
||||||
const checked = selectedTagIDs.has(String(tag.id))
|
const checked = selectedTagIDs.has(tag.id)
|
||||||
return (
|
return (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={tag.id}
|
key={tag.id}
|
||||||
value={`${tag.id} ${tag.path} ${tag.remark}`}
|
value={`${tag.id} ${tag.path} ${tag.remark}`}
|
||||||
onSelect={() => handleToggle(String(tag.id))}
|
onSelect={() => handleToggle(tag.id)}
|
||||||
>
|
>
|
||||||
<CheckIcon className={`mr-2 size-4 ${checked ? "opacity-100" : "opacity-0"}`} />
|
<CheckIcon className={`mr-2 size-4 ${checked ? "opacity-100" : "opacity-0"}`} />
|
||||||
<span className="truncate" style={{ paddingLeft: `${tag.depth * 12}px` }}>
|
<span className="truncate" style={{ paddingLeft: `${tag.depth * 12}px` }}>
|
||||||
@@ -274,13 +247,11 @@ function TicketEditDialogBody({
|
|||||||
const formId = "ticket-edit-form"
|
const formId = "ticket-edit-form"
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [tags, setTags] = useState<TagTree[]>([])
|
const [tags, setTags] = useState<TagTree[]>([])
|
||||||
const [priorities, setPriorities] = useState<TicketPriorityConfig[]>([])
|
|
||||||
const [teams, setTeams] = useState<AdminAgentTeam[]>([])
|
|
||||||
const [agents, setAgents] = useState<AdminAgentProfile[]>([])
|
const [agents, setAgents] = useState<AdminAgentProfile[]>([])
|
||||||
const form = useForm<
|
const form = useForm<
|
||||||
z.input<typeof ticketFormSchema>,
|
z.input<typeof schema>,
|
||||||
undefined,
|
undefined,
|
||||||
z.output<typeof ticketFormSchema>
|
z.output<typeof schema>
|
||||||
>({
|
>({
|
||||||
resolver: editFormResolver,
|
resolver: editFormResolver,
|
||||||
defaultValues: emptyForm,
|
defaultValues: emptyForm,
|
||||||
@@ -315,31 +286,16 @@ function TicketEditDialogBody({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const [tagData, priorityData, teamData, agentData] = await Promise.all([
|
const [tagData, agentData] = await Promise.all([
|
||||||
fetchTagsAll(),
|
fetchTagsAll(),
|
||||||
fetchTicketPriorityConfigsAll(),
|
|
||||||
fetchAgentTeamsAll(),
|
|
||||||
fetchAgentProfilesAll(),
|
fetchAgentProfilesAll(),
|
||||||
])
|
])
|
||||||
setTags(Array.isArray(tagData) ? tagData : [])
|
setTags(Array.isArray(tagData) ? tagData : [])
|
||||||
setPriorities(Array.isArray(priorityData) ? priorityData : [])
|
|
||||||
setTeams(Array.isArray(teamData) ? teamData : [])
|
|
||||||
setAgents(Array.isArray(agentData) ? agentData : [])
|
setAgents(Array.isArray(agentData) ? agentData : [])
|
||||||
})()
|
})()
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
const priorityOptions = priorities.map((priority) => ({
|
const agentOptions = [{ value: "0", label: "不指定处理人" }].concat(
|
||||||
value: String(priority.id),
|
|
||||||
label: priority.name,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const teamOptions = [{ value: "", label: "不指定团队" }].concat(
|
|
||||||
teams.map((team) => ({
|
|
||||||
value: String(team.id),
|
|
||||||
label: team.name,
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
const agentOptions = [{ value: "", label: "不指定处理人" }].concat(
|
|
||||||
agents.map((agent) => ({
|
agents.map((agent) => ({
|
||||||
value: String(agent.userId),
|
value: String(agent.userId),
|
||||||
label:
|
label:
|
||||||
@@ -426,9 +382,25 @@ function TicketEditDialogBody({
|
|||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field>
|
<Field>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<FieldLabel>处理人</FieldLabel>
|
||||||
<FieldLabel>工单标签</FieldLabel>
|
<FieldContent>
|
||||||
</div>
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="currentAssigneeId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<OptionCombobox
|
||||||
|
value={String(field.value ?? 0)}
|
||||||
|
onChange={(value) => field.onChange(Number(value))}
|
||||||
|
placeholder="请选择处理人"
|
||||||
|
options={agentOptions}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>工单标签</FieldLabel>
|
||||||
<FieldContent>
|
<FieldContent>
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
@@ -443,101 +415,6 @@ function TicketEditDialogBody({
|
|||||||
/>
|
/>
|
||||||
</FieldContent>
|
</FieldContent>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
|
||||||
<Field data-invalid={!!errors.priority}>
|
|
||||||
<FieldLabel>优先级</FieldLabel>
|
|
||||||
<FieldContent>
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="priority"
|
|
||||||
render={({ field }) => (
|
|
||||||
<OptionCombobox
|
|
||||||
value={field.value}
|
|
||||||
onChange={field.onChange}
|
|
||||||
placeholder="请选择优先级"
|
|
||||||
options={priorityOptions}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FieldError errors={[errors.priority]} />
|
|
||||||
</FieldContent>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field data-invalid={!!errors.severity}>
|
|
||||||
<FieldLabel>严重度</FieldLabel>
|
|
||||||
<FieldContent>
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="severity"
|
|
||||||
render={({ field }) => (
|
|
||||||
<OptionCombobox
|
|
||||||
value={field.value}
|
|
||||||
onChange={field.onChange}
|
|
||||||
placeholder="请选择严重度"
|
|
||||||
options={[
|
|
||||||
{ value: "1", label: "轻微" },
|
|
||||||
{ value: "2", label: "严重" },
|
|
||||||
{ value: "3", label: "致命" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FieldError errors={[errors.severity]} />
|
|
||||||
</FieldContent>
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
|
||||||
<Field>
|
|
||||||
<FieldLabel>处理团队</FieldLabel>
|
|
||||||
<FieldContent>
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="currentTeamId"
|
|
||||||
render={({ field }) => (
|
|
||||||
<OptionCombobox
|
|
||||||
value={field.value}
|
|
||||||
onChange={field.onChange}
|
|
||||||
placeholder="请选择团队"
|
|
||||||
options={teamOptions}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</FieldContent>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field>
|
|
||||||
<FieldLabel>处理人</FieldLabel>
|
|
||||||
<FieldContent>
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="currentAssigneeId"
|
|
||||||
render={({ field }) => (
|
|
||||||
<OptionCombobox
|
|
||||||
value={field.value}
|
|
||||||
onChange={field.onChange}
|
|
||||||
placeholder="请选择处理人"
|
|
||||||
options={agentOptions}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</FieldContent>
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Field data-invalid={!!errors.dueAt}>
|
|
||||||
<FieldLabel htmlFor="ticket-due-at">截止时间</FieldLabel>
|
|
||||||
<FieldContent>
|
|
||||||
<Input
|
|
||||||
id="ticket-due-at"
|
|
||||||
type="datetime-local"
|
|
||||||
aria-invalid={!!errors.dueAt}
|
|
||||||
{...register("dueAt")}
|
|
||||||
/>
|
|
||||||
<FieldError errors={[errors.dueAt]} />
|
|
||||||
</FieldContent>
|
|
||||||
</Field>
|
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useRef, useState } from "react"
|
||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
import { Controller, Resolver, useForm } from "react-hook-form"
|
import { Controller, Resolver, useForm } from "react-hook-form"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
@@ -19,15 +19,12 @@ import { Field, FieldContent, FieldError, FieldLabel } from "@/components/ui/fie
|
|||||||
import { Textarea } from "@/components/ui/textarea"
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
import {
|
import {
|
||||||
fetchAgentProfilesAll,
|
fetchAgentProfilesAll,
|
||||||
fetchAgentTeamsAll,
|
|
||||||
type AdminAgentProfile,
|
type AdminAgentProfile,
|
||||||
type AdminAgentTeam,
|
|
||||||
} from "@/lib/api/admin"
|
} from "@/lib/api/admin"
|
||||||
import { assignTicket, batchAssignTickets } from "@/lib/api/ticket"
|
import { assignTicket, batchAssignTickets } from "@/lib/api/ticket"
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
toUserId: z.string().trim().min(1, "请选择处理人"),
|
toUserId: z.string().trim().min(1, "请选择处理人"),
|
||||||
toTeamId: z.string().trim(),
|
|
||||||
reason: z.string().trim(),
|
reason: z.string().trim(),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -41,7 +38,6 @@ const resolver = zodResolver(schema as never) as Resolver<
|
|||||||
|
|
||||||
const emptyForm: FormValues = {
|
const emptyForm: FormValues = {
|
||||||
toUserId: "",
|
toUserId: "",
|
||||||
toTeamId: "",
|
|
||||||
reason: "",
|
reason: "",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +45,6 @@ type TicketAssignDialogProps = {
|
|||||||
open: boolean
|
open: boolean
|
||||||
ticketId: number | null
|
ticketId: number | null
|
||||||
ticketIds?: number[]
|
ticketIds?: number[]
|
||||||
currentTeamId?: number
|
|
||||||
currentAssigneeId?: number
|
currentAssigneeId?: number
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
onSuccess?: () => Promise<void> | void
|
onSuccess?: () => Promise<void> | void
|
||||||
@@ -59,7 +54,6 @@ export function TicketAssignDialog({
|
|||||||
open,
|
open,
|
||||||
ticketId,
|
ticketId,
|
||||||
ticketIds,
|
ticketIds,
|
||||||
currentTeamId,
|
|
||||||
currentAssigneeId,
|
currentAssigneeId,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
@@ -68,10 +62,9 @@ export function TicketAssignDialog({
|
|||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
{open ? (
|
{open ? (
|
||||||
<TicketAssignDialogBody
|
<TicketAssignDialogBody
|
||||||
key={ticketId ?? "ticket-assign"}
|
key={ticketId ?? ticketIds?.join(",") ?? "ticket-assign"}
|
||||||
ticketId={ticketId}
|
ticketId={ticketId}
|
||||||
ticketIds={ticketIds}
|
ticketIds={ticketIds}
|
||||||
currentTeamId={currentTeamId}
|
|
||||||
currentAssigneeId={currentAssigneeId}
|
currentAssigneeId={currentAssigneeId}
|
||||||
onOpenChange={onOpenChange}
|
onOpenChange={onOpenChange}
|
||||||
onSuccess={onSuccess}
|
onSuccess={onSuccess}
|
||||||
@@ -84,15 +77,14 @@ export function TicketAssignDialog({
|
|||||||
function TicketAssignDialogBody({
|
function TicketAssignDialogBody({
|
||||||
ticketId,
|
ticketId,
|
||||||
ticketIds,
|
ticketIds,
|
||||||
currentTeamId,
|
|
||||||
currentAssigneeId,
|
currentAssigneeId,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
}: Omit<TicketAssignDialogProps, "open">) {
|
}: Omit<TicketAssignDialogProps, "open">) {
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [teams, setTeams] = useState<AdminAgentTeam[]>([])
|
|
||||||
const [agents, setAgents] = useState<AdminAgentProfile[]>([])
|
const [agents, setAgents] = useState<AdminAgentProfile[]>([])
|
||||||
|
const activeRef = useRef(false)
|
||||||
|
|
||||||
const form = useForm<
|
const form = useForm<
|
||||||
z.input<typeof schema>,
|
z.input<typeof schema>,
|
||||||
@@ -111,25 +103,40 @@ function TicketAssignDialogBody({
|
|||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = form
|
} = form
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
activeRef.current = true
|
||||||
|
return () => {
|
||||||
|
activeRef.current = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
reset({
|
reset({
|
||||||
toUserId: currentAssigneeId ? String(currentAssigneeId) : "",
|
toUserId: currentAssigneeId ? String(currentAssigneeId) : "",
|
||||||
toTeamId: currentTeamId ? String(currentTeamId) : "",
|
|
||||||
reason: "",
|
reason: "",
|
||||||
})
|
})
|
||||||
}, [currentAssigneeId, currentTeamId, reset, ticketId])
|
}, [currentAssigneeId, reset, ticketId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
Promise.all([fetchAgentTeamsAll(), fetchAgentProfilesAll()])
|
fetchAgentProfilesAll()
|
||||||
.then(([teamData, agentData]) => {
|
.then((agentData) => {
|
||||||
setTeams(Array.isArray(teamData) ? teamData : [])
|
if (!activeRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
setAgents(Array.isArray(agentData) ? agentData : [])
|
setAgents(Array.isArray(agentData) ? agentData : [])
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
if (!activeRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
toast.error(error instanceof Error ? error.message : "加载处理人失败")
|
toast.error(error instanceof Error ? error.message : "加载处理人失败")
|
||||||
})
|
})
|
||||||
.finally(() => setLoading(false))
|
.finally(() => {
|
||||||
|
if (activeRef.current) {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
async function onFormSubmit(values: FormValues) {
|
async function onFormSubmit(values: FormValues) {
|
||||||
@@ -144,25 +151,37 @@ function TicketAssignDialogBody({
|
|||||||
await batchAssignTickets({
|
await batchAssignTickets({
|
||||||
ticketIds: validTicketIds,
|
ticketIds: validTicketIds,
|
||||||
toUserId: Number(values.toUserId),
|
toUserId: Number(values.toUserId),
|
||||||
toTeamId: values.toTeamId ? Number(values.toTeamId) : undefined,
|
|
||||||
reason: values.reason.trim() || undefined,
|
reason: values.reason.trim() || undefined,
|
||||||
})
|
})
|
||||||
|
if (!activeRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
toast.success(`已批量指派 ${validTicketIds.length} 张工单`)
|
toast.success(`已批量指派 ${validTicketIds.length} 张工单`)
|
||||||
} else {
|
} else {
|
||||||
await assignTicket({
|
await assignTicket({
|
||||||
ticketId: ticketId!,
|
ticketId: ticketId!,
|
||||||
toUserId: Number(values.toUserId),
|
toUserId: Number(values.toUserId),
|
||||||
toTeamId: values.toTeamId ? Number(values.toTeamId) : undefined,
|
|
||||||
reason: values.reason.trim() || undefined,
|
reason: values.reason.trim() || undefined,
|
||||||
})
|
})
|
||||||
|
if (!activeRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
toast.success("处理人已更新")
|
toast.success("处理人已更新")
|
||||||
}
|
}
|
||||||
|
if (!activeRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
await onSuccess?.()
|
await onSuccess?.()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (!activeRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
toast.error(error instanceof Error ? error.message : "指派工单失败")
|
toast.error(error instanceof Error ? error.message : "指派工单失败")
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
if (activeRef.current) {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,29 +192,6 @@ function TicketAssignDialogBody({
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||||
<div className="space-y-4 p-6">
|
<div className="space-y-4 p-6">
|
||||||
<Field>
|
|
||||||
<FieldLabel>处理团队</FieldLabel>
|
|
||||||
<FieldContent>
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="toTeamId"
|
|
||||||
render={({ field }) => (
|
|
||||||
<OptionCombobox
|
|
||||||
value={field.value}
|
|
||||||
onChange={field.onChange}
|
|
||||||
placeholder={loading ? "加载中..." : "选择处理团队"}
|
|
||||||
options={[
|
|
||||||
{ value: "", label: "不指定团队" },
|
|
||||||
...teams.map((team) => ({
|
|
||||||
value: String(team.id),
|
|
||||||
label: team.name,
|
|
||||||
})),
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</FieldContent>
|
|
||||||
</Field>
|
|
||||||
<Field data-invalid={!!errors.toUserId}>
|
<Field data-invalid={!!errors.toUserId}>
|
||||||
<FieldLabel>处理人</FieldLabel>
|
<FieldLabel>处理人</FieldLabel>
|
||||||
<FieldContent>
|
<FieldContent>
|
||||||
|
|||||||
@@ -1,149 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
|
||||||
import { Controller, Resolver, useForm } from "react-hook-form"
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
|
||||||
import { toast } from "sonner"
|
|
||||||
import { z } from "zod/v4"
|
|
||||||
|
|
||||||
import { OptionCombobox } from "@/components/option-combobox"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog"
|
|
||||||
import { Field, FieldContent, FieldError, FieldLabel } from "@/components/ui/field"
|
|
||||||
import { fetchAgentProfilesAll, type AdminAgentProfile } from "@/lib/api/admin"
|
|
||||||
import { addTicketCollaborator } from "@/lib/api/ticket"
|
|
||||||
|
|
||||||
const schema = z.object({
|
|
||||||
userId: z.string().trim().min(1, "请选择协作人"),
|
|
||||||
})
|
|
||||||
|
|
||||||
type FormValues = z.infer<typeof schema>
|
|
||||||
|
|
||||||
const resolver = zodResolver(schema as never) as Resolver<
|
|
||||||
z.input<typeof schema>,
|
|
||||||
undefined,
|
|
||||||
z.output<typeof schema>
|
|
||||||
>
|
|
||||||
|
|
||||||
type TicketCollaboratorDialogProps = {
|
|
||||||
open: boolean
|
|
||||||
ticketId: number | null
|
|
||||||
onOpenChange: (open: boolean) => void
|
|
||||||
onSuccess?: () => Promise<void> | void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TicketCollaboratorDialog({
|
|
||||||
open,
|
|
||||||
ticketId,
|
|
||||||
onOpenChange,
|
|
||||||
onSuccess,
|
|
||||||
}: TicketCollaboratorDialogProps) {
|
|
||||||
const [loadingAgents, setLoadingAgents] = useState(false)
|
|
||||||
const [agents, setAgents] = useState<AdminAgentProfile[]>([])
|
|
||||||
const userOptions = agents.map((agent) => ({
|
|
||||||
value: String(agent.userId),
|
|
||||||
label: agent.displayName || agent.nickname || agent.username || `客服 #${agent.userId}`,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const form = useForm<
|
|
||||||
z.input<typeof schema>,
|
|
||||||
undefined,
|
|
||||||
z.output<typeof schema>
|
|
||||||
>({
|
|
||||||
resolver,
|
|
||||||
defaultValues: { userId: "" },
|
|
||||||
})
|
|
||||||
const {
|
|
||||||
control,
|
|
||||||
handleSubmit,
|
|
||||||
reset,
|
|
||||||
formState: { errors, isSubmitting },
|
|
||||||
} = form
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
reset({ userId: "" })
|
|
||||||
}
|
|
||||||
}, [open, reset])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setLoadingAgents(true)
|
|
||||||
fetchAgentProfilesAll()
|
|
||||||
.then((data) => {
|
|
||||||
setAgents(Array.isArray(data) ? data : [])
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
toast.error(error instanceof Error ? error.message : "加载客服列表失败")
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setLoadingAgents(false)
|
|
||||||
})
|
|
||||||
}, [open])
|
|
||||||
|
|
||||||
async function onFormSubmit(values: FormValues) {
|
|
||||||
if (!ticketId) {
|
|
||||||
toast.error("工单不存在")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await addTicketCollaborator({ ticketId, userId: Number(values.userId) })
|
|
||||||
toast.success("协作人已添加")
|
|
||||||
onOpenChange(false)
|
|
||||||
await onSuccess?.()
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error instanceof Error ? error.message : "添加协作人失败")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent className="max-w-lg gap-0 p-0 sm:max-w-lg">
|
|
||||||
<DialogHeader className="px-6 pt-6">
|
|
||||||
<DialogTitle>新增协作人</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
|
||||||
<div className="space-y-4 p-6">
|
|
||||||
<Field data-invalid={!!errors.userId}>
|
|
||||||
<FieldLabel>协作人</FieldLabel>
|
|
||||||
<FieldContent>
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="userId"
|
|
||||||
render={({ field }) => (
|
|
||||||
<OptionCombobox
|
|
||||||
value={field.value}
|
|
||||||
options={userOptions}
|
|
||||||
placeholder={loadingAgents ? "加载中..." : "选择协作人"}
|
|
||||||
searchPlaceholder="搜索客服"
|
|
||||||
emptyText="暂无可选客服"
|
|
||||||
disabled={isSubmitting || loadingAgents}
|
|
||||||
onChange={field.onChange}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FieldError errors={[errors.userId]} />
|
|
||||||
</FieldContent>
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
<DialogFooter className="mx-0 mb-0 px-6 py-4">
|
|
||||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" disabled={isSubmitting}>
|
|
||||||
{isSubmitting ? "提交中..." : "确认添加"}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,529 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import {
|
|
||||||
Building2Icon,
|
|
||||||
Link2Icon,
|
|
||||||
MailIcon,
|
|
||||||
PencilIcon,
|
|
||||||
PhoneIcon,
|
|
||||||
UserRoundIcon,
|
|
||||||
} from "lucide-react"
|
|
||||||
import { useCallback, useEffect, useState } from "react"
|
|
||||||
import { toast } from "sonner"
|
|
||||||
|
|
||||||
import { type CustomerFormSavePayload } from "@/components/customer-form"
|
|
||||||
import { CustomerFormDialog } from "@/components/customer-form-dialog"
|
|
||||||
import { CustomerLinkOrCreateDialog } from "@/components/customer-link-or-create-dialog"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog"
|
|
||||||
import { Field, FieldContent, FieldLabel } from "@/components/ui/field"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
|
||||||
import { updateCompany, type AdminCompany } from "@/lib/api/company"
|
|
||||||
import {
|
|
||||||
fetchCustomer,
|
|
||||||
saveCustomerProfile,
|
|
||||||
type AdminCustomer,
|
|
||||||
} from "@/lib/api/customer"
|
|
||||||
import {
|
|
||||||
fetchCustomerContacts,
|
|
||||||
type AdminCustomerContact,
|
|
||||||
} from "@/lib/api/customer-contact"
|
|
||||||
import { Gender, GenderLabels, ContactType, ContactTypeLabels } from "@/lib/generated/enums"
|
|
||||||
import { cn, formatDateTime } from "@/lib/utils"
|
|
||||||
|
|
||||||
function contactTypeLabel(contactType: ContactType | string) {
|
|
||||||
return ContactTypeLabels[contactType as ContactType] ?? contactType
|
|
||||||
}
|
|
||||||
|
|
||||||
function ContactTypeIcon({ contactType }: { contactType: ContactType | string }) {
|
|
||||||
const cls = "size-3.5 shrink-0 text-muted-foreground"
|
|
||||||
switch (contactType) {
|
|
||||||
case ContactType.Mobile:
|
|
||||||
return <PhoneIcon className={cls} aria-hidden />
|
|
||||||
case ContactType.Email:
|
|
||||||
return <MailIcon className={cls} aria-hidden />
|
|
||||||
default:
|
|
||||||
return <Link2Icon className={cls} aria-hidden />
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function DetailRow({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
valueClassName,
|
|
||||||
}: {
|
|
||||||
label: string
|
|
||||||
value: string
|
|
||||||
valueClassName?: string
|
|
||||||
}) {
|
|
||||||
const empty = !value.trim()
|
|
||||||
return (
|
|
||||||
<div className="flex gap-2.5 text-sm leading-snug">
|
|
||||||
<span className="w-17 shrink-0 pt-px text-xs text-muted-foreground">{label}</span>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"min-w-0 flex-1 break-all text-foreground",
|
|
||||||
empty && "text-muted-foreground",
|
|
||||||
valueClassName,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{empty ? "—" : value}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SectionHeading({
|
|
||||||
children,
|
|
||||||
action,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode
|
|
||||||
action?: React.ReactNode
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<h3 className="text-xs font-medium text-muted-foreground">{children}</h3>
|
|
||||||
{action}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function UnlinkedCustomerEmpty({
|
|
||||||
ticketId,
|
|
||||||
onSuccess,
|
|
||||||
}: {
|
|
||||||
ticketId: number
|
|
||||||
onSuccess: () => void | Promise<void>
|
|
||||||
}) {
|
|
||||||
const [linkDialogOpen, setLinkDialogOpen] = useState(false)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4 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="mt-1 max-w-xs text-xs leading-relaxed text-muted-foreground">
|
|
||||||
当前工单未绑定客户主档。绑定后可在此查看客户资料、公司信息与联系方式。
|
|
||||||
</p>
|
|
||||||
<Button type="button" className="mt-4 gap-2" onClick={() => setLinkDialogOpen(true)}>
|
|
||||||
<Link2Icon className="size-4" />
|
|
||||||
关联或创建客户
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<CustomerLinkOrCreateDialog
|
|
||||||
open={linkDialogOpen}
|
|
||||||
onOpenChange={setLinkDialogOpen}
|
|
||||||
ticketId={ticketId}
|
|
||||||
onSuccess={onSuccess}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function MissingCustomerEmpty({
|
|
||||||
ticketId,
|
|
||||||
onSuccess,
|
|
||||||
}: {
|
|
||||||
ticketId: number
|
|
||||||
onSuccess: () => void | Promise<void>
|
|
||||||
}) {
|
|
||||||
const [linkDialogOpen, setLinkDialogOpen] = useState(false)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4 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="mt-1 max-w-xs text-xs leading-relaxed text-muted-foreground">
|
|
||||||
当前工单绑定的客户主档已不可用。你可以重新关联已有客户,或直接新建一个客户并绑定到当前工单。
|
|
||||||
</p>
|
|
||||||
<Button type="button" className="mt-4 gap-2" onClick={() => setLinkDialogOpen(true)}>
|
|
||||||
<Link2Icon className="size-4" />
|
|
||||||
重新关联或创建客户
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<CustomerLinkOrCreateDialog
|
|
||||||
open={linkDialogOpen}
|
|
||||||
onOpenChange={setLinkDialogOpen}
|
|
||||||
ticketId={ticketId}
|
|
||||||
onSuccess={onSuccess}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
type TicketCustomerPanelProps = {
|
|
||||||
ticketId: number
|
|
||||||
customerId?: number
|
|
||||||
onRefresh: () => void | Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
type TicketLinkedCustomerPanelProps = {
|
|
||||||
ticketId: number
|
|
||||||
customerId: number
|
|
||||||
onRefresh: () => void | Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TicketCustomerPanel({
|
|
||||||
ticketId,
|
|
||||||
customerId = 0,
|
|
||||||
onRefresh,
|
|
||||||
}: TicketCustomerPanelProps) {
|
|
||||||
if (customerId <= 0) {
|
|
||||||
return <UnlinkedCustomerEmpty ticketId={ticketId} onSuccess={onRefresh} />
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<TicketLinkedCustomerPanel
|
|
||||||
ticketId={ticketId}
|
|
||||||
customerId={customerId}
|
|
||||||
onRefresh={onRefresh}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function TicketLinkedCustomerPanel({
|
|
||||||
ticketId,
|
|
||||||
customerId,
|
|
||||||
onRefresh,
|
|
||||||
}: TicketLinkedCustomerPanelProps) {
|
|
||||||
const linkedCustomerId = customerId
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [customer, setCustomer] = useState<AdminCustomer | null>(null)
|
|
||||||
const [contacts, setContacts] = useState<AdminCustomerContact[]>([])
|
|
||||||
|
|
||||||
const [customerEditOpen, setCustomerEditOpen] = useState(false)
|
|
||||||
const [customerEditSaving, setCustomerEditSaving] = useState(false)
|
|
||||||
const [companyEditOpen, setCompanyEditOpen] = useState(false)
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
|
||||||
setLoading(true)
|
|
||||||
try {
|
|
||||||
const c = await fetchCustomer(linkedCustomerId)
|
|
||||||
setCustomer(c)
|
|
||||||
if (!c) {
|
|
||||||
setContacts([])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const list = await fetchCustomerContacts(linkedCustomerId)
|
|
||||||
setContacts(Array.isArray(list) ? list : [])
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error instanceof Error ? error.message : "加载客户信息失败")
|
|
||||||
setCustomer(null)
|
|
||||||
setContacts([])
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}, [linkedCustomerId])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void load()
|
|
||||||
}, [load])
|
|
||||||
|
|
||||||
if (loading && !customer) {
|
|
||||||
return <p className="pt-4 text-sm text-muted-foreground">加载客户信息…</p>
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!customer) {
|
|
||||||
return <MissingCustomerEmpty ticketId={ticketId} onSuccess={onRefresh} />
|
|
||||||
}
|
|
||||||
|
|
||||||
const displayName = customer.name.trim() || "未填写姓名"
|
|
||||||
const company = customer.company ?? null
|
|
||||||
const genderLabel =
|
|
||||||
customer.gender === Gender.Male || customer.gender === Gender.Female
|
|
||||||
? GenderLabels[customer.gender as Gender] ?? String(customer.gender)
|
|
||||||
: null
|
|
||||||
const isProfileEmpty =
|
|
||||||
!customer.name.trim() &&
|
|
||||||
!customer.primaryMobile.trim() &&
|
|
||||||
!customer.primaryEmail.trim() &&
|
|
||||||
customer.companyId === 0 &&
|
|
||||||
!customer.remark.trim()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4 pt-2">
|
|
||||||
{isProfileEmpty ? (
|
|
||||||
<div className="rounded-lg bg-amber-500/10 px-3 py-2.5 text-xs leading-relaxed text-amber-950 dark:text-amber-100">
|
|
||||||
客户主档已关联,但基础信息尚未填写。请点击「编辑」补全资料。
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<section className="space-y-2">
|
|
||||||
<SectionHeading
|
|
||||||
action={
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 shrink-0 gap-1 px-2 text-xs"
|
|
||||||
onClick={() => setCustomerEditOpen(true)}
|
|
||||||
>
|
|
||||||
<PencilIcon className="size-3.5" />
|
|
||||||
编辑
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
客户信息
|
|
||||||
</SectionHeading>
|
|
||||||
<div className="flex min-w-0 items-start gap-2 text-sm">
|
|
||||||
<UserRoundIcon className="mt-0.5 size-4 shrink-0 text-muted-foreground" aria-hidden />
|
|
||||||
<div className="min-w-0 flex-1 space-y-0.5">
|
|
||||||
<p className="line-clamp-2 leading-snug text-foreground">
|
|
||||||
<span className="font-medium">{displayName}</span>
|
|
||||||
{genderLabel ? (
|
|
||||||
<span className="font-normal text-muted-foreground"> · {genderLabel}</span>
|
|
||||||
) : null}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<DetailRow label="手机" value={customer.primaryMobile || ""} />
|
|
||||||
<DetailRow label="邮箱" value={customer.primaryEmail || ""} />
|
|
||||||
<DetailRow
|
|
||||||
label="最近活跃"
|
|
||||||
value={customer.lastActiveAt ? formatDateTime(customer.lastActiveAt) : ""}
|
|
||||||
/>
|
|
||||||
<DetailRow
|
|
||||||
label="备注"
|
|
||||||
value={customer.remark.trim() ? customer.remark : ""}
|
|
||||||
valueClassName="whitespace-pre-wrap"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="space-y-2">
|
|
||||||
<SectionHeading>联系方式</SectionHeading>
|
|
||||||
{contacts.length === 0 ? (
|
|
||||||
<p className="text-sm text-muted-foreground">暂无联系方式</p>
|
|
||||||
) : (
|
|
||||||
<ul className="space-y-3">
|
|
||||||
{contacts.map((row) => {
|
|
||||||
const tags: string[] = []
|
|
||||||
if (row.isPrimary) tags.push("主")
|
|
||||||
if (row.isVerified) tags.push("已验证")
|
|
||||||
return (
|
|
||||||
<li key={row.id} className="text-sm">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<ContactTypeIcon contactType={row.contactType} />
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<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)}
|
|
||||||
</span>
|
|
||||||
{tags.length > 0 ? (
|
|
||||||
<span className="ml-2 text-xs text-muted-foreground">
|
|
||||||
{tags.join(" · ")}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</p>
|
|
||||||
{row.remark ? (
|
|
||||||
<p className="mt-1 line-clamp-3 break-all text-xs leading-relaxed text-muted-foreground">
|
|
||||||
{row.remark}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="space-y-2 border-t pt-2">
|
|
||||||
<SectionHeading
|
|
||||||
action={
|
|
||||||
company ? (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 shrink-0 gap-1 px-2 text-xs"
|
|
||||||
onClick={() => setCompanyEditOpen(true)}
|
|
||||||
>
|
|
||||||
<PencilIcon className="size-3.5" />
|
|
||||||
编辑
|
|
||||||
</Button>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
>
|
|
||||||
公司信息
|
|
||||||
</SectionHeading>
|
|
||||||
{company ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex min-w-0 items-start gap-2 text-sm">
|
|
||||||
<Building2Icon className="mt-0.5 size-4 shrink-0 text-muted-foreground" aria-hidden />
|
|
||||||
<div className="min-w-0 flex-1 space-y-0.5">
|
|
||||||
<p className="line-clamp-2 font-medium leading-snug text-foreground">
|
|
||||||
{company.name}
|
|
||||||
</p>
|
|
||||||
{company.code ? (
|
|
||||||
<p className="font-mono text-xs text-muted-foreground">{company.code}</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2 pt-1">
|
|
||||||
<DetailRow label="创建" value={formatDateTime(company.createdAt)} />
|
|
||||||
<DetailRow label="更新" value={formatDateTime(company.updatedAt)} />
|
|
||||||
<DetailRow
|
|
||||||
label="备注"
|
|
||||||
value={company.remark.trim() ? company.remark : ""}
|
|
||||||
valueClassName="whitespace-pre-wrap"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
未关联公司。可通过编辑客户资料补充公司信息。
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<CustomerFormDialog
|
|
||||||
open={customerEditOpen}
|
|
||||||
onOpenChange={setCustomerEditOpen}
|
|
||||||
saving={customerEditSaving}
|
|
||||||
itemId={customer.id}
|
|
||||||
onSave={async (payload: CustomerFormSavePayload) => {
|
|
||||||
if (customerEditSaving) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setCustomerEditSaving(true)
|
|
||||||
try {
|
|
||||||
await saveCustomerProfile({ ...payload, id: customer.id })
|
|
||||||
toast.success("已保存")
|
|
||||||
await load()
|
|
||||||
await onRefresh()
|
|
||||||
setCustomerEditOpen(false)
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error instanceof Error ? error.message : "保存失败")
|
|
||||||
} finally {
|
|
||||||
setCustomerEditSaving(false)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{company ? (
|
|
||||||
<CompanyEditDialog
|
|
||||||
open={companyEditOpen}
|
|
||||||
onOpenChange={setCompanyEditOpen}
|
|
||||||
company={company}
|
|
||||||
onSaved={async () => {
|
|
||||||
await load()
|
|
||||||
await onRefresh()
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
type CompanyEditDialogProps = {
|
|
||||||
open: boolean
|
|
||||||
onOpenChange: (open: boolean) => void
|
|
||||||
company: AdminCompany
|
|
||||||
onSaved: () => void | Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
function CompanyEditDialog({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
company,
|
|
||||||
onSaved,
|
|
||||||
}: CompanyEditDialogProps) {
|
|
||||||
const [name, setName] = useState("")
|
|
||||||
const [code, setCode] = useState("")
|
|
||||||
const [remark, setRemark] = useState("")
|
|
||||||
const [saving, setSaving] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setName(company.name)
|
|
||||||
setCode(company.code)
|
|
||||||
setRemark(company.remark)
|
|
||||||
}, [open, company])
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
const trimmedName = name.trim()
|
|
||||||
if (!trimmedName) {
|
|
||||||
toast.error("公司名称不能为空")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setSaving(true)
|
|
||||||
try {
|
|
||||||
await updateCompany({
|
|
||||||
id: company.id,
|
|
||||||
name: trimmedName,
|
|
||||||
code: code.trim(),
|
|
||||||
remark: remark.trim(),
|
|
||||||
})
|
|
||||||
toast.success("已保存")
|
|
||||||
await onSaved()
|
|
||||||
onOpenChange(false)
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error instanceof Error ? error.message : "保存失败")
|
|
||||||
} finally {
|
|
||||||
setSaving(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent className="sm:max-w-md" showCloseButton>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>编辑公司</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="flex flex-col gap-4 py-1">
|
|
||||||
<Field orientation="vertical">
|
|
||||||
<FieldLabel htmlFor="ticket-company-name">公司名称</FieldLabel>
|
|
||||||
<FieldContent>
|
|
||||||
<Input
|
|
||||||
id="ticket-company-name"
|
|
||||||
value={name}
|
|
||||||
onChange={(event) => setName(event.target.value)}
|
|
||||||
/>
|
|
||||||
</FieldContent>
|
|
||||||
</Field>
|
|
||||||
<Field orientation="vertical">
|
|
||||||
<FieldLabel htmlFor="ticket-company-code">公司编码</FieldLabel>
|
|
||||||
<FieldContent>
|
|
||||||
<Input
|
|
||||||
id="ticket-company-code"
|
|
||||||
value={code}
|
|
||||||
onChange={(event) => setCode(event.target.value)}
|
|
||||||
/>
|
|
||||||
</FieldContent>
|
|
||||||
</Field>
|
|
||||||
<Field orientation="vertical">
|
|
||||||
<FieldLabel htmlFor="ticket-company-remark">备注</FieldLabel>
|
|
||||||
<FieldContent>
|
|
||||||
<Textarea
|
|
||||||
id="ticket-company-remark"
|
|
||||||
value={remark}
|
|
||||||
onChange={(event) => setRemark(event.target.value)}
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
</FieldContent>
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
<Button type="button" disabled={saving} onClick={() => void handleSubmit()}>
|
|
||||||
{saving ? "保存中…" : "保存"}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,426 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react"
|
||||||
|
import { MessageSquareTextIcon, RefreshCcwIcon, SendIcon, UserRoundIcon } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import {
|
||||||
|
changeTicketStatus,
|
||||||
|
createTicketProgress,
|
||||||
|
fetchTicketDetail,
|
||||||
|
type CreateTicketPayload,
|
||||||
|
type TicketDetail,
|
||||||
|
type TicketStatus,
|
||||||
|
type UpdateTicketPayload,
|
||||||
|
updateTicket,
|
||||||
|
} from "@/lib/api/ticket"
|
||||||
|
import { cn, formatDateTime } from "@/lib/utils"
|
||||||
|
import { EditDialog } from "./edit"
|
||||||
|
import { TicketAssignDialog } from "./ticket-assign-dialog"
|
||||||
|
import { TicketStatusBadge } from "./ticket-status-badge"
|
||||||
|
|
||||||
|
type TicketDetailDialogProps = {
|
||||||
|
ticketId: number | null
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
onChanged: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusOptions: Array<{ value: TicketStatus; label: string }> = [
|
||||||
|
{ value: "pending", label: "待处理" },
|
||||||
|
{ value: "in_progress", label: "处理中" },
|
||||||
|
{ value: "done", label: "已处理" },
|
||||||
|
]
|
||||||
|
|
||||||
|
function sourceLabel(source: string) {
|
||||||
|
switch (source) {
|
||||||
|
case "manual":
|
||||||
|
return "手动创建"
|
||||||
|
case "conversation":
|
||||||
|
return "会话生成"
|
||||||
|
default:
|
||||||
|
return source || "-"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function metadataValue(value?: string | number | null) {
|
||||||
|
if (value === undefined || value === null || value === "") {
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TicketDetailDialog({
|
||||||
|
ticketId,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onChanged,
|
||||||
|
}: TicketDetailDialogProps) {
|
||||||
|
const [detail, setDetail] = useState<TicketDetail | null>(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [statusSaving, setStatusSaving] = useState<TicketStatus | null>(null)
|
||||||
|
const [progressSaving, setProgressSaving] = useState(false)
|
||||||
|
const [progressContent, setProgressContent] = useState("")
|
||||||
|
const [assignOpen, setAssignOpen] = useState(false)
|
||||||
|
const [editOpen, setEditOpen] = useState(false)
|
||||||
|
const [editSaving, setEditSaving] = useState(false)
|
||||||
|
const loadSeqRef = useRef(0)
|
||||||
|
const dialogSeqRef = useRef(0)
|
||||||
|
const currentTicketIdRef = useRef<number | null>(null)
|
||||||
|
currentTicketIdRef.current = open ? ticketId : null
|
||||||
|
|
||||||
|
function isCurrentOperation(targetTicketId: number, dialogSeq: number) {
|
||||||
|
return currentTicketIdRef.current === targetTicketId && dialogSeqRef.current === dialogSeq
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadDetail = useCallback(async (targetTicketId = ticketId, dialogSeq = dialogSeqRef.current) => {
|
||||||
|
if (!targetTicketId || !isCurrentOperation(targetTicketId, dialogSeq)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const seq = loadSeqRef.current + 1
|
||||||
|
loadSeqRef.current = seq
|
||||||
|
if (!open || !targetTicketId) {
|
||||||
|
setDetail(null)
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await fetchTicketDetail(targetTicketId)
|
||||||
|
if (loadSeqRef.current !== seq || !isCurrentOperation(targetTicketId, dialogSeq)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setDetail(data)
|
||||||
|
} catch (error) {
|
||||||
|
if (loadSeqRef.current !== seq || !isCurrentOperation(targetTicketId, dialogSeq)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
toast.error(error instanceof Error ? error.message : "加载工单详情失败")
|
||||||
|
} finally {
|
||||||
|
if (loadSeqRef.current === seq) {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [open, ticketId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
dialogSeqRef.current += 1
|
||||||
|
setDetail(null)
|
||||||
|
setLoading(false)
|
||||||
|
setStatusSaving(null)
|
||||||
|
setProgressSaving(false)
|
||||||
|
setEditSaving(false)
|
||||||
|
setAssignOpen(false)
|
||||||
|
setEditOpen(false)
|
||||||
|
setProgressContent("")
|
||||||
|
}, [open, ticketId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadDetail(ticketId, dialogSeqRef.current)
|
||||||
|
}, [loadDetail, ticketId])
|
||||||
|
|
||||||
|
async function handleStatusChange(status: TicketStatus) {
|
||||||
|
if (!detail || detail.ticket.status === status) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const activeTicketId = detail.ticket.id
|
||||||
|
const activeDialogSeq = dialogSeqRef.current
|
||||||
|
setStatusSaving(status)
|
||||||
|
try {
|
||||||
|
await changeTicketStatus({ ticketId: activeTicketId, status })
|
||||||
|
if (!isCurrentOperation(activeTicketId, activeDialogSeq)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
toast.success("工单状态已更新")
|
||||||
|
await loadDetail(activeTicketId, activeDialogSeq)
|
||||||
|
if (!isCurrentOperation(activeTicketId, activeDialogSeq)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onChanged()
|
||||||
|
} catch (error) {
|
||||||
|
if (!isCurrentOperation(activeTicketId, activeDialogSeq)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
toast.error(error instanceof Error ? error.message : "更新工单状态失败")
|
||||||
|
} finally {
|
||||||
|
if (isCurrentOperation(activeTicketId, activeDialogSeq)) {
|
||||||
|
setStatusSaving(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateProgress() {
|
||||||
|
if (!detail) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const activeTicketId = detail.ticket.id
|
||||||
|
const activeDialogSeq = dialogSeqRef.current
|
||||||
|
const content = progressContent.trim()
|
||||||
|
if (!content) {
|
||||||
|
toast.error("请填写处理进展")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setProgressSaving(true)
|
||||||
|
try {
|
||||||
|
await createTicketProgress({
|
||||||
|
ticketId: activeTicketId,
|
||||||
|
content,
|
||||||
|
})
|
||||||
|
if (!isCurrentOperation(activeTicketId, activeDialogSeq)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
toast.success("处理进展已记录")
|
||||||
|
setProgressContent("")
|
||||||
|
await loadDetail(activeTicketId, activeDialogSeq)
|
||||||
|
if (!isCurrentOperation(activeTicketId, activeDialogSeq)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onChanged()
|
||||||
|
} catch (error) {
|
||||||
|
if (!isCurrentOperation(activeTicketId, activeDialogSeq)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
toast.error(error instanceof Error ? error.message : "记录处理进展失败")
|
||||||
|
} finally {
|
||||||
|
if (isCurrentOperation(activeTicketId, activeDialogSeq)) {
|
||||||
|
setProgressSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAssigned() {
|
||||||
|
const activeTicketId = ticket?.id
|
||||||
|
const activeDialogSeq = dialogSeqRef.current
|
||||||
|
if (!activeTicketId || !isCurrentOperation(activeTicketId, activeDialogSeq)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await loadDetail(activeTicketId, activeDialogSeq)
|
||||||
|
if (!isCurrentOperation(activeTicketId, activeDialogSeq)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpdateTicket(payload: CreateTicketPayload | UpdateTicketPayload) {
|
||||||
|
if (!("ticketId" in payload) || payload.ticketId <= 0) {
|
||||||
|
toast.error("请选择工单")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const activeDialogSeq = dialogSeqRef.current
|
||||||
|
setEditSaving(true)
|
||||||
|
try {
|
||||||
|
await updateTicket(payload)
|
||||||
|
if (!isCurrentOperation(payload.ticketId, activeDialogSeq)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
toast.success("工单已更新")
|
||||||
|
setEditOpen(false)
|
||||||
|
await loadDetail(payload.ticketId, activeDialogSeq)
|
||||||
|
if (!isCurrentOperation(payload.ticketId, activeDialogSeq)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onChanged()
|
||||||
|
} catch (error) {
|
||||||
|
if (!isCurrentOperation(payload.ticketId, activeDialogSeq)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
toast.error(error instanceof Error ? error.message : "更新工单失败")
|
||||||
|
} finally {
|
||||||
|
if (isCurrentOperation(payload.ticketId, activeDialogSeq)) {
|
||||||
|
setEditSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ticket = detail?.ticket
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-h-[88vh] gap-0 p-0 sm:max-w-3xl">
|
||||||
|
<DialogHeader className="border-b px-6 py-4">
|
||||||
|
<DialogTitle className="flex min-w-0 items-center gap-2 text-base">
|
||||||
|
<span className="truncate">{ticket?.title ?? "工单详情"}</span>
|
||||||
|
{ticket ? <TicketStatusBadge status={ticket.status} /> : null}
|
||||||
|
</DialogTitle>
|
||||||
|
{ticket ? (
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span className="font-mono">{ticket.ticketNo}</span>
|
||||||
|
<span>{sourceLabel(ticket.source)}</span>
|
||||||
|
<span>创建人:{metadataValue(ticket.createdByName || ticket.createdBy)}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<ScrollArea className="max-h-[calc(88vh-74px)]">
|
||||||
|
{loading && !ticket ? (
|
||||||
|
<div className="flex items-center justify-center gap-2 px-6 py-12 text-sm text-muted-foreground">
|
||||||
|
<RefreshCcwIcon className="size-4 animate-spin" />
|
||||||
|
加载中...
|
||||||
|
</div>
|
||||||
|
) : ticket ? (
|
||||||
|
<div className="space-y-5 p-6">
|
||||||
|
<section className="space-y-2">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground">描述</div>
|
||||||
|
<div className="whitespace-pre-wrap rounded-md border bg-muted/30 px-3 py-2 text-sm leading-6">
|
||||||
|
{ticket.description || "暂无描述"}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-4 md:grid-cols-[minmax(0,1fr)_220px]">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground">状态</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{statusOptions.map((option) => (
|
||||||
|
<Button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant={ticket.status === option.value ? "default" : "outline"}
|
||||||
|
disabled={!!statusSaving}
|
||||||
|
onClick={() => void handleStatusChange(option.value)}
|
||||||
|
>
|
||||||
|
{statusSaving === option.value ? "更新中..." : option.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground">负责人</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>
|
||||||
|
</div>
|
||||||
|
<Button type="button" size="sm" variant="outline" onClick={() => setAssignOpen(true)}>
|
||||||
|
指派
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground">标签</div>
|
||||||
|
<Button type="button" size="sm" variant="outline" onClick={() => setEditOpen(true)}>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{ticket.tags && ticket.tags.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{ticket.tags.map((tag) => (
|
||||||
|
<Badge key={tag.id} variant="outline">
|
||||||
|
{tag.name}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-muted-foreground">暂无标签</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-3 rounded-md border p-3 text-sm sm:grid-cols-2">
|
||||||
|
<MetadataItem label="客户" value={ticket.customer?.name || ticket.customerId} />
|
||||||
|
<MetadataItem label="联系方式" value={ticket.customer?.primaryMobile || ticket.customer?.primaryEmail} />
|
||||||
|
<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} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<section className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
<MessageSquareTextIcon className="size-4 text-muted-foreground" />
|
||||||
|
处理进展
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Textarea
|
||||||
|
rows={3}
|
||||||
|
placeholder="记录本次处理进展"
|
||||||
|
value={progressContent}
|
||||||
|
onChange={(event) => setProgressContent(event.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button type="button" size="sm" disabled={progressSaving} onClick={() => void handleCreateProgress()}>
|
||||||
|
<SendIcon className="size-3.5" />
|
||||||
|
{progressSaving ? "提交中..." : "添加进展"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{detail.progresses && detail.progresses.length > 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{detail.progresses.map((progress, index) => (
|
||||||
|
<div key={progress.id} className="flex gap-3">
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<span className="mt-1 size-2 rounded-full bg-primary" />
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"mt-1 w-px flex-1 bg-border",
|
||||||
|
index === detail.progresses!.length - 1 ? "opacity-0" : "opacity-100",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1 pb-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span>{progress.authorName || `用户#${progress.authorId}`}</span>
|
||||||
|
<span>{progress.createdAt ? formatDateTime(progress.createdAt) : "-"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 whitespace-pre-wrap text-sm leading-6">{progress.content}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-md border border-dashed px-3 py-6 text-center text-sm text-muted-foreground">
|
||||||
|
暂无处理进展
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="px-6 py-12 text-center text-sm text-muted-foreground">请选择工单</div>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<TicketAssignDialog
|
||||||
|
open={assignOpen}
|
||||||
|
ticketId={ticket?.id ?? null}
|
||||||
|
currentAssigneeId={ticket?.currentAssigneeId}
|
||||||
|
onOpenChange={setAssignOpen}
|
||||||
|
onSuccess={handleAssigned}
|
||||||
|
/>
|
||||||
|
<EditDialog
|
||||||
|
open={editOpen}
|
||||||
|
saving={editSaving}
|
||||||
|
itemId={ticket?.id ?? null}
|
||||||
|
onOpenChange={setEditOpen}
|
||||||
|
onSubmit={handleUpdateTicket}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetadataItem({ label, value }: { label: string; value?: string | number | null }) {
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-xs text-muted-foreground">{label}</div>
|
||||||
|
<div className="mt-1 truncate">{metadataValue(value)}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
|
||||||
import { getTicketPriorityMap } from "@/lib/ticket-priority"
|
|
||||||
|
|
||||||
const priorityClassNameMap: Record<number, string> = {
|
|
||||||
0: "bg-slate-500/10 text-slate-700 border-slate-500/20",
|
|
||||||
1: "bg-blue-500/10 text-blue-700 border-blue-500/20",
|
|
||||||
2: "bg-amber-500/10 text-amber-700 border-amber-500/20",
|
|
||||||
3: "bg-red-500/10 text-red-700 border-red-500/20",
|
|
||||||
4: "bg-fuchsia-500/10 text-fuchsia-700 border-fuchsia-500/20",
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ticketPriorityLabel(priority: number, priorityName?: string) {
|
|
||||||
return priorityName?.trim() || `P${priority}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TicketPriorityBadge({
|
|
||||||
priority,
|
|
||||||
priorityName,
|
|
||||||
}: {
|
|
||||||
priority: number
|
|
||||||
priorityName?: string
|
|
||||||
}) {
|
|
||||||
const [priorityMap, setPriorityMap] = useState<Record<number, string>>({})
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void (async () => {
|
|
||||||
setPriorityMap(await getTicketPriorityMap())
|
|
||||||
})()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className={priorityClassNameMap[priority] ?? priorityClassNameMap[0]}
|
|
||||||
>
|
|
||||||
{ticketPriorityLabel(priority, priorityName || priorityMap[priority])}
|
|
||||||
</Badge>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useEffect } from "react"
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
|
||||||
import { Resolver, useForm } from "react-hook-form"
|
|
||||||
import { toast } from "sonner"
|
|
||||||
import { z } from "zod/v4"
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog"
|
|
||||||
import { Field, FieldContent, FieldError, FieldLabel } from "@/components/ui/field"
|
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
|
||||||
import { closeTicket, reopenTicket } from "@/lib/api/ticket"
|
|
||||||
|
|
||||||
const schema = z.object({
|
|
||||||
reason: z.string().trim().min(1, "请输入原因"),
|
|
||||||
})
|
|
||||||
|
|
||||||
type FormValues = z.infer<typeof schema>
|
|
||||||
|
|
||||||
const resolver = zodResolver(schema as never) as Resolver<
|
|
||||||
z.input<typeof schema>,
|
|
||||||
undefined,
|
|
||||||
z.output<typeof schema>
|
|
||||||
>
|
|
||||||
|
|
||||||
type TicketReasonDialogProps = {
|
|
||||||
open: boolean
|
|
||||||
mode: "close" | "reopen"
|
|
||||||
ticketId: number | null
|
|
||||||
defaultReason?: string
|
|
||||||
onOpenChange: (open: boolean) => void
|
|
||||||
onSuccess?: () => Promise<void> | void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TicketReasonDialog({
|
|
||||||
open,
|
|
||||||
mode,
|
|
||||||
ticketId,
|
|
||||||
defaultReason,
|
|
||||||
onOpenChange,
|
|
||||||
onSuccess,
|
|
||||||
}: TicketReasonDialogProps) {
|
|
||||||
const form = useForm<
|
|
||||||
z.input<typeof schema>,
|
|
||||||
undefined,
|
|
||||||
z.output<typeof schema>
|
|
||||||
>({
|
|
||||||
resolver,
|
|
||||||
defaultValues: { reason: "" },
|
|
||||||
})
|
|
||||||
|
|
||||||
const {
|
|
||||||
register,
|
|
||||||
reset,
|
|
||||||
handleSubmit,
|
|
||||||
formState: { errors, isSubmitting },
|
|
||||||
} = form
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
reset({ reason: defaultReason || "" })
|
|
||||||
}, [defaultReason, reset, ticketId, open])
|
|
||||||
|
|
||||||
async function onFormSubmit(values: FormValues) {
|
|
||||||
if (!ticketId) {
|
|
||||||
toast.error("工单不存在")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
if (mode === "close") {
|
|
||||||
await closeTicket({ ticketId, closeReason: values.reason })
|
|
||||||
toast.success("工单已关闭")
|
|
||||||
} else {
|
|
||||||
await reopenTicket({ ticketId, reason: values.reason })
|
|
||||||
toast.success("工单已重开")
|
|
||||||
}
|
|
||||||
onOpenChange(false)
|
|
||||||
await onSuccess?.()
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error instanceof Error ? error.message : mode === "close" ? "关闭工单失败" : "重开工单失败")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent className="max-w-lg gap-0 p-0 sm:max-w-lg">
|
|
||||||
<DialogHeader className="px-6 pt-6">
|
|
||||||
<DialogTitle>{mode === "close" ? "关闭工单" : "重开工单"}</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
|
||||||
<div className="space-y-4 p-6">
|
|
||||||
<Field data-invalid={!!errors.reason}>
|
|
||||||
<FieldLabel>{mode === "close" ? "关闭原因" : "重开原因"}</FieldLabel>
|
|
||||||
<FieldContent>
|
|
||||||
<Textarea
|
|
||||||
rows={4}
|
|
||||||
placeholder={mode === "close" ? "请输入关闭原因" : "请输入重开原因"}
|
|
||||||
{...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)}>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" disabled={isSubmitting}>
|
|
||||||
{isSubmitting ? "提交中..." : mode === "close" ? "确认关闭" : "确认重开"}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,229 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
|
||||||
import { Resolver, useForm } from "react-hook-form"
|
|
||||||
import { toast } from "sonner"
|
|
||||||
import { z } from "zod/v4"
|
|
||||||
import { SearchIcon } from "lucide-react"
|
|
||||||
|
|
||||||
import { OptionCombobox } from "@/components/option-combobox"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog"
|
|
||||||
import { Field, FieldContent, FieldError, FieldLabel } from "@/components/ui/field"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { addTicketRelation, fetchTickets, type TicketItem } from "@/lib/api/ticket"
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const relationOptions = [
|
|
||||||
{ value: "duplicate", label: "重复工单" },
|
|
||||||
{ value: "related", label: "相关工单" },
|
|
||||||
{ value: "parent", label: "父工单" },
|
|
||||||
{ value: "child", label: "子工单" },
|
|
||||||
]
|
|
||||||
|
|
||||||
const schema = z.object({
|
|
||||||
relationType: z.string().trim().min(1, "请选择关联类型"),
|
|
||||||
relatedTicketId: z.number().int().positive("请选择关联工单"),
|
|
||||||
})
|
|
||||||
|
|
||||||
type FormValues = z.infer<typeof schema>
|
|
||||||
|
|
||||||
const resolver = zodResolver(schema as never) as Resolver<
|
|
||||||
z.input<typeof schema>,
|
|
||||||
undefined,
|
|
||||||
z.output<typeof schema>
|
|
||||||
>
|
|
||||||
|
|
||||||
type TicketRelationDialogProps = {
|
|
||||||
open: boolean
|
|
||||||
ticketId: number | null
|
|
||||||
onOpenChange: (open: boolean) => void
|
|
||||||
onSuccess?: () => Promise<void> | void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TicketRelationDialog({
|
|
||||||
open,
|
|
||||||
ticketId,
|
|
||||||
onOpenChange,
|
|
||||||
onSuccess,
|
|
||||||
}: TicketRelationDialogProps) {
|
|
||||||
const [keyword, setKeyword] = useState("")
|
|
||||||
const [searching, setSearching] = useState(false)
|
|
||||||
const [searchResults, setSearchResults] = useState<TicketItem[]>([])
|
|
||||||
|
|
||||||
const form = useForm<
|
|
||||||
z.input<typeof schema>,
|
|
||||||
undefined,
|
|
||||||
z.output<typeof schema>
|
|
||||||
>({
|
|
||||||
resolver,
|
|
||||||
defaultValues: {
|
|
||||||
relationType: "related",
|
|
||||||
relatedTicketId: 0,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const {
|
|
||||||
handleSubmit,
|
|
||||||
setValue,
|
|
||||||
reset,
|
|
||||||
watch,
|
|
||||||
formState: { errors, isSubmitting },
|
|
||||||
} = form
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
reset({ relationType: "related", relatedTicketId: 0 })
|
|
||||||
setKeyword("")
|
|
||||||
setSearchResults([])
|
|
||||||
}
|
|
||||||
}, [open, reset])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const trimmedKeyword = keyword.trim()
|
|
||||||
if (trimmedKeyword.length < 2) {
|
|
||||||
setSearchResults([])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const timer = window.setTimeout(async () => {
|
|
||||||
setSearching(true)
|
|
||||||
try {
|
|
||||||
const data = await fetchTickets({
|
|
||||||
keyword: trimmedKeyword,
|
|
||||||
page: 1,
|
|
||||||
limit: 8,
|
|
||||||
})
|
|
||||||
const results = Array.isArray(data.results) ? data.results : []
|
|
||||||
setSearchResults(results.filter((item) => item.id !== ticketId))
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error instanceof Error ? error.message : "搜索工单失败")
|
|
||||||
} finally {
|
|
||||||
setSearching(false)
|
|
||||||
}
|
|
||||||
}, 250)
|
|
||||||
return () => window.clearTimeout(timer)
|
|
||||||
}, [keyword, open, ticketId])
|
|
||||||
|
|
||||||
const selectedTicketId = watch("relatedTicketId")
|
|
||||||
const selectedTicket =
|
|
||||||
searchResults.find((item) => item.id === selectedTicketId) ?? null
|
|
||||||
|
|
||||||
async function onFormSubmit(values: FormValues) {
|
|
||||||
if (!ticketId) {
|
|
||||||
toast.error("工单不存在")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await addTicketRelation({
|
|
||||||
ticketId,
|
|
||||||
relationType: values.relationType,
|
|
||||||
relatedTicketId: values.relatedTicketId,
|
|
||||||
})
|
|
||||||
toast.success("关联工单已添加")
|
|
||||||
onOpenChange(false)
|
|
||||||
await onSuccess?.()
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error instanceof Error ? error.message : "添加关联工单失败")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent className="max-w-lg gap-0 p-0 sm:max-w-lg">
|
|
||||||
<DialogHeader className="px-6 pt-6">
|
|
||||||
<DialogTitle>新增关联工单</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
|
||||||
<div className="space-y-4 p-6">
|
|
||||||
<Field data-invalid={!!errors.relationType}>
|
|
||||||
<FieldLabel>关联类型</FieldLabel>
|
|
||||||
<FieldContent>
|
|
||||||
<OptionCombobox
|
|
||||||
value={watch("relationType")}
|
|
||||||
options={relationOptions}
|
|
||||||
placeholder="请选择关联类型"
|
|
||||||
onChange={(value) => setValue("relationType", value, { shouldValidate: true })}
|
|
||||||
/>
|
|
||||||
<FieldError errors={[errors.relationType]} />
|
|
||||||
</FieldContent>
|
|
||||||
</Field>
|
|
||||||
<Field data-invalid={!!errors.relatedTicketId}>
|
|
||||||
<FieldLabel>搜索并选择工单</FieldLabel>
|
|
||||||
<FieldContent>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="relative">
|
|
||||||
<SearchIcon className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
className="pl-9"
|
|
||||||
value={keyword}
|
|
||||||
placeholder="输入工单号或标题,至少 2 个字"
|
|
||||||
onChange={(event) => {
|
|
||||||
setKeyword(event.target.value)
|
|
||||||
setValue("relatedTicketId", 0, { shouldValidate: true })
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="max-h-64 overflow-y-auto rounded-lg border">
|
|
||||||
{searching ? (
|
|
||||||
<div className="p-3 text-sm text-muted-foreground">搜索中...</div>
|
|
||||||
) : searchResults.length > 0 ? (
|
|
||||||
searchResults.map((item) => {
|
|
||||||
const active = item.id === selectedTicketId
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={item.id}
|
|
||||||
type="button"
|
|
||||||
className={cn(
|
|
||||||
"flex w-full flex-col items-start gap-1 border-b px-3 py-3 text-left last:border-b-0",
|
|
||||||
active ? "bg-accent text-accent-foreground" : "hover:bg-muted/40",
|
|
||||||
)}
|
|
||||||
onClick={() => setValue("relatedTicketId", item.id, { shouldValidate: true })}
|
|
||||||
>
|
|
||||||
<div className="text-xs text-muted-foreground">{item.ticketNo}</div>
|
|
||||||
<div className="line-clamp-1 text-sm font-medium">{item.title}</div>
|
|
||||||
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
|
|
||||||
<span>状态:{item.status}</span>
|
|
||||||
<span>处理人:{item.currentAssigneeName || "未指派"}</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
) : (
|
|
||||||
<div className="p-3 text-sm text-muted-foreground">
|
|
||||||
{keyword.trim().length < 2 ? "输入至少 2 个字开始搜索" : "未找到匹配工单"}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{selectedTicket ? (
|
|
||||||
<div className="rounded-lg border bg-muted/20 p-3 text-sm">
|
|
||||||
已选中:{selectedTicket.ticketNo} / {selectedTicket.title}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<FieldError errors={[errors.relatedTicketId]} />
|
|
||||||
</FieldContent>
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
<DialogFooter className="mx-0 mb-0 px-6 py-4">
|
|
||||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" disabled={isSubmitting}>
|
|
||||||
{isSubmitting ? "提交中..." : "确认添加"}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,244 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react"
|
|
||||||
import { MessageSquarePlusIcon } from "lucide-react"
|
|
||||||
import { toast } from "sonner"
|
|
||||||
|
|
||||||
import { OptionCombobox } from "@/components/option-combobox"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog"
|
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
|
||||||
import { fetchAgentProfilesAll, type AdminAgentProfile } from "@/lib/api/admin"
|
|
||||||
import { addTicketInternalNote, replyTicket } from "@/lib/api/ticket"
|
|
||||||
|
|
||||||
type TicketReplyDialogProps = {
|
|
||||||
open: boolean
|
|
||||||
ticketId: number | null
|
|
||||||
onOpenChange: (open: boolean) => void
|
|
||||||
onSuccess?: () => Promise<void> | void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TicketReplyDialog({
|
|
||||||
open,
|
|
||||||
ticketId,
|
|
||||||
onOpenChange,
|
|
||||||
onSuccess,
|
|
||||||
}: TicketReplyDialogProps) {
|
|
||||||
const [replyMode, setReplyMode] = useState<"public" | "internal">("public")
|
|
||||||
const [replyContent, setReplyContent] = useState("")
|
|
||||||
const [mentionUserId, setMentionUserId] = useState("")
|
|
||||||
const [mentionedUsers, setMentionedUsers] = useState<AdminAgentProfile[]>([])
|
|
||||||
const [agents, setAgents] = useState<AdminAgentProfile[]>([])
|
|
||||||
const [loadingAgents, setLoadingAgents] = useState(false)
|
|
||||||
const [submitting, setSubmitting] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setReplyMode("public")
|
|
||||||
setReplyContent("")
|
|
||||||
setMentionUserId("")
|
|
||||||
setMentionedUsers([])
|
|
||||||
}, [open])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setLoadingAgents(true)
|
|
||||||
fetchAgentProfilesAll()
|
|
||||||
.then((data) => {
|
|
||||||
setAgents(Array.isArray(data) ? data : [])
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
toast.error(error instanceof Error ? error.message : "加载客服列表失败")
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setLoadingAgents(false)
|
|
||||||
})
|
|
||||||
}, [open])
|
|
||||||
|
|
||||||
const mentionOptions = useMemo(
|
|
||||||
() =>
|
|
||||||
agents.map((agent) => ({
|
|
||||||
value: String(agent.userId),
|
|
||||||
label:
|
|
||||||
agent.displayName ||
|
|
||||||
agent.nickname ||
|
|
||||||
agent.username ||
|
|
||||||
`客服 #${agent.userId}`,
|
|
||||||
})),
|
|
||||||
[agents],
|
|
||||||
)
|
|
||||||
|
|
||||||
function handleAddMentionUser() {
|
|
||||||
const userId = Number(mentionUserId)
|
|
||||||
if (!userId) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const user = agents.find((item) => item.userId === userId)
|
|
||||||
if (!user) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setMentionedUsers((current) => {
|
|
||||||
if (current.some((item) => item.userId === user.userId)) {
|
|
||||||
return current
|
|
||||||
}
|
|
||||||
return [...current, user]
|
|
||||||
})
|
|
||||||
setMentionUserId("")
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSubmit() {
|
|
||||||
if (!ticketId) {
|
|
||||||
toast.error("工单不存在")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!replyContent.trim()) {
|
|
||||||
toast.error(replyMode === "public" ? "回复内容不能为空" : "备注内容不能为空")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setSubmitting(true)
|
|
||||||
try {
|
|
||||||
if (replyMode === "public") {
|
|
||||||
await replyTicket({
|
|
||||||
ticketId,
|
|
||||||
contentType: "text",
|
|
||||||
content: replyContent.trim(),
|
|
||||||
})
|
|
||||||
toast.success("已回复客户")
|
|
||||||
} else {
|
|
||||||
const payload =
|
|
||||||
mentionedUsers.length > 0
|
|
||||||
? JSON.stringify({
|
|
||||||
mentionUserIds: mentionedUsers.map((item) => item.userId),
|
|
||||||
})
|
|
||||||
: undefined
|
|
||||||
await addTicketInternalNote({
|
|
||||||
ticketId,
|
|
||||||
contentType: "text",
|
|
||||||
content: replyContent.trim(),
|
|
||||||
payload,
|
|
||||||
})
|
|
||||||
toast.success("已添加内部备注")
|
|
||||||
}
|
|
||||||
|
|
||||||
onOpenChange(false)
|
|
||||||
await onSuccess?.()
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error instanceof Error ? error.message : "提交失败")
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent className="max-w-2xl gap-0 p-0 sm:max-w-2xl">
|
|
||||||
<DialogHeader className="px-6 pt-6">
|
|
||||||
<DialogTitle>回复与备注</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="space-y-4 p-6">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant={replyMode === "public" ? "default" : "outline"}
|
|
||||||
onClick={() => setReplyMode("public")}
|
|
||||||
disabled={submitting}
|
|
||||||
>
|
|
||||||
回复客户
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={replyMode === "internal" ? "default" : "outline"}
|
|
||||||
onClick={() => setReplyMode("internal")}
|
|
||||||
disabled={submitting}
|
|
||||||
>
|
|
||||||
内部备注
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Textarea
|
|
||||||
rows={8}
|
|
||||||
value={replyContent}
|
|
||||||
placeholder={replyMode === "public" ? "输入给客户的回复内容" : "输入内部备注"}
|
|
||||||
disabled={submitting}
|
|
||||||
onChange={(event) => setReplyContent(event.target.value)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{replyMode === "internal" ? (
|
|
||||||
<div className="space-y-3 rounded-lg border border-border/60 bg-muted/20 p-4">
|
|
||||||
<div className="text-sm font-medium">@提及协作人</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<div className="flex-1">
|
|
||||||
<OptionCombobox
|
|
||||||
value={mentionUserId}
|
|
||||||
options={mentionOptions}
|
|
||||||
placeholder={loadingAgents ? "加载中..." : "选择要提及的客服"}
|
|
||||||
searchPlaceholder="搜索客服"
|
|
||||||
emptyText="暂无可选客服"
|
|
||||||
disabled={submitting || loadingAgents}
|
|
||||||
onChange={setMentionUserId}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
disabled={submitting || loadingAgents}
|
|
||||||
onClick={handleAddMentionUser}
|
|
||||||
>
|
|
||||||
添加
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{mentionedUsers.length ? (
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{mentionedUsers.map((user) => (
|
|
||||||
<button
|
|
||||||
key={user.userId}
|
|
||||||
type="button"
|
|
||||||
className="rounded-full border px-3 py-1 text-xs"
|
|
||||||
onClick={() =>
|
|
||||||
setMentionedUsers((current) =>
|
|
||||||
current.filter((item) => item.userId !== user.userId),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
@
|
|
||||||
{user.displayName ||
|
|
||||||
user.nickname ||
|
|
||||||
user.username ||
|
|
||||||
`客服#${user.userId}`}{" "}
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-xs text-muted-foreground">未添加提及对象</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<DialogFooter className="mx-0 mb-0 px-6 py-4">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
disabled={submitting}
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
<Button type="button" disabled={submitting} onClick={() => void handleSubmit()}>
|
|
||||||
<MessageSquarePlusIcon className="size-4" />
|
|
||||||
{submitting ? "提交中..." : replyMode === "public" ? "发送回复" : "保存备注"}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
|
||||||
import type { TicketItem } from "@/lib/api/ticket"
|
|
||||||
|
|
||||||
function isClosedStatus(status: string) {
|
|
||||||
return status === "resolved" || status === "closed" || status === "cancelled"
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TicketSLABadge({ ticket }: { ticket: TicketItem }) {
|
|
||||||
if (isClosedStatus(ticket.status)) {
|
|
||||||
return (
|
|
||||||
<Badge variant="outline" className="border-border bg-muted text-muted-foreground">
|
|
||||||
已结束
|
|
||||||
</Badge>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (!ticket.resolveDeadlineAt) {
|
|
||||||
return (
|
|
||||||
<Badge variant="outline" className="border-border bg-muted text-muted-foreground">
|
|
||||||
未设置
|
|
||||||
</Badge>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const deadline = new Date(ticket.resolveDeadlineAt.replace(" ", "T"))
|
|
||||||
if (Number.isNaN(deadline.getTime())) {
|
|
||||||
return (
|
|
||||||
<Badge variant="outline" className="border-border bg-muted text-muted-foreground">
|
|
||||||
未设置
|
|
||||||
</Badge>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const remainingMinutes = Math.floor((deadline.getTime() - Date.now()) / 60000)
|
|
||||||
if (remainingMinutes < 0) {
|
|
||||||
return (
|
|
||||||
<Badge variant="outline" className="border-red-500/20 bg-red-500/10 text-red-700">
|
|
||||||
已超时
|
|
||||||
</Badge>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (remainingMinutes <= 60) {
|
|
||||||
return (
|
|
||||||
<Badge variant="outline" className="border-red-500/20 bg-red-500/10 text-red-700">
|
|
||||||
1 小时内
|
|
||||||
</Badge>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (remainingMinutes <= 240) {
|
|
||||||
return (
|
|
||||||
<Badge variant="outline" className="border-amber-500/20 bg-amber-500/10 text-amber-700">
|
|
||||||
今日风险
|
|
||||||
</Badge>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Badge variant="outline" className="border-emerald-500/20 bg-emerald-500/10 text-emerald-700">
|
|
||||||
正常
|
|
||||||
</Badge>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,35 +1,24 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import type { TicketStatus } from "@/lib/api/ticket"
|
||||||
|
|
||||||
const statusLabelMap: Record<string, string> = {
|
const statusMap = {
|
||||||
new: "新建",
|
pending: { label: "待处理", className: "border-amber-200 bg-amber-50 text-amber-700" },
|
||||||
open: "处理中",
|
in_progress: { label: "处理中", className: "border-blue-200 bg-blue-50 text-blue-700" },
|
||||||
pending_customer: "待客户反馈",
|
done: { label: "已处理", className: "border-emerald-200 bg-emerald-50 text-emerald-700" },
|
||||||
pending_internal: "待内部处理",
|
} as const
|
||||||
resolved: "已解决",
|
|
||||||
closed: "已关闭",
|
|
||||||
cancelled: "已取消",
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusClassNameMap: Record<string, string> = {
|
|
||||||
new: "bg-sky-500/10 text-sky-700 border-sky-500/20",
|
|
||||||
open: "bg-emerald-500/10 text-emerald-700 border-emerald-500/20",
|
|
||||||
pending_customer: "bg-amber-500/10 text-amber-700 border-amber-500/20",
|
|
||||||
pending_internal: "bg-orange-500/10 text-orange-700 border-orange-500/20",
|
|
||||||
resolved: "bg-lime-500/10 text-lime-700 border-lime-500/20",
|
|
||||||
closed: "bg-muted text-muted-foreground border-border",
|
|
||||||
cancelled: "bg-rose-500/10 text-rose-700 border-rose-500/20",
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ticketStatusLabel(status: string) {
|
export function ticketStatusLabel(status: string) {
|
||||||
return statusLabelMap[status] ?? status
|
return statusMap[status as TicketStatus]?.label ?? status
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TicketStatusBadge({ status }: { status: string }) {
|
export function TicketStatusBadge({ status }: { status: string }) {
|
||||||
|
const option = statusMap[status as TicketStatus]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Badge variant="outline" className={statusClassNameMap[status] ?? statusClassNameMap.closed}>
|
<Badge variant="outline" className={option?.className ?? "border-border bg-muted text-muted-foreground"}>
|
||||||
{ticketStatusLabel(status)}
|
{option?.label ?? status}
|
||||||
</Badge>
|
</Badge>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,279 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import Link from "next/link"
|
|
||||||
import { useEffect, useState } from "react"
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
|
||||||
import { Settings2Icon } from "lucide-react"
|
|
||||||
import { Controller, Resolver, useForm } from "react-hook-form"
|
|
||||||
import { toast } from "sonner"
|
|
||||||
import { z } from "zod/v4"
|
|
||||||
|
|
||||||
import { OptionCombobox } from "@/components/option-combobox"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog"
|
|
||||||
import { Field, FieldContent, FieldError, FieldLabel } from "@/components/ui/field"
|
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
|
||||||
import {
|
|
||||||
fetchTicketResolutionCodesAll,
|
|
||||||
type TicketResolutionCode,
|
|
||||||
} from "@/lib/api/ticket-config"
|
|
||||||
import { batchChangeTicketStatus, changeTicketStatus } from "@/lib/api/ticket"
|
|
||||||
|
|
||||||
const schema = z.object({
|
|
||||||
status: z.string().trim().min(1, "请选择状态"),
|
|
||||||
pendingReason: z.string().trim(),
|
|
||||||
closeReason: z.string().trim(),
|
|
||||||
resolutionCode: z.string().trim(),
|
|
||||||
resolutionSummary: z.string().trim(),
|
|
||||||
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 TicketStatusDialogProps = {
|
|
||||||
open: boolean
|
|
||||||
ticketId: number | null
|
|
||||||
ticketIds?: number[]
|
|
||||||
currentStatus?: string
|
|
||||||
onOpenChange: (open: boolean) => void
|
|
||||||
onSuccess?: () => Promise<void> | void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TicketStatusDialog({
|
|
||||||
open,
|
|
||||||
ticketId,
|
|
||||||
ticketIds,
|
|
||||||
currentStatus,
|
|
||||||
onOpenChange,
|
|
||||||
onSuccess,
|
|
||||||
}: TicketStatusDialogProps) {
|
|
||||||
const form = useForm<
|
|
||||||
z.input<typeof schema>,
|
|
||||||
undefined,
|
|
||||||
z.output<typeof schema>
|
|
||||||
>({
|
|
||||||
resolver,
|
|
||||||
defaultValues: {
|
|
||||||
status: "",
|
|
||||||
pendingReason: "",
|
|
||||||
closeReason: "",
|
|
||||||
resolutionCode: "",
|
|
||||||
resolutionSummary: "",
|
|
||||||
reason: "",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const {
|
|
||||||
control,
|
|
||||||
watch,
|
|
||||||
register,
|
|
||||||
reset,
|
|
||||||
handleSubmit,
|
|
||||||
formState: { errors, isSubmitting },
|
|
||||||
} = form
|
|
||||||
const [resolutionCodes, setResolutionCodes] = useState<TicketResolutionCode[]>([])
|
|
||||||
|
|
||||||
const targetStatus = watch("status")
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
reset({
|
|
||||||
status: currentStatus || "",
|
|
||||||
pendingReason: "",
|
|
||||||
closeReason: "",
|
|
||||||
resolutionCode: "",
|
|
||||||
resolutionSummary: "",
|
|
||||||
reason: "",
|
|
||||||
})
|
|
||||||
}, [currentStatus, reset, ticketId])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
const data = await fetchTicketResolutionCodesAll()
|
|
||||||
setResolutionCodes(Array.isArray(data) ? data : [])
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error instanceof Error ? error.message : "加载解决码失败")
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
}, [open])
|
|
||||||
|
|
||||||
const resolutionCodeOptions = resolutionCodes.map((item) => ({
|
|
||||||
value: item.code,
|
|
||||||
label: item.name,
|
|
||||||
}))
|
|
||||||
|
|
||||||
async function onFormSubmit(values: FormValues) {
|
|
||||||
const validTicketIds = (ticketIds ?? []).filter((item) => item > 0)
|
|
||||||
if (!ticketId && validTicketIds.length === 0) {
|
|
||||||
toast.error("请选择工单")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
if (validTicketIds.length > 0) {
|
|
||||||
await batchChangeTicketStatus({
|
|
||||||
ticketIds: validTicketIds,
|
|
||||||
status: values.status,
|
|
||||||
pendingReason: values.pendingReason || undefined,
|
|
||||||
closeReason: values.status === "closed" ? values.closeReason || undefined : undefined,
|
|
||||||
resolutionCode: values.resolutionCode || undefined,
|
|
||||||
resolutionSummary: values.resolutionSummary || undefined,
|
|
||||||
reason: values.reason || undefined,
|
|
||||||
})
|
|
||||||
toast.success(`已批量更新 ${validTicketIds.length} 张工单`)
|
|
||||||
} else {
|
|
||||||
await changeTicketStatus({
|
|
||||||
ticketId: ticketId!,
|
|
||||||
status: values.status,
|
|
||||||
pendingReason: values.pendingReason || undefined,
|
|
||||||
closeReason: values.status === "closed" ? values.closeReason || undefined : undefined,
|
|
||||||
resolutionCode: values.resolutionCode || undefined,
|
|
||||||
resolutionSummary: values.resolutionSummary || undefined,
|
|
||||||
reason: values.reason || undefined,
|
|
||||||
})
|
|
||||||
toast.success("状态已更新")
|
|
||||||
}
|
|
||||||
onOpenChange(false)
|
|
||||||
await onSuccess?.()
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error instanceof Error ? error.message : "更新状态失败")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent className="max-w-lg gap-0 p-0 sm:max-w-lg">
|
|
||||||
<DialogHeader className="px-6 pt-6">
|
|
||||||
<DialogTitle>{ticketIds?.length ? `批量变更状态(${ticketIds.length})` : "变更工单状态"}</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
|
||||||
<div className="space-y-4 p-6">
|
|
||||||
<Field data-invalid={!!errors.status}>
|
|
||||||
<FieldLabel>目标状态</FieldLabel>
|
|
||||||
<FieldContent>
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="status"
|
|
||||||
render={({ field }) => (
|
|
||||||
<OptionCombobox
|
|
||||||
value={field.value}
|
|
||||||
onChange={field.onChange}
|
|
||||||
placeholder="请选择状态"
|
|
||||||
options={[
|
|
||||||
{ value: "new", label: "新建" },
|
|
||||||
{ value: "open", label: "处理中" },
|
|
||||||
{ value: "pending_customer", label: "待客户反馈" },
|
|
||||||
{ value: "pending_internal", label: "待内部处理" },
|
|
||||||
{ value: "resolved", label: "已解决" },
|
|
||||||
{ value: "closed", label: "已关闭" },
|
|
||||||
{ value: "cancelled", label: "已取消" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FieldError errors={[errors.status]} />
|
|
||||||
</FieldContent>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
{(targetStatus === "pending_customer" ||
|
|
||||||
targetStatus === "pending_internal") && (
|
|
||||||
<Field data-invalid={!!errors.pendingReason}>
|
|
||||||
<FieldLabel>挂起原因</FieldLabel>
|
|
||||||
<FieldContent>
|
|
||||||
<Textarea rows={3} placeholder="请输入待处理原因" {...register("pendingReason")} />
|
|
||||||
<FieldError errors={[errors.pendingReason]} />
|
|
||||||
</FieldContent>
|
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{targetStatus === "resolved" && (
|
|
||||||
<>
|
|
||||||
<Field>
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<FieldLabel>解决编码</FieldLabel>
|
|
||||||
</div>
|
|
||||||
<FieldContent>
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="resolutionCode"
|
|
||||||
render={({ field }) => (
|
|
||||||
<OptionCombobox
|
|
||||||
value={field.value}
|
|
||||||
onChange={field.onChange}
|
|
||||||
placeholder="请选择解决编码"
|
|
||||||
options={resolutionCodeOptions}
|
|
||||||
emptyText="暂无可选解决码"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{resolutionCodeOptions.length === 0 ? (
|
|
||||||
<div className="mt-2 rounded-lg border border-amber-200 bg-amber-50/70 p-3 text-xs text-amber-900">
|
|
||||||
当前没有可用解决码,解决结果无法标准化统计。
|
|
||||||
<Link
|
|
||||||
href="/dashboard/ticket-resolution-codes"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="ml-1 font-medium underline underline-offset-4"
|
|
||||||
>
|
|
||||||
前往配置解决码
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</FieldContent>
|
|
||||||
</Field>
|
|
||||||
<Field>
|
|
||||||
<FieldLabel>解决说明</FieldLabel>
|
|
||||||
<FieldContent>
|
|
||||||
<Textarea rows={3} placeholder="请输入解决说明" {...register("resolutionSummary")} />
|
|
||||||
</FieldContent>
|
|
||||||
</Field>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{targetStatus === "closed" && (
|
|
||||||
<Field>
|
|
||||||
<FieldLabel>关闭原因</FieldLabel>
|
|
||||||
<FieldContent>
|
|
||||||
<Textarea rows={3} placeholder="请输入关闭原因" {...register("closeReason")} />
|
|
||||||
</FieldContent>
|
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Field>
|
|
||||||
<FieldLabel>操作说明</FieldLabel>
|
|
||||||
<FieldContent>
|
|
||||||
<Textarea
|
|
||||||
rows={3}
|
|
||||||
placeholder={targetStatus === "closed" ? "可补充本次批量关闭说明" : "填写本次状态变更说明"}
|
|
||||||
{...register("reason")}
|
|
||||||
/>
|
|
||||||
</FieldContent>
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
<DialogFooter className="mx-0 mb-0 px-6 py-4">
|
|
||||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" disabled={isSubmitting}>
|
|
||||||
{isSubmitting ? "提交中..." : "确认变更"}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
+377
-1120
File diff suppressed because it is too large
Load Diff
@@ -9,15 +9,12 @@ import { Button } from "@/components/ui/button"
|
|||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { linkConversationToCustomer } from "@/lib/api/agent"
|
import { linkConversationToCustomer } from "@/lib/api/agent"
|
||||||
import { fetchCustomers, saveCustomerProfile, type AdminCustomer } from "@/lib/api/customer"
|
import { fetchCustomers, saveCustomerProfile, type AdminCustomer } from "@/lib/api/customer"
|
||||||
import { linkTicketToCustomer } from "@/lib/api/ticket"
|
|
||||||
|
|
||||||
export type CustomerLinkOrCreateDialogProps = {
|
export type CustomerLinkOrCreateDialogProps = {
|
||||||
open: boolean
|
open: boolean
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
/** 传入时会话侧:关联已有或新建后绑定该会话 */
|
/** 传入时会话侧:关联已有或新建后绑定该会话 */
|
||||||
conversationId?: number | null
|
conversationId?: number | null
|
||||||
/** 传入时工单侧:关联已有或新建后绑定该工单 */
|
|
||||||
ticketId?: number | null
|
|
||||||
/** 绑定成功或仅新建成功后的回调 */
|
/** 绑定成功或仅新建成功后的回调 */
|
||||||
onSuccess?: () => void | Promise<void>
|
onSuccess?: () => void | Promise<void>
|
||||||
}
|
}
|
||||||
@@ -28,7 +25,6 @@ export function CustomerLinkOrCreateDialog({
|
|||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
conversationId,
|
conversationId,
|
||||||
ticketId,
|
|
||||||
onSuccess,
|
onSuccess,
|
||||||
}: CustomerLinkOrCreateDialogProps) {
|
}: CustomerLinkOrCreateDialogProps) {
|
||||||
const [searchText, setSearchText] = useState("")
|
const [searchText, setSearchText] = useState("")
|
||||||
@@ -74,7 +70,7 @@ export function CustomerLinkOrCreateDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleLinkExisting = async (customer: AdminCustomer) => {
|
const handleLinkExisting = async (customer: AdminCustomer) => {
|
||||||
if (!conversationId && !ticketId) {
|
if (!conversationId) {
|
||||||
toast.success(`已选择客户:${customer.name || `#${customer.id}`}`)
|
toast.success(`已选择客户:${customer.name || `#${customer.id}`}`)
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
await onSuccess?.()
|
await onSuccess?.()
|
||||||
@@ -87,11 +83,6 @@ export function CustomerLinkOrCreateDialog({
|
|||||||
conversationId,
|
conversationId,
|
||||||
customerId: customer.id,
|
customerId: customer.id,
|
||||||
})
|
})
|
||||||
} else if (ticketId) {
|
|
||||||
await linkTicketToCustomer({
|
|
||||||
ticketId,
|
|
||||||
customerId: customer.id,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
toast.success("已关联客户")
|
toast.success("已关联客户")
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
@@ -113,12 +104,6 @@ export function CustomerLinkOrCreateDialog({
|
|||||||
customerId: created.id,
|
customerId: created.id,
|
||||||
})
|
})
|
||||||
toast.success("已创建客户并关联当前会话")
|
toast.success("已创建客户并关联当前会话")
|
||||||
} else if (ticketId) {
|
|
||||||
await linkTicketToCustomer({
|
|
||||||
ticketId,
|
|
||||||
customerId: created.id,
|
|
||||||
})
|
|
||||||
toast.success("已创建客户并关联当前工单")
|
|
||||||
} else {
|
} else {
|
||||||
toast.success("已创建客户")
|
toast.success("已创建客户")
|
||||||
}
|
}
|
||||||
@@ -136,15 +121,11 @@ export function CustomerLinkOrCreateDialog({
|
|||||||
先搜索已有客户;
|
先搜索已有客户;
|
||||||
{conversationId
|
{conversationId
|
||||||
? "选中即可关联当前会话。"
|
? "选中即可关联当前会话。"
|
||||||
: ticketId
|
: "未接入上下文时仅创建或定位客户。"}
|
||||||
? "选中即可关联当前工单。"
|
|
||||||
: "未接入上下文时仅创建或定位客户。"}
|
|
||||||
若无结果,可填写下方新客户
|
若无结果,可填写下方新客户
|
||||||
{conversationId
|
{conversationId
|
||||||
? ",保存后将自动关联会话。"
|
? ",保存后将自动关联会话。"
|
||||||
: ticketId
|
: "。"}
|
||||||
? ",保存后将自动关联工单。"
|
|
||||||
: "。"}
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -171,8 +152,6 @@ export function CustomerLinkOrCreateDialog({
|
|||||||
? "提交中…"
|
? "提交中…"
|
||||||
: conversationId
|
: conversationId
|
||||||
? "创建并关联会话"
|
? "创建并关联会话"
|
||||||
: ticketId
|
|
||||||
? "创建并关联工单"
|
|
||||||
: "创建客户"}
|
: "创建客户"}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -237,8 +216,6 @@ export function CustomerLinkOrCreateDialog({
|
|||||||
? "处理中…"
|
? "处理中…"
|
||||||
: conversationId
|
: conversationId
|
||||||
? "关联"
|
? "关联"
|
||||||
: ticketId
|
|
||||||
? "关联"
|
|
||||||
: "选用"}
|
: "选用"}
|
||||||
</Button>
|
</Button>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -1,139 +0,0 @@
|
|||||||
import { request } from "@/lib/api/client"
|
|
||||||
|
|
||||||
export type Paging = {
|
|
||||||
page: number
|
|
||||||
limit: number
|
|
||||||
total: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PageResult<T> = {
|
|
||||||
results: T[]
|
|
||||||
page: Paging
|
|
||||||
}
|
|
||||||
|
|
||||||
export type TicketResolutionCode = {
|
|
||||||
id: number
|
|
||||||
name: string
|
|
||||||
code: string
|
|
||||||
sortNo: number
|
|
||||||
status: number
|
|
||||||
remark: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type TicketPriorityConfig = {
|
|
||||||
id: number
|
|
||||||
name: string
|
|
||||||
sortNo: number
|
|
||||||
firstResponseMinutes: number
|
|
||||||
resolutionMinutes: number
|
|
||||||
status: number
|
|
||||||
remark: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CreateTicketResolutionCodePayload = {
|
|
||||||
name: string
|
|
||||||
code: string
|
|
||||||
sortNo: number
|
|
||||||
status: number
|
|
||||||
remark: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type UpdateTicketResolutionCodePayload = CreateTicketResolutionCodePayload & {
|
|
||||||
id: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CreateTicketPriorityConfigPayload = {
|
|
||||||
name: string
|
|
||||||
firstResponseMinutes: number
|
|
||||||
resolutionMinutes: number
|
|
||||||
status: number
|
|
||||||
remark: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type UpdateTicketPriorityConfigPayload = CreateTicketPriorityConfigPayload & {
|
|
||||||
id: number
|
|
||||||
}
|
|
||||||
|
|
||||||
function toQueryString(query?: Record<string, string | number | undefined>) {
|
|
||||||
if (!query) {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
const params = new URLSearchParams()
|
|
||||||
Object.entries(query).forEach(([key, value]) => {
|
|
||||||
if (value === undefined || value === "") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
params.set(key, String(value))
|
|
||||||
})
|
|
||||||
const output = params.toString()
|
|
||||||
return output ? `?${output}` : ""
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fetchTicketResolutionCodes(query?: Record<string, string | number | undefined>) {
|
|
||||||
return request<PageResult<TicketResolutionCode>>(
|
|
||||||
`/api/dashboard/ticket-resolution-code/list${toQueryString(query)}`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fetchTicketResolutionCodesAll() {
|
|
||||||
return request<TicketResolutionCode[]>("/api/dashboard/ticket-resolution-code/list_all")
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createTicketResolutionCode(payload: CreateTicketResolutionCodePayload) {
|
|
||||||
return request<TicketResolutionCode>("/api/dashboard/ticket-resolution-code/create", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateTicketResolutionCode(payload: UpdateTicketResolutionCodePayload) {
|
|
||||||
return request<void>("/api/dashboard/ticket-resolution-code/update", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deleteTicketResolutionCode(id: number) {
|
|
||||||
return request<void>("/api/dashboard/ticket-resolution-code/delete", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ id }),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fetchTicketPriorityConfigs(query?: Record<string, string | number | undefined>) {
|
|
||||||
return request<TicketPriorityConfig[]>(
|
|
||||||
`/api/dashboard/ticket-priority-config/list${toQueryString(query)}`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fetchTicketPriorityConfigsAll() {
|
|
||||||
return request<TicketPriorityConfig[]>("/api/dashboard/ticket-priority-config/list_all")
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createTicketPriorityConfig(payload: CreateTicketPriorityConfigPayload) {
|
|
||||||
return request<TicketPriorityConfig>("/api/dashboard/ticket-priority-config/create", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateTicketPriorityConfig(payload: UpdateTicketPriorityConfigPayload) {
|
|
||||||
return request<void>("/api/dashboard/ticket-priority-config/update", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateTicketPriorityConfigSort(ids: number[]) {
|
|
||||||
return request<void>("/api/dashboard/ticket-priority-config/update_sort", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(ids),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deleteTicketPriorityConfig(id: number) {
|
|
||||||
return request<void>("/api/dashboard/ticket-priority-config/delete", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ id }),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
+43
-290
@@ -12,6 +12,9 @@ export type PageResult<T> = {
|
|||||||
page: Paging
|
page: Paging
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TicketStatus = "pending" | "in_progress" | "done"
|
||||||
|
export type TicketSource = "manual" | "conversation"
|
||||||
|
|
||||||
export type TicketCustomer = {
|
export type TicketCustomer = {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
@@ -28,41 +31,12 @@ export type TicketCustomer = {
|
|||||||
primaryEmail?: string
|
primaryEmail?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TicketSLA = {
|
export type TicketProgress = {
|
||||||
slaType: string
|
|
||||||
targetMinutes: number
|
|
||||||
status: string
|
|
||||||
startedAt?: string
|
|
||||||
pausedAt?: string
|
|
||||||
stoppedAt?: string
|
|
||||||
breachedAt?: string
|
|
||||||
elapsedMin: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type TicketComment = {
|
|
||||||
id: number
|
id: number
|
||||||
ticketId: number
|
ticketId: number
|
||||||
commentType: string
|
content: string
|
||||||
authorType: string
|
|
||||||
authorId: number
|
authorId: number
|
||||||
authorName?: string
|
authorName?: string
|
||||||
contentType: string
|
|
||||||
content: string
|
|
||||||
payload?: string
|
|
||||||
createdAt?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type TicketEvent = {
|
|
||||||
id: number
|
|
||||||
ticketId: number
|
|
||||||
eventType: string
|
|
||||||
operatorType: string
|
|
||||||
operatorId: number
|
|
||||||
operatorName?: string
|
|
||||||
oldValue?: string
|
|
||||||
newValue?: string
|
|
||||||
content?: string
|
|
||||||
payload?: string
|
|
||||||
createdAt?: string
|
createdAt?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,100 +45,35 @@ export type TicketItem = {
|
|||||||
ticketNo: string
|
ticketNo: string
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
source: string
|
source: TicketSource
|
||||||
channel: string
|
channel: string
|
||||||
customerId: number
|
customerId: number
|
||||||
conversationId: number
|
conversationId: number
|
||||||
tags?: Tag[]
|
tags?: Tag[]
|
||||||
type: string
|
status: TicketStatus
|
||||||
priority: number
|
|
||||||
priorityName?: string
|
|
||||||
severity: number
|
|
||||||
status: string
|
|
||||||
currentTeamId: number
|
|
||||||
currentTeamName?: string
|
|
||||||
currentAssigneeId: number
|
currentAssigneeId: number
|
||||||
currentAssigneeName?: string
|
currentAssigneeName?: string
|
||||||
watchedByMe: boolean
|
createdBy: number
|
||||||
pendingReason?: string
|
createdByName?: string
|
||||||
closeReason?: string
|
handledAt?: string
|
||||||
resolutionCode?: string
|
|
||||||
resolutionCodeName?: string
|
|
||||||
resolutionSummary?: string
|
|
||||||
firstResponseAt?: string
|
|
||||||
resolvedAt?: string
|
|
||||||
closedAt?: string
|
|
||||||
dueAt?: string
|
|
||||||
nextReplyDeadlineAt?: string
|
|
||||||
resolveDeadlineAt?: string
|
|
||||||
reopenedCount: number
|
|
||||||
createdAt?: string
|
createdAt?: string
|
||||||
updatedAt?: string
|
updatedAt?: string
|
||||||
customer?: TicketCustomer
|
customer?: TicketCustomer
|
||||||
sla?: TicketSLA[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TicketDetail = {
|
export type TicketDetail = {
|
||||||
ticket: TicketItem
|
ticket: TicketItem
|
||||||
watchers?: Array<{
|
progresses?: TicketProgress[]
|
||||||
id: number
|
|
||||||
userId: number
|
|
||||||
userName?: string
|
|
||||||
}>
|
|
||||||
collaborators?: TicketCollaborator[]
|
|
||||||
comments?: TicketComment[]
|
|
||||||
events?: TicketEvent[]
|
|
||||||
relatedTickets?: TicketRelation[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export type TicketCollaborator = {
|
|
||||||
id: number
|
|
||||||
userId: number
|
|
||||||
userName?: string
|
|
||||||
teamName?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type TicketRelation = {
|
|
||||||
id: number
|
|
||||||
ticketId: number
|
|
||||||
relatedTicketId: number
|
|
||||||
relationType: string
|
|
||||||
relatedTicketNo?: string
|
|
||||||
relatedTicketTitle?: string
|
|
||||||
relatedTicketStatus?: string
|
|
||||||
currentTeamName?: string
|
|
||||||
currentAssigneeName?: string
|
|
||||||
updatedAt?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TicketSummary = {
|
export type TicketSummary = {
|
||||||
all: number
|
all: number
|
||||||
|
pending: number
|
||||||
|
inProgress: number
|
||||||
|
done: number
|
||||||
|
unassigned: number
|
||||||
mine: number
|
mine: number
|
||||||
watching: number
|
stale: number
|
||||||
collaboration: number
|
|
||||||
participating: number
|
|
||||||
mentioned: number
|
|
||||||
unassigned: number
|
|
||||||
pendingCustomer: number
|
|
||||||
pendingInternal: number
|
|
||||||
overdue: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type TicketRiskReason = {
|
|
||||||
code: string
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
count: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type TicketRiskOverview = {
|
|
||||||
overdue: number
|
|
||||||
highRisk: number
|
|
||||||
unassigned: number
|
|
||||||
pendingInternal: number
|
|
||||||
pendingCustomer: number
|
|
||||||
riskWindowMins: number
|
|
||||||
reasons?: TicketRiskReason[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TicketSavedView = {
|
export type TicketSavedView = {
|
||||||
@@ -178,47 +87,27 @@ export type TicketListQuery = {
|
|||||||
page?: number
|
page?: number
|
||||||
limit?: number
|
limit?: number
|
||||||
keyword?: string
|
keyword?: string
|
||||||
status?: string
|
status?: TicketStatus
|
||||||
priority?: number
|
|
||||||
severity?: number
|
|
||||||
tagId?: number
|
tagId?: number
|
||||||
currentTeamId?: number
|
|
||||||
currentAssigneeId?: number
|
currentAssigneeId?: number
|
||||||
customerId?: number
|
customerId?: number
|
||||||
conversationId?: number
|
conversationId?: number
|
||||||
source?: string
|
source?: TicketSource
|
||||||
watching?: number
|
channel?: string
|
||||||
collaboration?: number
|
mine?: number | boolean
|
||||||
collaborating?: number
|
unassigned?: number | boolean
|
||||||
mentioned?: number
|
staleHours?: number
|
||||||
mine?: number
|
|
||||||
unassigned?: number
|
|
||||||
overdue?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type TicketRiskListQuery = {
|
|
||||||
riskType: "overdue" | "high_risk" | "unassigned" | "pending_internal" | "pending_customer"
|
|
||||||
currentTeamId?: number
|
|
||||||
riskWindowMins?: number
|
|
||||||
page?: number
|
|
||||||
limit?: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CreateTicketPayload = {
|
export type CreateTicketPayload = {
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
source?: string
|
source?: TicketSource
|
||||||
channel?: string
|
channel?: string
|
||||||
customerId?: number
|
customerId?: number
|
||||||
conversationId?: number
|
conversationId?: number
|
||||||
tagIds?: number[]
|
tagIds?: number[]
|
||||||
type?: string
|
|
||||||
priority: number
|
|
||||||
severity: number
|
|
||||||
currentTeamId?: number
|
|
||||||
currentAssigneeId?: number
|
currentAssigneeId?: number
|
||||||
dueAt?: string
|
|
||||||
customFields?: Record<string, unknown>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CreateTicketFromConversationPayload = {
|
export type CreateTicketFromConversationPayload = {
|
||||||
@@ -226,12 +115,7 @@ export type CreateTicketFromConversationPayload = {
|
|||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
tagIds?: number[]
|
tagIds?: number[]
|
||||||
priority: number
|
|
||||||
severity: number
|
|
||||||
currentTeamId?: number
|
|
||||||
currentAssigneeId?: number
|
currentAssigneeId?: number
|
||||||
syncToConversation: boolean
|
|
||||||
customFields?: Record<string, unknown>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UpdateTicketPayload = {
|
export type UpdateTicketPayload = {
|
||||||
@@ -239,16 +123,10 @@ export type UpdateTicketPayload = {
|
|||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
tagIds?: number[]
|
tagIds?: number[]
|
||||||
type?: string
|
|
||||||
priority: number
|
|
||||||
severity: number
|
|
||||||
currentTeamId?: number
|
|
||||||
currentAssigneeId?: number
|
currentAssigneeId?: number
|
||||||
dueAt?: string
|
|
||||||
customFields?: Record<string, unknown>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toQueryString(query?: Record<string, string | number | undefined>) {
|
function toQueryString(query?: Record<string, string | number | boolean | undefined>) {
|
||||||
if (!query) {
|
if (!query) {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -268,47 +146,14 @@ export function fetchTickets(query?: TicketListQuery) {
|
|||||||
return request<PageResult<TicketItem>>(`/api/dashboard/ticket/list${toQueryString(query)}`)
|
return request<PageResult<TicketItem>>(`/api/dashboard/ticket/list${toQueryString(query)}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fetchTicketSummary(query?: { staleHours?: number }) {
|
||||||
|
return request<TicketSummary>(`/api/dashboard/ticket/summary${toQueryString(query)}`)
|
||||||
|
}
|
||||||
|
|
||||||
export function fetchTicketDetail(id: number) {
|
export function fetchTicketDetail(id: number) {
|
||||||
return request<TicketDetail>(`/api/dashboard/ticket/${id}`)
|
return request<TicketDetail>(`/api/dashboard/ticket/${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchTicketSummary() {
|
|
||||||
return request<TicketSummary>("/api/dashboard/ticket/summary")
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fetchTicketViews() {
|
|
||||||
return request<TicketSavedView[]>("/api/dashboard/ticket/view_list")
|
|
||||||
}
|
|
||||||
|
|
||||||
export function saveTicketView(payload: {
|
|
||||||
id?: number
|
|
||||||
name: string
|
|
||||||
filters?: Record<string, unknown>
|
|
||||||
}) {
|
|
||||||
return request<TicketSavedView>("/api/dashboard/ticket/save_view", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deleteTicketView(id: number) {
|
|
||||||
return request<void>("/api/dashboard/ticket/delete_view", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ id }),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fetchTicketRiskOverview(query?: {
|
|
||||||
currentTeamId?: number
|
|
||||||
riskWindowMins?: number
|
|
||||||
}) {
|
|
||||||
return request<TicketRiskOverview>(`/api/dashboard/ticket/risk_overview${toQueryString(query)}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fetchTicketRiskList(query: TicketRiskListQuery) {
|
|
||||||
return request<PageResult<TicketItem>>(`/api/dashboard/ticket/risk_list${toQueryString(query)}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createTicket(payload: CreateTicketPayload) {
|
export function createTicket(payload: CreateTicketPayload) {
|
||||||
return request<TicketItem>("/api/dashboard/ticket/create", {
|
return request<TicketItem>("/api/dashboard/ticket/create", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -330,20 +175,9 @@ export function updateTicket(payload: UpdateTicketPayload) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function linkTicketToCustomer(payload: {
|
|
||||||
ticketId: number
|
|
||||||
customerId: number
|
|
||||||
}) {
|
|
||||||
return request<void>("/api/dashboard/ticket/link_customer", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function assignTicket(payload: {
|
export function assignTicket(payload: {
|
||||||
ticketId: number
|
ticketId: number
|
||||||
toUserId: number
|
toUserId: number
|
||||||
toTeamId?: number
|
|
||||||
reason?: string
|
reason?: string
|
||||||
}) {
|
}) {
|
||||||
return request<void>("/api/dashboard/ticket/assign", {
|
return request<void>("/api/dashboard/ticket/assign", {
|
||||||
@@ -355,7 +189,6 @@ export function assignTicket(payload: {
|
|||||||
export function batchAssignTickets(payload: {
|
export function batchAssignTickets(payload: {
|
||||||
ticketIds: number[]
|
ticketIds: number[]
|
||||||
toUserId: number
|
toUserId: number
|
||||||
toTeamId?: number
|
|
||||||
reason?: string
|
reason?: string
|
||||||
}) {
|
}) {
|
||||||
return request<void>("/api/dashboard/ticket/batch_assign", {
|
return request<void>("/api/dashboard/ticket/batch_assign", {
|
||||||
@@ -366,12 +199,7 @@ export function batchAssignTickets(payload: {
|
|||||||
|
|
||||||
export function changeTicketStatus(payload: {
|
export function changeTicketStatus(payload: {
|
||||||
ticketId: number
|
ticketId: number
|
||||||
status: string
|
status: TicketStatus
|
||||||
pendingReason?: string
|
|
||||||
closeReason?: string
|
|
||||||
resolutionCode?: string
|
|
||||||
resolutionSummary?: string
|
|
||||||
reason?: string
|
|
||||||
}) {
|
}) {
|
||||||
return request<void>("/api/dashboard/ticket/change_status", {
|
return request<void>("/api/dashboard/ticket/change_status", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -379,109 +207,34 @@ export function changeTicketStatus(payload: {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function batchChangeTicketStatus(payload: {
|
export function createTicketProgress(payload: {
|
||||||
ticketIds: number[]
|
|
||||||
status: string
|
|
||||||
pendingReason?: string
|
|
||||||
closeReason?: string
|
|
||||||
resolutionCode?: string
|
|
||||||
resolutionSummary?: string
|
|
||||||
reason?: string
|
|
||||||
}) {
|
|
||||||
return request<void>("/api/dashboard/ticket/batch_change_status", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function replyTicket(payload: {
|
|
||||||
ticketId: number
|
ticketId: number
|
||||||
contentType?: string
|
|
||||||
content: string
|
content: string
|
||||||
payload?: string
|
|
||||||
}) {
|
}) {
|
||||||
return request<TicketComment>("/api/dashboard/ticket/reply", {
|
return request<TicketProgress>("/api/dashboard/ticket/progress/create", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addTicketInternalNote(payload: {
|
export function fetchTicketViews() {
|
||||||
ticketId: number
|
return request<TicketSavedView[]>("/api/dashboard/ticket/view_list")
|
||||||
contentType?: string
|
}
|
||||||
content: string
|
|
||||||
payload?: string
|
export function saveTicketView(payload: {
|
||||||
|
id?: number
|
||||||
|
name: string
|
||||||
|
filters: Record<string, unknown>
|
||||||
}) {
|
}) {
|
||||||
return request<TicketComment>("/api/dashboard/ticket/internal_note", {
|
return request<TicketSavedView>("/api/dashboard/ticket/save_view", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function closeTicket(payload: { ticketId: number; closeReason: string }) {
|
export function deleteTicketView(id: number) {
|
||||||
return request<void>("/api/dashboard/ticket/close", {
|
return request<void>("/api/dashboard/ticket/delete_view", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify({ id }),
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function reopenTicket(payload: { ticketId: number; reason: string }) {
|
|
||||||
return request<void>("/api/dashboard/ticket/reopen", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function watchTicket(ticketId: number) {
|
|
||||||
return request<void>("/api/dashboard/ticket/watch", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ ticketId }),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function unwatchTicket(ticketId: number) {
|
|
||||||
return request<void>("/api/dashboard/ticket/unwatch", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ ticketId }),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function batchWatchTickets(payload: { ticketIds: number[]; watched: boolean }) {
|
|
||||||
return request<void>("/api/dashboard/ticket/batch_watch", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function addTicketRelation(payload: {
|
|
||||||
ticketId: number
|
|
||||||
relatedTicketId?: number
|
|
||||||
relatedTicketNo?: string
|
|
||||||
relationType: string
|
|
||||||
}) {
|
|
||||||
return request<void>("/api/dashboard/ticket/add_relation", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deleteTicketRelation(payload: { ticketId: number; relationId: number }) {
|
|
||||||
return request<void>("/api/dashboard/ticket/delete_relation", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function addTicketCollaborator(payload: { ticketId: number; userId: number }) {
|
|
||||||
return request<void>("/api/dashboard/ticket/add_collaborator", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deleteTicketCollaborator(payload: { ticketId: number; collaboratorId: number }) {
|
|
||||||
return request<void>("/api/dashboard/ticket/delete_collaborator", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -353,34 +353,15 @@ export const ThirdProviderLabels: Record<ThirdProvider, string> = {
|
|||||||
[ThirdProvider.Dingtalk]: "钉钉",
|
[ThirdProvider.Dingtalk]: "钉钉",
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum TicketSeverity {
|
|
||||||
Minor = 1,
|
|
||||||
Major = 2,
|
|
||||||
Critical = 3,
|
|
||||||
}
|
|
||||||
export const TicketSeverityLabels: Record<TicketSeverity, string> = {
|
|
||||||
[TicketSeverity.Minor]: "轻微",
|
|
||||||
[TicketSeverity.Major]: "严重",
|
|
||||||
[TicketSeverity.Critical]: "致命",
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum TicketStatus {
|
export enum TicketStatus {
|
||||||
New = "new",
|
Pending = "pending",
|
||||||
Open = "open",
|
InProgress = "in_progress",
|
||||||
PendingCustomer = "pending_customer",
|
Done = "done",
|
||||||
PendingInternal = "pending_internal",
|
|
||||||
Resolved = "resolved",
|
|
||||||
Closed = "closed",
|
|
||||||
Cancelled = "cancelled",
|
|
||||||
}
|
}
|
||||||
export const TicketStatusLabels: Record<TicketStatus, string> = {
|
export const TicketStatusLabels: Record<TicketStatus, string> = {
|
||||||
[TicketStatus.New]: "新建",
|
[TicketStatus.Pending]: "待处理",
|
||||||
[TicketStatus.Open]: "处理中",
|
[TicketStatus.InProgress]: "处理中",
|
||||||
[TicketStatus.PendingCustomer]: "待客户反馈",
|
[TicketStatus.Done]: "已处理",
|
||||||
[TicketStatus.PendingInternal]: "待内部处理",
|
|
||||||
[TicketStatus.Resolved]: "已解决",
|
|
||||||
[TicketStatus.Closed]: "已关闭",
|
|
||||||
[TicketStatus.Cancelled]: "已取消",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum VectorDBType {
|
export enum VectorDBType {
|
||||||
|
|||||||
@@ -4,14 +4,12 @@ import {
|
|||||||
BrainCircuitIcon,
|
BrainCircuitIcon,
|
||||||
Building2Icon,
|
Building2Icon,
|
||||||
CalendarClockIcon,
|
CalendarClockIcon,
|
||||||
ChartColumnIncreasingIcon,
|
|
||||||
FileTextIcon,
|
FileTextIcon,
|
||||||
GlobeIcon,
|
GlobeIcon,
|
||||||
KeyRoundIcon,
|
KeyRoundIcon,
|
||||||
LayoutDashboardIcon,
|
LayoutDashboardIcon,
|
||||||
MessageSquareCodeIcon,
|
MessageSquareCodeIcon,
|
||||||
MessageSquareMoreIcon,
|
MessageSquareMoreIcon,
|
||||||
Settings2Icon,
|
|
||||||
ShieldCheckIcon,
|
ShieldCheckIcon,
|
||||||
TagsIcon,
|
TagsIcon,
|
||||||
UserCogIcon,
|
UserCogIcon,
|
||||||
@@ -119,12 +117,6 @@ export const dashboardNavSections: DashboardNavSectionConfig[] = [
|
|||||||
icon: <BotMessageSquareIcon />,
|
icon: <BotMessageSquareIcon />,
|
||||||
requiredPermission: "conversation.view",
|
requiredPermission: "conversation.view",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: "SLA风险",
|
|
||||||
url: "/dashboard/ticket-risk",
|
|
||||||
icon: <ChartColumnIncreasingIcon />,
|
|
||||||
requiredPermission: "ticket.view",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: "客户管理",
|
title: "客户管理",
|
||||||
url: "/dashboard/customers",
|
url: "/dashboard/customers",
|
||||||
@@ -154,18 +146,6 @@ export const dashboardNavSections: DashboardNavSectionConfig[] = [
|
|||||||
icon: <MessageSquareMoreIcon />,
|
icon: <MessageSquareMoreIcon />,
|
||||||
requiredPermission: "quickReply.view",
|
requiredPermission: "quickReply.view",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: "工单优先级",
|
|
||||||
url: "/dashboard/ticket-priorities",
|
|
||||||
icon: <Settings2Icon />,
|
|
||||||
requiredPermission: "ticketPriorityConfig.view",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "工单解决码",
|
|
||||||
url: "/dashboard/ticket-resolution-codes",
|
|
||||||
icon: <KeyRoundIcon />,
|
|
||||||
requiredPermission: "ticketResolutionCode.view",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: "客服档案",
|
title: "客服档案",
|
||||||
url: "/dashboard/agents",
|
url: "/dashboard/agents",
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
import { fetchTicketPriorityConfigsAll } from "@/lib/api/ticket-config"
|
|
||||||
|
|
||||||
export type TicketPriorityOption = {
|
|
||||||
value: string
|
|
||||||
label: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getTicketPriorityOptions() {
|
|
||||||
const list = await fetchTicketPriorityConfigsAll()
|
|
||||||
return (Array.isArray(list) ? list : []).map((item) => ({
|
|
||||||
value: String(item.id),
|
|
||||||
label: item.name,
|
|
||||||
})) satisfies TicketPriorityOption[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getTicketPriorityMap() {
|
|
||||||
const options = await getTicketPriorityOptions()
|
|
||||||
return Object.fromEntries(options.map((item) => [Number(item.value), item.label])) as Record<number, string>
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user