344 lines
11 KiB
Vue
344 lines
11 KiB
Vue
<!--
|
||
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>
|