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

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

No files matched your search

+250
View File
@@ -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>