Files
replypier/frontend/src/layouts/DashboardLayout.vue
T
WangShen007 a20884c2e4 docs: rebuild README previews from 2x captures
Rework the product preview section around three real screens captured at
deviceScaleFactor 2 instead of the previous 900 px shots taken at a 75%
browser zoom, which read as blurry.

- capture the conversation workspace, the agent customization page and the
  visitor widget, then crop each one to a consistent frame
- drop the tight 480 px widget crop and its leftover gray strip
- restructure the preview section with one lead line and a caption per shot,
  and fold the local start-up steps into a shorter quick start
- keep the browser overlay out of the captures and note the method in the
  asset manifest
- finish the visible ReplyPier rename (sidebar wordmark, widget footer,
  page title, login screen, demo page) so screenshots and UI agree
2026-10-22 19:38:57 +08:00

726 lines
23 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!--
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, watch, computed } from 'vue'
import { useAuth } from '@/composables/useAuth'
import { useTheme } from '@/composables/useTheme'
import AppSidebar from '@/components/layout/AppSidebar.vue'
import BottomNav from '@/components/layout/BottomNav.vue'
import MoreSheet from '@/components/layout/MoreSheet.vue'
import { navIconSvg } from '@/components/layout/navIcons'
import NotificationList from '@/components/notifications/NotificationList.vue'
import EnablePushPrompt from '@/components/notifications/EnablePushPrompt.vue'
import { userService } from '@/services/user'
import { permissionChecks } from '@/utils/permissions'
import type { User } from '@/types/user'
import { useNotifications } from '@/composables/useNotifications'
import { notificationService } from '@/services/notification'
import { useRoute, useRouter } from 'vue-router'
import { updateUserStatus } from '@/services/users'
import { useEnterpriseFeatures } from '@/composables/useEnterpriseFeatures'
import { useBreakpoint } from '@/composables/useBreakpoint'
const props = defineProps<{
hideSidebar?: boolean
hideHeader?: boolean
/** Full-bleed workspace pages (inbox) draw their own column padding */
flush?: boolean
}>()
// Initialize sidebar state based on current route and screen size
const route = useRoute()
const { isMobile: isPhone, isTablet: isMobile } = useBreakpoint()
const isSidebarOpen = ref(
route.path !== '/conversations' && !isMobile.value
)
const showNotifications = ref(false)
const showMoreSheet = ref(false)
const currentUser = ref<User>(userService.getCurrentUser() as User)
const userName = ref(userService.getUserName())
const userRole = ref(userService.getUserRole())
const unreadCount = ref(0)
const statusUpdating = ref(false)
const { logout } = useAuth()
const { mode: themeMode, toggle: toggleTheme } = useTheme()
const themeTitle = computed(() =>
themeMode.value === 'dark' ? '主题:深色 — 点击切换为浅色'
: themeMode.value === 'light' ? '主题:浅色 — 点击切换为跟随系统'
: '主题:跟随系统 — 点击切换为深色'
)
// Attaches push listeners when permission is already granted; the permission
// request itself only happens from the EnablePushPrompt user gesture.
const { enableNotifications } = useNotifications()
const router = useRouter()
const PAGE_TITLES: Record<string, string> = {
'/ai-agents': 'AI 客服',
'/human-agents': '人工客服',
'/conversations': '会话工作台',
'/tickets': '工单',
'/people': '联系人',
'/knowledge': '知识库',
'/faq': '帮助中心',
'/analytics': '数据分析',
'/settings/organization': '组织',
'/settings/subscription': '订阅',
'/settings/ticketing': '工单设置',
'/settings/integrations': '集成',
'/settings/app-integrations': '应用集成',
'/settings/widget-apps': '网站组件',
'/settings/ai-config': 'AI 配置',
'/settings/user': '个人设置',
}
const pageTitle = computed(() => PAGE_TITLES[route.path] || '')
// Breadcrumb group per page, mirroring the sidebar sections so the two can
// never tell a different story about where you are.
const PAGE_GROUPS: Record<string, { group: string; en: string }> = {
'/conversations': { group: '接待', en: 'Reception' },
'/tickets': { group: '接待', en: 'Reception' },
'/people': { group: '接待', en: 'Reception' },
'/ai-agents': { group: '智能', en: 'Intelligence' },
'/knowledge': { group: '智能', en: 'Intelligence' },
'/faq': { group: '智能', en: 'Intelligence' },
'/human-agents': { group: '团队', en: 'Team' },
'/analytics': { group: '团队', en: 'Team' },
}
const pageCrumb = computed(() => {
if (PAGE_GROUPS[route.path]) return PAGE_GROUPS[route.path]
if (route.path.startsWith('/settings')) return { group: '配置', en: 'Settings' }
return { group: '工作台', en: 'Workspace' }
})
// Initialize enterprise features
const { hasEnterpriseModule, subscriptionStore, initializeSubscriptionStore, showMessageLimitWarning, messageLimitStatus, enterpriseComponent, moduleImports } = useEnterpriseFeatures()
// Promo strip / usage nudge / post-signup popup (enterprise only; empty in OSS)
const PromoSurfaces = enterpriseComponent(moduleImports.promoSurfaces)
// "Finish setup" bar until the org's agent is live (enterprise only; empty in OSS)
const SetupProgressBar = enterpriseComponent(moduleImports.setupProgressBar)
// Always-on offer pill in the header: survives the strip being dismissed
const PromoHeaderPill = enterpriseComponent(moduleImports.promoHeaderPill)
// Help button opening the hosted support chat (empty in OSS)
const SupportChatButton = enterpriseComponent(moduleImports.supportChatButton)
const isLoadingPlan = computed(() => subscriptionStore.value.isLoadingPlan)
const isInTrial = computed(() => subscriptionStore.value.isInTrial)
const trialDaysLeft = computed(() => subscriptionStore.value.trialDaysLeft)
const toggleOnlineStatus = async () => {
if (statusUpdating.value) return
try {
statusUpdating.value = true
const newStatus = !currentUser.value?.is_online
await updateUserStatus(currentUser.value?.id as string, newStatus)
// Update local state
currentUser.value = {
...currentUser.value,
is_online: newStatus,
last_seen: new Date().toISOString()
} as User
userService.setCurrentUser(currentUser.value as User)
} catch (error) {
console.error('Failed to update status:', error)
} finally {
statusUpdating.value = false
}
}
// Bottom nav shows on phones only; hidden in fullscreen workflows and on the
// full-screen chat pane (mobile chat detail = /conversations with ?session=)
const showBottomNav = computed(() =>
isPhone.value &&
!props.hideSidebar &&
!(route.path === '/conversations' && route.query.session)
)
// Watch for route changes to update sidebar state and close menus
watch(
() => route.path,
(newPath) => {
showNotifications.value = false
showMoreSheet.value = false
// Set sidebar state based on route and screen size
if (newPath === '/conversations') {
isSidebarOpen.value = false // Collapsed for conversations
} else if (isMobile.value) {
isSidebarOpen.value = false // Collapsed on mobile by default
} else {
isSidebarOpen.value = true // Expanded for all other routes on desktop
}
}
)
const fetchUnreadCount = async () => {
try {
unreadCount.value = await notificationService.getUnreadCount()
} catch (err) {
console.error('Error fetching unread count:', err)
}
}
// React to breakpoint changes (was a manual resize listener)
watch(isMobile, (mobile) => {
if (mobile && isSidebarOpen.value) {
isSidebarOpen.value = false
} else if (!mobile && !isSidebarOpen.value && route.path !== '/conversations') {
isSidebarOpen.value = true
}
})
onMounted(() => {
fetchUnreadCount()
if (hasEnterpriseModule) {
initializeSubscriptionStore().then(() => {
subscriptionStore.value.fetchCurrentPlan().then(() => {
}).catch((err: Error) => {
console.error('Error fetching current plan:', err)
})
}).catch((err: Error) => {
console.error('Error initializing subscription store:', err)
})
}
})
const toggleSidebar = () => {
isSidebarOpen.value = !isSidebarOpen.value
}
const closeSidebar = () => {
// On mobile, close the sidebar when clicking backdrop
if (isMobile.value) {
isSidebarOpen.value = false
}
}
const navigateToUpgrade = () => {
router.push('/settings/subscription')
}
// The usage warning is for everyone — an agent hitting a message ceiling needs
// to know why replies stop. The two remedies are not: both lead to pages a
// non-admin cannot open, so they rendered as doors into a 403.
const canManageSubscription = permissionChecks.canManageSubscription()
const canViewAIConfig = permissionChecks.canViewAIConfig()
// Computed for layout classes
const layoutClasses = computed(() => ({
'sidebar-collapsed': !isSidebarOpen.value || props.hideSidebar,
'header-hidden': props.hideHeader,
'fullscreen-workflow': props.hideSidebar && props.hideHeader,
'flush-content': props.flush,
'has-bottom-nav': showBottomNav.value
}))
const openNotificationsFromSheet = () => {
showMoreSheet.value = false
showNotifications.value = true
}
</script>
<template>
<div class="dashboard-layout cm-shell" :class="layoutClasses">
<!-- Backdrop for mobile -->
<div
v-if="!props.hideSidebar && isSidebarOpen"
class="sidebar-backdrop"
@click="closeSidebar"
></div>
<AppSidebar
v-if="!props.hideSidebar"
:isCollapsed="!isSidebarOpen"
:user-name="userName"
:user-role="userRole"
:is-online="currentUser?.is_online"
:status-updating="statusUpdating"
@toggle="toggleSidebar"
@toggle-status="toggleOnlineStatus"
@logout="logout"
/>
<!-- Main Content -->
<div class="main-content">
<!-- Message Limit Warning Banner -->
<div v-if="!props.hideHeader && hasEnterpriseModule && showMessageLimitWarning && messageLimitStatus"
class="message-limit-banner"
:class="messageLimitStatus.type">
<div class="banner-content">
<div class="banner-text">
<span class="banner-icon" v-if="messageLimitStatus.type === 'error'">⚠️</span>
<span class="banner-icon" v-else>ℹ️</span>
{{ messageLimitStatus.message }}
</div>
<div v-if="canManageSubscription || canViewAIConfig" class="banner-actions">
<button
v-if="canManageSubscription"
class="action-button"
@click="navigateToUpgrade"
>
升级套餐
</button>
<button
v-if="canViewAIConfig"
class="action-button secondary"
@click="router.push('/settings/ai-config')"
>
配置自有模型
</button>
</div>
</div>
<div class="usage-bar">
<div class="usage-progress"
:style="{ width: `${Math.min(messageLimitStatus.percentage, 100)}%` }"
:class="{ 'exceeded': messageLimitStatus.percentage >= 100 }">
</div>
</div>
</div>
<!-- Promo offer surfaces (strip, usage nudge, first-landing popup) -->
<PromoSurfaces
v-if="!props.hideHeader && hasEnterpriseModule"
:hide-usage-card="showMessageLimitWarning"
/>
<!-- Header -->
<header v-if="!props.hideHeader" class="topbar">
<button class="hamburger-menu" @click="toggleSidebar" aria-label="切换菜单">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 12H21M3 6H21M3 18H21" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<div class="crumb">
<span class="en">{{ pageCrumb.en }}</span>
<span class="sep">/</span>
<span>{{ pageCrumb.group }}</span>
<span class="sep">/</span>
<b>{{ pageTitle }}</b>
</div>
<div class="topbar-spacer"></div>
<SupportChatButton v-if="hasEnterpriseModule" />
<PromoHeaderPill v-if="hasEnterpriseModule" />
<div v-if="hasEnterpriseModule && (isLoadingPlan || isInTrial)" class="plan-display">
<div v-if="isLoadingPlan" class="plan-loading">
<span class="loading-spinner"></span>
加载中…
</div>
<div v-else-if="isInTrial" class="trial-info">
<span
class="trial-badge"
:class="{ clickable: canManageSubscription }"
@click="canManageSubscription && navigateToUpgrade()"
>
试用(剩余 {{ trialDaysLeft }} 天)
</span>
</div>
</div>
<button
class="presence"
:data-state="currentUser?.is_online ? 'online' : 'away'"
:disabled="statusUpdating"
:title="currentUser?.is_online ? '在线接待中 — 点击暂停接收新的转人工会话' : '暂时离开 — 点击恢复接待'"
@click="toggleOnlineStatus"
>
<span class="pulse"></span>
<span>{{ currentUser?.is_online ? '在线接待' : '暂时离开' }}</span>
</button>
<button class="icon-btn" @click="toggleTheme" :title="themeTitle" :aria-label="themeTitle"
v-html="navIconSvg(themeMode === 'dark' ? 'moon' : themeMode === 'light' ? 'sun' : 'monitor', 18)">
</button>
<button class="icon-btn" @click="showNotifications = !showNotifications" aria-label="通知">
<span v-html="navIconSvg('bell', 18)"></span>
<span v-if="unreadCount > 0" class="nav-badge alert badge-float">
{{ unreadCount > 99 ? '99+' : unreadCount }}
</span>
</button>
</header>
<!-- Main Content Area -->
<main class="content">
<!-- The agents page shows its own copy, beside the wizard and list -->
<SetupProgressBar v-if="route.name !== 'ai-agents'" />
<slot></slot>
</main>
<!-- Footer: omitted for full-page layouts (hideHeader) and workspace pages
(flush, e.g. the inbox) — those pages size themselves to the viewport, so a
footer would clip the last message / message input out of view. -->
<footer v-if="!props.hideHeader && !props.flush" class="footer">
<div class="footer-content">
<p>&copy; 2024 ReplyPier。保留所有权利。</p>
<nav class="footer-links">
<a href="https://chattermate.chat/privacy_policy.html">隐私政策</a>
<a href="https://chattermate.chat/terms_and_conditions.html">服务条款</a>
</nav>
</div>
</footer>
</div>
<!-- Notification drawer (fixed) — outside the header so the More sheet
can open it on pages that hide the header (e.g. Inbox) -->
<NotificationList :is-open="showNotifications" @close="showNotifications = false"
@notification-read="fetchUnreadCount" />
<EnablePushPrompt @enable="enableNotifications" />
<!-- Mobile app shell -->
<BottomNav
v-if="showBottomNav"
:unread-count="unreadCount"
:more-open="showMoreSheet"
@more="showMoreSheet = true"
/>
<MoreSheet
:open="showMoreSheet"
:is-online="currentUser?.is_online"
:status-updating="statusUpdating"
:theme-mode="themeMode"
:unread-count="unreadCount"
@close="showMoreSheet = false"
@toggle-status="toggleOnlineStatus"
@toggle-theme="toggleTheme"
@notifications="openNotificationsFromSheet"
@logout="logout"
/>
</div>
</template>
<style scoped>
/* 墨与瓷 shell. The shared .cm-shell rules in components.css carry the
sidebar / topbar look; this block only owns what is specific to the
dashboard frame (grid, banners, footer, mobile overlay). */
.dashboard-layout {
display: grid;
/* The sidebar sets its own width so the collapsed rail can shrink it */
grid-template-columns: auto minmax(0, 1fr);
height: 100vh;
height: 100dvh;
width: 100%;
overflow: hidden;
position: relative;
background: var(--paper);
color: var(--ink);
transition: grid-template-columns var(--t-med);
}
.dashboard-layout.fullscreen-workflow {
grid-template-columns: 1fr;
}
.sidebar-backdrop {
display: none;
}
.main-content {
display: flex;
flex-direction: column;
min-width: 0;
height: 100vh;
height: 100dvh;
/* The topbar must not scroll away with the page: the content pane owns
the scrollbar instead of the whole column. */
overflow: hidden;
}
/* ── Topbar ────────────────────────────────────────── */
.topbar {
/* Standalone PWA: content extends under the status bar (black-translucent) */
padding-top: var(--safe-top);
height: calc(64px + var(--safe-top));
}
.hamburger-menu {
display: none;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
margin-left: -8px;
border: 1px solid transparent;
border-radius: var(--radius-md);
background: none;
color: var(--text2);
cursor: pointer;
transition: background-color var(--t-fast), border-color var(--t-fast);
}
.hamburger-menu:hover {
background: var(--surface);
border-color: var(--line);
}
.hamburger-menu svg {
display: block;
}
.badge-float {
position: absolute;
top: 2px;
right: 2px;
min-width: 16px;
height: 16px;
padding: 0 4px;
font-size: 10px;
box-shadow: 0 0 0 2px var(--paper);
}
.presence:disabled {
opacity: 0.6;
cursor: progress;
}
/* ── Enterprise plan chips ─────────────────────────── */
.plan-display {
display: flex;
align-items: center;
gap: var(--space-md);
}
.plan-loading {
color: var(--muted);
font-size: var(--text-sm);
display: flex;
align-items: center;
gap: 6px;
}
.trial-info {
display: flex;
align-items: center;
}
.trial-badge {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: var(--radius-pill);
border: 1px solid var(--brass-line);
background: var(--brass-soft);
color: var(--brass);
font-size: 12.5px;
font-weight: 600;
}
.trial-badge.clickable {
cursor: pointer;
}
/* ── Content ───────────────────────────────────────── */
.content {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 40px 48px;
}
/* Full-page layouts (ConversationsView) size themselves to the viewport, so
the frame must not add padding or a footer below them. */
.dashboard-layout.header-hidden .content,
.dashboard-layout.flush-content .content {
padding: 0;
}
/* ── Footer ────────────────────────────────────────── */
.footer {
border-top: 1px solid var(--line);
padding: 16px 32px;
font-size: 12px;
color: var(--muted);
}
.footer-content {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.footer-links {
display: flex;
gap: var(--space-lg);
}
.footer-links a {
color: var(--muted);
transition: color var(--t-fast);
}
.footer-links a:hover {
color: var(--ink);
}
/* ── Usage / limit banner ──────────────────────────── */
.message-limit-banner {
position: relative;
padding: 14px 28px;
background: var(--warning-bg);
border-bottom: 1px solid var(--brass-line);
width: 100%;
overflow: hidden;
color: var(--ink-2);
font-size: 13.5px;
}
.message-limit-banner.error {
background: var(--error-bg);
border-bottom: 1px solid var(--vermilion-line);
}
.banner-content {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-md);
}
.banner-text {
display: flex;
align-items: center;
gap: 8px;
font-weight: 500;
word-break: break-word;
flex: 1;
}
.banner-icon {
font-size: 1.1em;
}
.banner-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.action-button {
padding: 6px 12px;
border-radius: var(--radius-sm);
font-size: 13px;
font-weight: 600;
cursor: pointer;
border: 1px solid transparent;
background: var(--primary-bg);
color: var(--primary-fg);
}
.action-button.secondary {
background: var(--surface);
color: var(--text);
border-color: var(--line-2);
}
.usage-bar {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 2px;
background: var(--line);
}
.usage-progress {
height: 100%;
background: var(--brass);
transition: width 0.3s ease;
}
.usage-progress.exceeded {
background: var(--vermilion);
}
.loading-spinner {
width: 12px;
height: 12px;
border-radius: 50%;
border: 2px solid var(--line-2);
border-top-color: var(--ink);
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* ── Responsive ────────────────────────────────────── */
@media (max-width: 1024px) {
.dashboard-layout {
grid-template-columns: minmax(0, 1fr);
}
.hamburger-menu {
display: flex;
}
.sidebar-backdrop {
display: block;
position: fixed;
inset: 0;
z-index: 999;
background: var(--scrim);
backdrop-filter: blur(2px);
}
}
@media (max-width: 1100px) {
.content {
padding: 28px 24px;
}
}
@media (max-width: 900px) {
.content {
padding: 20px 18px;
}
.crumb {
font-size: 12.5px;
}
.crumb .en,
.crumb .sep:first-of-type {
display: none;
}
.presence span:last-child {
display: none;
}
.presence {
padding: 0 12px;
}
}
/* Bottom nav sits above the content on phones */
.dashboard-layout.has-bottom-nav .main-content {
padding-bottom: calc(var(--bottom-nav-height) + var(--safe-bottom));
}
</style>