feat(handoff): 转人工状态、待接管列表与接待时段

同会话内 AI 转人工,待接管列表按分组和在线状态展示,接管后保留上下文。
This commit is contained in:
WangShen007 committed 2026-10-06 21:45:00 +08:00
1 parent d9fe727c3f
commit 8e3a4d95da
6 files changed
+803

No files matched your search

@@ -0,0 +1,124 @@
/*
Copyright 2024-2026 ReplyPier
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)
})
})
+250
View File
@@ -0,0 +1,250 @@
<!--
Copyright 2024-2026 ReplyPier
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>