Files
replypier/frontend/src/views/HumanAgentView.vue
T

343 lines
11 KiB
Vue
Raw Normal View History

<!--
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, computed } 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 { navIconSvg } from '@/components/layout/navIcons'
import { listUsers, getTeamOverview } from '@/services/users'
import { listGroups } from '@/services/groups'
import { listRoles } from '@/services/roles'
import type { TeamKpis } from '@/services/users'
import type { User } from '@/types/user'
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)
// ---------------------------------------------------------------------------
// Team metrics
//
// The page (not the list) owns the KPI strip: it stays put while the tabs
// switch between 人员 / 团队 / 角色. DATA: GET /users/team-overview when the
// deployment exposes it, otherwise a derived subset from GET /users — the
// same graceful fallback the list uses for its rows.
// ---------------------------------------------------------------------------
const people = ref<User[]>([])
const peopleLoaded = ref(false)
const overviewKpis = ref<TeamKpis | null>(null)
const statsLoading = ref(true)
const isAdminRole = (roleName?: string | null) => !!roleName && /admin/i.test(roleName)
const derivedKpis = computed<TeamKpis | null>(() => {
// Only claim a figure once the roster actually came back: before that a 0
// would read as "nobody on the team" instead of "not loaded yet".
if (!peopleLoaded.value) return null
const admins = people.value.filter((u) => isAdminRole(u.role?.name)).length
return {
team_size: people.value.length,
admins,
agents: people.value.length - admins,
online_now: people.value.filter((u) => u.is_online).length,
// The users endpoint carries no live-chat figures; 0 reads as "unknown"
// and the strip renders a dash for those two cells.
active_chats: 0,
total_capacity: 0,
waiting_handoff: 0,
oldest_wait_minutes: 0,
}
})
const teamKpis = computed<TeamKpis | null>(() => overviewKpis.value ?? derivedKpis.value)
const capacityPercent = computed<number | null>(() => {
const k = teamKpis.value
if (!k || k.total_capacity <= 0) return null
return Math.round((k.active_chats / k.total_capacity) * 100)
})
const loadOverviewKpis = async () => {
const overview = await getTeamOverview()
overviewKpis.value = overview?.kpis ?? null
statsLoading.value = false
}
const loadCounts = async () => {
try {
const list = await listUsers()
people.value = list
peopleCount.value = list.length
peopleLoaded.value = true
} catch {
/* 计数与指标条只是装饰,接口不可用时保留原值 */
}
try {
teamsCount.value = (await listGroups()).length
} catch {
/* noop */
}
try {
rolesCount.value = (await listRoles()).length
} catch {
/* noop */
}
}
const onChanged = async () => {
await loadCounts()
await loadOverviewKpis()
}
onMounted(async () => {
await Promise.all([loadCounts(), loadOverviewKpis()])
})
const onInvite = async () => {
activeTab.value = 'users'
await nextTick()
userListRef.value?.openCreate?.()
}
</script>
<template>
<DashboardLayout>
<div class="page-inner human-agent-container">
<!-- Page header -->
<header class="page-head reveal" style="--i: 0">
<div>
<div class="eyebrow">Team · 团队</div>
<h1 class="title">人工客服</h1>
<p class="lede">
AI 身后的真人团队。对话需要人工接手时,按分组与在线状态分配给他们。
</p>
</div>
<div class="actions">
<button type="button" class="btn btn-primary" @click="onInvite">
<span v-html="navIconSvg('plus', 16, 'icon')" aria-hidden="true"></span>
邀请客服
</button>
</div>
</header>
<!-- Team metrics: one container, hairline-divided cells -->
<div class="stats reveal" style="--i: 1">
<div class="stat">
<div class="stat-label">团队规模</div>
<div class="stat-value">{{ teamKpis ? teamKpis.team_size : '—' }}</div>
<div class="stat-foot">
<template v-if="teamKpis">
{{ teamKpis.admins }} 位管理员 · {{ teamKpis.agents }} 位客服
</template>
<span v-else class="muted">{{ statsLoading ? '统计中…' : '暂无数据' }}</span>
</div>
</div>
<div class="stat">
<div class="stat-label">当前在线</div>
<div class="stat-value">{{ teamKpis ? teamKpis.online_now : '—' }}</div>
<div class="stat-foot">
<span v-if="teamKpis && teamKpis.online_now > 0" class="chip chip-ai">
<i class="d" aria-hidden="true"></i>可接待转接
</span>
<span v-else-if="teamKpis" class="chip chip-quiet">全部离线</span>
<span v-else class="muted">—</span>
</div>
</div>
<div class="stat">
<div class="stat-label">实时负载</div>
<div class="stat-value">
<template v-if="teamKpis && teamKpis.total_capacity > 0">
{{ teamKpis.active_chats }}<small>/ {{ teamKpis.total_capacity }}</small>
</template>
<template v-else>—</template>
</div>
<div class="stat-foot">
<span class="muted">
{{ capacityPercent === null ? '暂无容量数据' : `已用容量 ${capacityPercent}%` }}
</span>
</div>
</div>
<div class="stat">
<div class="stat-label">等待转接</div>
<div
class="stat-value"
:class="{ 'is-alert': teamKpis !== null }"
>
{{ teamKpis ? teamKpis.waiting_handoff : '—' }}
</div>
<div class="stat-foot">
<template v-if="teamKpis && teamKpis.waiting_handoff > 0">
最长等待
<b class="num is-alert">{{ teamKpis.oldest_wait_minutes }} 分钟</b>
</template>
<span v-else class="muted">{{ teamKpis ? '队列为空' : '—' }}</span>
</div>
</div>
</div>
<!-- Tabs + search -->
<div class="toolbar reveal" style="--i: 2">
<nav class="tabs tab-pills" role="tablist" aria-label="人工客服视图">
<button
type="button"
role="tab"
:aria-selected="activeTab === 'users'"
:class="['tab-pill', { active: activeTab === 'users' }]"
@click="activeTab = 'users'"
>
人员 <span v-if="peopleCount !== null" class="count">{{ peopleCount }}</span>
</button>
<button
type="button"
role="tab"
:aria-selected="activeTab === 'groups'"
:class="['tab-pill', { active: activeTab === 'groups' }]"
@click="activeTab = 'groups'"
>
团队 <span v-if="teamsCount !== null" class="count">{{ teamsCount }}</span>
</button>
<button
type="button"
role="tab"
:aria-selected="activeTab === 'roles'"
:class="['tab-pill', { active: activeTab === 'roles' }]"
@click="activeTab = 'roles'"
>
角色 <span v-if="rolesCount !== null" class="count">{{ rolesCount }}</span>
</button>
</nav>
<span class="toolbar-grow"></span>
<label class="search members-search">
<span class="search-icon" aria-hidden="true" v-html="navIconSvg('search', 16, 'icon-sm')"></span>
<input
v-model="searchQuery"
type="search"
placeholder="搜索成员…"
aria-label="搜索成员"
/>
</label>
</div>
<!-- Content -->
<div class="content-area">
<UserList
v-if="activeTab === 'users'"
ref="userListRef"
:search-query="searchQuery"
@changed="onChanged"
/>
<GroupList v-else-if="activeTab === 'groups'" :search-query="searchQuery" @changed="onChanged" />
<RoleList v-else-if="activeTab === 'roles'" :search-query="searchQuery" @changed="onChanged" />
</div>
</div>
</DashboardLayout>
</template>
<style scoped>
/* .page-inner (global, inside .cm-shell) provides the centred column and the
shell provides the 40/48px padding — this class is only kept as the semantic
hook the existing view tests mount against. */
.human-agent-container {
min-width: 0;
}
/* ── Toolbar ──────────────────────────────────────────────────────────── */
.toolbar {
display: flex;
align-items: center;
gap: var(--space-md);
flex-wrap: wrap;
margin-bottom: var(--space-lg);
}
/* The tabs sit in a toolbar here, not under a rule of their own */
.tab-pills {
margin-bottom: 0;
border-bottom: 0;
}
/* Class hooks kept for the existing tests — styling comes from global .tabs */
.tab-pill {
border: 0;
background: none;
}
.toolbar-grow {
flex: 1;
}
/* Scoped one level deeper than the shell's `.cm-shell .search` so the page's
search keeps its own width (and stays visible on phones, see below). */
.toolbar .members-search {
width: 250px;
}
.search-icon {
display: inline-flex;
color: var(--muted);
}
.content-area {
min-width: 0;
}
/* ── Metric strip accents ─────────────────────────────────────────────── */
.stat-value.is-alert,
.num.is-alert {
color: var(--vermilion);
}
.num.is-alert {
font-weight: 500;
}
@media (max-width: 1100px) {
.toolbar .members-search {
width: 240px;
}
}
/* The shell hides .search at 900px (its topbar search); the page's own
search has to stay usable, so it opts back in. */
@media (max-width: 900px) {
.toolbar {
gap: var(--space-sm);
}
.toolbar .members-search {
display: flex;
width: 100%;
}
.toolbar-grow {
display: none;
}
}
</style>