feat(ui): refresh dashboard and paper boat branding

This commit is contained in:
WangShen007 committed 2026-10-22 17:44:36 +08:00
1 parent 308332eb94
commit 1752a5b685
93 files changed
+11134 -7543

No files matched your search

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