2026-10-06 21:45:00 +08:00
|
|
|
|
<!--
|
|
|
|
|
|
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">
|
2026-10-22 17:44:36 +08:00
|
|
|
|
import { ref, onMounted, nextTick, computed } from 'vue'
|
2026-10-06 21:45:00 +08:00
|
|
|
|
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'
|
2026-10-22 17:44:36 +08:00
|
|
|
|
import { navIconSvg } from '@/components/layout/navIcons'
|
|
|
|
|
|
import { listUsers, getTeamOverview } from '@/services/users'
|
2026-10-06 21:45:00 +08:00
|
|
|
|
import { listGroups } from '@/services/groups'
|
|
|
|
|
|
import { listRoles } from '@/services/roles'
|
2026-10-22 17:44:36 +08:00
|
|
|
|
import type { TeamKpis } from '@/services/users'
|
|
|
|
|
|
import type { User } from '@/types/user'
|
2026-10-06 21:45:00 +08:00
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
2026-10-22 17:44:36 +08:00
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-06 21:45:00 +08:00
|
|
|
|
const loadCounts = async () => {
|
2026-10-22 17:44:36 +08:00
|
|
|
|
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()
|
2026-10-06 21:45:00 +08:00
|
|
|
|
}
|
2026-10-22 17:44:36 +08:00
|
|
|
|
|
|
|
|
|
|
onMounted(async () => {
|
|
|
|
|
|
await Promise.all([loadCounts(), loadOverviewKpis()])
|
|
|
|
|
|
})
|
2026-10-06 21:45:00 +08:00
|
|
|
|
|
|
|
|
|
|
const onInvite = async () => {
|
|
|
|
|
|
activeTab.value = 'users'
|
|
|
|
|
|
await nextTick()
|
|
|
|
|
|
userListRef.value?.openCreate?.()
|
|
|
|
|
|
}
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<template>
|
|
|
|
|
|
<DashboardLayout>
|
2026-10-22 17:44:36 +08:00
|
|
|
|
<div class="page-inner human-agent-container">
|
2026-10-06 21:45:00 +08:00
|
|
|
|
<!-- Page header -->
|
2026-10-22 17:44:36 +08:00
|
|
|
|
<header class="page-head reveal" style="--i: 0">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div class="eyebrow">Team · 团队</div>
|
|
|
|
|
|
<h1 class="title">人工客服</h1>
|
|
|
|
|
|
<p class="lede">
|
|
|
|
|
|
AI 身后的真人团队。对话需要人工接手时,按分组与在线状态分配给他们。
|
|
|
|
|
|
</p>
|
2026-10-06 21:45:00 +08:00
|
|
|
|
</div>
|
2026-10-22 17:44:36 +08:00
|
|
|
|
<div class="actions">
|
|
|
|
|
|
<button type="button" class="btn btn-primary" @click="onInvite">
|
|
|
|
|
|
<span v-html="navIconSvg('plus', 16, 'icon')" aria-hidden="true"></span>
|
2026-10-06 21:45:00 +08:00
|
|
|
|
邀请客服
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</header>
|
|
|
|
|
|
|
2026-10-22 17:44:36 +08:00
|
|
|
|
<!-- 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>
|
2026-10-06 21:45:00 +08:00
|
|
|
|
|
|
|
|
|
|
<!-- Content -->
|
|
|
|
|
|
<div class="content-area">
|
2026-10-22 17:44:36 +08:00
|
|
|
|
<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" />
|
2026-10-06 21:45:00 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</DashboardLayout>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
2026-10-22 17:44:36 +08:00
|
|
|
|
/* .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. */
|
2026-10-06 21:45:00 +08:00
|
|
|
|
.human-agent-container {
|
2026-10-22 17:44:36 +08:00
|
|
|
|
min-width: 0;
|
2026-10-06 21:45:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-22 17:44:36 +08:00
|
|
|
|
/* ── Toolbar ──────────────────────────────────────────────────────────── */
|
|
|
|
|
|
.toolbar {
|
2026-10-06 21:45:00 +08:00
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
2026-10-22 17:44:36 +08:00
|
|
|
|
gap: var(--space-md);
|
2026-10-06 21:45:00 +08:00
|
|
|
|
flex-wrap: wrap;
|
2026-10-22 17:44:36 +08:00
|
|
|
|
margin-bottom: var(--space-lg);
|
2026-10-06 21:45:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-22 17:44:36 +08:00
|
|
|
|
/* The tabs sit in a toolbar here, not under a rule of their own */
|
|
|
|
|
|
.tab-pills {
|
|
|
|
|
|
margin-bottom: 0;
|
|
|
|
|
|
border-bottom: 0;
|
2026-10-06 21:45:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-22 17:44:36 +08:00
|
|
|
|
/* Class hooks kept for the existing tests — styling comes from global .tabs */
|
|
|
|
|
|
.tab-pill {
|
|
|
|
|
|
border: 0;
|
2026-10-06 21:45:00 +08:00
|
|
|
|
background: none;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-22 17:44:36 +08:00
|
|
|
|
.toolbar-grow {
|
|
|
|
|
|
flex: 1;
|
2026-10-06 21:45:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-22 17:44:36 +08:00
|
|
|
|
/* 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;
|
2026-10-06 21:45:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-22 17:44:36 +08:00
|
|
|
|
.search-icon {
|
2026-10-06 21:45:00 +08:00
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-22 17:44:36 +08:00
|
|
|
|
.content-area {
|
|
|
|
|
|
min-width: 0;
|
2026-10-06 21:45:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-22 17:44:36 +08:00
|
|
|
|
/* ── Metric strip accents ─────────────────────────────────────────────── */
|
|
|
|
|
|
.stat-value.is-alert,
|
|
|
|
|
|
.num.is-alert {
|
|
|
|
|
|
color: var(--vermilion);
|
2026-10-06 21:45:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-22 17:44:36 +08:00
|
|
|
|
.num.is-alert {
|
|
|
|
|
|
font-weight: 500;
|
2026-10-06 21:45:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-22 17:44:36 +08:00
|
|
|
|
@media (max-width: 1100px) {
|
|
|
|
|
|
.toolbar .members-search {
|
|
|
|
|
|
width: 240px;
|
|
|
|
|
|
}
|
2026-10-06 21:45:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-22 17:44:36 +08:00
|
|
|
|
/* 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);
|
|
|
|
|
|
}
|
2026-10-06 21:45:00 +08:00
|
|
|
|
|
2026-10-22 17:44:36 +08:00
|
|
|
|
.toolbar .members-search {
|
|
|
|
|
|
display: flex;
|
2026-10-06 21:45:00 +08:00
|
|
|
|
width: 100%;
|
|
|
|
|
|
}
|
2026-10-22 17:44:36 +08:00
|
|
|
|
|
|
|
|
|
|
.toolbar-grow {
|
|
|
|
|
|
display: none;
|
2026-10-06 21:45:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
</style>
|