feat(ui): refresh dashboard and paper boat branding
This commit is contained in:
1 parent
308332eb94
commit
1752a5b685
93 files changed
+11134
-7543
No files matched your search
@@ -15,14 +15,17 @@ limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, nextTick } from 'vue'
|
||||
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 { listUsers } from '@/services/users'
|
||||
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')
|
||||
@@ -34,12 +37,83 @@ 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 */ }
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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
|
||||
}
|
||||
onMounted(loadCounts)
|
||||
|
||||
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'
|
||||
@@ -50,201 +124,220 @@ const onInvite = async () => {
|
||||
|
||||
<template>
|
||||
<DashboardLayout>
|
||||
<div class="human-agent-container">
|
||||
<div class="page-inner human-agent-container">
|
||||
<!-- Page header -->
|
||||
<header class="page-header">
|
||||
<div class="title-block">
|
||||
<h1 class="page-title">人工客服</h1>
|
||||
<p class="page-subtitle">AI 背后的真人团队——对话需要人工接手时,由他们来处理。</p>
|
||||
<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="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>
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
<!-- 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="loadCounts" />
|
||||
<GroupList v-else-if="activeTab === 'groups'" :search-query="searchQuery" @changed="loadCounts" />
|
||||
<RoleList v-else-if="activeTab === 'roles'" :search-query="searchQuery" @changed="loadCounts" />
|
||||
<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 {
|
||||
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;
|
||||
/* ── Toolbar ──────────────────────────────────────────────────────────── */
|
||||
.toolbar {
|
||||
display: 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);
|
||||
gap: var(--space-md);
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.invite-btn:hover {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
|
||||
/* Pill tabs */
|
||||
/* The tabs sit in a toolbar here, not under a rule of their own */
|
||||
.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;
|
||||
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;
|
||||
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;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header-actions {
|
||||
/* ── 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%;
|
||||
}
|
||||
.search-box {
|
||||
flex: 1;
|
||||
|
||||
.toolbar-grow {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user