2026-09-24 21:35: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">
|
|
|
|
|
|
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
|
2026-10-22 17:44:36 +08:00
|
|
|
|
/** Full-bleed workspace pages (inbox) draw their own column padding */
|
|
|
|
|
|
flush?: boolean
|
2026-09-24 21:35:00 +08:00
|
|
|
|
}>()
|
|
|
|
|
|
|
|
|
|
|
|
// 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': '人工客服',
|
2026-10-22 17:44:36 +08:00
|
|
|
|
'/conversations': '会话工作台',
|
|
|
|
|
|
'/tickets': '工单',
|
|
|
|
|
|
'/people': '联系人',
|
|
|
|
|
|
'/knowledge': '知识库',
|
|
|
|
|
|
'/faq': '帮助中心',
|
2026-09-24 21:35:00 +08:00
|
|
|
|
'/analytics': '数据分析',
|
|
|
|
|
|
'/settings/organization': '组织',
|
|
|
|
|
|
'/settings/subscription': '订阅',
|
2026-10-22 17:44:36 +08:00
|
|
|
|
'/settings/ticketing': '工单设置',
|
2026-09-24 21:35:00 +08:00
|
|
|
|
'/settings/integrations': '集成',
|
|
|
|
|
|
'/settings/app-integrations': '应用集成',
|
|
|
|
|
|
'/settings/widget-apps': '网站组件',
|
|
|
|
|
|
'/settings/ai-config': 'AI 配置',
|
|
|
|
|
|
'/settings/user': '个人设置',
|
|
|
|
|
|
}
|
|
|
|
|
|
const pageTitle = computed(() => PAGE_TITLES[route.path] || '')
|
|
|
|
|
|
|
2026-10-22 17:44:36 +08:00
|
|
|
|
// 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' }
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-09-24 21:35:00 +08:00
|
|
|
|
// 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,
|
2026-10-22 17:44:36 +08:00
|
|
|
|
'flush-content': props.flush,
|
2026-09-24 21:35:00 +08:00
|
|
|
|
'has-bottom-nav': showBottomNav.value
|
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
|
|
const openNotificationsFromSheet = () => {
|
|
|
|
|
|
showMoreSheet.value = false
|
|
|
|
|
|
showNotifications.value = true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<template>
|
2026-10-22 17:44:36 +08:00
|
|
|
|
<div class="dashboard-layout cm-shell" :class="layoutClasses">
|
2026-09-24 21:35:00 +08:00
|
|
|
|
<!-- Backdrop for mobile -->
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="!props.hideSidebar && isSidebarOpen"
|
|
|
|
|
|
class="sidebar-backdrop"
|
|
|
|
|
|
@click="closeSidebar"
|
|
|
|
|
|
></div>
|
|
|
|
|
|
|
2026-10-22 17:44:36 +08:00
|
|
|
|
<AppSidebar
|
2026-09-24 21:35:00 +08:00
|
|
|
|
v-if="!props.hideSidebar"
|
2026-10-22 17:44:36 +08:00
|
|
|
|
:isCollapsed="!isSidebarOpen"
|
|
|
|
|
|
:user-name="userName"
|
|
|
|
|
|
:user-role="userRole"
|
|
|
|
|
|
:is-online="currentUser?.is_online"
|
|
|
|
|
|
:status-updating="statusUpdating"
|
|
|
|
|
|
@toggle="toggleSidebar"
|
|
|
|
|
|
@toggle-status="toggleOnlineStatus"
|
|
|
|
|
|
@logout="logout"
|
2026-09-24 21:35:00 +08:00
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 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 -->
|
2026-10-22 17:44:36 +08:00
|
|
|
|
<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>
|
|
|
|
|
|
加载中…
|
2026-09-24 21:35:00 +08:00
|
|
|
|
</div>
|
2026-10-22 17:44:36 +08:00
|
|
|
|
<div v-else-if="isInTrial" class="trial-info">
|
|
|
|
|
|
<span
|
|
|
|
|
|
class="trial-badge"
|
|
|
|
|
|
:class="{ clickable: canManageSubscription }"
|
|
|
|
|
|
@click="canManageSubscription && navigateToUpgrade()"
|
|
|
|
|
|
>
|
|
|
|
|
|
试用(剩余 {{ trialDaysLeft }} 天)
|
|
|
|
|
|
</span>
|
2026-09-24 21:35:00 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-10-22 17:44:36 +08:00
|
|
|
|
|
|
|
|
|
|
<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>
|
2026-09-24 21:35:00 +08:00
|
|
|
|
</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>
|
|
|
|
|
|
|
2026-10-22 17:44:36 +08:00
|
|
|
|
<!-- 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">
|
2026-09-24 21:35:00 +08:00
|
|
|
|
<div class="footer-content">
|
|
|
|
|
|
<p>© 2024 ChatterMate。保留所有权利。</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>
|
2026-10-22 17:44:36 +08:00
|
|
|
|
/* 墨与瓷 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). */
|
2026-09-24 21:35:00 +08:00
|
|
|
|
.dashboard-layout {
|
|
|
|
|
|
display: grid;
|
2026-10-22 17:44:36 +08:00
|
|
|
|
/* The sidebar sets its own width so the collapsed rail can shrink it */
|
|
|
|
|
|
grid-template-columns: auto minmax(0, 1fr);
|
2026-09-24 21:35:00 +08:00
|
|
|
|
height: 100vh;
|
|
|
|
|
|
height: 100dvh;
|
|
|
|
|
|
width: 100%;
|
2026-10-22 17:44:36 +08:00
|
|
|
|
overflow: hidden;
|
2026-09-24 21:35:00 +08:00
|
|
|
|
position: relative;
|
2026-10-22 17:44:36 +08:00
|
|
|
|
background: var(--paper);
|
|
|
|
|
|
color: var(--ink);
|
|
|
|
|
|
transition: grid-template-columns var(--t-med);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.dashboard-layout.fullscreen-workflow {
|
|
|
|
|
|
grid-template-columns: 1fr;
|
2026-09-24 21:35:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.sidebar-backdrop {
|
|
|
|
|
|
display: none;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-22 17:44:36 +08:00
|
|
|
|
.main-content {
|
2026-09-24 21:35:00 +08:00
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
2026-10-22 17:44:36 +08:00
|
|
|
|
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;
|
2026-09-24 21:35:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-22 17:44:36 +08:00
|
|
|
|
/* ── Topbar ────────────────────────────────────────── */
|
|
|
|
|
|
.topbar {
|
2026-09-24 21:35:00 +08:00
|
|
|
|
/* Standalone PWA: content extends under the status bar (black-translucent) */
|
|
|
|
|
|
padding-top: var(--safe-top);
|
2026-10-22 17:44:36 +08:00
|
|
|
|
height: calc(64px + var(--safe-top));
|
2026-09-24 21:35:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.hamburger-menu {
|
|
|
|
|
|
display: none;
|
2026-10-22 17:44:36 +08:00
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
width: 38px;
|
|
|
|
|
|
height: 38px;
|
|
|
|
|
|
margin-left: -8px;
|
|
|
|
|
|
border: 1px solid transparent;
|
|
|
|
|
|
border-radius: var(--radius-md);
|
2026-09-24 21:35:00 +08:00
|
|
|
|
background: none;
|
2026-10-22 17:44:36 +08:00
|
|
|
|
color: var(--text2);
|
2026-09-24 21:35:00 +08:00
|
|
|
|
cursor: pointer;
|
2026-10-22 17:44:36 +08:00
|
|
|
|
transition: background-color var(--t-fast), border-color var(--t-fast);
|
2026-09-24 21:35:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.hamburger-menu:hover {
|
2026-10-22 17:44:36 +08:00
|
|
|
|
background: var(--surface);
|
|
|
|
|
|
border-color: var(--line);
|
2026-09-24 21:35:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.hamburger-menu svg {
|
|
|
|
|
|
display: block;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-22 17:44:36 +08:00
|
|
|
|
.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);
|
2026-09-24 21:35:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-22 17:44:36 +08:00
|
|
|
|
.presence:disabled {
|
|
|
|
|
|
opacity: 0.6;
|
|
|
|
|
|
cursor: progress;
|
2026-09-24 21:35:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-22 17:44:36 +08:00
|
|
|
|
/* ── Enterprise plan chips ─────────────────────────── */
|
|
|
|
|
|
.plan-display {
|
2026-09-24 21:35:00 +08:00
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
2026-10-22 17:44:36 +08:00
|
|
|
|
gap: var(--space-md);
|
2026-09-24 21:35:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-22 17:44:36 +08:00
|
|
|
|
.plan-loading {
|
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
|
font-size: var(--text-sm);
|
2026-09-24 21:35:00 +08:00
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
2026-10-22 17:44:36 +08:00
|
|
|
|
gap: 6px;
|
2026-09-24 21:35:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-22 17:44:36 +08:00
|
|
|
|
.trial-info {
|
2026-09-24 21:35:00 +08:00
|
|
|
|
display: flex;
|
2026-10-22 17:44:36 +08:00
|
|
|
|
align-items: center;
|
2026-09-24 21:35:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-22 17:44:36 +08:00
|
|
|
|
.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;
|
2026-09-24 21:35:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-22 17:44:36 +08:00
|
|
|
|
.trial-badge.clickable {
|
2026-09-24 21:35:00 +08:00
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-22 17:44:36 +08:00
|
|
|
|
/* ── Content ───────────────────────────────────────── */
|
2026-09-24 21:35:00 +08:00
|
|
|
|
.content {
|
2026-10-22 17:44:36 +08:00
|
|
|
|
flex: 1;
|
|
|
|
|
|
min-height: 0;
|
|
|
|
|
|
overflow-y: auto;
|
|
|
|
|
|
padding: 40px 48px;
|
2026-09-24 21:35:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-22 17:44:36 +08:00
|
|
|
|
/* 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 {
|
2026-09-24 21:35:00 +08:00
|
|
|
|
padding: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-22 17:44:36 +08:00
|
|
|
|
/* ── Footer ────────────────────────────────────────── */
|
2026-09-24 21:35:00 +08:00
|
|
|
|
.footer {
|
2026-10-22 17:44:36 +08:00
|
|
|
|
border-top: 1px solid var(--line);
|
|
|
|
|
|
padding: 16px 32px;
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
color: var(--muted);
|
2026-09-24 21:35:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.footer-content {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
align-items: center;
|
2026-10-22 17:44:36 +08:00
|
|
|
|
gap: 16px;
|
|
|
|
|
|
flex-wrap: wrap;
|
2026-09-24 21:35:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.footer-links {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: var(--space-lg);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.footer-links a {
|
|
|
|
|
|
color: var(--muted);
|
2026-10-22 17:44:36 +08:00
|
|
|
|
transition: color var(--t-fast);
|
2026-09-24 21:35:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-22 17:44:36 +08:00
|
|
|
|
.footer-links a:hover {
|
|
|
|
|
|
color: var(--ink);
|
2026-09-24 21:35:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-22 17:44:36 +08:00
|
|
|
|
/* ── Usage / limit banner ──────────────────────────── */
|
2026-09-24 21:35:00 +08:00
|
|
|
|
.message-limit-banner {
|
|
|
|
|
|
position: relative;
|
2026-10-22 17:44:36 +08:00
|
|
|
|
padding: 14px 28px;
|
2026-09-24 21:35:00 +08:00
|
|
|
|
background: var(--warning-bg);
|
2026-10-22 17:44:36 +08:00
|
|
|
|
border-bottom: 1px solid var(--brass-line);
|
2026-09-24 21:35:00 +08:00
|
|
|
|
width: 100%;
|
|
|
|
|
|
overflow: hidden;
|
2026-10-22 17:44:36 +08:00
|
|
|
|
color: var(--ink-2);
|
|
|
|
|
|
font-size: 13.5px;
|
2026-09-24 21:35:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.message-limit-banner.error {
|
|
|
|
|
|
background: var(--error-bg);
|
2026-10-22 17:44:36 +08:00
|
|
|
|
border-bottom: 1px solid var(--vermilion-line);
|
2026-09-24 21:35:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.banner-content {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: var(--space-md);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.banner-text {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
2026-10-22 17:44:36 +08:00
|
|
|
|
gap: 8px;
|
2026-09-24 21:35:00 +08:00
|
|
|
|
font-weight: 500;
|
|
|
|
|
|
word-break: break-word;
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.banner-icon {
|
2026-10-22 17:44:36 +08:00
|
|
|
|
font-size: 1.1em;
|
2026-09-24 21:35:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.banner-actions {
|
|
|
|
|
|
display: flex;
|
2026-10-22 17:44:36 +08:00
|
|
|
|
gap: 8px;
|
2026-09-24 21:35:00 +08:00
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.action-button {
|
2026-10-22 17:44:36 +08:00
|
|
|
|
padding: 6px 12px;
|
2026-09-24 21:35:00 +08:00
|
|
|
|
border-radius: var(--radius-sm);
|
2026-10-22 17:44:36 +08:00
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
font-weight: 600;
|
2026-09-24 21:35:00 +08:00
|
|
|
|
cursor: pointer;
|
2026-10-22 17:44:36 +08:00
|
|
|
|
border: 1px solid transparent;
|
|
|
|
|
|
background: var(--primary-bg);
|
|
|
|
|
|
color: var(--primary-fg);
|
2026-09-24 21:35:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.action-button.secondary {
|
2026-10-22 17:44:36 +08:00
|
|
|
|
background: var(--surface);
|
|
|
|
|
|
color: var(--text);
|
|
|
|
|
|
border-color: var(--line-2);
|
2026-09-24 21:35:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.usage-bar {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
bottom: 0;
|
|
|
|
|
|
left: 0;
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
height: 2px;
|
2026-10-22 17:44:36 +08:00
|
|
|
|
background: var(--line);
|
2026-09-24 21:35:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.usage-progress {
|
|
|
|
|
|
height: 100%;
|
2026-10-22 17:44:36 +08:00
|
|
|
|
background: var(--brass);
|
2026-09-24 21:35:00 +08:00
|
|
|
|
transition: width 0.3s ease;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.usage-progress.exceeded {
|
2026-10-22 17:44:36 +08:00
|
|
|
|
background: var(--vermilion);
|
2026-09-24 21:35:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-22 17:44:36 +08:00
|
|
|
|
.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;
|
2026-09-24 21:35:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-22 17:44:36 +08:00
|
|
|
|
@keyframes spin {
|
|
|
|
|
|
to {
|
|
|
|
|
|
transform: rotate(360deg);
|
2026-09-24 21:35:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-22 17:44:36 +08:00
|
|
|
|
/* ── Responsive ────────────────────────────────────── */
|
|
|
|
|
|
@media (max-width: 1024px) {
|
2026-09-24 21:35:00 +08:00
|
|
|
|
.dashboard-layout {
|
2026-10-22 17:44:36 +08:00
|
|
|
|
grid-template-columns: minmax(0, 1fr);
|
2026-09-24 21:35:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-22 17:44:36 +08:00
|
|
|
|
.hamburger-menu {
|
|
|
|
|
|
display: flex;
|
2026-09-24 21:35:00 +08:00
|
|
|
|
}
|
2026-10-22 17:44:36 +08:00
|
|
|
|
|
2026-09-24 21:35:00 +08:00
|
|
|
|
.sidebar-backdrop {
|
|
|
|
|
|
display: block;
|
|
|
|
|
|
position: fixed;
|
2026-10-22 17:44:36 +08:00
|
|
|
|
inset: 0;
|
2026-09-24 21:35:00 +08:00
|
|
|
|
z-index: 999;
|
2026-10-22 17:44:36 +08:00
|
|
|
|
background: var(--scrim);
|
2026-09-24 21:35:00 +08:00
|
|
|
|
backdrop-filter: blur(2px);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-22 17:44:36 +08:00
|
|
|
|
@media (max-width: 1100px) {
|
|
|
|
|
|
.content {
|
|
|
|
|
|
padding: 28px 24px;
|
2026-09-24 21:35:00 +08:00
|
|
|
|
}
|
2026-10-22 17:44:36 +08:00
|
|
|
|
}
|
2026-09-24 21:35:00 +08:00
|
|
|
|
|
2026-10-22 17:44:36 +08:00
|
|
|
|
@media (max-width: 900px) {
|
2026-09-24 21:35:00 +08:00
|
|
|
|
.content {
|
2026-10-22 17:44:36 +08:00
|
|
|
|
padding: 20px 18px;
|
2026-09-24 21:35:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-22 17:44:36 +08:00
|
|
|
|
.crumb {
|
|
|
|
|
|
font-size: 12.5px;
|
2026-09-24 21:35:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-22 17:44:36 +08:00
|
|
|
|
.crumb .en,
|
|
|
|
|
|
.crumb .sep:first-of-type {
|
|
|
|
|
|
display: none;
|
2026-09-24 21:35:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-22 17:44:36 +08:00
|
|
|
|
.presence span:last-child {
|
2026-09-24 21:35:00 +08:00
|
|
|
|
display: none;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-22 17:44:36 +08:00
|
|
|
|
.presence {
|
|
|
|
|
|
padding: 0 12px;
|
2026-09-24 21:35:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-22 17:44:36 +08:00
|
|
|
|
/* 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));
|
2026-09-24 21:35:00 +08:00
|
|
|
|
}
|
2026-10-22 17:44:36 +08:00
|
|
|
|
</style>
|