fix: 修复客服权限与收件箱配置问题
This commit is contained in:
@@ -2291,6 +2291,7 @@ func TestInboxHandler_List_WithDB_Cov26(t *testing.T) {
|
||||
svc := service.NewInboxService(repo, agentBotInboxRepo, agentBotRepo, campaignRepo, webhookSubRepo, nil, nil)
|
||||
h := NewInboxHandler(svc)
|
||||
c, w := ctxCov26("GET", "/api/v1/accounts/"+uitoaCov26(acc.ID)+"/inboxes", map[string]string{"account_id": uitoaCov26(acc.ID)})
|
||||
c.Set("role", "administrator")
|
||||
safeCallCov26(t, "InboxList_WithDB", func() { h.List(c) })
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
@@ -63,7 +63,20 @@ func (h *InboxHandler) List(c *gin.Context) {
|
||||
perPage := getPageSize(c)
|
||||
offset := (page - 1) * perPage
|
||||
|
||||
inboxes, _, svcErr := h.svc.ListByAccount(c.Request.Context(), accountID, offset, perPage)
|
||||
var (
|
||||
inboxes []model.Inbox
|
||||
svcErr error
|
||||
)
|
||||
if getRole(c) == "administrator" {
|
||||
inboxes, _, svcErr = h.svc.ListByAccount(c.Request.Context(), accountID, offset, perPage)
|
||||
} else {
|
||||
userID := getUserID(c)
|
||||
if userID == 0 {
|
||||
response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "current user is required")
|
||||
return
|
||||
}
|
||||
inboxes, _, svcErr = h.svc.ListByAccountAndUser(c.Request.Context(), accountID, userID, offset, perPage)
|
||||
}
|
||||
if svcErr != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to list inboxes"})
|
||||
return
|
||||
|
||||
@@ -42,6 +42,48 @@ func newNilInboxHandler() *InboxHandler {
|
||||
return NewInboxHandler(&service.InboxService{})
|
||||
}
|
||||
|
||||
func setupInboxListAccessRouter(t *testing.T) (*gin.Engine, *gorm.DB, *model.Account, *model.User) {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file:inbox_list_access?mode=memory&cache=shared"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&model.Account{}, &model.User{}, &model.Inbox{}, &model.InboxMember{}, &model.WorkingHour{}))
|
||||
account := &model.Account{Name: "Inbox List Access", Locale: "en", Active: true}
|
||||
require.NoError(t, db.Create(account).Error)
|
||||
user := &model.User{Name: "Scoped Agent", Email: "handler-scoped-agent@example.com"}
|
||||
require.NoError(t, db.Create(user).Error)
|
||||
|
||||
handler := NewInboxHandler(service.NewInboxService(repository.NewInboxRepo(db), nil, nil, nil, nil, nil, nil))
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("user_id", user.ID)
|
||||
c.Set("role", "agent")
|
||||
c.Next()
|
||||
})
|
||||
router.GET("/api/v1/accounts/:account_id/inboxes", handler.List)
|
||||
return router, db, account, user
|
||||
}
|
||||
|
||||
func TestInboxHandler_ListScopesAgentToAssignedInboxes(t *testing.T) {
|
||||
router, db, account, user := setupInboxListAccessRouter(t)
|
||||
assigned := &model.Inbox{AccountID: account.ID, Name: "Assigned", ChannelType: "web_widget", ChannelID: 1}
|
||||
hidden := &model.Inbox{AccountID: account.ID, Name: "Hidden", ChannelType: "api", ChannelID: 2}
|
||||
require.NoError(t, db.Create(assigned).Error)
|
||||
require.NoError(t, db.Create(hidden).Error)
|
||||
require.NoError(t, db.Create(&model.InboxMember{InboxID: assigned.ID, UserID: user.ID}).Error)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/accounts/1/inboxes", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
||||
var body struct {
|
||||
Payload []map[string]any `json:"payload"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
||||
require.Len(t, body.Payload, 1)
|
||||
assert.Equal(t, "Assigned", body.Payload[0]["name"])
|
||||
}
|
||||
|
||||
type fakeInboxHandlerWhatsAppService struct{}
|
||||
|
||||
func (f *fakeInboxHandlerWhatsAppService) FetchMessageTemplates(context.Context, *channelmodel.ChannelWhatsApp) ([]interface{}, error) {
|
||||
|
||||
@@ -50,6 +50,25 @@ func (r *InboxRepo) FindByAccount(ctx context.Context, accountID uint, offset, l
|
||||
return inboxes, total, err
|
||||
}
|
||||
|
||||
// FindByAccountAndUser retrieves inboxes assigned to a user within an account.
|
||||
func (r *InboxRepo) FindByAccountAndUser(ctx context.Context, accountID, userID uint, offset, limit int) ([]model.Inbox, int64, error) {
|
||||
var inboxes []model.Inbox
|
||||
var total int64
|
||||
|
||||
base := r.db.WithContext(ctx).Model(&model.Inbox{}).
|
||||
Joins("JOIN inbox_members ON inbox_members.inbox_id = inboxes.id").
|
||||
Where("inboxes.account_id = ? AND inbox_members.user_id = ?", accountID, userID)
|
||||
if err := base.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
err := r.withWorkingHours(base.Session(&gorm.Session{})).
|
||||
Select("inboxes.*").
|
||||
Offset(offset).Limit(limit).Order("inboxes.id DESC").
|
||||
Find(&inboxes).Error
|
||||
return inboxes, total, err
|
||||
}
|
||||
|
||||
// Create inserts a new inbox.
|
||||
func (r *InboxRepo) Create(ctx context.Context, inbox *model.Inbox) error {
|
||||
return r.db.WithContext(ctx).Create(inbox).Error
|
||||
|
||||
@@ -119,6 +119,43 @@ func TestInboxRepo_FindByAccount_Pagination(t *testing.T) {
|
||||
assert.Len(t, inboxes, 3)
|
||||
}
|
||||
|
||||
func TestInboxRepo_FindByAccountAndUser_OnlyAssignedInboxes(t *testing.T) {
|
||||
db := setupTestDB(t, &model.InboxMember{})
|
||||
repo := NewInboxRepo(db)
|
||||
|
||||
account := &model.Account{Name: "InboxMemberScopedOrg", Locale: "en", Active: true}
|
||||
require.NoError(t, db.Create(account).Error)
|
||||
user := &model.User{Name: "Scoped Agent", Email: "scoped-agent@example.com"}
|
||||
require.NoError(t, db.Create(user).Error)
|
||||
assigned := createTestInbox(t, db, account.ID, "AssignedInbox")
|
||||
_ = createTestInbox(t, db, account.ID, "HiddenInbox")
|
||||
require.NoError(t, db.Create(&model.InboxMember{InboxID: assigned.ID, UserID: user.ID}).Error)
|
||||
|
||||
inboxes, total, err := repo.FindByAccountAndUser(context.Background(), account.ID, user.ID, 0, 10)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), total)
|
||||
require.Len(t, inboxes, 1)
|
||||
assert.Equal(t, assigned.ID, inboxes[0].ID)
|
||||
}
|
||||
|
||||
func TestInboxRepo_FindByAccountAndUser_EmptyWhenUnassigned(t *testing.T) {
|
||||
db := setupTestDB(t, &model.InboxMember{})
|
||||
repo := NewInboxRepo(db)
|
||||
|
||||
account := &model.Account{Name: "UnassignedInboxOrg", Locale: "en", Active: true}
|
||||
require.NoError(t, db.Create(account).Error)
|
||||
user := &model.User{Name: "Unassigned Agent", Email: "unassigned-agent@example.com"}
|
||||
require.NoError(t, db.Create(user).Error)
|
||||
_ = createTestInbox(t, db, account.ID, "HiddenInbox")
|
||||
|
||||
inboxes, total, err := repo.FindByAccountAndUser(context.Background(), account.ID, user.ID, 0, 10)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Zero(t, total)
|
||||
assert.Empty(t, inboxes)
|
||||
}
|
||||
|
||||
func TestInboxRepo_Create(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
repo := NewInboxRepo(db)
|
||||
@@ -254,7 +291,7 @@ func TestInboxRepo_UpdateFields_BooleanFields(t *testing.T) {
|
||||
assert.False(t, inbox.GreetingEnabled)
|
||||
|
||||
err := repo.UpdateFields(context.Background(), inbox.ID, map[string]interface{}{
|
||||
"greeting_enabled": true,
|
||||
"greeting_enabled": true,
|
||||
"csat_survey_enabled": true,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
@@ -293,4 +330,4 @@ func TestInboxRepo_UpdateFields_EmptyMap(t *testing.T) {
|
||||
found, err := repo.FindByID(context.Background(), inbox.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "EmptyMapInbox", found.Name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,6 +104,11 @@ func (s *InboxService) ListByAccount(ctx context.Context, accountID uint, offset
|
||||
return s.repo.FindByAccount(ctx, accountID, offset, limit)
|
||||
}
|
||||
|
||||
// ListByAccountAndUser retrieves inboxes assigned to a user within an account.
|
||||
func (s *InboxService) ListByAccountAndUser(ctx context.Context, accountID, userID uint, offset, limit int) ([]model.Inbox, int64, error) {
|
||||
return s.repo.FindByAccountAndUser(ctx, accountID, userID, offset, limit)
|
||||
}
|
||||
|
||||
// GetByID retrieves a single inbox.
|
||||
func (s *InboxService) GetByID(ctx context.Context, id uint) (*model.Inbox, error) {
|
||||
return s.repo.FindByID(ctx, id)
|
||||
|
||||
@@ -120,6 +120,24 @@ func createInboxTestPrereqs(t *testing.T, db *gorm.DB, channelType string) (*mod
|
||||
|
||||
func ptrUint(v uint) *uint { return &v }
|
||||
|
||||
func TestInboxService_ListByAccountAndUser_OnlyAssignedInboxes(t *testing.T) {
|
||||
svc, db := setupInboxServiceTest(t)
|
||||
require.NoError(t, db.AutoMigrate(&model.User{}, &model.InboxMember{}))
|
||||
account, assigned := createInboxTestPrereqs(t, db, "web_widget")
|
||||
hidden := &model.Inbox{AccountID: account.ID, Name: "Hidden", ChannelType: "api", ChannelID: 2}
|
||||
require.NoError(t, db.Create(hidden).Error)
|
||||
user := &model.User{Name: "Scoped Agent", Email: "service-scoped-agent@example.com"}
|
||||
require.NoError(t, db.Create(user).Error)
|
||||
require.NoError(t, db.Create(&model.InboxMember{InboxID: assigned.ID, UserID: user.ID}).Error)
|
||||
|
||||
inboxes, total, err := svc.ListByAccountAndUser(context.Background(), account.ID, user.ID, 0, 25)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), total)
|
||||
require.Len(t, inboxes, 1)
|
||||
assert.Equal(t, assigned.ID, inboxes[0].ID)
|
||||
}
|
||||
|
||||
func createWhatsAppInboxTestPrereqs(t *testing.T, db *gorm.DB, provider string) (*model.Account, *model.Inbox, *channelmodel.ChannelWhatsApp) {
|
||||
t.Helper()
|
||||
account, inbox := createInboxTestPrereqs(t, db, "whatsapp")
|
||||
|
||||
@@ -13,6 +13,17 @@ describe('#routeIsAccessibleFor', () => {
|
||||
expect(routeIsAccessibleFor(route, ['agent'])).toEqual(false);
|
||||
expect(routeIsAccessibleFor(route, ['administrator'])).toEqual(true);
|
||||
});
|
||||
|
||||
it('allows agents to open the help center entry route', () => {
|
||||
const route = {
|
||||
name: 'portals_index',
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent', 'knowledge_base_manage'],
|
||||
},
|
||||
};
|
||||
|
||||
expect(routeIsAccessibleFor(route, ['agent'])).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#defaultRedirectPage', () => {
|
||||
|
||||
@@ -104,6 +104,7 @@
|
||||
"RESET_DESC": "Copy this new password now and share it with the agent securely. The old password no longer works, and this password cannot be shown again after closing.",
|
||||
"COPY": "Copy password",
|
||||
"COPY_SUCCESS": "Password copied to clipboard",
|
||||
"COPY_FAILED": "Unable to copy the password. Please select and copy it manually.",
|
||||
"DONE": "I have saved it"
|
||||
},
|
||||
"SEARCH_PLACEHOLDER": "Search agents...",
|
||||
|
||||
@@ -393,6 +393,12 @@
|
||||
"USERNAME": "Login username",
|
||||
"PASSWORD": "Login password",
|
||||
"DESIRED_PRESENCE": "Initial presence",
|
||||
"PRESENCE": {
|
||||
"ONLINE": "Online",
|
||||
"BUSY": "Busy",
|
||||
"AWAY": "Away",
|
||||
"OFFLINE": "Offline"
|
||||
},
|
||||
"WEBHOOK_URL": "Connector webhook URL",
|
||||
"SUBMIT_BUTTON": "Create Shangwutong channel",
|
||||
"API": {
|
||||
@@ -1180,6 +1186,45 @@
|
||||
"REFRESH": "Refresh status",
|
||||
"DESIRED_PRESENCE": "Desired presence",
|
||||
"DESIRED_PRESENCE_HELP": "Changes are applied online. Offline logs out only this Shangwutong account.",
|
||||
"PRESENCE": {
|
||||
"ONLINE": "Online",
|
||||
"BUSY": "Busy",
|
||||
"AWAY": "Away",
|
||||
"OFFLINE": "Offline"
|
||||
},
|
||||
"STATUS_FIELDS": {
|
||||
"CONNECTION_STATUS": "Connection status",
|
||||
"ACTUAL_PRESENCE": "Current presence",
|
||||
"CREDENTIAL_STATUS": "Credential status",
|
||||
"LAST_HEARTBEAT_AT": "Last heartbeat"
|
||||
},
|
||||
"STATUS_VALUES": {
|
||||
"UNKNOWN": "Unknown",
|
||||
"CONNECTION": {
|
||||
"PENDING": "Pending",
|
||||
"LOGGING_IN": "Logging in",
|
||||
"CONNECTED": "Connected",
|
||||
"DEGRADED": "Degraded",
|
||||
"RELOGIN_REQUIRED": "Relogin required",
|
||||
"VERIFICATION_REQUIRED": "Verification required",
|
||||
"AUTH_FAILED": "Authentication failed",
|
||||
"DISABLED": "Disabled",
|
||||
"OFFLINE": "Offline"
|
||||
},
|
||||
"PRESENCE": {
|
||||
"ONLINE": "Online",
|
||||
"BUSY": "Busy",
|
||||
"AWAY": "Away",
|
||||
"OFFLINE": "Offline"
|
||||
},
|
||||
"CREDENTIAL": {
|
||||
"PENDING": "Pending verification",
|
||||
"VERIFYING": "Verifying",
|
||||
"APPLIED": "Applied",
|
||||
"REJECTED": "Rejected",
|
||||
"VERIFICATION_REQUIRED": "Verification required"
|
||||
}
|
||||
},
|
||||
"PASSWORD": "Replace password",
|
||||
"PASSWORD_HELP": "Leave blank to keep the current password. A new password is verified online before replacing the working credential.",
|
||||
"WEBHOOK_URL": "Connector webhook URL",
|
||||
|
||||
@@ -104,6 +104,7 @@
|
||||
"RESET_DESC": "请立即复制并安全地将此新密码交给客服。旧密码已失效,关闭后将无法再次查看。",
|
||||
"COPY": "复制密码",
|
||||
"COPY_SUCCESS": "密码已复制到剪贴板",
|
||||
"COPY_FAILED": "无法复制密码,请手动选择并复制。",
|
||||
"DONE": "我已保存"
|
||||
},
|
||||
"SEARCH_PLACEHOLDER": "搜索客服代表...",
|
||||
|
||||
@@ -393,6 +393,12 @@
|
||||
"USERNAME": "登录账号",
|
||||
"PASSWORD": "登录密码",
|
||||
"DESIRED_PRESENCE": "初始在线状态",
|
||||
"PRESENCE": {
|
||||
"ONLINE": "在线",
|
||||
"BUSY": "忙碌",
|
||||
"AWAY": "离开",
|
||||
"OFFLINE": "离线"
|
||||
},
|
||||
"WEBHOOK_URL": "Connector Webhook 地址",
|
||||
"SUBMIT_BUTTON": "创建商务通渠道",
|
||||
"API": {
|
||||
@@ -838,50 +844,50 @@
|
||||
},
|
||||
"ASSIGNMENT": {
|
||||
"TITLE": "对话分配",
|
||||
"DESCRIPTION": "Automatically assign incoming conversations to available agents based on assignment policies",
|
||||
"ENABLE_AUTO_ASSIGNMENT": "Enable automatic conversation assignment",
|
||||
"DEFAULT_RULES_TITLE": "Default assignment rules",
|
||||
"DEFAULT_RULES_DESCRIPTION": "Using the default assignment behavior for all conversations",
|
||||
"DEFAULT_RULE_1": "Earliest created conversations first",
|
||||
"DEFAULT_RULE_2": "Round robin distribution",
|
||||
"CUSTOMIZE_WITH_POLICY": "Customize with assignment policy",
|
||||
"USING_POLICY": "Using custom assignment policy for this inbox",
|
||||
"CUSTOMIZE_POLICY": "Customize with assignment policy",
|
||||
"DELETE_POLICY": "Delete policy",
|
||||
"POLICY_LABEL": "Assignment policy",
|
||||
"ASSIGNMENT_ORDER_LABEL": "Assignment Order",
|
||||
"ASSIGNMENT_METHOD_LABEL": "Assignment Method",
|
||||
"DESCRIPTION": "根据分配策略将新对话自动分配给可用客服",
|
||||
"ENABLE_AUTO_ASSIGNMENT": "启用自动分配对话",
|
||||
"DEFAULT_RULES_TITLE": "默认分配规则",
|
||||
"DEFAULT_RULES_DESCRIPTION": "所有对话使用默认分配方式",
|
||||
"DEFAULT_RULE_1": "优先分配最早创建的对话",
|
||||
"DEFAULT_RULE_2": "轮询分配",
|
||||
"CUSTOMIZE_WITH_POLICY": "使用分配策略自定义",
|
||||
"USING_POLICY": "此收件箱正在使用自定义分配策略",
|
||||
"CUSTOMIZE_POLICY": "自定义分配策略",
|
||||
"DELETE_POLICY": "删除策略",
|
||||
"POLICY_LABEL": "分配策略",
|
||||
"ASSIGNMENT_ORDER_LABEL": "分配顺序",
|
||||
"ASSIGNMENT_METHOD_LABEL": "分配方式",
|
||||
"POLICY_STATUS": {
|
||||
"ACTIVE": "状态",
|
||||
"INACTIVE": "Inactive"
|
||||
"ACTIVE": "启用",
|
||||
"INACTIVE": "停用"
|
||||
},
|
||||
"PRIORITY": {
|
||||
"EARLIEST_CREATED": "Earliest created",
|
||||
"LONGEST_WAITING": "Longest waiting"
|
||||
"EARLIEST_CREATED": "最早创建",
|
||||
"LONGEST_WAITING": "等待时间最长"
|
||||
},
|
||||
"METHOD": {
|
||||
"ROUND_ROBIN": "Round robin",
|
||||
"BALANCED": "Balanced assignment"
|
||||
"ROUND_ROBIN": "轮询分配",
|
||||
"BALANCED": "均衡分配"
|
||||
},
|
||||
"UPGRADE_PROMPT": "Custom assignment policies are available on the Business plan",
|
||||
"UPGRADE_TO_BUSINESS": "Upgrade to Business",
|
||||
"DEFAULT_POLICY_LINKED": "Default policy linked",
|
||||
"DEFAULT_POLICY_DESCRIPTION": "Link a custom assignment policy to customize how conversations are assigned to agents in this inbox.",
|
||||
"LINK_EXISTING_POLICY": "Link existing policy",
|
||||
"CREATE_NEW_POLICY": "Create new policy",
|
||||
"NO_POLICIES": "No assignment policies found",
|
||||
"VIEW_ALL_POLICIES": "View all policies",
|
||||
"CURRENT_BEHAVIOR": "Currently using default assignment behavior:",
|
||||
"LINK_SUCCESS": "Assignment policy linked successfully",
|
||||
"LINK_ERROR": "Failed to link assignment policy"
|
||||
"UPGRADE_PROMPT": "自定义分配策略适用于商业版套餐",
|
||||
"UPGRADE_TO_BUSINESS": "升级到商业版",
|
||||
"DEFAULT_POLICY_LINKED": "已关联默认策略",
|
||||
"DEFAULT_POLICY_DESCRIPTION": "关联自定义分配策略,以配置此收件箱如何将对话分配给客服。",
|
||||
"LINK_EXISTING_POLICY": "关联现有策略",
|
||||
"CREATE_NEW_POLICY": "创建新策略",
|
||||
"NO_POLICIES": "未找到分配策略",
|
||||
"VIEW_ALL_POLICIES": "查看全部策略",
|
||||
"CURRENT_BEHAVIOR": "当前使用默认分配方式:",
|
||||
"LINK_SUCCESS": "分配策略关联成功",
|
||||
"LINK_ERROR": "分配策略关联失败"
|
||||
},
|
||||
"ASSIGNMENT_POLICY": {
|
||||
"DELETE_CONFIRM_TITLE": "Delete assignment policy?",
|
||||
"DELETE_CONFIRM_MESSAGE": "Are you sure you want to remove this assignment policy from this inbox? The inbox will revert to default assignment rules.",
|
||||
"DELETE_CONFIRM_TITLE": "删除分配策略?",
|
||||
"DELETE_CONFIRM_MESSAGE": "确定要从此收件箱移除该分配策略吗?移除后,收件箱将恢复使用默认分配规则。",
|
||||
"CANCEL": "取消",
|
||||
"CONFIRM_DELETE": "删除",
|
||||
"DELETE_SUCCESS": "Assignment policy removed successfully",
|
||||
"DELETE_ERROR": "Failed to remove assignment policy"
|
||||
"DELETE_SUCCESS": "分配策略已移除",
|
||||
"DELETE_ERROR": "移除分配策略失败"
|
||||
},
|
||||
"FACEBOOK_REAUTHORIZE": {
|
||||
"TITLE": "重新授权",
|
||||
@@ -1175,15 +1181,54 @@
|
||||
}
|
||||
},
|
||||
"SHANGWUTONG_SETTINGS": {
|
||||
"RUNTIME_STATUS": "Connector 运行状态",
|
||||
"RUNTIME_STATUS_HELP": "显示 Connector 最近回报的连接、在线与凭据状态。",
|
||||
"RUNTIME_STATUS": "连接器运行状态",
|
||||
"RUNTIME_STATUS_HELP": "显示连接器最近回报的连接、在线与凭据状态。",
|
||||
"REFRESH": "刷新状态",
|
||||
"DESIRED_PRESENCE": "期望在线状态",
|
||||
"DESIRED_PRESENCE_HELP": "状态在线切换;选择 offline 只会退出当前商务通账号。",
|
||||
"DESIRED_PRESENCE_HELP": "状态在线切换;选择离线只会退出当前商务通账号。",
|
||||
"PRESENCE": {
|
||||
"ONLINE": "在线",
|
||||
"BUSY": "忙碌",
|
||||
"AWAY": "离开",
|
||||
"OFFLINE": "离线"
|
||||
},
|
||||
"STATUS_FIELDS": {
|
||||
"CONNECTION_STATUS": "连接状态",
|
||||
"ACTUAL_PRESENCE": "当前在线状态",
|
||||
"CREDENTIAL_STATUS": "凭据状态",
|
||||
"LAST_HEARTBEAT_AT": "最后心跳时间"
|
||||
},
|
||||
"STATUS_VALUES": {
|
||||
"UNKNOWN": "未知",
|
||||
"CONNECTION": {
|
||||
"PENDING": "等待连接",
|
||||
"LOGGING_IN": "正在登录",
|
||||
"CONNECTED": "已连接",
|
||||
"DEGRADED": "连接异常",
|
||||
"RELOGIN_REQUIRED": "需要重新登录",
|
||||
"VERIFICATION_REQUIRED": "需要验证",
|
||||
"AUTH_FAILED": "认证失败",
|
||||
"DISABLED": "已禁用",
|
||||
"OFFLINE": "已离线"
|
||||
},
|
||||
"PRESENCE": {
|
||||
"ONLINE": "在线",
|
||||
"BUSY": "忙碌",
|
||||
"AWAY": "离开",
|
||||
"OFFLINE": "离线"
|
||||
},
|
||||
"CREDENTIAL": {
|
||||
"PENDING": "等待验证",
|
||||
"VERIFYING": "正在验证",
|
||||
"APPLIED": "已生效",
|
||||
"REJECTED": "验证失败",
|
||||
"VERIFICATION_REQUIRED": "需要验证"
|
||||
}
|
||||
},
|
||||
"PASSWORD": "修改密码",
|
||||
"PASSWORD_HELP": "留空表示保持原密码。新密码在线验证成功后才替换当前可用凭据。",
|
||||
"WEBHOOK_URL": "Connector Webhook 地址",
|
||||
"WEBHOOK_URL_HELP": "所有启用的商务通收件箱必须使用同一个 Connector 地址。"
|
||||
"WEBHOOK_URL": "连接器 Webhook 地址",
|
||||
"WEBHOOK_URL_HELP": "所有启用的商务通收件箱必须使用同一个连接器地址。"
|
||||
},
|
||||
"CHANNELS": {
|
||||
"MESSENGER": "Messenger",
|
||||
|
||||
@@ -322,7 +322,7 @@
|
||||
"CAPTAIN_INBOXES": "收件箱",
|
||||
"CAPTAIN_SETTINGS": "设置",
|
||||
"HOME": "首页",
|
||||
"AGENTS": "客服代理",
|
||||
"AGENTS": "客服",
|
||||
"AGENT_BOTS": "机器人",
|
||||
"AUDIT_LOGS": "审计日志",
|
||||
"INBOXES": "收件箱",
|
||||
|
||||
@@ -102,10 +102,7 @@ const portalRoutes = [
|
||||
{
|
||||
path: getPortalRoute(':navigationPath'),
|
||||
name: 'portals_index',
|
||||
meta: {
|
||||
featureFlag: FEATURE_FLAGS.HELP_CENTER,
|
||||
permissions: ['administrator', 'knowledge_base_manage'],
|
||||
},
|
||||
meta,
|
||||
component: PortalsIndex,
|
||||
},
|
||||
];
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import helpcenterRoutes from './helpcenter.routes';
|
||||
|
||||
const findRoute = name => {
|
||||
const [rootRoute] = helpcenterRoutes.routes;
|
||||
return rootRoute.children.find(route => route.name === name);
|
||||
};
|
||||
|
||||
describe('Help center routes', () => {
|
||||
it('allows agents to enter the help center router', () => {
|
||||
expect(findRoute('portals_index').meta.permissions).toContain('agent');
|
||||
});
|
||||
|
||||
it('keeps article routes accessible to agents', () => {
|
||||
expect(findRoute('portals_articles_index').meta.permissions).toContain(
|
||||
'agent'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps portal creation restricted to administrators or custom roles', () => {
|
||||
expect(findRoute('portals_new').meta.permissions).not.toContain('agent');
|
||||
});
|
||||
});
|
||||
+6
-2
@@ -29,8 +29,12 @@ const open = () => dialogRef.value?.open();
|
||||
const close = () => dialogRef.value?.close();
|
||||
|
||||
const copyPassword = async password => {
|
||||
await copyTextToClipboard(password);
|
||||
useAlert(t('AGENT_MGMT.PASSWORD_DIALOG.COPY_SUCCESS'));
|
||||
try {
|
||||
await copyTextToClipboard(password);
|
||||
useAlert(t('AGENT_MGMT.PASSWORD_DIALOG.COPY_SUCCESS'));
|
||||
} catch {
|
||||
useAlert(t('AGENT_MGMT.PASSWORD_DIALOG.COPY_FAILED'));
|
||||
}
|
||||
};
|
||||
|
||||
defineExpose({ open, close });
|
||||
|
||||
+12
-4
@@ -143,10 +143,18 @@ export default {
|
||||
<label>
|
||||
{{ $t('INBOX_MGMT.ADD.SHANGWUTONG_CHANNEL.DESIRED_PRESENCE') }}
|
||||
<select v-model="desiredPresence">
|
||||
<option value="online">online</option>
|
||||
<option value="busy">busy</option>
|
||||
<option value="away">away</option>
|
||||
<option value="offline">offline</option>
|
||||
<option value="online">
|
||||
{{ $t('INBOX_MGMT.ADD.SHANGWUTONG_CHANNEL.PRESENCE.ONLINE') }}
|
||||
</option>
|
||||
<option value="busy">
|
||||
{{ $t('INBOX_MGMT.ADD.SHANGWUTONG_CHANNEL.PRESENCE.BUSY') }}
|
||||
</option>
|
||||
<option value="away">
|
||||
{{ $t('INBOX_MGMT.ADD.SHANGWUTONG_CHANNEL.PRESENCE.AWAY') }}
|
||||
</option>
|
||||
<option value="offline">
|
||||
{{ $t('INBOX_MGMT.ADD.SHANGWUTONG_CHANNEL.PRESENCE.OFFLINE') }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
|
||||
+54
-17
@@ -32,6 +32,13 @@ export default {
|
||||
this.fetchHealth();
|
||||
},
|
||||
methods: {
|
||||
statusLabel(type, value, fallback) {
|
||||
const status = value || fallback;
|
||||
const key = `INBOX_MGMT.SHANGWUTONG_SETTINGS.STATUS_VALUES.${type}.${status.toUpperCase()}`;
|
||||
return this.$te(key)
|
||||
? this.$t(key)
|
||||
: this.$t('INBOX_MGMT.SHANGWUTONG_SETTINGS.STATUS_VALUES.UNKNOWN');
|
||||
},
|
||||
async fetchHealth() {
|
||||
this.isLoadingHealth = true;
|
||||
try {
|
||||
@@ -88,43 +95,65 @@ export default {
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div class="rounded-lg border border-n-weak p-3">
|
||||
<p class="text-label-small text-n-slate-10">
|
||||
connection_status
|
||||
{{
|
||||
$t(
|
||||
'INBOX_MGMT.SHANGWUTONG_SETTINGS.STATUS_FIELDS.CONNECTION_STATUS'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<p class="text-body-main text-n-slate-12">
|
||||
{{
|
||||
health?.connection_status ||
|
||||
inbox.connection_status ||
|
||||
'pending'
|
||||
statusLabel(
|
||||
'CONNECTION',
|
||||
health?.connection_status || inbox.connection_status,
|
||||
'pending'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-n-weak p-3">
|
||||
<p class="text-label-small text-n-slate-10">
|
||||
actual_presence
|
||||
{{
|
||||
$t(
|
||||
'INBOX_MGMT.SHANGWUTONG_SETTINGS.STATUS_FIELDS.ACTUAL_PRESENCE'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<p class="text-body-main text-n-slate-12">
|
||||
{{
|
||||
health?.actual_presence ||
|
||||
inbox.actual_presence ||
|
||||
'offline'
|
||||
statusLabel(
|
||||
'PRESENCE',
|
||||
health?.actual_presence || inbox.actual_presence,
|
||||
'offline'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-n-weak p-3">
|
||||
<p class="text-label-small text-n-slate-10">
|
||||
credential_status
|
||||
{{
|
||||
$t(
|
||||
'INBOX_MGMT.SHANGWUTONG_SETTINGS.STATUS_FIELDS.CREDENTIAL_STATUS'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<p class="text-body-main text-n-slate-12">
|
||||
{{
|
||||
health?.credential_status ||
|
||||
inbox.credential_status ||
|
||||
'pending'
|
||||
statusLabel(
|
||||
'CREDENTIAL',
|
||||
health?.credential_status || inbox.credential_status,
|
||||
'pending'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-n-weak p-3">
|
||||
<p class="text-label-small text-n-slate-10">
|
||||
last_heartbeat_at
|
||||
{{
|
||||
$t(
|
||||
'INBOX_MGMT.SHANGWUTONG_SETTINGS.STATUS_FIELDS.LAST_HEARTBEAT_AT'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<p class="text-body-main text-n-slate-12">
|
||||
{{
|
||||
@@ -156,10 +185,18 @@ export default {
|
||||
"
|
||||
>
|
||||
<select v-model="desiredPresence" class="w-full">
|
||||
<option value="online">online</option>
|
||||
<option value="busy">busy</option>
|
||||
<option value="away">away</option>
|
||||
<option value="offline">offline</option>
|
||||
<option value="online">
|
||||
{{ $t('INBOX_MGMT.SHANGWUTONG_SETTINGS.PRESENCE.ONLINE') }}
|
||||
</option>
|
||||
<option value="busy">
|
||||
{{ $t('INBOX_MGMT.SHANGWUTONG_SETTINGS.PRESENCE.BUSY') }}
|
||||
</option>
|
||||
<option value="away">
|
||||
{{ $t('INBOX_MGMT.SHANGWUTONG_SETTINGS.PRESENCE.AWAY') }}
|
||||
</option>
|
||||
<option value="offline">
|
||||
{{ $t('INBOX_MGMT.SHANGWUTONG_SETTINGS.PRESENCE.OFFLINE') }}
|
||||
</option>
|
||||
</select>
|
||||
</SettingsFieldSection>
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ export const copyTextToClipboard = async data => {
|
||||
document.body.appendChild(textarea);
|
||||
|
||||
try {
|
||||
textarea.focus({ preventScroll: true });
|
||||
textarea.select();
|
||||
textarea.setSelectionRange(0, textarea.value.length);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { copyTextToClipboard, handleOtpPaste } from '../clipboard';
|
||||
|
||||
const mockWriteText = vi.fn();
|
||||
const mockExecCommand = vi.fn();
|
||||
const originalCreateElement = document.createElement.bind(document);
|
||||
|
||||
const setClipboard = clipboard => {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
@@ -148,9 +149,16 @@ describe('copyTextToClipboard', () => {
|
||||
|
||||
it('falls back when clipboard API is not available', async () => {
|
||||
setClipboard(undefined);
|
||||
const focus = vi.fn();
|
||||
vi.spyOn(document, 'createElement').mockImplementation(tagName => {
|
||||
const element = originalCreateElement(tagName);
|
||||
if (tagName === 'textarea') element.focus = focus;
|
||||
return element;
|
||||
});
|
||||
|
||||
await copyTextToClipboard('test');
|
||||
|
||||
expect(focus).toHaveBeenCalledWith({ preventScroll: true });
|
||||
expect(mockExecCommand).toHaveBeenCalledWith('copy');
|
||||
expect(document.querySelector('textarea')).toBeNull();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user