feat(ui): refresh dashboard and paper boat branding
This commit is contained in:
1 parent
6cd904d460
commit
b3a53a7f58
93 files changed
+11134
-7543
No files matched your search
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import BrandMark from '@/components/common/BrandMark.vue'
|
||||
import AppSidebar from '@/components/layout/AppSidebar.vue'
|
||||
import paperBoat from '@/assets/logo.svg'
|
||||
|
||||
vi.mock('vue-router', () => ({ useRoute: () => ({ path: '/' }) }))
|
||||
vi.mock('@/components/layout/navItems', () => ({
|
||||
useNavItems: () => ({ navGroups: [] }), navIconSvg: () => '',
|
||||
}))
|
||||
|
||||
describe('paper boat brand mark', () => {
|
||||
it('renders a decorative vector at the requested icon size', () => {
|
||||
const wrapper = mount(BrandMark, { props: { size: 16 } })
|
||||
const image = wrapper.get('img')
|
||||
expect(image.attributes('width')).toBe('16')
|
||||
expect(image.attributes('height')).toBe('16')
|
||||
expect(image.attributes('aria-hidden')).toBe('true')
|
||||
expect(image.attributes('src')).toBe(paperBoat)
|
||||
})
|
||||
|
||||
it('registers the real brand image in both expanded and collapsed sidebars', async () => {
|
||||
const wrapper = mount(AppSidebar, {
|
||||
props: { isCollapsed: false }, global: { stubs: { RouterLink: true } },
|
||||
})
|
||||
expect(wrapper.get('.brand img').attributes('width')).toBe('34')
|
||||
await wrapper.setProps({ isCollapsed: true })
|
||||
expect(wrapper.get('.brand img').attributes('width')).toBe('34')
|
||||
})
|
||||
})
|
||||
@@ -145,7 +145,7 @@ describe('useNavItems', () => {
|
||||
|
||||
it('keeps the sidebar section headings in the More sheet', () => {
|
||||
const { moreNavGroups } = useNavItems()
|
||||
expect(moreNavGroups.value.map((g) => g.section)).toEqual(['主菜单', '设置'])
|
||||
expect(moreNavGroups.value.map((g) => g.section)).toEqual(['接待', '智能', '团队', '配置'])
|
||||
// No empty groups — a section fully covered by the bottom nav is dropped
|
||||
moreNavGroups.value.forEach((g) => expect(g.items.length).toBeGreaterThan(0))
|
||||
})
|
||||
@@ -153,15 +153,15 @@ describe('useNavItems', () => {
|
||||
// User Settings is always visible while the Settings heading used to be
|
||||
// permission-gated separately, which orphaned the item into Main Menu.
|
||||
it('keeps always-visible settings items under Settings for restricted users', () => {
|
||||
// Knowledge, so Main Menu still has a non-primary item to group; no
|
||||
// settings grants, so Settings holds only the always-visible entry.
|
||||
// Knowledge, so 智能 still has a non-primary item to group; no settings
|
||||
// grants, so 配置 holds only the always-visible entry.
|
||||
asUser(['manage_knowledge'])
|
||||
|
||||
const { moreNavGroups } = useNavItems()
|
||||
const settings = moreNavGroups.value.find((g) => g.section === '设置')
|
||||
const settings = moreNavGroups.value.find((g) => g.section === '配置')
|
||||
|
||||
expect(settings?.items.map((i) => i.to)).toEqual(['/settings/user'])
|
||||
expect(moreNavGroups.value.find((g) => g.section === '主菜单')?.items.map((i) => i.to))
|
||||
expect(moreNavGroups.value.find((g) => g.section === '智能')?.items.map((i) => i.to))
|
||||
.not.toContain('/settings/user')
|
||||
})
|
||||
|
||||
@@ -170,8 +170,8 @@ describe('useNavItems', () => {
|
||||
|
||||
const { moreNavGroups, navItems } = useNavItems()
|
||||
|
||||
// Only User Settings survives, so Main Menu disappears entirely
|
||||
expect(moreNavGroups.value.map((g) => g.section)).toEqual(['设置'])
|
||||
expect(navItems.value.filter((i) => i.section).map((i) => i.section)).toEqual(['设置'])
|
||||
// Only User Settings survives, so only 配置 is left
|
||||
expect(moreNavGroups.value.map((g) => g.section)).toEqual(['配置'])
|
||||
expect(navItems.value.filter((i) => i.section).map((i) => i.section)).toEqual(['配置'])
|
||||
})
|
||||
})
|
||||
@@ -27,8 +27,6 @@ interface DashboardLayoutInstance extends ComponentPublicInstance {
|
||||
currentUser: {
|
||||
is_online: boolean
|
||||
}
|
||||
showSettings: boolean
|
||||
showUserMenu: boolean
|
||||
showNotifications: boolean
|
||||
userName: string
|
||||
userRole: string
|
||||
@@ -146,47 +144,28 @@ describe('DashboardLayout', () => {
|
||||
expect(wrapper.classes()).toContain('sidebar-collapsed')
|
||||
})
|
||||
|
||||
it('displays user information correctly', async () => {
|
||||
const vm = wrapper.vm as unknown as DashboardLayoutInstance
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(vm.userName).toBe('Test User')
|
||||
expect(vm.userRole).toBe('Admin')
|
||||
|
||||
// Find name element in the sidebar-open state
|
||||
const userInfo = wrapper.find('.user-info')
|
||||
if (userInfo.exists()) {
|
||||
expect(userInfo.find('.name').text()).toBe('Test User')
|
||||
} else {
|
||||
// If user-info is not visible (responsive design), just verify the data
|
||||
expect(vm.userName).toBe('Test User')
|
||||
}
|
||||
})
|
||||
|
||||
it('shows user menu when clicking profile', async () => {
|
||||
const profileTrigger = wrapper.find('.profile-trigger')
|
||||
expect(wrapper.find('.dropdown-menu').exists()).toBe(false)
|
||||
|
||||
await profileTrigger.trigger('click')
|
||||
expect(wrapper.find('.dropdown-menu').exists()).toBe(true)
|
||||
it('renders the workspace topbar with the current page title', async () => {
|
||||
const topbar = wrapper.find('.topbar')
|
||||
expect(topbar.exists()).toBe(true)
|
||||
expect(topbar.find('.crumb').exists()).toBe(true)
|
||||
// '/' is not a titled page, so the crumb keeps its group but no leaf
|
||||
expect(topbar.find('.crumb').text()).toContain('工作台')
|
||||
})
|
||||
|
||||
it('shows notification badge with correct count', async () => {
|
||||
await wrapper.vm.$nextTick() // Wait for mounted hook
|
||||
const badge = wrapper.find('.notification-badge')
|
||||
const badge = wrapper.find('.topbar .badge-float')
|
||||
expect(badge.exists()).toBe(true)
|
||||
expect(badge.text()).toBe('5')
|
||||
})
|
||||
|
||||
it('toggles online status correctly', async () => {
|
||||
it('toggles online status from the presence control', async () => {
|
||||
const vm = wrapper.vm as unknown as DashboardLayoutInstance
|
||||
const profileTrigger = wrapper.find('.profile-trigger')
|
||||
await profileTrigger.trigger('click')
|
||||
|
||||
const statusToggle = wrapper.find('.status-toggle')
|
||||
expect(statusToggle.classes()).toContain('online')
|
||||
|
||||
await statusToggle.trigger('click')
|
||||
const presence = wrapper.find('.presence')
|
||||
expect(presence.exists()).toBe(true)
|
||||
expect(presence.attributes('data-state')).toBe('online')
|
||||
|
||||
await presence.trigger('click')
|
||||
expect(vm.currentUser.is_online).toBe(false)
|
||||
})
|
||||
|
||||
@@ -195,16 +174,12 @@ describe('DashboardLayout', () => {
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
it('closes menus when route changes', async () => {
|
||||
it('closes the notification drawer when the route changes', async () => {
|
||||
const vm = wrapper.vm as any
|
||||
// Open menus first
|
||||
vm.showUserMenu = true
|
||||
vm.showNotifications = true
|
||||
|
||||
// Trigger route change
|
||||
|
||||
await router.push('/new-route')
|
||||
|
||||
expect(vm.showUserMenu).toBe(false)
|
||||
|
||||
expect(vm.showNotifications).toBe(false)
|
||||
})
|
||||
|
||||
@@ -217,4 +192,4 @@ describe('DashboardLayout', () => {
|
||||
// Skip this test since we don't want to modify the component
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import {
|
||||
getOrbStyleAt, ORB_PALETTE_COUNT, PAPER_AVATAR_NAMES,
|
||||
orbIndexForSeed, orbSvgDataUri, resolveAvatarImage, resolveOrbStyle,
|
||||
terminalMarkSvgDataUri,
|
||||
} from '@/utils/orb'
|
||||
import { useWidgetStyles } from '@/composables/useWidgetStyles'
|
||||
import type { AgentCustomization } from '@/types/widget'
|
||||
|
||||
const decode = (uri: string) => decodeURIComponent(uri.replace('data:image/svg+xml,', ''))
|
||||
|
||||
describe('paper-craft avatars', () => {
|
||||
it('ships ten distinct flat vectors under the existing variant indexes', () => {
|
||||
expect(ORB_PALETTE_COUNT).toBe(10)
|
||||
expect(PAPER_AVATAR_NAMES).toHaveLength(10)
|
||||
const variants = Array.from({ length: 10 }, (_, i) => orbSvgDataUri('support', i))
|
||||
expect(new Set(variants).size).toBe(10)
|
||||
for (const uri of variants) {
|
||||
const svg = new DOMParser().parseFromString(decode(uri), 'image/svg+xml')
|
||||
expect(svg.querySelector('parsererror')).toBeNull()
|
||||
expect(svg.documentElement.getAttribute('viewBox')).toBe('0 0 100 100')
|
||||
expect(svg.querySelector('radialGradient, linearGradient, script, image')).toBeNull()
|
||||
expect(svg.querySelector('path')).not.toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('uses the exact same artwork for CSS avatar slots and img slots', () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
expect(getOrbStyleAt(i).background).toContain(orbSvgDataUri('support', i))
|
||||
expect(resolveOrbStyle('support', i)).toEqual(getOrbStyleAt(i))
|
||||
}
|
||||
})
|
||||
|
||||
it('retains stable seeds and normalizes out-of-range stored indexes', () => {
|
||||
expect(orbSvgDataUri('support')).toBe(orbSvgDataUri('', orbIndexForSeed('support')))
|
||||
expect(orbSvgDataUri('', -1)).toBe(orbSvgDataUri('', 9))
|
||||
expect(orbSvgDataUri('', 12)).toBe(orbSvgDataUri('', 2))
|
||||
expect(orbSvgDataUri('', 2.5)).toBe(orbSvgDataUri('', 2))
|
||||
for (const invalid of [NaN, Infinity, '2', null]) {
|
||||
expect(orbSvgDataUri('support', invalid)).toBe(orbSvgDataUri('support'))
|
||||
}
|
||||
})
|
||||
|
||||
it('replaces an old stored orb from metadata, not from the old data URI', () => {
|
||||
expect(resolveAvatarImage('support', {
|
||||
photo_url: 'data:image/svg+xml,old-gradient',
|
||||
customization_metadata: { avatar_style: 'orb', orb_variant: 0 },
|
||||
})).toBe(orbSvgDataUri('support', 0))
|
||||
})
|
||||
|
||||
it.each([
|
||||
'/api/v1/uploads/agents/a.png',
|
||||
'https://cdn.example/a.png?signature=preserved',
|
||||
'data:image/png;base64,customer-photo',
|
||||
'blob:https://example.com/preview',
|
||||
])('preserves an uploaded image: %s', (photo) => {
|
||||
expect(resolveAvatarImage('support', { photo_url: photo })).toBe(photo)
|
||||
expect(resolveAvatarImage('support', {
|
||||
photo_url: photo, customization_metadata: { avatar_style: 'photo', orb_variant: 0 },
|
||||
})).toBe(photo)
|
||||
})
|
||||
|
||||
it('provides a complete avatar when no photo exists', () => {
|
||||
expect(resolveAvatarImage('support')).toBe(orbSvgDataUri('support'))
|
||||
})
|
||||
|
||||
it('retains the terminal option and rejects markup in its color', () => {
|
||||
const terminal = terminalMarkSvgDataUri()
|
||||
expect(decode(terminal)).toContain('#8BB49C')
|
||||
expect(terminalMarkSvgDataUri('\"/><script>alert(1)</script>')).toBe(terminal)
|
||||
expect(resolveAvatarImage('support', {
|
||||
photo_url: terminal, customization_metadata: { avatar_style: 'terminal' },
|
||||
})).toBe(terminal)
|
||||
})
|
||||
|
||||
it('updates the real widget image reactively and preserves its runtime upload origin', () => {
|
||||
const customization = ref<AgentCustomization>({
|
||||
photo_url: 'data:image/svg+xml,old-gradient',
|
||||
customization_metadata: { avatar_style: 'orb', orb_variant: 0 },
|
||||
})
|
||||
const { photoUrl } = useWidgetStyles(customization, ref('support'))
|
||||
expect(photoUrl.value).toBe(orbSvgDataUri('support', 0))
|
||||
customization.value = {
|
||||
photo_url: 'https://cdn.example/customer.png',
|
||||
customization_metadata: { avatar_style: 'photo' },
|
||||
}
|
||||
expect(photoUrl.value).toBe('https://cdn.example/customer.png')
|
||||
})
|
||||
})
|
||||
@@ -128,7 +128,7 @@ describe('LoginView', () => {
|
||||
})
|
||||
|
||||
it('renders login form properly', () => {
|
||||
expect(wrapper.find('.auth-page').exists()).toBe(true)
|
||||
expect(wrapper.find('.login').exists()).toBe(true)
|
||||
expect(wrapper.find('input[type="email"]').exists()).toBe(true)
|
||||
expect(wrapper.find('input[type="password"]').exists()).toBe(true)
|
||||
expect(wrapper.find('button[type="submit"]').exists()).toBe(true)
|
||||
|
||||
Reference in new issue
Block a user