feat(handoff): 转人工状态、待接管列表与接待时段
同会话内 AI 转人工,待接管列表按分组和在线状态展示,接管后保留上下文。
This commit is contained in:
1 parent
f4d8019829
commit
5da5f910da
6 files changed
+803
No files matched your search
@@ -0,0 +1,95 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.auth import has_any_permission
|
||||
from app.core.socketio import sio
|
||||
from app.repositories.user import UserRepository
|
||||
from app.services.chat_notifications import UNASSIGNED_CHAT_VIEWERS
|
||||
|
||||
# Per-organization room every inbox page joins (see handle_join_room), for
|
||||
# updates about any of the org's conversations — including AI-owned ones,
|
||||
# which have no handling agent to address.
|
||||
ORG_INBOX_ROOM_PREFIX = "org_inbox_"
|
||||
|
||||
|
||||
# chat_reply `type`s the inbox appends to a conversation
|
||||
USER_MESSAGE = 'user_message'
|
||||
BOT_MESSAGE = 'bot'
|
||||
|
||||
|
||||
def org_inbox_room(organization_id) -> str:
|
||||
return f"{ORG_INBOX_ROOM_PREFIX}{organization_id}"
|
||||
|
||||
|
||||
def is_org_inbox_room(room: str) -> bool:
|
||||
return room.startswith(ORG_INBOX_ROOM_PREFIX)
|
||||
|
||||
|
||||
def may_join_org_inbox(db: Session, socket_session: dict, room: str) -> bool:
|
||||
"""Whether a dashboard socket may join an org inbox room: its own
|
||||
organization's, and only for people who can see chats nobody owns."""
|
||||
if str(socket_session.get('organization_id')) != room.removeprefix(ORG_INBOX_ROOM_PREFIX):
|
||||
return False
|
||||
user_id = socket_session.get('user_id')
|
||||
user = UserRepository(db).get_user(user_id) if user_id else None
|
||||
return (user is not None and user.is_active
|
||||
and has_any_permission(user, UNASSIGNED_CHAT_VIEWERS))
|
||||
|
||||
|
||||
async def emit_to_session_agents(event: str, payload: dict, session_id,
|
||||
user_id: Optional[object] = None) -> None:
|
||||
"""Push a live update to the dashboard for one conversation.
|
||||
|
||||
The inbox listens on the handling agent's personal room (user_<id>); the
|
||||
session room reaches anyone who joined that conversation explicitly.
|
||||
"""
|
||||
if user_id is not None:
|
||||
await sio.emit(event, payload, room=f"user_{user_id}", namespace='/agent')
|
||||
await sio.emit(event, payload, room=str(session_id), namespace='/agent')
|
||||
|
||||
|
||||
async def emit_to_org_inbox(event: str, payload: dict, organization_id,
|
||||
user_id: Optional[object] = None) -> None:
|
||||
"""Push a live update to every inbox open on the organization, and to the
|
||||
handling agent's personal room (where a chat they own is listening)."""
|
||||
await sio.emit(event, payload, room=org_inbox_room(organization_id), namespace='/agent')
|
||||
if user_id is not None:
|
||||
await sio.emit(event, payload, room=f"user_{user_id}", namespace='/agent')
|
||||
|
||||
|
||||
async def relay_stored_message(session_record, stored, message_type: str) -> None:
|
||||
"""Push a stored message into the dashboard conversation live, the way
|
||||
the widget's chat_reply events do — a customer message, or one sent from
|
||||
outside the inbox (e.g. by an external app) into a chat a human holds."""
|
||||
session_id = str(session_record.session_id)
|
||||
payload = {
|
||||
'message': stored.message,
|
||||
'message_id': stored.id,
|
||||
'type': message_type,
|
||||
'transfer_to_human': False,
|
||||
'session_id': session_id,
|
||||
# The inbox appends the message live off this field; without it the
|
||||
# handler builds an invalid Date and the message only shows on refetch.
|
||||
'created_at': (stored.created_at or datetime.now(timezone.utc)).isoformat(),
|
||||
# Carries e.g. the button-reply marker, so tags show live too
|
||||
'attributes': stored.attributes or {},
|
||||
}
|
||||
await emit_to_session_agents('chat_reply', payload, session_id, session_record.user_id)
|
||||
@@ -0,0 +1,165 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.channels.constants import MessageSource
|
||||
from app.core.logger import get_logger
|
||||
from app.models.session_to_agent import SessionStatus
|
||||
from app.repositories.chat import ChatRepository
|
||||
from app.repositories.group import GroupRepository
|
||||
from app.repositories.session_to_agent import SessionToAgentRepository
|
||||
from app.repositories.user import UserRepository
|
||||
from app.services.chat_notifications import UNASSIGNED_CHAT_VIEWERS
|
||||
from app.services.message_delivery import deliver_to_customer
|
||||
from app.services.notifications import ChatNotificationEvent, notify_chat_event
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# The widget shows handover state in its own UI, so only external channels need
|
||||
# a message spelling out what just happened.
|
||||
WEB_CHANNEL = 'web'
|
||||
|
||||
# Sent when a chat is queued for the team but nobody has picked it up yet. The
|
||||
# widget composes an availability-aware line of its own; channels get this.
|
||||
QUEUED_FOR_HUMAN_NOTICE = (
|
||||
"I'm passing this to a member of our team. Someone will reply here shortly."
|
||||
)
|
||||
|
||||
# First reply on an agent whose AI is switched off. Deliberately says nothing
|
||||
# about transferring or passing anything on — there was no AI in the
|
||||
# conversation to hand it over. It exists so the visitor is not left staring at
|
||||
# an empty thread wondering whether the message arrived.
|
||||
HUMAN_ONLY_ACK = (
|
||||
"Thanks for your message. Someone from our team will reply here shortly."
|
||||
)
|
||||
|
||||
|
||||
async def notify_customer(
|
||||
db: Session,
|
||||
session,
|
||||
message: str,
|
||||
user_id: Optional[str] = None,
|
||||
channels_only: bool = True,
|
||||
) -> None:
|
||||
"""Record a message in the thread and push it to the customer.
|
||||
|
||||
Best-effort: a failed notice must never fail the action that triggered it.
|
||||
Skips the widget by default, which renders handover state in its own UI —
|
||||
pass channels_only=False for a message the visitor should actually read.
|
||||
"""
|
||||
if channels_only and getattr(session, 'channel', None) in (None, WEB_CHANNEL):
|
||||
return
|
||||
try:
|
||||
stored = ChatRepository(db).create_message({
|
||||
'message': message,
|
||||
'message_type': 'agent' if user_id else 'bot',
|
||||
'session_id': str(session.session_id),
|
||||
'organization_id': str(session.organization_id),
|
||||
'agent_id': str(session.agent_id) if session.agent_id else None,
|
||||
'customer_id': str(session.customer_id) if session.customer_id else None,
|
||||
'user_id': user_id,
|
||||
'attributes': {'channel': session.channel, 'handover_notice': True},
|
||||
})
|
||||
result = await deliver_to_customer(db, session, {
|
||||
'message': message,
|
||||
'type': 'chat_response',
|
||||
}, message_id=stored.id, source=MessageSource.SYSTEM)
|
||||
if not result.ok:
|
||||
logger.warning(
|
||||
f"Handover notice not delivered on {session.channel}: {result.reason}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed sending handover notice: {str(e)}")
|
||||
|
||||
|
||||
def _transfer_recipients(db: Session, session, group_id) -> list:
|
||||
"""Who to tell that a chat is waiting.
|
||||
|
||||
A group queue notifies that group; without one the chat belongs to nobody,
|
||||
so it goes to whoever could actually open it from the inbox.
|
||||
"""
|
||||
if group_id:
|
||||
group = GroupRepository(db).get_group_with_users(str(group_id))
|
||||
users = group.users if group else []
|
||||
else:
|
||||
users = UserRepository(db).get_users_with_any_permission(
|
||||
session.organization_id, UNASSIGNED_CHAT_VIEWERS
|
||||
)
|
||||
return [user.id for user in users]
|
||||
|
||||
|
||||
async def route_session_to_human(
|
||||
db: Session,
|
||||
session,
|
||||
*,
|
||||
reason: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
group_id: Optional[str | UUID] = None,
|
||||
notify: bool = True,
|
||||
) -> bool:
|
||||
"""Take the AI out of a conversation and queue it for the team.
|
||||
|
||||
`TRANSFERRED` with no assignee is the state every AI path already checks:
|
||||
the widget stops calling the model and answers with an availability line,
|
||||
and channels relay straight to the dashboard. So this needs no new status —
|
||||
it puts the session into the one the guards already understand.
|
||||
|
||||
Returns False without touching anything when there is nothing to route: a
|
||||
human already holds it, it is closed, or it is queued already. That makes
|
||||
the call idempotent, so a double click cannot double-notify the team or
|
||||
send the customer the holding message twice.
|
||||
"""
|
||||
if session is None:
|
||||
return False
|
||||
if session.user_id is not None:
|
||||
return False
|
||||
if session.status in (SessionStatus.CLOSED, SessionStatus.TRANSFERRED):
|
||||
return False
|
||||
|
||||
updates = {
|
||||
'status': SessionStatus.TRANSFERRED,
|
||||
'transfer_reason': reason,
|
||||
'transfer_description': description,
|
||||
}
|
||||
# Only when a queue was named: passing None would clear a group the AI's
|
||||
# own transfer had already set.
|
||||
if group_id:
|
||||
updates['group_id'] = group_id
|
||||
|
||||
if not SessionToAgentRepository(db).update_session(session.session_id, updates):
|
||||
return False
|
||||
|
||||
if notify:
|
||||
recipients = _transfer_recipients(db, session, group_id or session.group_id)
|
||||
if recipients:
|
||||
await notify_chat_event(
|
||||
db=db,
|
||||
user_ids=recipients,
|
||||
event=ChatNotificationEvent.CHAT_TRANSFER,
|
||||
title="Chat waiting for a human",
|
||||
message=description or "A chat is waiting for someone to take it over.",
|
||||
metadata={
|
||||
"session_id": str(session.session_id),
|
||||
"transfer_reason": reason,
|
||||
"transfer_description": description,
|
||||
},
|
||||
)
|
||||
|
||||
await notify_customer(db, session, QUEUED_FOR_HUMAN_NOTICE)
|
||||
return True
|
||||
@@ -0,0 +1,56 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models.permission import Permission
|
||||
from app.services.agent_rooms import may_join_org_inbox
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def viewer(db, test_user, test_role):
|
||||
permission = Permission(name="view_unassigned_chats")
|
||||
db.add(permission)
|
||||
test_role.permissions = [*test_role.permissions, permission]
|
||||
db.commit()
|
||||
return test_user
|
||||
|
||||
|
||||
def _socket(user, org=None):
|
||||
return {"user_id": str(user.id), "organization_id": str(org or user.organization_id)}
|
||||
|
||||
|
||||
def test_a_viewer_of_unowned_chats_may_join_their_orgs_room(db, viewer):
|
||||
assert may_join_org_inbox(db, _socket(viewer), f"org_inbox_{viewer.organization_id}")
|
||||
|
||||
|
||||
def test_not_another_orgs_room(db, viewer):
|
||||
assert not may_join_org_inbox(db, _socket(viewer), f"org_inbox_{uuid4()}")
|
||||
|
||||
|
||||
def test_not_without_the_permission(db, test_user):
|
||||
assert not may_join_org_inbox(db, _socket(test_user), f"org_inbox_{test_user.organization_id}")
|
||||
|
||||
|
||||
def test_not_once_deactivated_or_deleted(db, viewer):
|
||||
viewer.is_active = False
|
||||
db.commit()
|
||||
assert not may_join_org_inbox(db, _socket(viewer), f"org_inbox_{viewer.organization_id}")
|
||||
ghost = SimpleNamespace(id=uuid4(), organization_id=viewer.organization_id)
|
||||
assert not may_join_org_inbox(db, _socket(ghost), f"org_inbox_{viewer.organization_id}")
|
||||
@@ -0,0 +1,113 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
Taking the AI out of a conversation: the state transition behind the per-chat
|
||||
"hand to my team" action.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models.session_to_agent import SessionToAgent, SessionStatus
|
||||
from app.models.chat_history import ChatHistory
|
||||
from app.services.human_routing import notify_customer, route_session_to_human
|
||||
from app.services.message_delivery import DeliveryResult
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def open_session(db, test_agent, test_customer, test_organization) -> SessionToAgent:
|
||||
session = SessionToAgent(
|
||||
session_id=uuid4(),
|
||||
agent_id=test_agent.id,
|
||||
customer_id=test_customer.id,
|
||||
organization_id=test_organization.id,
|
||||
status=SessionStatus.OPEN,
|
||||
assigned_at=datetime.now(timezone.utc),
|
||||
)
|
||||
db.add(session)
|
||||
db.commit()
|
||||
db.refresh(session)
|
||||
return session
|
||||
|
||||
|
||||
def _silenced():
|
||||
"""Notifications and channel delivery are covered by their own tests."""
|
||||
return (
|
||||
patch("app.services.human_routing.notify_chat_event", new=AsyncMock()),
|
||||
patch("app.services.human_routing.notify_customer", new=AsyncMock()),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_routing_queues_the_chat_without_an_assignee(db, open_session):
|
||||
"""TRANSFERRED with user_id null is the state every AI guard checks."""
|
||||
notify, customer = _silenced()
|
||||
with notify, customer:
|
||||
assert await route_session_to_human(
|
||||
db, open_session, reason="DIRECT_REQUEST", description="test") is True
|
||||
|
||||
db.refresh(open_session)
|
||||
assert open_session.status == SessionStatus.TRANSFERRED
|
||||
assert open_session.user_id is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_routing_refuses_a_claimed_or_closed_chat(db, open_session, test_user):
|
||||
"""Nothing to take away from the AI — it is not answering either one."""
|
||||
notify, customer = _silenced()
|
||||
|
||||
open_session.user_id = test_user.id
|
||||
db.commit()
|
||||
with notify, customer:
|
||||
assert await route_session_to_human(db, open_session, reason="x") is False
|
||||
|
||||
open_session.user_id = None
|
||||
open_session.status = SessionStatus.CLOSED
|
||||
db.commit()
|
||||
notify, customer = _silenced()
|
||||
with notify, customer:
|
||||
assert await route_session_to_human(db, open_session, reason="x") is False
|
||||
|
||||
db.refresh(open_session)
|
||||
assert open_session.status == SessionStatus.CLOSED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_routing_is_idempotent(db, open_session):
|
||||
"""A second call must not re-notify or re-message the customer."""
|
||||
notify, customer = _silenced()
|
||||
with notify, customer:
|
||||
assert await route_session_to_human(db, open_session, reason="x") is True
|
||||
|
||||
with patch("app.services.human_routing.notify_chat_event", new=AsyncMock()) as n, \
|
||||
patch("app.services.human_routing.notify_customer", new=AsyncMock()) as c:
|
||||
assert await route_session_to_human(db, open_session, reason="x") is False
|
||||
assert not n.called
|
||||
assert not c.called
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handover_notice_is_delivered_against_its_stored_row(db, open_session):
|
||||
"""The notice's channel message id must land on the notice's own row."""
|
||||
open_session.channel = "whatsapp"
|
||||
db.commit()
|
||||
with patch("app.services.human_routing.deliver_to_customer",
|
||||
new=AsyncMock(return_value=DeliveryResult(ok=True))) as deliver:
|
||||
await notify_customer(db, open_session, "You're now connected with our team.")
|
||||
|
||||
notice = db.query(ChatHistory).filter_by(session_id=open_session.session_id).one()
|
||||
assert deliver.await_args.kwargs["message_id"] == notice.id
|
||||
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { mount, VueWrapper } from '@vue/test-utils'
|
||||
import HumanAgentView from '../../views/HumanAgentView.vue'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
|
||||
// Mock the components
|
||||
vi.mock('@/layouts/DashboardLayout.vue', () => ({
|
||||
default: {
|
||||
name: 'DashboardLayout',
|
||||
template: '<div class="dashboard-layout"><slot /></div>'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/human-agent/UserList.vue', () => ({
|
||||
default: {
|
||||
name: 'UserList',
|
||||
template: '<div class="user-list"></div>'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/human-agent/GroupList.vue', () => ({
|
||||
default: {
|
||||
name: 'GroupList',
|
||||
template: '<div class="group-list"></div>'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/human-agent/RoleList.vue', () => ({
|
||||
default: {
|
||||
name: 'RoleList',
|
||||
template: '<div class="role-list"></div>'
|
||||
}
|
||||
}))
|
||||
|
||||
describe('HumanAgentView', () => {
|
||||
let wrapper: VueWrapper
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
wrapper = mount(HumanAgentView)
|
||||
})
|
||||
|
||||
it('renders properly', () => {
|
||||
expect(wrapper.find('.human-agent-container').exists()).toBe(true)
|
||||
expect(wrapper.find('.tab-pills').exists()).toBe(true)
|
||||
expect(wrapper.find('.content-area').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('displays all tab buttons', () => {
|
||||
const tabButtons = wrapper.findAll('.tab-pill')
|
||||
expect(tabButtons).toHaveLength(3)
|
||||
// Labels may include an optional count badge, so match on the label text.
|
||||
expect(tabButtons[0].text()).toContain('人员')
|
||||
expect(tabButtons[1].text()).toContain('团队')
|
||||
expect(tabButtons[2].text()).toContain('角色')
|
||||
})
|
||||
|
||||
it('shows UserList component by default', () => {
|
||||
expect(wrapper.findComponent({ name: 'UserList' }).exists()).toBe(true)
|
||||
expect(wrapper.findComponent({ name: 'GroupList' }).exists()).toBe(false)
|
||||
expect(wrapper.findComponent({ name: 'RoleList' }).exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('switches to GroupList when Groups tab is clicked', async () => {
|
||||
const groupsTab = wrapper.findAll('.tab-pill')[1]
|
||||
await groupsTab.trigger('click')
|
||||
|
||||
expect(wrapper.findComponent({ name: 'UserList' }).exists()).toBe(false)
|
||||
expect(wrapper.findComponent({ name: 'GroupList' }).exists()).toBe(true)
|
||||
expect(wrapper.findComponent({ name: 'RoleList' }).exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('switches to RoleList when Roles tab is clicked', async () => {
|
||||
const rolesTab = wrapper.findAll('.tab-pill')[2]
|
||||
await rolesTab.trigger('click')
|
||||
|
||||
expect(wrapper.findComponent({ name: 'UserList' }).exists()).toBe(false)
|
||||
expect(wrapper.findComponent({ name: 'GroupList' }).exists()).toBe(false)
|
||||
expect(wrapper.findComponent({ name: 'RoleList' }).exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('applies active class to selected tab button', async () => {
|
||||
// Initially Users tab should be active
|
||||
let tabButtons = wrapper.findAll('.tab-pill')
|
||||
expect(tabButtons[0].classes()).toContain('active')
|
||||
expect(tabButtons[1].classes()).not.toContain('active')
|
||||
expect(tabButtons[2].classes()).not.toContain('active')
|
||||
|
||||
// Click Groups tab
|
||||
await tabButtons[1].trigger('click')
|
||||
tabButtons = wrapper.findAll('.tab-pill')
|
||||
expect(tabButtons[0].classes()).not.toContain('active')
|
||||
expect(tabButtons[1].classes()).toContain('active')
|
||||
expect(tabButtons[2].classes()).not.toContain('active')
|
||||
|
||||
// Click Roles tab
|
||||
await tabButtons[2].trigger('click')
|
||||
tabButtons = wrapper.findAll('.tab-pill')
|
||||
expect(tabButtons[0].classes()).not.toContain('active')
|
||||
expect(tabButtons[1].classes()).not.toContain('active')
|
||||
expect(tabButtons[2].classes()).toContain('active')
|
||||
})
|
||||
|
||||
it('is wrapped in DashboardLayout', () => {
|
||||
const dashboardLayout = wrapper.findComponent({ name: 'DashboardLayout' })
|
||||
expect(dashboardLayout.exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,250 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, nextTick } from 'vue'
|
||||
import DashboardLayout from '@/layouts/DashboardLayout.vue'
|
||||
import UserList from '@/components/human-agent/UserList.vue'
|
||||
import GroupList from '@/components/human-agent/GroupList.vue'
|
||||
import RoleList from '@/components/human-agent/RoleList.vue'
|
||||
import { listUsers } from '@/services/users'
|
||||
import { listGroups } from '@/services/groups'
|
||||
import { listRoles } from '@/services/roles'
|
||||
|
||||
type Tab = 'users' | 'groups' | 'roles'
|
||||
const activeTab = ref<Tab>('users')
|
||||
const searchQuery = ref('')
|
||||
|
||||
const peopleCount = ref<number | null>(null)
|
||||
const teamsCount = ref<number | null>(null)
|
||||
const rolesCount = ref<number | null>(null)
|
||||
|
||||
const userListRef = ref<InstanceType<typeof UserList> | null>(null)
|
||||
|
||||
const loadCounts = async () => {
|
||||
try { peopleCount.value = (await listUsers()).length } catch { /* noop */ }
|
||||
try { teamsCount.value = (await listGroups()).length } catch { /* noop */ }
|
||||
try { rolesCount.value = (await listRoles()).length } catch { /* noop */ }
|
||||
}
|
||||
onMounted(loadCounts)
|
||||
|
||||
const onInvite = async () => {
|
||||
activeTab.value = 'users'
|
||||
await nextTick()
|
||||
userListRef.value?.openCreate?.()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DashboardLayout>
|
||||
<div class="human-agent-container">
|
||||
<!-- Page header -->
|
||||
<header class="page-header">
|
||||
<div class="title-block">
|
||||
<h1 class="page-title">人工客服</h1>
|
||||
<p class="page-subtitle">AI 背后的真人团队——对话需要人工接手时,由他们来处理。</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<div class="search-box">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4-4"></path></svg>
|
||||
<input v-model="searchQuery" placeholder="搜索人员…" />
|
||||
</div>
|
||||
<button class="invite-btn" @click="onInvite">
|
||||
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M19 8v6M22 11h-6"></path></svg>
|
||||
邀请客服
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Pill tabs -->
|
||||
<nav class="tab-pills">
|
||||
<button :class="['tab-pill', { active: activeTab === 'users' }]" @click="activeTab = 'users'">
|
||||
人员 <span class="tab-badge" v-if="peopleCount !== null">{{ peopleCount }}</span>
|
||||
</button>
|
||||
<button :class="['tab-pill', { active: activeTab === 'groups' }]" @click="activeTab = 'groups'">
|
||||
团队 <span class="tab-badge" v-if="teamsCount !== null">{{ teamsCount }}</span>
|
||||
</button>
|
||||
<button :class="['tab-pill', { active: activeTab === 'roles' }]" @click="activeTab = 'roles'">
|
||||
角色 <span class="tab-badge" v-if="rolesCount !== null">{{ rolesCount }}</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="content-area">
|
||||
<UserList v-if="activeTab === 'users'" ref="userListRef" :search-query="searchQuery" @changed="loadCounts" />
|
||||
<GroupList v-else-if="activeTab === 'groups'" :search-query="searchQuery" @changed="loadCounts" />
|
||||
<RoleList v-else-if="activeTab === 'roles'" :search-query="searchQuery" @changed="loadCounts" />
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.human-agent-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
padding: var(--space-lg) var(--space-md);
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 30px;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0 0 6px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-size: 15px;
|
||||
color: var(--muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 11px 14px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o10);
|
||||
border-radius: var(--radius-btn);
|
||||
min-width: 220px;
|
||||
color: var(--muted2);
|
||||
}
|
||||
|
||||
.search-box input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.search-box input::placeholder {
|
||||
color: var(--muted2);
|
||||
}
|
||||
|
||||
.invite-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 20px;
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
border: none;
|
||||
border-radius: var(--radius-btn);
|
||||
font-size: 14.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-sans);
|
||||
white-space: nowrap;
|
||||
transition: filter var(--transition-fast);
|
||||
}
|
||||
|
||||
.invite-btn:hover {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
|
||||
/* Pill tabs */
|
||||
.tab-pills {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o08);
|
||||
border-radius: 12px;
|
||||
margin-bottom: 24px;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.tab-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 9px 16px;
|
||||
border-radius: 9px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
transition: background-color var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
|
||||
.tab-pill:hover:not(.active) {
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.tab-pill.active {
|
||||
background: var(--accent-bg-12);
|
||||
color: var(--accent-ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tab-badge {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
padding: 1px 7px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--o06);
|
||||
color: var(--muted2);
|
||||
}
|
||||
|
||||
.tab-pill.active .tab-badge {
|
||||
background: var(--accent-bg-12);
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
.content-area {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header-actions {
|
||||
width: 100%;
|
||||
}
|
||||
.search-box {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user