feat(ui): refresh dashboard and paper boat branding
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)
|
||||
|
||||
|
Before Width: | Height: | Size: 245 KiB After Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 247 KiB After Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 277 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 266 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 250 KiB After Width: | Height: | Size: 133 KiB |
|
Before Width: | Height: | Size: 258 KiB After Width: | Height: | Size: 127 KiB |
|
Before Width: | Height: | Size: 251 KiB After Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 245 KiB After Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 263 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 276 KiB After Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 284 KiB After Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 237 KiB After Width: | Height: | Size: 131 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" fill="#E3EADD"/><g transform="translate(9 18) scale(1.28)"><path d="M29 20 39 6 42 21Z" fill="#568B73"/><path d="M4 16 32 26 23 43H15Z" fill="#2E6A5C"/><path d="M35 26 60 16 49 43H27Z" fill="#8BB49C"/></g></svg>
|
||||
|
After Width: | Height: | Size: 305 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" fill="#E3EADD"/><path d="M18 25 47 34V77L18 66Z" fill="#2E6A5C"/><path d="M53 34 82 25V66L53 77Z" fill="#8BB49C"/><path d="M23 20 47 29V36L23 27Z" fill="#F8F4E9"/><path d="M53 29 77 20V27L53 36Z" fill="#FFFDF6"/></svg>
|
||||
|
After Width: | Height: | Size: 312 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" fill="#F2E6DC"/><path d="M19 36 31 24 47 24 50 33 53 24H69L81 36V50L50 78 19 50Z" fill="#B45132"/><path d="M50 33 69 24 81 36V50L50 78Z" fill="#D68C65"/></svg>
|
||||
|
After Width: | Height: | Size: 253 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" fill="#E3EADD"/><path d="M23 48H77V78H23Z" fill="#8BB49C"/><path d="M16 48 50 20 84 48Z" fill="#2E6A5C"/><path d="M50 20 84 48H50Z" fill="#568B73"/><path d="M43 58H57V78H43Z" fill="#F4F1EB"/></svg>
|
||||
|
After Width: | Height: | Size: 291 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" fill="#233E36"/><g transform="translate(9 18) scale(1.28)"><path d="M29 20 39 6 42 21Z" fill="#F4F1EB"/><path d="M4 16 32 26 23 43H15Z" fill="#DCE8DC"/><path d="M35 26 60 16 49 43H27Z" fill="#8BB49C"/></g></svg>
|
||||
|
After Width: | Height: | Size: 305 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" fill="#F2E8DC"/><path d="M51 15 77 43 51 72 25 43Z" fill="#D5AE68"/><path d="M51 15V72L25 43Z" fill="#B45132"/><path d="M51 72 48 81 55 87" fill="none" stroke="#8A6326" stroke-width="3" stroke-linecap="round"/></svg>
|
||||
|
After Width: | Height: | Size: 310 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" fill="#E8EBDC"/><path d="M25 76C15 40 36 19 78 22 80 60 59 81 25 76Z" fill="#8BB49C"/><path d="M25 76C15 40 36 19 78 22Z" fill="#2E6A5C"/></svg>
|
||||
|
After Width: | Height: | Size: 238 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" fill="#F1E8D9"/><path d="M20 31H80V71H20Z" fill="#D6B47C"/><path d="M20 71 50 45 80 71Z" fill="#ECD7AE"/><path d="M20 31 50 55 80 31Z" fill="#2E6A5C"/></svg>
|
||||
|
After Width: | Height: | Size: 251 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" fill="#F0E6D8"/><path d="M50 50 26 21 53 29Z" fill="#B45132"/><path d="M50 50 79 26 71 53Z" fill="#D5AE68"/><path d="M50 50 74 79 47 71Z" fill="#8BB49C"/><path d="M50 50 21 74 29 47Z" fill="#2E6A5C"/><circle cx="50" cy="50" r="4" fill="#F4F1EB"/></svg>
|
||||
|
After Width: | Height: | Size: 346 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" fill="#E5ECE4"/><path d="M17 43 83 22 57 79 45 54Z" fill="#8BB49C"/><path d="M17 43 45 54 83 22Z" fill="#2E6A5C"/><path d="M45 54 57 79 55 48 83 22Z" fill="#568B73"/></svg>
|
||||
|
After Width: | Height: | Size: 266 B |
@@ -29,21 +29,30 @@ html, body {
|
||||
body {
|
||||
line-height: 1.6;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 15px;
|
||||
font-size: 14px;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Display headings */
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: var(--font-display);
|
||||
font-weight: var(--font-weight-bold);
|
||||
/* Headings — 墨与瓷 sets them in the serif face; the smallest two stay sans
|
||||
because they sit inside dense UI where a serif reads as a mistake. */
|
||||
h1, h2, h3, h4 {
|
||||
font-family: var(--font-serif);
|
||||
font-weight: 600;
|
||||
letter-spacing: var(--tracking-display);
|
||||
color: var(--text);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
h5, h6 {
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0;
|
||||
color: var(--text);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* Inputs inherit font */
|
||||
input, textarea, select, button {
|
||||
font-family: var(--font-sans);
|
||||
@@ -56,13 +65,8 @@ input::placeholder, textarea::placeholder {
|
||||
|
||||
/* Text selection */
|
||||
::selection {
|
||||
background: #C9F24E;
|
||||
color: #0B0C10;
|
||||
}
|
||||
|
||||
[data-theme="light"] ::selection {
|
||||
background: #4C7A0B;
|
||||
color: #ffffff;
|
||||
background: var(--vermilion);
|
||||
color: var(--seal-fg);
|
||||
}
|
||||
|
||||
/* Scrollbars */
|
||||
|
||||
@@ -1,6 +1 @@
|
||||
<svg width="120" height="120" viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="20" y="20" width="80" height="80" rx="22" fill="#C9F24E"/>
|
||||
<circle cx="42" cy="60" r="6" fill="#0B0C10"/>
|
||||
<circle cx="60" cy="60" r="6" fill="#0B0C10"/>
|
||||
<circle cx="78" cy="60" r="6" fill="#0B0C10"/>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 48" fill="none"><path d="M29 20 39 6 42 21Z" fill="#568B73"/><path d="M4 16 32 26 23 43H15Z" fill="#2E6A5C"/><path d="M35 26 60 16 49 43H27Z" fill="#8BB49C"/></svg>
|
||||
|
Before Width: | Height: | Size: 324 B After Width: | Height: | Size: 221 B |
@@ -14,41 +14,106 @@
|
||||
body {
|
||||
min-height: 100vh;
|
||||
color: var(--text-color);
|
||||
background: var(--background-color);
|
||||
background: var(--paper);
|
||||
line-height: 1.6;
|
||||
font-family: var(--font-family);
|
||||
font-size: var(--text-base);
|
||||
text-rendering: optimizeLegibility;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
font-feature-settings: 'ss01', 'cv11';
|
||||
transition: background-color var(--t-med), color var(--t-med);
|
||||
}
|
||||
|
||||
/* Paper grain — the texture that keeps the warm surfaces from looking flat */
|
||||
body::after {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
opacity: var(--grain-opacity);
|
||||
mix-blend-mode: var(--grain-blend);
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--vermilion);
|
||||
color: var(--seal-fg);
|
||||
}
|
||||
|
||||
/* Typography */
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
h1 { font-size: var(--text-4xl); }
|
||||
h2 { font-size: var(--text-3xl); }
|
||||
h3 { font-size: var(--text-2xl); }
|
||||
h4 { font-size: var(--text-xl); }
|
||||
h5 { font-size: var(--text-lg); }
|
||||
h6 { font-size: var(--text-base); }
|
||||
h1 {
|
||||
font-size: var(--text-4xl);
|
||||
}
|
||||
h2 {
|
||||
font-size: var(--text-3xl);
|
||||
}
|
||||
h3 {
|
||||
font-size: var(--text-2xl);
|
||||
}
|
||||
h4 {
|
||||
font-size: var(--text-xl);
|
||||
}
|
||||
h5 {
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
h6 {
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
/* Links */
|
||||
a {
|
||||
color: var(--primary-color);
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
a:hover {
|
||||
opacity: 0.8;
|
||||
/* Scrollbars — hairlines, not gutters */
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--line-2);
|
||||
border-radius: 10px;
|
||||
border: 3px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Focus Styles */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
outline: none;
|
||||
box-shadow: var(--ring);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* Helpers shared by every surface */
|
||||
.num {
|
||||
font-family: var(--font-num);
|
||||
font-variant-numeric: lining-nums tabular-nums;
|
||||
font-optical-sizing: auto;
|
||||
}
|
||||
.mono {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.92em;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.serif {
|
||||
font-family: var(--font-serif);
|
||||
}
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
@@ -1,49 +1,135 @@
|
||||
/* ReplyPier Design Tokens — dark-first, lime accent */
|
||||
:root {
|
||||
/* ReplyPier Design Tokens — 墨与瓷 (ink & porcelain)
|
||||
*
|
||||
* Light = 瓷 (porcelain, default), Dark = 夜墨 (night ink).
|
||||
* One accent with meaning: 朱 vermilion = brand + needs attention.
|
||||
* 青 celadon = AI, 金 brass = ratings, 黛 indigo = info. Primary action = ink.
|
||||
*
|
||||
* The lower half of :root maps the legacy token names onto this palette so the
|
||||
* existing components keep working without a per-file rewrite.
|
||||
*/
|
||||
|
||||
:root,
|
||||
[data-theme='light'] {
|
||||
/* ── Surfaces ─────────────────────────────────────── */
|
||||
--paper: #f4f1eb; /* app background */
|
||||
--paper-2: #ece8e0; /* sidebar, recessed wells */
|
||||
--surface: #fffefb; /* cards, panels */
|
||||
--surface-2: #f9f7f2; /* subtle fills inside cards */
|
||||
--surface-sunk: #efebe4; /* inputs on surface, tracks */
|
||||
|
||||
/* ── Ink scale ────────────────────────────────────── */
|
||||
--ink: #16171a;
|
||||
--ink-2: #33343a;
|
||||
--ink-muted: #6a665f; /* AA 5.0:1 on paper */
|
||||
--ink-faint: #9a958c; /* decorative / placeholder only */
|
||||
|
||||
/* ── Hairlines ────────────────────────────────────── */
|
||||
--line: rgba(22, 23, 26, 0.08);
|
||||
--line-2: rgba(22, 23, 26, 0.14);
|
||||
--line-3: rgba(22, 23, 26, 0.22);
|
||||
|
||||
/* ── Meaningful colour ────────────────────────────── */
|
||||
--vermilion: #b23a22; /* 朱 — brand, waiting, unread, destructive */
|
||||
--celadon: #2e6a5c; /* 青 — AI, online, success */
|
||||
--brass: #8a6326; /* 金 — ratings, premium */
|
||||
--indigo: #3a4b7c; /* 黛 — info, links */
|
||||
|
||||
--vermilion-soft: color-mix(in srgb, var(--vermilion) 8%, transparent);
|
||||
--vermilion-line: color-mix(in srgb, var(--vermilion) 26%, transparent);
|
||||
--celadon-soft: color-mix(in srgb, var(--celadon) 8%, transparent);
|
||||
--celadon-line: color-mix(in srgb, var(--celadon) 24%, transparent);
|
||||
--brass-soft: color-mix(in srgb, var(--brass) 10%, transparent);
|
||||
--brass-line: color-mix(in srgb, var(--brass) 28%, transparent);
|
||||
--indigo-soft: color-mix(in srgb, var(--indigo) 8%, transparent);
|
||||
--indigo-line: color-mix(in srgb, var(--indigo) 24%, transparent);
|
||||
|
||||
/* ── Actions ──────────────────────────────────────── */
|
||||
--primary-bg: var(--ink);
|
||||
--primary-fg: #f7f4ee;
|
||||
--primary-hover: #2a2b30;
|
||||
--seal-fg: #fbf6ee;
|
||||
|
||||
/* ── Message bubbles ──────────────────────────────── */
|
||||
--bubble-visitor: var(--surface);
|
||||
--bubble-ai: color-mix(in srgb, var(--celadon) 6%, var(--surface));
|
||||
--bubble-human: var(--ink);
|
||||
--bubble-human-fg: #f3efe7;
|
||||
|
||||
/* ── Elevation — paper-like, low and warm ─────────── */
|
||||
--elev-1: 0 1px 0 rgba(22, 23, 26, 0.03), 0 1px 2px rgba(22, 23, 26, 0.05);
|
||||
--elev-2: 0 1px 0 rgba(22, 23, 26, 0.03), 0 10px 30px -12px rgba(40, 30, 15, 0.14);
|
||||
--elev-3: 0 30px 80px -24px rgba(40, 30, 15, 0.28);
|
||||
--ring: 0 0 0 3px color-mix(in srgb, var(--ink) 12%, transparent);
|
||||
|
||||
/* ── Type ─────────────────────────────────────────── */
|
||||
--font-serif: 'Noto Serif SC', 'Songti SC', 'STSong', serif;
|
||||
--font-num: 'Fraunces', 'Noto Serif SC', Georgia, serif;
|
||||
--font-sans-cn: 'Hanken Grotesk', 'PingFang SC', 'Hiragino Sans GB', 'Noto Sans SC',
|
||||
'Microsoft YaHei', sans-serif;
|
||||
--font-mono-cn: 'IBM Plex Mono', ui-monospace, 'SF Mono', monospace;
|
||||
|
||||
/* ── Motion ───────────────────────────────────────── */
|
||||
--ease: cubic-bezier(0.2, 0.7, 0.1, 1);
|
||||
--t-fast: 0.16s var(--ease);
|
||||
--t-med: 0.28s var(--ease);
|
||||
|
||||
/* ── Chart series (validated categorical pair, both modes) ── */
|
||||
--viz-1: #00876c; /* AI */
|
||||
--viz-2: #b5650c; /* 人工 */
|
||||
--viz-grid: rgba(22, 23, 26, 0.07);
|
||||
--viz-axis: #8b867d;
|
||||
|
||||
/* ── Grain overlay ────────────────────────────────── */
|
||||
--grain-opacity: 0.045;
|
||||
--grain-blend: multiply;
|
||||
|
||||
color-scheme: light;
|
||||
|
||||
/* ══════════════════════════════════════════════════════
|
||||
Legacy token layer — same names, 墨与瓷 values.
|
||||
══════════════════════════════════════════════════════ */
|
||||
|
||||
/* Surfaces */
|
||||
--bg: #0B0C10;
|
||||
--bg2: #0E0F14;
|
||||
--surface: #13151C;
|
||||
--topbar: rgba(11,12,16,.8);
|
||||
--bg: var(--paper);
|
||||
--bg2: var(--surface);
|
||||
--topbar: color-mix(in srgb, var(--paper) 88%, transparent);
|
||||
--bg-elevated: var(--surface);
|
||||
--bg-deep: var(--paper-2);
|
||||
--scrim: rgba(22, 23, 26, 0.4);
|
||||
--header-gradient: linear-gradient(180deg, var(--paper), var(--paper));
|
||||
|
||||
/* Accent */
|
||||
--accent-ink: #C9F24E; /* accent for text / borders / icons (darkens in light mode) */
|
||||
--accent-solid: #C9F24E; /* accent for FILLED surfaces (stays lime in both themes) */
|
||||
--on-accent-solid: #0B0C10; /* text/icon on --accent-solid (dark in both themes) */
|
||||
--c-lime: #C9F24E;
|
||||
--c-teal: #5FE3D6;
|
||||
--c-purple: #9D8CFF;
|
||||
--c-coral: #FF8A73;
|
||||
/* Accent — the single primary action colour is ink */
|
||||
--accent-ink: var(--ink);
|
||||
--accent-solid: var(--ink);
|
||||
--on-accent-solid: var(--primary-fg);
|
||||
--c-lime: var(--vermilion);
|
||||
--c-teal: var(--celadon);
|
||||
--c-purple: var(--indigo);
|
||||
--c-coral: var(--vermilion);
|
||||
|
||||
/* Contrast inks — text/icon placed on top of a fill */
|
||||
--on-accent: #0B0C10; /* on lime accent */
|
||||
--on-light: #0B0C10; /* on any light fill */
|
||||
--on-dark: #F5F6F8; /* on any dark/coloured fill (theme-invariant) */
|
||||
|
||||
/* Elevated surfaces / header gradient */
|
||||
--bg-elevated: #101119;
|
||||
--bg-deep: #0A0B0E;
|
||||
--header-gradient: linear-gradient(180deg, var(--bg-elevated), var(--bg));
|
||||
--scrim: rgba(6,7,10,.72);
|
||||
/* Contrast inks */
|
||||
--on-accent: var(--primary-fg);
|
||||
--on-light: var(--ink);
|
||||
--on-dark: #f5f6f8;
|
||||
|
||||
/* Mode pills (agent editor) */
|
||||
--mode-ai-bg: #3B82F6;
|
||||
--mode-ai-fg: #FFFFFF;
|
||||
--mode-wf-bg: #9D8CFF;
|
||||
--mode-wf-fg: #0B0C10;
|
||||
--mode-ai-bg: var(--celadon);
|
||||
--mode-ai-fg: #f2faf6;
|
||||
--mode-wf-bg: var(--indigo);
|
||||
--mode-wf-fg: #f4f6fb;
|
||||
--pill-idle-bg: var(--o05);
|
||||
--pill-idle-fg: var(--muted);
|
||||
|
||||
/* Extended status / chart palette */
|
||||
--c-danger: #FF6B6B;
|
||||
--c-warn: #E6A23C;
|
||||
--c-info: #8FB8FF;
|
||||
--c-positive: #7CE38B;
|
||||
--c-neutral: #8A93A3;
|
||||
--c-online: #8FEDE3; /* online label text */
|
||||
--c-pro: #C5BDFF; /* PRO badge text */
|
||||
/* Status / chart palette */
|
||||
--c-danger: var(--vermilion);
|
||||
--c-warn: var(--brass);
|
||||
--c-info: var(--indigo);
|
||||
--c-positive: var(--celadon);
|
||||
--c-neutral: var(--ink-muted);
|
||||
--c-online: var(--celadon);
|
||||
--c-pro: var(--brass);
|
||||
|
||||
/* Theme-aware tints (derived from base palette, auto-adjust per theme) */
|
||||
/* Theme-aware tints */
|
||||
--accent-bg-06: color-mix(in srgb, var(--accent-ink) 6%, transparent);
|
||||
--accent-bg-08: color-mix(in srgb, var(--accent-ink) 8%, transparent);
|
||||
--accent-bg-12: color-mix(in srgb, var(--accent-ink) 12%, transparent);
|
||||
@@ -51,72 +137,73 @@
|
||||
--teal-bg-10: color-mix(in srgb, var(--c-teal) 10%, transparent);
|
||||
--teal-bg: color-mix(in srgb, var(--c-teal) 12%, transparent);
|
||||
--teal-border: color-mix(in srgb, var(--c-teal) 22%, transparent);
|
||||
--purple-bg: color-mix(in srgb, var(--c-purple) 15%, transparent);
|
||||
--purple-border: color-mix(in srgb, var(--c-purple) 28%, transparent);
|
||||
--purple-bg: color-mix(in srgb, var(--c-purple) 12%, transparent);
|
||||
--purple-border: color-mix(in srgb, var(--c-purple) 26%, transparent);
|
||||
--coral-bg: color-mix(in srgb, var(--c-coral) 12%, transparent);
|
||||
--coral-border: color-mix(in srgb, var(--c-coral) 30%, transparent);
|
||||
|
||||
/* Controls */
|
||||
--toggle-knob: #FFFFFF;
|
||||
--toggle-knob: #ffffff;
|
||||
--toggle-track-off: var(--o12);
|
||||
--toggle-on-teal: var(--c-teal);
|
||||
--toggle-on-accent: var(--accent-ink);
|
||||
|
||||
/* Inbox message bubbles */
|
||||
--bubble-customer-bg: var(--o05);
|
||||
--bubble-customer-bg: var(--surface-2);
|
||||
--bubble-customer-fg: var(--text2);
|
||||
--bubble-ai-bg: var(--teal-bg-10);
|
||||
--bubble-ai-border: var(--teal-border);
|
||||
--bubble-ai-bg: var(--bubble-ai);
|
||||
--bubble-ai-border: var(--celadon-line);
|
||||
--bubble-ai-fg: var(--text2);
|
||||
--bubble-agent-bg: var(--accent-ink);
|
||||
--bubble-agent-fg: var(--on-accent);
|
||||
--bubble-agent-bg: var(--bubble-human);
|
||||
--bubble-agent-fg: var(--bubble-human-fg);
|
||||
|
||||
/* Brand gradients / avatars */
|
||||
--grad-purple-teal: linear-gradient(135deg, var(--c-purple), var(--c-teal));
|
||||
--grad-lime-teal: linear-gradient(135deg, var(--accent-ink), var(--c-teal));
|
||||
--grad-generate: linear-gradient(135deg, var(--c-purple), #7C6AE6);
|
||||
--grad-purple-teal: linear-gradient(135deg, var(--indigo), var(--celadon));
|
||||
--grad-lime-teal: linear-gradient(135deg, var(--ink), var(--celadon));
|
||||
--grad-generate: linear-gradient(135deg, var(--indigo), color-mix(in srgb, var(--indigo) 70%, var(--ink)));
|
||||
|
||||
/* Text scale */
|
||||
--text: #F5F6F8;
|
||||
--text2: #E7EAF0;
|
||||
--text3: #C7CCD6;
|
||||
--muted: #9CA3B0;
|
||||
/* Small text on every surface (down to #1A1C23) must reach WCAG AA 4.5:1. */
|
||||
--muted2: #868D9C;
|
||||
--faint: #7F8696;
|
||||
--code: #D7DBE3;
|
||||
--text: var(--ink);
|
||||
--text2: var(--ink-2);
|
||||
--text3: color-mix(in srgb, var(--ink) 82%, var(--paper));
|
||||
--muted: var(--ink-muted);
|
||||
--muted2: color-mix(in srgb, var(--ink-muted) 82%, var(--paper));
|
||||
--faint: var(--ink-faint);
|
||||
--code: var(--ink-2);
|
||||
|
||||
/* White-opacity tokens — borders, dividers, overlays */
|
||||
--o03: rgba(255,255,255,.03);
|
||||
--o04: rgba(255,255,255,.04);
|
||||
--o05: rgba(255,255,255,.05);
|
||||
--o06: rgba(255,255,255,.06);
|
||||
--o07: rgba(255,255,255,.07);
|
||||
--o08: rgba(255,255,255,.08);
|
||||
--o10: rgba(255,255,255,.1);
|
||||
--o12: rgba(255,255,255,.12);
|
||||
--o14: rgba(255,255,255,.14);
|
||||
--o16: rgba(255,255,255,.16);
|
||||
--o20: rgba(255,255,255,.2);
|
||||
--o25: rgba(255,255,255,.25);
|
||||
--o30: rgba(255,255,255,.3);
|
||||
/* White-opacity tokens → ink-opacity tokens (they are borders/overlays) */
|
||||
--o03: color-mix(in srgb, var(--ink) 3%, transparent);
|
||||
--o04: color-mix(in srgb, var(--ink) 4%, transparent);
|
||||
--o05: color-mix(in srgb, var(--ink) 5%, transparent);
|
||||
--o06: color-mix(in srgb, var(--ink) 6%, transparent);
|
||||
--o07: color-mix(in srgb, var(--ink) 7.5%, transparent);
|
||||
--o08: color-mix(in srgb, var(--ink) 8%, transparent);
|
||||
--o10: color-mix(in srgb, var(--ink) 10%, transparent);
|
||||
--o12: color-mix(in srgb, var(--ink) 12%, transparent);
|
||||
--o14: color-mix(in srgb, var(--ink) 14%, transparent);
|
||||
--o16: color-mix(in srgb, var(--ink) 16%, transparent);
|
||||
--o20: color-mix(in srgb, var(--ink) 20%, transparent);
|
||||
--o25: color-mix(in srgb, var(--ink) 25%, transparent);
|
||||
--o30: color-mix(in srgb, var(--ink) 30%, transparent);
|
||||
|
||||
/* Semantic */
|
||||
--success-color: #0f9d6e;
|
||||
--error-color: #DC2626;
|
||||
--error-dark: #b91c1c;
|
||||
--warning-color: #D97706;
|
||||
--info-color: #2563EB;
|
||||
--success-bg: rgba(15,157,110,.1);
|
||||
--error-bg: rgba(220,38,38,.1);
|
||||
--warning-bg: rgba(217,119,6,.1);
|
||||
--info-bg: rgba(37,99,235,.1);
|
||||
--success-color: var(--celadon);
|
||||
--error-color: var(--vermilion);
|
||||
--error-dark: #8f2c18;
|
||||
--warning-color: var(--brass);
|
||||
--info-color: var(--indigo);
|
||||
--success-bg: var(--celadon-soft);
|
||||
--error-bg: var(--vermilion-soft);
|
||||
--warning-bg: var(--brass-soft);
|
||||
--info-bg: var(--indigo-soft);
|
||||
|
||||
/* Fonts */
|
||||
--font-family: 'Instrument Sans', system-ui, -apple-system, sans-serif;
|
||||
--font-sans: 'Instrument Sans', system-ui, -apple-system, sans-serif;
|
||||
--font-display: 'Space Grotesk', sans-serif;
|
||||
--font-mono: 'JetBrains Mono', monospace;
|
||||
--font-family: var(--font-sans-cn);
|
||||
--font-sans: var(--font-sans-cn);
|
||||
/* Legacy alias: 121 call sites use it for headings *and* small labels, so it
|
||||
stays sans. Titles that should be serif use --font-serif explicitly. */
|
||||
--font-display: var(--font-sans-cn);
|
||||
--font-mono: var(--font-mono-cn);
|
||||
|
||||
/* Font weights */
|
||||
--font-weight-normal: 400;
|
||||
@@ -125,7 +212,7 @@
|
||||
--font-weight-bold: 700;
|
||||
|
||||
/* Letter spacing */
|
||||
--tracking-display: -0.02em;
|
||||
--tracking-display: 0.02em;
|
||||
--tracking-tight: -0.01em;
|
||||
--tracking-wide: 0.04em;
|
||||
|
||||
@@ -149,34 +236,35 @@
|
||||
--space-3xl: 4rem;
|
||||
|
||||
/* Border radius */
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 16px;
|
||||
--radius-sm: 7px;
|
||||
--radius-md: 10px;
|
||||
--radius-lg: 14px;
|
||||
--radius-full: 9999px;
|
||||
/* Design-spec radii */
|
||||
--radius-chip: 10px;
|
||||
--radius-btn: 11px;
|
||||
--radius-input: 12px;
|
||||
--radius-card: 18px;
|
||||
--radius-chip: 8px;
|
||||
--radius-btn: 10px;
|
||||
--radius-input: 10px;
|
||||
--radius-card: 14px;
|
||||
--radius-card-lg: 20px;
|
||||
--radius-pill: 999px;
|
||||
--radius-xl: 20px;
|
||||
|
||||
/* Transitions */
|
||||
--transition-fast: 0.18s cubic-bezier(0.4,0,0.2,1);
|
||||
--transition-normal: 0.25s cubic-bezier(0.4,0,0.2,1);
|
||||
--transition-slow: 0.4s cubic-bezier(0.4,0,0.2,1);
|
||||
--transition-fast: var(--t-fast);
|
||||
--transition-normal: var(--t-med);
|
||||
--transition-slow: 0.4s var(--ease);
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 2px rgba(0,0,0,.15);
|
||||
--shadow-md: 0 4px 12px rgba(0,0,0,.2);
|
||||
--shadow-lg: 0 20px 50px rgba(0,0,0,.5);
|
||||
--shadow-sm: var(--elev-1);
|
||||
--shadow-md: var(--elev-2);
|
||||
--shadow-lg: var(--elev-3);
|
||||
|
||||
/* Focus ring */
|
||||
--ring-focus: 0 0 0 3px rgba(201,242,78,.15);
|
||||
--ring-focus: var(--ring);
|
||||
|
||||
/* Icon filters (for SVG img elements on dark bg) */
|
||||
--icon-filter: brightness(0) invert(1);
|
||||
--icon-opacity: 0.5;
|
||||
/* Icon filters (for SVG img elements) */
|
||||
--icon-filter: brightness(1);
|
||||
--icon-opacity: 0.55;
|
||||
--icon-active-opacity: 0.9;
|
||||
|
||||
/* Mobile app shell */
|
||||
@@ -187,15 +275,14 @@
|
||||
/* Overlay stacking order — every fixed overlay must use one of these */
|
||||
--z-topbar: 50;
|
||||
--z-bottom-nav: 900;
|
||||
--z-drawer: 1000; /* notification drawer, sidebar overlay */
|
||||
--z-fullscreen-page: 1050; /* mobile chat-info "Details" page */
|
||||
--z-prompt: 1090; /* push/install prompts */
|
||||
--z-sheet: 1100; /* More bottom sheet */
|
||||
--z-support-chat: 1150; /* hosted support chat drawer (read from JS) */
|
||||
--z-drawer: 1000;
|
||||
--z-fullscreen-page: 1050;
|
||||
--z-prompt: 1090;
|
||||
--z-sheet: 1100;
|
||||
--z-support-chat: 1150;
|
||||
--z-modal: 2000;
|
||||
|
||||
/* Breakpoints — keep in sync with useBreakpoint.ts (JS can't read these
|
||||
at media-query time: --breakpoint-sm = MOBILE, --breakpoint-md = TABLET) */
|
||||
/* Breakpoints — keep in sync with useBreakpoint.ts */
|
||||
--breakpoint-xs: 480px;
|
||||
--breakpoint-sm: 768px;
|
||||
--breakpoint-md: 1024px;
|
||||
@@ -206,7 +293,7 @@
|
||||
|
||||
/* Legacy aliases (keep for component compatibility) */
|
||||
--primary-color: var(--accent-ink);
|
||||
--primary-dark: #b3d943;
|
||||
--primary-dark: var(--ink-2);
|
||||
--background-color: var(--bg);
|
||||
--background-base: var(--surface);
|
||||
--background-soft: var(--bg2);
|
||||
@@ -221,120 +308,96 @@
|
||||
--text-muted: var(--muted);
|
||||
--text-placeholder: var(--faint);
|
||||
--code-bg: var(--surface);
|
||||
--primary-soft: rgba(201,242,78,.1);
|
||||
--primary-soft: var(--accent-bg-08);
|
||||
|
||||
/* Legacy aliases used by the agent editor (resolve via flipping tokens) */
|
||||
/* Legacy aliases used by the agent editor */
|
||||
--accent-color: var(--accent-ink);
|
||||
--background-alt: var(--bg2);
|
||||
--background-muted: var(--o05);
|
||||
--radius-xl: 20px;
|
||||
--success: var(--success-color);
|
||||
--success-dark: var(--success-color);
|
||||
--success-light: color-mix(in srgb, var(--success-color) 14%, transparent);
|
||||
--success-color-soft: color-mix(in srgb, var(--success-color) 12%, transparent);
|
||||
--success-dark: var(--celadon);
|
||||
--success-light: var(--success-bg);
|
||||
--success-color-soft: var(--success-bg);
|
||||
--warning: var(--warning-color);
|
||||
--warning-light: color-mix(in srgb, var(--warning-color) 14%, transparent);
|
||||
--error-light: var(--coral-bg);
|
||||
--error-soft: var(--coral-bg);
|
||||
--error-color-soft: var(--coral-bg);
|
||||
--warning-light: var(--warning-bg);
|
||||
--error-light: var(--error-bg);
|
||||
--error-soft: var(--error-bg);
|
||||
--error-color-soft: var(--error-bg);
|
||||
--primary-color-filter: none;
|
||||
}
|
||||
|
||||
/* Light theme override */
|
||||
[data-theme="light"] {
|
||||
--bg: #EEF0F4;
|
||||
--bg2: #FFFFFF;
|
||||
--surface: #FFFFFF;
|
||||
--topbar: rgba(255,255,255,.85);
|
||||
/* ── 夜墨 ─────────────────────────────────────────────── */
|
||||
[data-theme='dark'] {
|
||||
--paper: #121211;
|
||||
--paper-2: #0c0c0b;
|
||||
--surface: #1a1917;
|
||||
--surface-2: #201f1c;
|
||||
--surface-sunk: #151413;
|
||||
|
||||
/* Accent and status inks are used as text on white and on their own pale
|
||||
tints: darkened until they reach WCAG AA 4.5:1 on both. */
|
||||
--accent-ink: #44700A;
|
||||
--c-lime: #44700A;
|
||||
--c-teal: #097171;
|
||||
--c-purple: #6454BF;
|
||||
--c-coral: #AC4829;
|
||||
--warning-color: #9C5604;
|
||||
--ink: #ede7dc;
|
||||
--ink-2: #cfc8bc;
|
||||
--ink-muted: #a09a8f;
|
||||
--ink-faint: #6f6a62;
|
||||
|
||||
--text: #171A21;
|
||||
--text2: #222732;
|
||||
--text3: #3C424E;
|
||||
--muted: #5C6473;
|
||||
/* Small text on every surface (down to #EEF0F4) must reach WCAG AA 4.5:1. */
|
||||
--muted2: #626977;
|
||||
--faint: #646B79;
|
||||
--code: #3C424E;
|
||||
--line: rgba(237, 231, 220, 0.08);
|
||||
--line-2: rgba(237, 231, 220, 0.14);
|
||||
--line-3: rgba(237, 231, 220, 0.22);
|
||||
|
||||
--o03: rgba(17,21,30,.03);
|
||||
--o04: rgba(17,21,30,.04);
|
||||
--o05: rgba(17,21,30,.05);
|
||||
--o06: rgba(17,21,30,.06);
|
||||
--o07: rgba(17,21,30,.075);
|
||||
--o08: rgba(17,21,30,.08);
|
||||
--o10: rgba(17,21,30,.09);
|
||||
--o12: rgba(17,21,30,.1);
|
||||
--o14: rgba(17,21,30,.11);
|
||||
--o16: rgba(17,21,30,.12);
|
||||
--o20: rgba(17,21,30,.14);
|
||||
--o25: rgba(17,21,30,.18);
|
||||
--o30: rgba(17,21,30,.2);
|
||||
--vermilion: #e2603f;
|
||||
--celadon: #74b9a6;
|
||||
--brass: #d3aa62;
|
||||
--indigo: #9aabdd;
|
||||
|
||||
/* Icon filters for light theme — SVGs are already dark, no inversion needed */
|
||||
--icon-filter: brightness(1);
|
||||
--icon-opacity: 0.6;
|
||||
--icon-active-opacity: 0.85;
|
||||
--primary-bg: #ede7dc;
|
||||
--primary-fg: #121211;
|
||||
--primary-hover: #ffffff;
|
||||
--seal-fg: #fff7ee;
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(17,21,30,.05);
|
||||
--shadow-md: 0 4px 12px rgba(17,21,30,.08);
|
||||
--shadow-lg: 0 20px 50px rgba(17,21,30,.15);
|
||||
--bubble-ai: color-mix(in srgb, var(--celadon) 9%, var(--surface));
|
||||
--bubble-human: #ede7dc;
|
||||
--bubble-human-fg: #151413;
|
||||
|
||||
--ring-focus: 0 0 0 3px rgba(76,122,11,.2);
|
||||
--primary-soft: rgba(76,122,11,.08);
|
||||
--elev-1: 0 1px 0 rgba(0, 0, 0, 0.3);
|
||||
--elev-2: 0 1px 0 rgba(255, 255, 255, 0.02) inset, 0 14px 34px -14px rgba(0, 0, 0, 0.7);
|
||||
--elev-3: 0 30px 90px -20px rgba(0, 0, 0, 0.85);
|
||||
--ring: 0 0 0 3px color-mix(in srgb, var(--ink) 18%, transparent);
|
||||
|
||||
/* Contrast inks — accent is dark-olive in light, so text on it must be white */
|
||||
--on-accent: #FFFFFF;
|
||||
--on-light: #0B0C10;
|
||||
--on-dark: #F5F6F8;
|
||||
--viz-1: #1e9e80;
|
||||
--viz-2: #b97c1c;
|
||||
--viz-grid: rgba(237, 231, 220, 0.07);
|
||||
--viz-axis: #8d887f;
|
||||
|
||||
/* Elevated surfaces / scrim (gradients & tints referencing base tokens auto-recompute) */
|
||||
--bg-elevated: #FFFFFF;
|
||||
--bg-deep: #F4F6FA;
|
||||
--scrim: rgba(17,21,30,.4);
|
||||
--grain-opacity: 0.06;
|
||||
--grain-blend: screen;
|
||||
|
||||
/* Mode pills — darker fills need light text in light theme */
|
||||
--mode-ai-bg: #2563EB;
|
||||
--mode-ai-fg: #FFFFFF;
|
||||
--mode-wf-bg: #6D5BD0;
|
||||
--mode-wf-fg: #FFFFFF;
|
||||
color-scheme: dark;
|
||||
|
||||
/* Extended status / chart palette tuned for light bg: each reaches WCAG AA
|
||||
4.5:1 as text on white and on its own pale tint. */
|
||||
--c-danger: #B33F3F;
|
||||
--c-warn: #8F5E18;
|
||||
--c-info: #225BD8;
|
||||
--c-positive: #187742;
|
||||
--c-neutral: #606773;
|
||||
--c-online: #097171;
|
||||
--c-pro: #6454BF;
|
||||
/* Legacy overrides that cannot derive from the base palette */
|
||||
--on-accent: var(--primary-fg);
|
||||
--on-light: #121211;
|
||||
--on-dark: #f5f6f8;
|
||||
|
||||
/* Generate gradient — darker end stop for light */
|
||||
--grad-generate: linear-gradient(135deg, var(--c-purple), #5B49C9);
|
||||
--mode-ai-bg: var(--celadon);
|
||||
--mode-ai-fg: #10201c;
|
||||
--mode-wf-bg: var(--indigo);
|
||||
--mode-wf-fg: #12151d;
|
||||
|
||||
/* Legacy aliases */
|
||||
--primary-color: var(--accent-ink);
|
||||
--primary-dark: #3d6209;
|
||||
--background-color: var(--bg);
|
||||
--background-base: var(--surface);
|
||||
--toggle-knob: #121211;
|
||||
--grad-purple-teal: linear-gradient(135deg, var(--indigo), var(--celadon));
|
||||
--grad-lime-teal: linear-gradient(135deg, var(--ink), var(--celadon));
|
||||
--grad-generate: linear-gradient(135deg, var(--indigo), color-mix(in srgb, var(--indigo) 70%, #ffffff));
|
||||
|
||||
--text3: color-mix(in srgb, var(--ink) 82%, var(--paper));
|
||||
--muted2: color-mix(in srgb, var(--ink-muted) 82%, var(--paper));
|
||||
|
||||
--error-dark: #f08a72;
|
||||
--background-soft: var(--bg2);
|
||||
--background-mute: var(--o08);
|
||||
--hover-bg: var(--o05);
|
||||
--border-color: var(--o10);
|
||||
--border-color-hover: var(--o16);
|
||||
--text-primary: var(--text);
|
||||
--text-secondary: var(--text3);
|
||||
--text-color: var(--text);
|
||||
--text-heading: var(--text);
|
||||
--text-muted: var(--muted);
|
||||
--text-placeholder: var(--faint);
|
||||
--code-bg: var(--surface);
|
||||
--header-gradient: linear-gradient(180deg, var(--paper), var(--paper));
|
||||
|
||||
--icon-filter: brightness(0) invert(1);
|
||||
--icon-opacity: 0.5;
|
||||
--icon-active-opacity: 0.9;
|
||||
|
||||
--primary-dark: var(--ink-2);
|
||||
--success-dark: var(--celadon);
|
||||
}
|
||||
@@ -15,10 +15,11 @@ limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import BrandMark from '@/components/common/BrandMark.vue'
|
||||
import { computed, onMounted, onUnmounted, ref, watch, nextTick } from 'vue'
|
||||
import type { AgentCustomization } from '@/types/agent'
|
||||
import { resolveUploadUrl } from '@/config/api'
|
||||
import { orbSvgDataUri, resolveOrbStyle } from '@/utils/orb'
|
||||
import { resolveAvatarImage, resolveOrbStyle } from '@/utils/orb'
|
||||
import { themeCssVars } from '@/webclient/widget-theme'
|
||||
import '@/webclient/widget-surface.css'
|
||||
import { useAgentChat } from '@/composables/useAgentChat'
|
||||
@@ -327,23 +328,9 @@ const messageNameStyles = computed(() => ({
|
||||
color: 'var(--rp-text)'
|
||||
}))
|
||||
|
||||
const photoUrl = computed(() => {
|
||||
// These are <img> slots in chat bubbles, so the fallback has to be an image:
|
||||
// the orb as an SVG data URI. Previously the retired /avatars/*.svg artwork.
|
||||
// Seeded off orbSeed (the agent slug) so it matches the orb on the detail
|
||||
// header and in the list, which use the same seed.
|
||||
if (!props.customization.photo_url) {
|
||||
return orbSvgDataUri(
|
||||
props.orbSeed || props.agentName,
|
||||
props.customization.customization_metadata?.orb_variant,
|
||||
)
|
||||
}
|
||||
|
||||
// Absolute S3/CDN URLs pass through — the API signs photo_url itself, so
|
||||
// there is no separate signed field to prefer. Local paths are resolved
|
||||
// against the runtime API origin.
|
||||
return resolveUploadUrl(props.customization.photo_url)
|
||||
})
|
||||
const photoUrl = computed(() => resolveUploadUrl(
|
||||
resolveAvatarImage(props.orbSeed || props.agentName, props.customization),
|
||||
))
|
||||
|
||||
// Function to format message content
|
||||
const formatMessage = (content: string) => {
|
||||
@@ -372,7 +359,7 @@ const isAskAnythingStyle = computed(() => {
|
||||
return props.customization.chat_style === 'ASK_ANYTHING' || isAuroraStyle.value
|
||||
})
|
||||
|
||||
// Show the generated aurora orb when the user explicitly picked "orb", or as the Aurora
|
||||
// Show the paper-craft avatar when the user explicitly picked "orb", or as the Aurora
|
||||
// fallback when no profile photo is set. A selected profile picture always takes precedence.
|
||||
const orbMeta = computed(() => props.customization.customization_metadata as Record<string, unknown> | undefined)
|
||||
const useOrbAvatar = computed(() => {
|
||||
@@ -382,7 +369,7 @@ const useOrbAvatar = computed(() => {
|
||||
return isAuroraStyle.value && !props.customization.photo_url
|
||||
})
|
||||
|
||||
const orbStyle = computed(() => resolveOrbStyle(props.agentName, orbMeta.value?.orb_variant))
|
||||
const orbStyle = computed(() => resolveOrbStyle(props.orbSeed || props.agentName, orbMeta.value?.orb_variant))
|
||||
|
||||
// Computed property for container styles
|
||||
const containerStyles = computed(() => {
|
||||
@@ -607,12 +594,7 @@ const handleInitiationClick = () => {
|
||||
|
||||
<!-- Powered by footer for welcome message -->
|
||||
<div class="powered-by-welcome" :style="messageNameStyles">
|
||||
<svg class="replypier-logo" width="15" height="15" viewBox="0 0 60 60" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19 3H41A16 16 0 0 1 57 19V41A16 16 0 0 1 41 57H9A6 6 0 0 1 3 51V19A16 16 0 0 1 19 3Z" fill="#C9F24E"/>
|
||||
<circle cx="19.7" cy="30" r="4.3" fill="#0B0C10"/>
|
||||
<circle cx="30" cy="30" r="4.3" fill="#0B0C10"/>
|
||||
<circle cx="40.3" cy="30" r="4.3" fill="#0B0C10"/>
|
||||
</svg>
|
||||
<BrandMark class="replypier-logo" :size="20" />
|
||||
<span class="rp-powered-prefix">技术支持:</span><strong class="rp-brand">ReplyPier</strong>
|
||||
</div>
|
||||
</div>
|
||||
@@ -710,12 +692,7 @@ const handleInitiationClick = () => {
|
||||
|
||||
<!-- Add powered by footer -->
|
||||
<div class="powered-by" :style="messageNameStyles">
|
||||
<svg class="replypier-logo" width="15" height="15" viewBox="0 0 60 60" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19 3H41A16 16 0 0 1 57 19V41A16 16 0 0 1 41 57H9A6 6 0 0 1 3 51V19A16 16 0 0 1 19 3Z" fill="#C9F24E"/>
|
||||
<circle cx="19.7" cy="30" r="4.3" fill="#0B0C10"/>
|
||||
<circle cx="30" cy="30" r="4.3" fill="#0B0C10"/>
|
||||
<circle cx="40.3" cy="30" r="4.3" fill="#0B0C10"/>
|
||||
</svg>
|
||||
<BrandMark class="replypier-logo" :size="20" />
|
||||
<span class="rp-powered-prefix">技术支持:</span><strong class="rp-brand">ReplyPier</strong>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1729,4 +1706,4 @@ const handleInitiationClick = () => {
|
||||
.initiation-close::after {
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@@ -149,7 +149,7 @@ const chatStyleOptions = [
|
||||
{
|
||||
value: 'AURORA' as ChatStyle,
|
||||
label: '极光',
|
||||
description: '全新的随便问——深色主题,配发光极光球头像。',
|
||||
description: '深色随便问主题,可搭配纸艺头像。',
|
||||
group: 'new' as const,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -19,7 +19,7 @@ import { useRouter } from 'vue-router'
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import type { AgentWithCustomization, AgentCustomization } from '@/types/agent'
|
||||
import { getApiUrl, getWidgetUrl, resolveUploadUrl } from '@/config/api'
|
||||
import { ORB_PALETTE_COUNT, getOrbStyleAt, resolveOrbStyle, orbSvgDataUri, terminalMarkSvgDataUri } from '@/utils/orb'
|
||||
import { ORB_PALETTE_COUNT, PAPER_AVATAR_NAMES, getOrbStyleAt, resolveOrbStyle, orbSvgDataUri, terminalMarkSvgDataUri } from '@/utils/orb'
|
||||
|
||||
import KnowledgeExplorer from '@/components/knowledge/KnowledgeExplorer.vue'
|
||||
import AgentCustomizationView from './AgentCustomizationView.vue'
|
||||
@@ -64,7 +64,7 @@ const toggleAvatarPicker = () => {
|
||||
if (isUploading.value) return
|
||||
showAvatarPicker.value = !showAvatarPicker.value
|
||||
}
|
||||
// Aurora orb avatar (generated, no photo). Stored in customization_metadata so no
|
||||
// Paper-craft avatar (legacy orb storage key). Stored in customization_metadata so no
|
||||
// schema change is needed. Selecting a real picture resets this back to 'photo'.
|
||||
const orbCount = ORB_PALETTE_COUNT
|
||||
const orbMeta = computed(
|
||||
@@ -104,7 +104,7 @@ const selectOrbAvatar = async (variant: number) => {
|
||||
if (isUploading.value) return
|
||||
closeAvatarPicker()
|
||||
try {
|
||||
// Store the orb as an SVG data URI in photo_url so it propagates everywhere
|
||||
// Store the paper-craft avatar as an SVG data URI in photo_url so it propagates everywhere
|
||||
// (agent list, widget header, previews) via the normal avatar path.
|
||||
const orbUrl = orbSvgDataUri(agentData.value.name || '', variant)
|
||||
await persistAvatarMeta({ avatar_style: 'orb', orb_variant: variant }, orbUrl)
|
||||
@@ -118,7 +118,7 @@ const selectTerminalMark = async () => {
|
||||
if (isUploading.value) return
|
||||
closeAvatarPicker()
|
||||
try {
|
||||
// Tint the ">" mark with the agent's accent (falls back to the Terminal lime).
|
||||
// Tint the ">" mark with the agent's accent (falls back to the muted celadon).
|
||||
const accent = (agentData.value.customization?.accent_color as string | undefined) || undefined
|
||||
const markUrl = terminalMarkSvgDataUri(accent)
|
||||
await persistAvatarMeta({ avatar_style: 'terminal' }, markUrl)
|
||||
@@ -134,7 +134,7 @@ const selectPresetAvatar = async (url: string) => {
|
||||
optimisticPhoto.value = url
|
||||
try {
|
||||
await applyPresetAvatar(url)
|
||||
// A real picture was chosen — turn off the orb if it was on.
|
||||
// A real picture was chosen — turn off the paper-craft avatar if it was on.
|
||||
if (useOrbAvatar.value) await persistAvatarMeta({ avatar_style: 'photo' })
|
||||
} finally {
|
||||
optimisticPhoto.value = null
|
||||
@@ -873,8 +873,8 @@ onMounted(async () => {
|
||||
:class="{ active: useOrbAvatar && (currentOrbVariant ?? -1) === n - 1 }"
|
||||
:disabled="isUploading"
|
||||
@click="selectOrbAvatar(n - 1)"
|
||||
:aria-label="`光球 ${n}`"
|
||||
:title="`光球 ${n}`"
|
||||
:aria-label="PAPER_AVATAR_NAMES[n - 1]"
|
||||
:title="PAPER_AVATAR_NAMES[n - 1]"
|
||||
>
|
||||
<span class="avatar-orb-thumb" :style="getOrbStyleAt(n - 1)"></span>
|
||||
</button>
|
||||
@@ -887,7 +887,7 @@ onMounted(async () => {
|
||||
aria-label="终端标记"
|
||||
title="Terminal >"
|
||||
>
|
||||
<span class="avatar-terminal-thumb">></span>
|
||||
<img :src="terminalMarkSvgDataUri()" alt="">
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" class="avatar-picker-upload" :disabled="isUploading" @click="chooseUploadAvatar">
|
||||
@@ -1515,7 +1515,7 @@ onMounted(async () => {
|
||||
box-shadow: 0 0 0 2px var(--accent-bg-12);
|
||||
}
|
||||
|
||||
/* Aurora orb tile in the picker grid */
|
||||
/* Paper-craft tile; class names retain existing selectors. */
|
||||
.avatar-pick-orb {
|
||||
background: transparent;
|
||||
}
|
||||
@@ -1525,22 +1525,6 @@ onMounted(async () => {
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
}
|
||||
/* Terminal ">" prompt mark tile in the picker grid */
|
||||
.avatar-terminal-thumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
background: #0b0c10;
|
||||
box-shadow: inset 0 0 0 2px rgba(201, 242, 78, 0.55);
|
||||
color: #c9f24e;
|
||||
font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-weight: 700;
|
||||
font-size: 1.05rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.avatar-pick-orb.active {
|
||||
border-color: var(--accent-ink);
|
||||
box-shadow: 0 0 0 2px var(--accent-bg-12);
|
||||
@@ -1620,7 +1604,7 @@ onMounted(async () => {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* Generated aurora orb shown in place of the avatar image */
|
||||
/* Generated paper-craft artwork shown in place of an uploaded photo. */
|
||||
.agent-orb-avatar {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -3231,4 +3215,4 @@ input:checked + .slider:before {
|
||||
}
|
||||
|
||||
|
||||
</style>
|
||||
</style>
|
||||
@@ -542,7 +542,7 @@ onMounted(() => {
|
||||
|
||||
.view-details-btn {
|
||||
background-color: var(--accent-solid);
|
||||
color: white;
|
||||
color: var(--on-accent-solid);
|
||||
border: none;
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import paperBoat from '../../assets/logo.svg'
|
||||
|
||||
withDefaults(defineProps<{ size?: number }>(), { size: 20 })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<img :src="paperBoat" :width="size" :height="size" alt="" aria-hidden="true">
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
img { display: inline-block; vertical-align: middle; flex-shrink: 0; object-fit: contain; }
|
||||
</style>
|
||||
@@ -17,6 +17,7 @@ limitations under the License.
|
||||
<script setup lang="ts">
|
||||
import type { Teammate } from '@/services/users'
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { navIconSvg } from '@/components/layout/navIcons'
|
||||
|
||||
interface FilterValues {
|
||||
customerEmailFilter: string
|
||||
@@ -121,27 +122,25 @@ onBeforeUnmount(() => {
|
||||
<div class="filter-header-section">
|
||||
<!-- Filter Toggle Button -->
|
||||
<button
|
||||
type="button"
|
||||
@click="emit('toggle')"
|
||||
class="filter-toggle-btn"
|
||||
:class="{ active: showFilters, 'has-filters': hasActiveFilters }"
|
||||
aria-label="筛选"
|
||||
:aria-expanded="showFilters"
|
||||
aria-label="筛选会话"
|
||||
title="筛选会话"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/>
|
||||
</svg>
|
||||
<span class="filter-glyph" v-html="navIconSvg('filter', 16)"></span>
|
||||
<span v-if="hasActiveFilters" class="filter-count">{{ activeFilterCount }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Advanced Filters Dropdown -->
|
||||
<div v-if="showFilters" class="filters-container">
|
||||
<div v-if="showFilters" class="filters-container" @click.self="emit('toggle')">
|
||||
<div class="filters-dropdown">
|
||||
<div class="filters-header">
|
||||
<h3>筛选会话</h3>
|
||||
<button @click="emit('toggle')" class="close-btn" aria-label="关闭筛选">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
<button type="button" @click="emit('toggle')" class="close-btn" aria-label="关闭筛选">
|
||||
<span v-html="navIconSvg('x', 15)"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -209,8 +208,8 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<div class="filter-actions">
|
||||
<button @click="applyFilters" class="apply-btn">应用筛选</button>
|
||||
<button @click="clearFilters" class="clear-btn">清除全部</button>
|
||||
<button type="button" @click="clearFilters" class="btn btn-ghost btn-sm clear-btn">清除全部</button>
|
||||
<button type="button" @click="applyFilters" class="btn btn-primary btn-sm apply-btn">应用筛选</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -220,75 +219,99 @@ onBeforeUnmount(() => {
|
||||
<style scoped>
|
||||
.filter-header-section {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-toggle-btn {
|
||||
position: relative;
|
||||
display: flex;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--background-color);
|
||||
color: var(--text-muted);
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex: none;
|
||||
padding: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
color: var(--text2);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
transition:
|
||||
border-color var(--t-fast),
|
||||
background-color var(--t-fast),
|
||||
color var(--t-fast);
|
||||
}
|
||||
|
||||
.filter-glyph {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.filter-glyph :deep(svg) {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.filter-toggle-btn:hover {
|
||||
background: var(--background-soft);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--primary-color-soft);
|
||||
border-color: var(--line-3);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.filter-toggle-btn:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--ring);
|
||||
}
|
||||
|
||||
.filter-toggle-btn.active {
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
border-color: var(--primary-color);
|
||||
background: var(--ink);
|
||||
border-color: var(--ink);
|
||||
color: var(--primary-fg);
|
||||
}
|
||||
|
||||
.filter-toggle-btn.has-filters {
|
||||
background: var(--primary-color-soft);
|
||||
color: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
.filter-toggle-btn.has-filters:not(.active) {
|
||||
border-color: var(--line-3);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.filter-count {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -6px;
|
||||
background: var(--error-color);
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: flex;
|
||||
min-width: 17px;
|
||||
height: 17px;
|
||||
padding: 0 4px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--ink);
|
||||
color: var(--primary-fg);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
box-shadow: 0 0 0 2px var(--paper);
|
||||
}
|
||||
|
||||
/* Opens from the filter button towards the thread. The width is capped so the
|
||||
panel still fits a 769px viewport with the queue at its 300px minimum. */
|
||||
.filters-container {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
top: calc(100% + 10px);
|
||||
left: 0;
|
||||
right: auto;
|
||||
z-index: 1000;
|
||||
background: var(--background-soft);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
animation: slideDown 0.2s ease-out;
|
||||
min-width: 600px;
|
||||
max-width: 90vw;
|
||||
width: 520px;
|
||||
max-width: calc(100vw - 24px);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-md);
|
||||
animation: filterDrop var(--t-fast);
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
@keyframes filterDrop {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
@@ -297,169 +320,154 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.filters-dropdown {
|
||||
padding: var(--space-lg);
|
||||
padding: 20px 22px 18px;
|
||||
}
|
||||
|
||||
.filters-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-md);
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.filters-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-family: var(--font-serif);
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
display: flex;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
transition:
|
||||
background-color var(--t-fast),
|
||||
color var(--t-fast);
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: var(--background-color);
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-2);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.filters-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: var(--space-md);
|
||||
margin-bottom: var(--space-lg);
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px 16px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.filter-group label {
|
||||
font-size: 12px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--muted);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.filter-input {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--background-color);
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
transition: all var(--transition-fast);
|
||||
width: 100%;
|
||||
height: 38px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: var(--radius-input);
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 13px;
|
||||
transition:
|
||||
border-color var(--t-fast),
|
||||
box-shadow var(--t-fast);
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
|
||||
background-position: right 8px center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 16px;
|
||||
padding-right: 40px;
|
||||
cursor: pointer;
|
||||
.filter-input:hover {
|
||||
border-color: var(--line-3);
|
||||
}
|
||||
|
||||
.filter-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px var(--primary-color-soft);
|
||||
border-color: var(--line-3);
|
||||
box-shadow: var(--ring);
|
||||
}
|
||||
|
||||
.filter-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.filter-input:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Native selects keep the platform chevron, which follows color-scheme in
|
||||
both themes — a hard-coded data-URI arrow would not. */
|
||||
.filter-select {
|
||||
appearance: auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.filter-actions {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
justify-content: flex-end;
|
||||
padding-top: var(--space-md);
|
||||
border-top: 1px solid var(--border-color);
|
||||
gap: 8px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.apply-btn, .clear-btn {
|
||||
padding: 10px 20px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
border: 1px solid transparent;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.apply-btn {
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
}
|
||||
|
||||
.apply-btn:hover {
|
||||
background: var(--primary-color-hover);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
background: var(--background-color);
|
||||
color: var(--text-muted);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
.clear-btn:hover {
|
||||
background: var(--background-soft);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
/* Responsive: a full-screen sheet on phones */
|
||||
@media (max-width: 768px) {
|
||||
.filters-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
min-width: unset;
|
||||
max-width: unset;
|
||||
inset: 0;
|
||||
width: auto;
|
||||
max-width: none;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
background: var(--scrim);
|
||||
animation: none;
|
||||
}
|
||||
|
||||
|
||||
.filters-dropdown {
|
||||
background: var(--background-color);
|
||||
border-radius: var(--radius-lg);
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
width: min(560px, calc(100vw - 32px));
|
||||
max-height: 86vh;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
|
||||
.filters-grid {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
|
||||
.filter-actions {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.apply-btn, .clear-btn {
|
||||
|
||||
.apply-btn,
|
||||
.clear-btn {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,13 +17,14 @@ limitations under the License.
|
||||
<script setup lang="ts">
|
||||
import type { Conversation, ChatDetail } from '@/types/chat'
|
||||
import ConversationChat from '@/components/conversations/ConversationChat.vue'
|
||||
import ChannelBadge from '@/components/common/ChannelBadge.vue'
|
||||
import HandlerBadge from '@/components/conversations/HandlerBadge.vue'
|
||||
import { useConversationsList } from '@/composables/useConversationsList'
|
||||
import { useBreakpoint } from '@/composables/useBreakpoint'
|
||||
import { chatHandler } from '@/utils/chatState'
|
||||
import { userService } from '@/services/user'
|
||||
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
|
||||
import { getInitials } from '@/utils/text'
|
||||
import { navIconSvg } from '@/components/layout/navIcons'
|
||||
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
conversations: Conversation[]
|
||||
@@ -52,22 +53,39 @@ const emit = defineEmits<{
|
||||
const { isMobile } = useBreakpoint()
|
||||
const currentUserId = userService.getUserId()
|
||||
|
||||
// The preview used to be prefixed with the AI agent's name unconditionally,
|
||||
// which read as "the bot said this" even on a chat a colleague had taken over.
|
||||
// Anonymous visitors have neither a name nor a real email (the backend hides the
|
||||
// placeholder), so the row would otherwise show nothing at all.
|
||||
const customerName = (conv: Conversation) =>
|
||||
conv.customer?.full_name || conv.customer?.email || '访客'
|
||||
|
||||
// Display labels for the backend status enum, which stays in English in the API.
|
||||
const statusLabels: Record<string, string> = {
|
||||
open: '进行中',
|
||||
closed: '已结束',
|
||||
transferred: '已转交',
|
||||
pending: '等待中'
|
||||
const initials = (conv: Conversation) => getInitials(customerName(conv), '访')
|
||||
|
||||
// Handler state colours the row: a queued visitor is the one thing that should
|
||||
// catch the eye in the queue, so that row takes 朱砂; a claimed chat takes ink.
|
||||
const avatarTone = (conv: Conversation) => {
|
||||
const kind = chatHandler(conv, currentUserId).kind
|
||||
return kind === 'waiting' ? 'vermilion' : kind === 'human' ? 'ink' : ''
|
||||
}
|
||||
|
||||
const statusLabel = (status: string) => statusLabels[status] || status
|
||||
const isWaiting = (conv: Conversation) => chatHandler(conv, currentUserId).kind === 'waiting'
|
||||
|
||||
// Human-readable channel names for the quiet chip on each row. The widget is
|
||||
// the default context, so it gets the plain label rather than a brand one.
|
||||
const CHANNEL_LABELS: Record<string, string> = {
|
||||
web: '网页',
|
||||
shopify: 'Shopify',
|
||||
whatsapp: 'WhatsApp',
|
||||
messenger: 'Messenger',
|
||||
instagram: 'Instagram',
|
||||
telegram: 'Telegram',
|
||||
slack: 'Slack',
|
||||
email: '邮件',
|
||||
sms: '短信',
|
||||
line: 'LINE',
|
||||
api: 'API'
|
||||
}
|
||||
const channelLabel = (channel?: string | null) =>
|
||||
(channel && CHANNEL_LABELS[channel]) || channel || '网页'
|
||||
|
||||
const previewPrefix = (conv: Conversation) => {
|
||||
const handler = chatHandler(conv, currentUserId)
|
||||
@@ -157,10 +175,14 @@ watch(() => props.statusFilter, () => {
|
||||
filterChanged.value = true
|
||||
})
|
||||
|
||||
// Watch for loading state changes to select first conversation after filter change
|
||||
// Watch for loading state changes. Two things happen when a load finishes:
|
||||
// a filter switch opens the top of the new queue, and the first desktop paint
|
||||
// opens the top row so the thread pane is never blank on arrival.
|
||||
watch(() => props.loading, (isLoading, prevLoading) => {
|
||||
// Only proceed if loading has just completed (was true, now false)
|
||||
if (prevLoading === true && isLoading === false && filterChanged.value) {
|
||||
if (prevLoading !== true || isLoading !== false) return
|
||||
|
||||
if (filterChanged.value) {
|
||||
// Reset the flag
|
||||
filterChanged.value = false
|
||||
|
||||
@@ -173,6 +195,18 @@ watch(() => props.loading, (isLoading, prevLoading) => {
|
||||
selectedChat.value = null
|
||||
selectedId.value = null
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// First paint: open the top of the queue on desktop. A deep link, a live
|
||||
// selection or the mobile list pane all opt out.
|
||||
if (
|
||||
!isMobile.value &&
|
||||
!props.initialSessionId &&
|
||||
!selectedId.value &&
|
||||
props.conversations.length > 0
|
||||
) {
|
||||
loadChatDetailWithSelection(props.conversations[0].session_id)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -252,37 +286,25 @@ onBeforeUnmount(() => {
|
||||
<template>
|
||||
<div class="conversations-container"
|
||||
:class="{ 'with-chat-info': showChatInfo, 'mobile-show-chat': mobilePane === 'chat' }">
|
||||
<!-- Sidebar with conversation list -->
|
||||
<div class="conversations-sidebar">
|
||||
<!-- Filter controls -->
|
||||
<div class="filter-controls">
|
||||
<button
|
||||
class="filter-btn"
|
||||
:class="{ active: statusFilter === 'open' }"
|
||||
@click="emit('updateFilter', 'open')"
|
||||
>
|
||||
进行中
|
||||
</button>
|
||||
<button
|
||||
class="filter-btn"
|
||||
:class="{ active: statusFilter === 'closed' }"
|
||||
@click="emit('updateFilter', 'closed')"
|
||||
>
|
||||
已结束
|
||||
</button>
|
||||
<!-- Queue column -->
|
||||
<aside class="conversations-sidebar" aria-label="会话队列">
|
||||
<div v-if="loading && !loadingMore" class="state">
|
||||
<span class="state-spinner" aria-hidden="true"></span>
|
||||
<p class="state-title">加载会话中…</p>
|
||||
</div>
|
||||
|
||||
<div v-if="loading && !loadingMore" class="loading-state">
|
||||
加载会话中…
|
||||
<div v-else-if="error" class="state">
|
||||
<p class="state-title">加载失败</p>
|
||||
<p class="state-note">{{ error }}</p>
|
||||
<button class="btn btn-ghost btn-sm" @click="emit('refresh')">重试</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="error-state">
|
||||
{{ error }}
|
||||
<button @click="emit('refresh')" class="refresh-button">重试</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="conversations.length === 0" class="empty-state">
|
||||
暂无会话
|
||||
<div v-else-if="conversations.length === 0" class="state">
|
||||
<span class="state-glyph" v-html="navIconSvg('inbox', 22)"></span>
|
||||
<p class="state-title">暂无会话</p>
|
||||
<p class="state-note">
|
||||
{{ statusFilter === 'closed' ? '还没有已结束的会话。' : '新的访客会话会出现在这里。' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="conversations-list" ref="listContainer" tabindex="0" aria-label="会话列表">
|
||||
@@ -290,7 +312,7 @@ onBeforeUnmount(() => {
|
||||
v-for="conv in formattedConversations"
|
||||
:key="conv.session_id"
|
||||
class="conversation-item"
|
||||
:class="{ active: selectedId === conv.session_id }"
|
||||
:class="{ active: selectedId === conv.session_id, waiting: isWaiting(conv) }"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-label="`与 ${customerName(conv)} 的会话`"
|
||||
@@ -299,35 +321,33 @@ onBeforeUnmount(() => {
|
||||
@keydown.enter.prevent="handleConversationClick(conv.session_id)"
|
||||
@keydown.space.prevent="handleConversationClick(conv.session_id)"
|
||||
>
|
||||
<div class="conversation-item-header">
|
||||
<h3>{{ customerName(conv) }}</h3>
|
||||
<ChannelBadge :channel="conv.channel" />
|
||||
<span class="timestamp">{{ conv.timeAgo }}</span>
|
||||
</div>
|
||||
<div class="conversation-preview">
|
||||
<span class="agent-name">{{ previewPrefix(conv) }}:</span>
|
||||
<!-- Product message preview -->
|
||||
<template v-if="conv.message_type === 'product' && conv.shopify_output?.products?.length">
|
||||
<p class="last-message product-preview">
|
||||
<span class="product-icon">🛍️</span>
|
||||
<span class="avatar" :class="avatarTone(conv)">{{ initials(conv) }}</span>
|
||||
|
||||
<div class="conv-main">
|
||||
<div class="conv-row">
|
||||
<b>{{ customerName(conv) }}</b>
|
||||
<time class="num">{{ conv.timeAgo }}</time>
|
||||
</div>
|
||||
|
||||
<p class="conv-preview">
|
||||
<em>{{ previewPrefix(conv) }}:</em>
|
||||
<!-- Product message preview -->
|
||||
<template v-if="conv.message_type === 'product' && conv.shopify_output?.products?.length">
|
||||
已分享 {{ conv.shopify_output.products.length }} 件商品
|
||||
</p>
|
||||
</template>
|
||||
<!-- Regular message preview -->
|
||||
<template v-else>
|
||||
<p class="last-message">{{ conv.last_message }}</p>
|
||||
</template>
|
||||
<div v-if="unreadMessages[conv.session_id]" class="unread-bubble">
|
||||
{{ unreadMessages[conv.session_id] }}
|
||||
</template>
|
||||
<!-- Regular message preview -->
|
||||
<template v-else>{{ conv.last_message || '暂无消息' }}</template>
|
||||
</p>
|
||||
|
||||
<div class="conv-tags">
|
||||
<HandlerBadge :chat="conv" :current-user-id="currentUserId" />
|
||||
<span class="chip chip-quiet">{{ channelLabel(conv.channel) }}</span>
|
||||
<span class="conv-count num">{{ conv.message_count }} 条</span>
|
||||
<span v-if="unreadMessages[conv.session_id]" class="unread num">
|
||||
{{ unreadMessages[conv.session_id] }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="message-count">{{ conv.message_count }} 条消息</div>
|
||||
<div class="conversation-footer">
|
||||
<div class="conversation-status" :class="conv.status">
|
||||
{{ statusLabel(conv.status) }}
|
||||
</div>
|
||||
<HandlerBadge :chat="conv" :current-user-id="currentUserId" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading more indicator -->
|
||||
@@ -337,18 +357,18 @@ onBeforeUnmount(() => {
|
||||
ref="loadMoreTrigger"
|
||||
>
|
||||
<div v-if="loadingMore" class="loading-more">
|
||||
<div class="loading-spinner"></div>
|
||||
<span class="state-spinner state-spinner--sm" aria-hidden="true"></span>
|
||||
<span>加载更多会话中…</span>
|
||||
</div>
|
||||
<div v-else-if="hasMore" class="load-more-container">
|
||||
<span class="load-more-hint">向下滚动加载更多</span>
|
||||
<button
|
||||
class="load-more-button"
|
||||
class="btn btn-ghost btn-sm"
|
||||
@click="emit('loadMore')"
|
||||
:disabled="loadingMore"
|
||||
>
|
||||
加载更多
|
||||
</button>
|
||||
<span class="load-more-hint">或继续向下滚动</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -356,20 +376,20 @@ onBeforeUnmount(() => {
|
||||
<!-- Scroll to top button -->
|
||||
<button
|
||||
v-if="showScrollToTop"
|
||||
class="scroll-to-top-btn"
|
||||
class="scroll-to-top-btn icon-btn bordered"
|
||||
@click="scrollToTop"
|
||||
aria-label="回到顶部"
|
||||
title="回到顶部"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 15l-6-6-6 6"/>
|
||||
</svg>
|
||||
<span class="scroll-glyph" v-html="navIconSvg('chevronRight', 15)"></span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Chat view -->
|
||||
<!-- Thread column -->
|
||||
<div class="chat-view">
|
||||
<div v-if="chatLoading" class="loading-state">
|
||||
加载会话中…
|
||||
<div v-if="chatLoading" class="state">
|
||||
<span class="state-spinner" aria-hidden="true"></span>
|
||||
<p class="state-title">加载会话中…</p>
|
||||
</div>
|
||||
<ConversationChat
|
||||
v-else-if="selectedChat"
|
||||
@@ -392,6 +412,11 @@ onBeforeUnmount(() => {
|
||||
@back="emit('back')"
|
||||
@info="emit('info')"
|
||||
/>
|
||||
<div v-else class="chat-empty">
|
||||
<span class="empty-glyph" v-html="navIconSvg('message', 24)"></span>
|
||||
<p class="empty-title">选择一段会话</p>
|
||||
<p class="empty-note">选中后即可查看完整对话并回复访客。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -399,25 +424,22 @@ onBeforeUnmount(() => {
|
||||
<style scoped>
|
||||
.conversations-container {
|
||||
display: grid;
|
||||
grid-template-columns: 320px 1fr;
|
||||
/* Same as the parent grid: without a bounded row the sidebar's height: 100%
|
||||
resolves against content, so .conversations-list can never scroll. */
|
||||
grid-template-columns: var(--queue-w, 344px) minmax(0, 1fr);
|
||||
/* Without a bounded row the sidebar's height: 100% resolves against content,
|
||||
so .conversations-list can never scroll. */
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
gap: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: var(--bg);
|
||||
min-height: 0;
|
||||
background: var(--paper);
|
||||
color: var(--text);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.conversations-container.with-chat-info {
|
||||
grid-template-columns: 320px 1fr;
|
||||
}
|
||||
|
||||
.conversations-sidebar {
|
||||
border-right: 1px solid var(--o08);
|
||||
background: var(--bg2);
|
||||
border-right: 1px solid var(--line);
|
||||
background: var(--paper);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
@@ -426,98 +448,179 @@ onBeforeUnmount(() => {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.filter-controls {
|
||||
display: flex;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--o07);
|
||||
background: var(--bg2);
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
flex: 1;
|
||||
padding: 7px 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.filter-btn:hover {
|
||||
background: var(--o05);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.filter-btn.active {
|
||||
background: var(--o10);
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
/* The queue heading is drawn by ConversationsView above this pane, so the
|
||||
scrolling list starts underneath it. Scoped to desktop because the heading
|
||||
joins the normal flow on phones. */
|
||||
@media (min-width: 769px) {
|
||||
.conversations-sidebar {
|
||||
padding-top: var(--queue-head-h, 124px);
|
||||
}
|
||||
}
|
||||
|
||||
.conversations-list {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
padding: 6px 12px 20px;
|
||||
margin: 0;
|
||||
scrollbar-width: thin; /* For Firefox */
|
||||
scrollbar-color: var(--border-color) transparent; /* For Firefox */
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Webkit scrollbar styling */
|
||||
.conversations-list::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.conversations-list::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.conversations-list::-webkit-scrollbar-thumb {
|
||||
background-color: var(--border-color);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Status and handler sit on one line and wrap together on a narrow row. */
|
||||
.conversation-footer {
|
||||
.conversation-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 4px;
|
||||
gap: 12px;
|
||||
padding: 14px 12px;
|
||||
border-radius: var(--radius-card);
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color var(--t-fast),
|
||||
box-shadow var(--t-fast);
|
||||
}
|
||||
|
||||
.conversation-item + .conversation-item {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.conversation-item:hover {
|
||||
background: color-mix(in srgb, var(--surface) 58%, transparent);
|
||||
}
|
||||
|
||||
.conversation-item:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--ring);
|
||||
}
|
||||
|
||||
.conversation-item.active {
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-sm), 0 0 0 1px var(--line);
|
||||
}
|
||||
|
||||
.conversation-item.active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 16px;
|
||||
bottom: 16px;
|
||||
width: 2px;
|
||||
border-radius: 2px;
|
||||
background: var(--ink);
|
||||
}
|
||||
|
||||
/* Waiting visitors are the queue's one urgent row — 朱砂 marks it. */
|
||||
.conversation-item.waiting {
|
||||
background: var(--vermilion-soft);
|
||||
box-shadow: inset 0 0 0 1px var(--vermilion-line);
|
||||
}
|
||||
|
||||
.conversation-item.waiting::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 16px;
|
||||
bottom: 16px;
|
||||
width: 2px;
|
||||
border-radius: 2px;
|
||||
background: var(--vermilion);
|
||||
}
|
||||
|
||||
.conversation-item.waiting.active {
|
||||
background: var(--vermilion-soft);
|
||||
box-shadow: inset 0 0 0 1px var(--vermilion-line), var(--shadow-sm);
|
||||
}
|
||||
|
||||
.conversation-item .avatar {
|
||||
--a: 38px;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.conv-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.conversation-status {
|
||||
font-size: 10px;
|
||||
font-family: var(--font-mono);
|
||||
letter-spacing: 0.04em;
|
||||
padding: 2px 7px;
|
||||
border-radius: var(--radius-full);
|
||||
text-transform: uppercase;
|
||||
display: inline-block;
|
||||
.conv-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.conv-row b {
|
||||
min-width: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.conversation-status.open {
|
||||
background: rgba(201,242,78,.12);
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
.conversation-status.closed {
|
||||
background: var(--o06);
|
||||
.conv-row time {
|
||||
margin-left: auto;
|
||||
flex: none;
|
||||
font-size: 11.5px;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.conversation-status.transferred {
|
||||
background-color: var(--warning-color-soft);
|
||||
color: var(--warning-color);
|
||||
.conv-preview {
|
||||
margin-top: 3px;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
line-height: 1.55;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.conv-preview em {
|
||||
font-style: normal;
|
||||
color: var(--ink-2);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.conv-tags {
|
||||
margin-top: 9px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.conv-tags .chip {
|
||||
height: 21px;
|
||||
padding: 0 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.conv-count {
|
||||
font-size: 11px;
|
||||
color: var(--faint);
|
||||
margin-left: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.unread {
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--ink);
|
||||
color: var(--primary-fg);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Chat view */
|
||||
.chat-view {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
@@ -528,245 +631,154 @@ onBeforeUnmount(() => {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sidebar-header h2 {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.conversation-item {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--o06);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
min-height: 72px;
|
||||
}
|
||||
|
||||
.conversation-item:hover {
|
||||
background: var(--o05);
|
||||
}
|
||||
|
||||
.conversation-item.active {
|
||||
background: var(--accent-bg-06);
|
||||
border-left: 3px solid var(--accent-ink);
|
||||
}
|
||||
|
||||
.conversation-item-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.conversation-item-header h3 {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 170px;
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.conversation-preview {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 4px;
|
||||
font-size: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.agent-name {
|
||||
font-weight: 500;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.last-message {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 140px;
|
||||
}
|
||||
|
||||
.message-count {
|
||||
font-size: 11px;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.unread-bubble {
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
border-radius: 50%;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.no-chat-selected {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--muted);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.loading-state,
|
||||
.error-state,
|
||||
.empty-state {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
/* Loading / error / empty — one centred column in the queue */
|
||||
.state {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 28px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.refresh-button {
|
||||
padding: 4px 8px;
|
||||
background: var(--o06);
|
||||
border: 1px solid var(--o10);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text3);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
.state-glyph {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
margin-bottom: 2px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.state-title {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
|
||||
.state-note {
|
||||
max-width: 24ch;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.65;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.state .btn {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.state-spinner {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--line-2);
|
||||
border-top-color: var(--ink);
|
||||
animation: queue-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.state-spinner--sm {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
border-width: 1.5px;
|
||||
}
|
||||
|
||||
@keyframes queue-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.chat-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-glyph {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
margin-bottom: 6px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--line-2);
|
||||
background: var(--surface);
|
||||
color: var(--muted);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.empty-note {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.load-more-trigger {
|
||||
height: 40px;
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 12px 0;
|
||||
margin: 8px 0;
|
||||
padding: 12px 0 8px;
|
||||
}
|
||||
|
||||
.loading-more {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid var(--primary-color);
|
||||
border-radius: 50%;
|
||||
border-top-color: transparent;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.load-more-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.load-more-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.load-more-button {
|
||||
padding: 6px 12px;
|
||||
background-color: var(--background-soft);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.load-more-button:hover {
|
||||
background-color: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.load-more-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
font-size: 11.5px;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.scroll-to-top-btn {
|
||||
position: absolute;
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
border: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
transition: all var(--transition-fast);
|
||||
bottom: 18px;
|
||||
right: 18px;
|
||||
z-index: 10;
|
||||
box-shadow: var(--shadow-md);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.scroll-to-top-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
.scroll-glyph {
|
||||
display: inline-flex;
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.scroll-to-top-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.product-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--primary-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.product-icon {
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.last-message.product-preview {
|
||||
color: var(--primary-color);
|
||||
max-width: 160px;
|
||||
.scroll-glyph :deep(svg) {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Mobile: single stacked pane — the list by default, the chat when a session
|
||||
@@ -774,7 +786,7 @@ onBeforeUnmount(() => {
|
||||
@media (max-width: 768px) {
|
||||
.conversations-container,
|
||||
.conversations-container.with-chat-info {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.conversations-container .chat-view {
|
||||
@@ -793,23 +805,21 @@ onBeforeUnmount(() => {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.conversations-list {
|
||||
padding: 4px 10px calc(24px + var(--safe-bottom));
|
||||
}
|
||||
|
||||
.conversation-item {
|
||||
padding: 14px 16px;
|
||||
padding: 14px 12px;
|
||||
}
|
||||
|
||||
.conversation-item.active {
|
||||
background: transparent;
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.conversation-item-header h3 {
|
||||
max-width: none;
|
||||
.conv-row b {
|
||||
font-size: 15px;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.last-message {
|
||||
max-width: none;
|
||||
flex: 1;
|
||||
.conv-tags .chip {
|
||||
height: 22px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@@ -16,20 +16,14 @@ limitations under the License.
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { chatHandler, type ChatHandlerKind, type HandlerCandidate } from '@/utils/chatState'
|
||||
import { chatHandler, type HandlerCandidate } from '@/utils/chatState'
|
||||
import { navIconSvg } from '@/components/layout/navIcons'
|
||||
|
||||
const props = defineProps<{
|
||||
chat?: HandlerCandidate | null
|
||||
currentUserId?: string | null
|
||||
}>()
|
||||
|
||||
const HANDLER_ICONS: Record<ChatHandlerKind, string> = {
|
||||
ai: 'fa-solid fa-robot',
|
||||
waiting: 'fa-solid fa-user-clock',
|
||||
human: 'fa-solid fa-user',
|
||||
closed: 'fa-solid fa-lock',
|
||||
}
|
||||
|
||||
const handler = computed(() => chatHandler(props.chat, props.currentUserId))
|
||||
|
||||
// A closed chat has nobody handling it, and the status pill beside this badge
|
||||
@@ -38,25 +32,29 @@ const visible = computed(() => handler.value.kind !== 'closed')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span v-if="visible" class="handler-badge" :class="`handler-${handler.kind}`">
|
||||
<font-awesome-icon :icon="HANDLER_ICONS[handler.kind]" />
|
||||
<span
|
||||
v-if="visible"
|
||||
class="handler-badge chip"
|
||||
:class="[
|
||||
`handler-${handler.kind}`,
|
||||
handler.kind === 'ai' ? 'chip-ai' : handler.kind === 'waiting' ? 'chip-wait' : '',
|
||||
]"
|
||||
>
|
||||
<span
|
||||
v-if="handler.kind === 'human'"
|
||||
class="handler-icon"
|
||||
v-html="navIconSvg('headset', 12)"
|
||||
></span>
|
||||
<i v-else class="d"></i>
|
||||
<span class="handler-label">{{ handler.label }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* The shared .chip pill owns the shape; these classes only recolor it for the
|
||||
three handler states. Names are kept for the component tests. */
|
||||
.handler-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: 100%;
|
||||
padding: 2px 7px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
line-height: 1.6;
|
||||
letter-spacing: 0.02em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* The name can be long; the badge shrinks rather than pushing the row wider. */
|
||||
@@ -65,18 +63,30 @@ const visible = computed(() => handler.value.kind !== 'closed')
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.handler-icon {
|
||||
display: inline-flex;
|
||||
color: currentColor;
|
||||
}
|
||||
|
||||
.handler-icon :deep(svg) {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.handler-ai {
|
||||
background: var(--accent-bg-12);
|
||||
color: var(--accent-ink);
|
||||
background: var(--celadon-soft);
|
||||
border-color: var(--celadon-line);
|
||||
color: var(--celadon);
|
||||
}
|
||||
|
||||
.handler-waiting {
|
||||
background: var(--warning-color-soft);
|
||||
color: var(--warning-color);
|
||||
background: var(--vermilion-soft);
|
||||
border-color: var(--vermilion-line);
|
||||
color: var(--vermilion);
|
||||
}
|
||||
|
||||
.handler-human {
|
||||
background: var(--success-color-soft);
|
||||
color: var(--success-color);
|
||||
background: transparent;
|
||||
border-color: var(--line-2);
|
||||
color: var(--text2);
|
||||
}
|
||||
</style>
|
||||
@@ -16,21 +16,22 @@ limitations under the License.
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { navIconSvg } from '@/components/layout/navIcons'
|
||||
|
||||
// The channel's delivery receipt for a message we sent. 'failed' has its own
|
||||
// "Not delivered" notice, so it shows no tick.
|
||||
type ChannelStatus = 'sent' | 'delivered' | 'read'
|
||||
|
||||
const TICKS: Record<ChannelStatus, { icon: string; label: string }> = {
|
||||
sent: { icon: 'fa-solid fa-check', label: '已发送' },
|
||||
delivered: { icon: 'fa-solid fa-check-double', label: '已送达' },
|
||||
read: { icon: 'fa-solid fa-check-double', label: '已读' },
|
||||
sent: { icon: 'check', label: '已发送' },
|
||||
delivered: { icon: 'checks', label: '已送达' },
|
||||
read: { icon: 'checks', label: '已读' },
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
status?: string | null
|
||||
// On an accent-filled bubble no blue holds contrast in either theme, so
|
||||
// read becomes an inverted badge in the bubble's own colour pair
|
||||
// the tick inherits the bubble's own foreground instead.
|
||||
onAccent?: boolean
|
||||
}>()
|
||||
|
||||
@@ -48,26 +49,40 @@ const tick = computed(() =>
|
||||
:aria-label="tick.label"
|
||||
role="img"
|
||||
>
|
||||
<font-awesome-icon :icon="tick.icon" />
|
||||
<span class="tick-icon" v-html="navIconSvg(tick.icon, 13)"></span>
|
||||
<span class="tick-label">{{ tick.label }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.status-ticks {
|
||||
display: inline-flex;
|
||||
margin-left: 4px;
|
||||
font-size: var(--text-xs);
|
||||
vertical-align: baseline;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
color: var(--celadon);
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-ticks--read {
|
||||
color: var(--c-info);
|
||||
.tick-icon {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.tick-icon :deep(svg) {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* A sent message is not a success yet — keep it quiet until it lands. */
|
||||
.status-ticks--sent {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.status-ticks--on-accent {
|
||||
color: color-mix(in srgb, var(--bubble-human-fg) 78%, transparent);
|
||||
}
|
||||
|
||||
.status-ticks--on-accent.status-ticks--read {
|
||||
padding: 0 4px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--bubble-agent-fg);
|
||||
color: var(--bubble-agent-bg);
|
||||
color: var(--bubble-human-fg);
|
||||
}
|
||||
</style>
|
||||
@@ -16,7 +16,8 @@ limitations under the License.
|
||||
|
||||
<script setup lang="ts">
|
||||
// A small icon + label above a message saying where it came from (a tapped
|
||||
// button, an external app).
|
||||
// button, an external app). The icon name stays a Font Awesome string: the
|
||||
// message surface passes the same tokens it always has.
|
||||
defineProps<{
|
||||
icon: string
|
||||
label: string
|
||||
@@ -33,9 +34,20 @@ defineProps<{
|
||||
.message-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-bottom: 4px;
|
||||
font-size: var(--text-xs);
|
||||
gap: 5px;
|
||||
margin-bottom: 7px;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-pill);
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface-2);
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.6;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.message-tag :deep(svg) {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
}
|
||||
</style>
|
||||
@@ -16,6 +16,7 @@ limitations under the License.
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type { AddSourcePayload, CrawlScope } from '@/composables/useKnowledgeExplorer'
|
||||
import { navIconSvg } from '@/components/layout/navIcons'
|
||||
|
||||
type SourceKind = 'website' | 'sitemap' | 'pdf' | 'text'
|
||||
|
||||
@@ -40,11 +41,11 @@ const content = ref('')
|
||||
const dragOver = ref(false)
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const types: { key: SourceKind; title: string; sub: string }[] = [
|
||||
{ key: 'website', title: '网站', sub: '抓取单个页面或整个站点' },
|
||||
{ key: 'sitemap', title: '站点地图', sub: '索引其中列出的所有页面' },
|
||||
{ key: 'pdf', title: '文档', sub: 'PDF 文件,最大 25 MB' },
|
||||
{ key: 'text', title: '文本', sub: '直接粘贴内容' },
|
||||
const types: { key: SourceKind; title: string; sub: string; icon: string }[] = [
|
||||
{ key: 'website', title: '网站', sub: '抓取单个页面或整个站点', icon: 'globe' },
|
||||
{ key: 'sitemap', title: '站点地图', sub: '索引其中列出的所有页面', icon: 'link' },
|
||||
{ key: 'pdf', title: '文档', sub: 'PDF 文件,最大 25 MB', icon: 'file' },
|
||||
{ key: 'text', title: '文本', sub: '直接粘贴内容', icon: 'edit' },
|
||||
]
|
||||
|
||||
const willQueue = computed(() => kind.value !== 'text')
|
||||
@@ -157,9 +158,8 @@ function submit() {
|
||||
<h3 class="modal__title">添加知识来源</h3>
|
||||
<p class="modal__sub">选择来源类型,我们会在后台抓取并建立索引。</p>
|
||||
</div>
|
||||
<button class="icon-btn" type="button" aria-label="关闭" @click="emit('close')">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18" /></svg>
|
||||
<button class="icon-btn sm modal__close" type="button" aria-label="关闭" @click="emit('close')">
|
||||
<span v-html="navIconSvg('x', 16, 'icon-sm')"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -170,10 +170,14 @@ function submit() {
|
||||
type="button"
|
||||
class="type"
|
||||
:class="{ 'type--active': kind === t.key }"
|
||||
:aria-pressed="kind === t.key"
|
||||
@click="kind = t.key"
|
||||
>
|
||||
<span class="type__title">{{ t.title }}</span>
|
||||
<span class="type__sub">{{ t.sub }}</span>
|
||||
<span class="type__ico" v-html="navIconSvg(t.icon, 16, 'icon')"></span>
|
||||
<span class="type__text">
|
||||
<span class="type__title">{{ t.title }}</span>
|
||||
<span class="type__sub">{{ t.sub }}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -181,14 +185,28 @@ function submit() {
|
||||
<!-- website -->
|
||||
<template v-if="kind === 'website'">
|
||||
<label class="field-label" for="kb-add-url">页面 URL</label>
|
||||
<input id="kb-add-url" v-model="url" class="text-input" type="text"
|
||||
placeholder="https://docs.yourcompany.com/help" @keyup.enter="submit" />
|
||||
<input
|
||||
id="kb-add-url"
|
||||
v-model="url"
|
||||
class="form-input"
|
||||
type="text"
|
||||
placeholder="https://docs.yourcompany.com/help"
|
||||
@keyup.enter="submit"
|
||||
/>
|
||||
|
||||
<label class="field-label">抓取范围</label>
|
||||
<div class="scope">
|
||||
<button v-for="opt in scopes" :key="opt.key" type="button" class="scope__opt"
|
||||
:class="{ 'scope__opt--active': scope === opt.key }" @click="scope = opt.key">
|
||||
<button
|
||||
v-for="opt in scopes"
|
||||
:key="opt.key"
|
||||
type="button"
|
||||
class="scope__opt"
|
||||
:class="{ 'scope__opt--active': scope === opt.key }"
|
||||
:aria-pressed="scope === opt.key"
|
||||
@click="scope = opt.key"
|
||||
>
|
||||
<span class="radio" :class="{ 'radio--on': scope === opt.key }"></span>
|
||||
<span>
|
||||
<span class="scope__text">
|
||||
<span class="scope__title">{{ opt.title }}</span>
|
||||
<span class="scope__sub">{{ opt.sub }}</span>
|
||||
</span>
|
||||
@@ -202,10 +220,17 @@ function submit() {
|
||||
<!-- sitemap -->
|
||||
<template v-else-if="kind === 'sitemap'">
|
||||
<label class="field-label" for="kb-add-sitemap">站点地图 URL</label>
|
||||
<input id="kb-add-sitemap" v-model="url" class="text-input" type="text"
|
||||
placeholder="https://yourcompany.com/sitemap.xml" @keyup.enter="submit" />
|
||||
<div class="note note--teal">
|
||||
我们会读取站点地图,并将其中列出的每个页面加入抓取队列。较大的站点地图会分批处理。
|
||||
<input
|
||||
id="kb-add-sitemap"
|
||||
v-model="url"
|
||||
class="form-input"
|
||||
type="text"
|
||||
placeholder="https://yourcompany.com/sitemap.xml"
|
||||
@keyup.enter="submit"
|
||||
/>
|
||||
<div class="note note--info">
|
||||
<span class="note__ico" v-html="navIconSvg('link', 14, 'icon-sm')"></span>
|
||||
<span>我们会读取站点地图,并将其中列出的每个页面加入抓取队列。较大的站点地图会分批处理。</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -214,29 +239,51 @@ function submit() {
|
||||
<div
|
||||
class="drop"
|
||||
:class="{ 'drop--over': dragOver }"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="pickFiles"
|
||||
@keyup.enter="pickFiles"
|
||||
@dragover.prevent="dragOver = true"
|
||||
@dragleave.prevent="dragOver = false"
|
||||
@drop.prevent="onDrop"
|
||||
>
|
||||
<input ref="fileInput" type="file" accept="application/pdf" multiple class="hidden-file"
|
||||
@change="onFilesSelected" />
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept="application/pdf"
|
||||
multiple
|
||||
class="hidden-file"
|
||||
@change="onFilesSelected"
|
||||
/>
|
||||
<span class="drop__ico" v-html="navIconSvg('upload', 20, 'icon')"></span>
|
||||
<span v-if="files.length" class="drop__name">{{ files.map((f) => f.name).join(', ') }}</span>
|
||||
<span v-else class="drop__name">拖入 PDF 或点击选择文件</span>
|
||||
<span class="drop__hint">PDF 文件 · 每个最大 25 MB</span>
|
||||
</div>
|
||||
<div class="note note--coral">
|
||||
我们会提取文本、切分成多个章节,并逐一建立索引。扫描版 PDF 会经过 OCR 处理。
|
||||
<div class="note note--warn">
|
||||
<span class="note__ico" v-html="navIconSvg('file', 14, 'icon-sm')"></span>
|
||||
<span>我们会提取文本、切分成多个章节,并逐一建立索引。扫描版 PDF 会经过 OCR 处理。</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- text -->
|
||||
<template v-else>
|
||||
<label class="field-label" for="kb-add-title">标题</label>
|
||||
<input id="kb-add-title" v-model="title" class="text-input" type="text" placeholder="例如:退款政策" />
|
||||
<input
|
||||
id="kb-add-title"
|
||||
v-model="title"
|
||||
class="form-input"
|
||||
type="text"
|
||||
placeholder="例如:退款政策"
|
||||
/>
|
||||
|
||||
<label class="field-label" for="kb-add-content">内容</label>
|
||||
<textarea id="kb-add-content" v-model="content" class="textarea"
|
||||
placeholder="粘贴或输入希望智能体学习的内容…"></textarea>
|
||||
<textarea
|
||||
id="kb-add-content"
|
||||
v-model="content"
|
||||
class="form-input textarea"
|
||||
placeholder="粘贴或输入希望智能体学习的内容…"
|
||||
></textarea>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -247,8 +294,10 @@ function submit() {
|
||||
<template v-else>立即添加,无需抓取。</template>
|
||||
</span>
|
||||
<div class="foot__actions">
|
||||
<button class="btn btn--ghost" type="button" :disabled="submitting" @click="emit('close')">取消</button>
|
||||
<button class="btn btn--primary" type="button" :disabled="!canSubmit" @click="submit">
|
||||
<button class="btn btn-ghost" type="button" :disabled="submitting" @click="emit('close')">
|
||||
取消
|
||||
</button>
|
||||
<button class="btn btn-primary" type="button" :disabled="!canSubmit" @click="submit">
|
||||
{{ submitting ? '添加中…' : submitLabel }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -261,150 +310,143 @@ function submit() {
|
||||
.scrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
background: var(--scrim);
|
||||
z-index: var(--z-modal);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
padding: 48px 20px;
|
||||
overflow-y: auto;
|
||||
background: var(--scrim);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.modal {
|
||||
width: 560px;
|
||||
width: 600px;
|
||||
max-width: 100%;
|
||||
background: var(--bg2);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.4);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-card-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 20px 24px 16px;
|
||||
border-bottom: 1px solid var(--o08);
|
||||
padding: 22px 26px 18px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.modal__title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 19px;
|
||||
color: var(--text);
|
||||
margin: 0 0 4px;
|
||||
font-family: var(--font-serif);
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.modal__sub {
|
||||
font-size: 13px;
|
||||
margin-top: 4px;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.6;
|
||||
color: var(--muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 9px;
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o10);
|
||||
.modal__close {
|
||||
margin-top: 2px;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background: var(--o08);
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.types {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 9px;
|
||||
padding: 18px 24px 4px;
|
||||
gap: 10px;
|
||||
padding: 20px 26px 4px;
|
||||
}
|
||||
|
||||
.type {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 12px 13px;
|
||||
border-radius: 11px;
|
||||
cursor: pointer;
|
||||
align-items: flex-start;
|
||||
gap: 11px;
|
||||
padding: 12px 14px;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: 1.5px solid var(--o10);
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color var(--t-fast),
|
||||
background-color var(--t-fast),
|
||||
box-shadow var(--t-fast);
|
||||
}
|
||||
|
||||
.type:hover {
|
||||
background: var(--o04);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.type--active {
|
||||
background: var(--accent-bg-06);
|
||||
border-color: var(--accent-solid);
|
||||
background: var(--surface-2);
|
||||
border-color: var(--ink);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.type__ico {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
margin-top: 2px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.type--active .type__ico {
|
||||
color: var(--vermilion);
|
||||
}
|
||||
|
||||
.type__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.type__title {
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text2);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.type__sub {
|
||||
font-size: 11.5px;
|
||||
color: var(--muted2);
|
||||
line-height: 1.3;
|
||||
line-height: 1.45;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: 16px 24px 4px;
|
||||
padding: 18px 26px 8px;
|
||||
min-height: 172px;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
display: block;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
letter-spacing: 0.07em;
|
||||
color: var(--muted2);
|
||||
margin-bottom: 8px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.field-label:not(:first-child) {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.text-input,
|
||||
.textarea {
|
||||
width: 100%;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: 11px;
|
||||
padding: 12px 14px;
|
||||
font-size: 14px;
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
font-family: var(--font-sans);
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
min-height: 120px;
|
||||
line-height: 1.6;
|
||||
color: var(--text3);
|
||||
min-height: 136px;
|
||||
line-height: 1.75;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.text-input:focus,
|
||||
.textarea:focus {
|
||||
border-color: var(--accent-border, var(--accent-ink));
|
||||
}
|
||||
|
||||
.scope {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -416,52 +458,75 @@ function submit() {
|
||||
align-items: flex-start;
|
||||
gap: 11px;
|
||||
width: 100%;
|
||||
padding: 12px 13px;
|
||||
border-radius: 11px;
|
||||
cursor: pointer;
|
||||
padding: 11px 13px;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: 1.5px solid var(--o10);
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color var(--t-fast),
|
||||
background-color var(--t-fast);
|
||||
}
|
||||
|
||||
.scope__opt:hover {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.scope__opt--active {
|
||||
background: var(--accent-bg-06);
|
||||
border-color: var(--accent-solid);
|
||||
background: var(--surface-2);
|
||||
border-color: var(--ink);
|
||||
}
|
||||
|
||||
.radio {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex: none;
|
||||
margin-top: 2px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--o20);
|
||||
border: 1.5px solid var(--line-3);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.radio--on {
|
||||
border-color: var(--accent-solid);
|
||||
background: radial-gradient(circle, var(--accent-solid) 0 45%, transparent 48%);
|
||||
border-color: var(--ink);
|
||||
}
|
||||
|
||||
.radio--on::after {
|
||||
content: '';
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--ink);
|
||||
}
|
||||
|
||||
.scope__text {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.scope__title {
|
||||
display: block;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text2);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.scope__sub {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--muted2);
|
||||
margin-top: 1px;
|
||||
margin-top: 2px;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.5;
|
||||
color: var(--muted);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.scope__hint {
|
||||
margin: 8px 2px 0;
|
||||
font-size: 12px;
|
||||
color: var(--muted2);
|
||||
margin: 10px 2px 0;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.6;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.drop {
|
||||
@@ -471,16 +536,29 @@ function submit() {
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 30px 20px;
|
||||
background: var(--bg);
|
||||
border: 1.5px dashed var(--o20);
|
||||
border-radius: 14px;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
background: var(--surface-2);
|
||||
border: 1px dashed var(--line-3);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color var(--t-fast),
|
||||
background-color var(--t-fast);
|
||||
}
|
||||
|
||||
.drop:hover {
|
||||
border-color: var(--line-3);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.drop--over {
|
||||
border-color: var(--c-coral);
|
||||
background: var(--coral-bg);
|
||||
border-color: var(--celadon);
|
||||
background: var(--celadon-soft);
|
||||
}
|
||||
|
||||
.drop__ico {
|
||||
display: inline-flex;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.hidden-file {
|
||||
@@ -488,37 +566,44 @@ function submit() {
|
||||
}
|
||||
|
||||
.drop__name {
|
||||
font-size: 14px;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text2);
|
||||
color: var(--ink-2);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.drop__hint {
|
||||
font-size: 12px;
|
||||
color: var(--muted2);
|
||||
font-size: 11.5px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.note {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
padding: 12px 14px;
|
||||
border-radius: 11px;
|
||||
font-size: 12.5px;
|
||||
color: var(--text3);
|
||||
line-height: 1.5;
|
||||
gap: 9px;
|
||||
margin-top: 14px;
|
||||
padding: 11px 13px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
|
||||
.note--teal {
|
||||
background: var(--teal-bg);
|
||||
border: 1px solid var(--teal-border);
|
||||
.note__ico {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
margin-top: 2px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.note--coral {
|
||||
background: var(--coral-bg);
|
||||
border: 1px solid var(--coral-border);
|
||||
.note--info {
|
||||
background: var(--indigo-soft);
|
||||
border: 1px solid var(--indigo-line);
|
||||
}
|
||||
|
||||
.note--warn {
|
||||
background: var(--brass-soft);
|
||||
border: 1px solid var(--brass-line);
|
||||
}
|
||||
|
||||
.foot {
|
||||
@@ -526,72 +611,66 @@ function submit() {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid var(--o08);
|
||||
background: var(--surface);
|
||||
padding: 16px 26px;
|
||||
border-top: 1px solid var(--line);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.foot__note {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12.5px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.foot__dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
flex: none;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.foot__dot--queue {
|
||||
background: var(--c-purple);
|
||||
background: var(--indigo);
|
||||
}
|
||||
|
||||
.foot__dot--instant {
|
||||
background: var(--c-teal);
|
||||
background: var(--celadon);
|
||||
}
|
||||
|
||||
.foot__actions {
|
||||
display: flex;
|
||||
gap: 9px;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 10px 18px;
|
||||
border-radius: 10px;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.scrim {
|
||||
padding: 20px 14px 40px;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.modal__head,
|
||||
.types,
|
||||
.body,
|
||||
.foot {
|
||||
padding-left: 18px;
|
||||
padding-right: 18px;
|
||||
}
|
||||
|
||||
.btn--ghost {
|
||||
background: transparent;
|
||||
border-color: var(--o12);
|
||||
color: var(--muted);
|
||||
}
|
||||
.types {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.btn--ghost:hover:not(:disabled) {
|
||||
background: var(--o05);
|
||||
}
|
||||
.foot {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.btn--primary {
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
}
|
||||
|
||||
.btn--primary:hover:not(:disabled) {
|
||||
filter: brightness(1.05);
|
||||
.foot__actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -18,6 +18,7 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import {
|
||||
useKnowledgeExplorer,
|
||||
type ExplorerSource,
|
||||
type SourceStatus,
|
||||
type AddSourcePayload,
|
||||
} from '@/composables/useKnowledgeExplorer'
|
||||
import KnowledgeSourceTree from './KnowledgeSourceTree.vue'
|
||||
@@ -26,6 +27,7 @@ import KnowledgePageEditor from './KnowledgePageEditor.vue'
|
||||
import KnowledgePlanMeters from './KnowledgePlanMeters.vue'
|
||||
import KnowledgeAddSourceModal from './KnowledgeAddSourceModal.vue'
|
||||
import KnowledgeLinkModal from './KnowledgeLinkModal.vue'
|
||||
import { navIconSvg } from '@/components/layout/navIcons'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -73,6 +75,42 @@ const largestSubpageCount = computed(() => {
|
||||
return s ? (s.pages ?? s.pageStubs).length : 0
|
||||
})
|
||||
|
||||
const statusText: Record<SourceStatus, string> = {
|
||||
queued: '排队中',
|
||||
crawling: '抓取中',
|
||||
synced: '已索引',
|
||||
error: '需要同步',
|
||||
}
|
||||
|
||||
const statusChip: Record<SourceStatus, string> = {
|
||||
queued: 'chip-quiet',
|
||||
crawling: 'chip-brass',
|
||||
synced: 'chip-ai',
|
||||
error: 'chip-wait',
|
||||
}
|
||||
|
||||
// The right-hand information column only exists on the full knowledge page.
|
||||
const hasInfoPane = computed(() => props.showPlanMeters)
|
||||
|
||||
const infoSource = computed(() => ex.selectedSource.value)
|
||||
const infoPage = computed(() => ex.selectedPage.value)
|
||||
const infoStatus = computed<SourceStatus | null>(() =>
|
||||
infoSource.value ? ex.sourceStatus(infoSource.value) : null,
|
||||
)
|
||||
const infoChip = computed(() => (infoStatus.value ? statusChip[infoStatus.value] : ''))
|
||||
const infoSourcePages = computed(() => {
|
||||
const source = infoSource.value
|
||||
return source ? (source.pages ?? source.pageStubs).length : 0
|
||||
})
|
||||
const infoSourceType = computed(() => {
|
||||
const t = (infoSource.value?.type || '').toLowerCase()
|
||||
if (t.includes('pdf') || t.includes('file')) return '文档'
|
||||
if (t.includes('sitemap')) return '站点地图'
|
||||
if (t.includes('web') || t.includes('site')) return '网页'
|
||||
if (t.includes('text') || t.includes('custom')) return '手动页面'
|
||||
return infoSource.value?.type || '来源'
|
||||
})
|
||||
|
||||
function openAdd() {
|
||||
addOpen.value = true
|
||||
}
|
||||
@@ -157,91 +195,166 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="explorer">
|
||||
<header class="explorer__header">
|
||||
<div class="explorer" :class="`explorer--${variant}`">
|
||||
<header
|
||||
class="explorer__head"
|
||||
:class="variant === 'page' ? 'page-head' : 'explorer__head--section'"
|
||||
>
|
||||
<div class="explorer__heading">
|
||||
<component :is="variant === 'page' ? 'h1' : 'h3'" class="explorer__title" :class="`explorer__title--${variant}`">
|
||||
<div v-if="variant === 'page'" class="eyebrow">Knowledge · 智能</div>
|
||||
<component
|
||||
:is="variant === 'page' ? 'h1' : 'h3'"
|
||||
:class="variant === 'page' ? 'title' : 'section-title'"
|
||||
>
|
||||
{{ title }}
|
||||
</component>
|
||||
<p v-if="description" class="explorer__desc">{{ description }}</p>
|
||||
<p
|
||||
v-if="description"
|
||||
:class="variant === 'page' ? 'lede' : 'explorer__section-desc'"
|
||||
>{{ description }}</p>
|
||||
</div>
|
||||
<div class="explorer__actions">
|
||||
|
||||
<div class="actions">
|
||||
<slot name="actions" />
|
||||
<button v-if="mode === 'agent'" class="btn btn--ghost" type="button" @click="ex.openLinkPicker">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.2"
|
||||
stroke-linecap="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7 0l2-2a5 5 0 0 0-7-7l-1 1M14 11a5 5 0 0 0-7 0l-2 2a5 5 0 0 0 7 7l1-1" /></svg>
|
||||
<button
|
||||
v-if="mode === 'agent'"
|
||||
class="btn btn-ghost"
|
||||
type="button"
|
||||
@click="ex.openLinkPicker"
|
||||
>
|
||||
<span v-html="navIconSvg('link', 16, 'icon')"></span>
|
||||
关联已有
|
||||
</button>
|
||||
<button class="btn btn--primary" type="button" @click="openAdd">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.2"
|
||||
stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14" /></svg>
|
||||
<button class="btn btn-primary" type="button" @click="openAdd">
|
||||
<span v-html="navIconSvg('plus', 16, 'icon')"></span>
|
||||
添加来源
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<KnowledgePlanMeters
|
||||
v-if="showPlanMeters"
|
||||
class="explorer__meters"
|
||||
:source-count="ex.sources.value.length"
|
||||
:largest-source-name="largestSource?.name ?? null"
|
||||
:largest-subpage-count="largestSubpageCount"
|
||||
/>
|
||||
|
||||
<div v-if="ex.error.value" class="banner" role="alert">
|
||||
{{ ex.error.value }}
|
||||
<button class="banner__close" type="button" aria-label="关闭" @click="ex.error.value = null">×</button>
|
||||
<span class="banner__icon" v-html="navIconSvg('shield', 15, 'icon-sm')"></span>
|
||||
<span class="banner__text">{{ ex.error.value }}</span>
|
||||
<button
|
||||
class="banner__close"
|
||||
type="button"
|
||||
aria-label="关闭提示"
|
||||
@click="ex.error.value = null"
|
||||
>
|
||||
<span v-html="navIconSvg('x', 14, 'icon-xs')"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<KnowledgeSourceTree
|
||||
class="grid__tree"
|
||||
:sources="ex.filteredSources.value"
|
||||
:selected-page-id="ex.selectedPageId.value"
|
||||
:selected-source-id="ex.selectedSourceId.value"
|
||||
:query="ex.query.value"
|
||||
:status-of="ex.sourceStatus"
|
||||
:page-rows-of="ex.pageRows"
|
||||
@update:query="ex.query.value = $event"
|
||||
@toggle="ex.toggleSource"
|
||||
@select="(source, pageId) => ex.selectPage(source, pageId)"
|
||||
@delete-source="askDeleteSource"
|
||||
@add-page="ex.startAddPage"
|
||||
/>
|
||||
<div class="panel explorer__panel">
|
||||
<div class="explorer__grid" :class="{ 'explorer__grid--info': hasInfoPane }">
|
||||
<section class="pane pane--tree">
|
||||
<div class="pane-head">
|
||||
<span class="pane-title">来源</span>
|
||||
<span class="pane-count num">{{ ex.filteredSources.value.length }}</span>
|
||||
</div>
|
||||
<KnowledgeSourceTree
|
||||
:sources="ex.filteredSources.value"
|
||||
:selected-page-id="ex.selectedPageId.value"
|
||||
:selected-source-id="ex.selectedSourceId.value"
|
||||
:query="ex.query.value"
|
||||
:status-of="ex.sourceStatus"
|
||||
:page-rows-of="ex.pageRows"
|
||||
:loading="ex.isLoading.value"
|
||||
@update:query="ex.query.value = $event"
|
||||
@toggle="ex.toggleSource"
|
||||
@select="(source, pageId) => ex.selectPage(source, pageId)"
|
||||
@delete-source="askDeleteSource"
|
||||
@add-page="ex.startAddPage"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<div class="grid__detail">
|
||||
<KnowledgePageEditor
|
||||
v-if="ex.editing.value && (ex.selectedPage.value || ex.isAddingPage.value)"
|
||||
:title="ex.draftTitle.value"
|
||||
:content="ex.draftContent.value"
|
||||
:saving="ex.isSaving.value"
|
||||
:submit-label="ex.isAddingPage.value ? '添加子页面' : '保存页面'"
|
||||
:url="ex.draftUrl.value"
|
||||
:show-url="ex.isAddingPage.value"
|
||||
@update:title="ex.draftTitle.value = $event"
|
||||
@update:content="ex.draftContent.value = $event"
|
||||
@update:url="ex.draftUrl.value = $event"
|
||||
@save="ex.savePage"
|
||||
@cancel="ex.cancelEdit"
|
||||
/>
|
||||
<KnowledgePageDetail
|
||||
v-else-if="ex.selectedPage.value && ex.selectedSource.value"
|
||||
:page="ex.selectedPage.value"
|
||||
:source-name="ex.selectedSource.value.name"
|
||||
:source-type="ex.selectedSource.value.type"
|
||||
:agents="ex.selectedSource.value.agents"
|
||||
:status="ex.sourceStatus(ex.selectedSource.value)"
|
||||
:deleting="ex.isDeleting.value"
|
||||
@edit="ex.startEdit"
|
||||
@delete="askDeletePage"
|
||||
/>
|
||||
<div v-else class="empty">
|
||||
<div class="empty__orb"></div>
|
||||
<div class="empty__title">选择一个页面查看</div>
|
||||
<p class="empty__text">
|
||||
在左侧选择任一已提取的页面,即可查看内容、编辑或移除,也可以添加自己的来源。
|
||||
</p>
|
||||
</div>
|
||||
<section class="pane pane--main">
|
||||
<KnowledgePageEditor
|
||||
v-if="ex.editing.value && (ex.selectedPage.value || ex.isAddingPage.value)"
|
||||
:title="ex.draftTitle.value"
|
||||
:content="ex.draftContent.value"
|
||||
:saving="ex.isSaving.value"
|
||||
:submit-label="ex.isAddingPage.value ? '添加子页面' : '保存页面'"
|
||||
:url="ex.draftUrl.value"
|
||||
:show-url="ex.isAddingPage.value"
|
||||
@update:title="ex.draftTitle.value = $event"
|
||||
@update:content="ex.draftContent.value = $event"
|
||||
@update:url="ex.draftUrl.value = $event"
|
||||
@save="ex.savePage"
|
||||
@cancel="ex.cancelEdit"
|
||||
/>
|
||||
|
||||
<KnowledgePageDetail
|
||||
v-else-if="ex.selectedPage.value && ex.selectedSource.value"
|
||||
:page="ex.selectedPage.value"
|
||||
:source-name="ex.selectedSource.value.name"
|
||||
:source-type="ex.selectedSource.value.type"
|
||||
:agents="ex.selectedSource.value.agents"
|
||||
:status="ex.sourceStatus(ex.selectedSource.value)"
|
||||
:deleting="ex.isDeleting.value"
|
||||
@edit="ex.startEdit"
|
||||
@delete="askDeletePage"
|
||||
/>
|
||||
|
||||
<div v-else-if="ex.isLoading.value" class="state">
|
||||
<span class="state__mark" v-html="navIconSvg('clock', 20, 'icon')"></span>
|
||||
<p class="state__title">正在加载知识库</p>
|
||||
<p class="state__text">正在读取来源与页面,请稍候。</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="state">
|
||||
<span class="state__mark" v-html="navIconSvg('knowledge', 20, 'icon')"></span>
|
||||
<p class="state__title">选择一个页面查看</p>
|
||||
<p class="state__text">
|
||||
在左侧展开来源、选择任一页面,即可查看内容、编辑或移除;也可以新建子页面。
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside v-if="hasInfoPane" class="pane pane--info">
|
||||
<div class="pane-body pane-body--info">
|
||||
<section class="info-group">
|
||||
<h3 class="info-group__title">计划用量</h3>
|
||||
<KnowledgePlanMeters
|
||||
:source-count="ex.sources.value.length"
|
||||
:largest-source-name="largestSource?.name ?? null"
|
||||
:largest-subpage-count="largestSubpageCount"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section v-if="infoPage && infoSource" class="info-group">
|
||||
<h3 class="info-group__title">页面信息</h3>
|
||||
<dl class="info-list">
|
||||
<div class="info-row">
|
||||
<dt>来源</dt>
|
||||
<dd :title="infoSource.name">{{ infoSource.name }}</dd>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<dt>类型</dt>
|
||||
<dd>{{ infoSourceType }}</dd>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<dt>状态</dt>
|
||||
<dd>
|
||||
<span
|
||||
v-if="infoStatus"
|
||||
class="chip chip-sq"
|
||||
:class="infoChip"
|
||||
>
|
||||
<i class="d"></i>{{ statusText[infoStatus] }}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<dt>该来源页数</dt>
|
||||
<dd class="num">{{ infoSourcePages }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<p v-else class="info-empty">选择页面后,这里会显示它的来源与状态。</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -250,8 +363,20 @@ onUnmounted(() => {
|
||||
<h3 class="confirm__title">{{ confirmState.title }}</h3>
|
||||
<p class="confirm__msg">{{ confirmState.message }}</p>
|
||||
<div class="confirm__actions">
|
||||
<button class="btn btn--ghost" type="button" :disabled="ex.isDeleting.value" @click="confirmState = null">取消</button>
|
||||
<button class="btn btn--danger-solid" type="button" :disabled="ex.isDeleting.value" @click="runConfirm">
|
||||
<button
|
||||
class="btn btn-ghost"
|
||||
type="button"
|
||||
:disabled="ex.isDeleting.value"
|
||||
@click="confirmState = null"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-danger"
|
||||
type="button"
|
||||
:disabled="ex.isDeleting.value"
|
||||
@click="runConfirm"
|
||||
>
|
||||
{{ ex.isDeleting.value ? confirmState.busyLabel : confirmState.actionLabel }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -286,215 +411,288 @@ onUnmounted(() => {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.explorer__header {
|
||||
.explorer__head {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.explorer__head--section {
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
padding-bottom: 16px;
|
||||
margin-bottom: 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.explorer__heading {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.explorer__title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
.explorer__title--page {
|
||||
font-size: 30px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.explorer__title--section {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.explorer__desc {
|
||||
font-size: 14px;
|
||||
.explorer__section-desc {
|
||||
margin-top: 8px;
|
||||
max-width: 60ch;
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
color: var(--muted);
|
||||
margin: 0;
|
||||
max-width: 560px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.explorer__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.explorer__meters {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 47px;
|
||||
padding: 0 18px;
|
||||
border-radius: 11px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn--primary {
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
}
|
||||
|
||||
.btn--primary:hover:not(:disabled) { filter: brightness(1.05); }
|
||||
|
||||
.btn--ghost {
|
||||
background: var(--o05);
|
||||
border-color: var(--o12);
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.btn--ghost:hover:not(:disabled) { background: var(--o08); }
|
||||
|
||||
.btn--danger-solid {
|
||||
background: var(--c-coral);
|
||||
color: var(--on-accent-solid);
|
||||
}
|
||||
|
||||
.btn--danger-solid:hover:not(:disabled) { filter: brightness(1.05); }
|
||||
|
||||
.banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 11px 14px;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 16px;
|
||||
background: var(--error-bg);
|
||||
border: 1px solid var(--coral-border);
|
||||
border-radius: 11px;
|
||||
color: var(--c-coral);
|
||||
font-size: 13.5px;
|
||||
border: 1px solid var(--vermilion-line);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--vermilion-soft);
|
||||
color: var(--vermilion);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.banner__icon {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.banner__text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.banner__close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: inherit;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.grid {
|
||||
flex: none;
|
||||
display: grid;
|
||||
grid-template-columns: 344px 1fr;
|
||||
gap: 18px;
|
||||
align-items: stretch;
|
||||
place-items: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.banner__close:hover {
|
||||
background: color-mix(in srgb, var(--vermilion) 12%, transparent);
|
||||
}
|
||||
|
||||
.grid__tree,
|
||||
.grid__detail {
|
||||
height: 640px;
|
||||
}
|
||||
|
||||
.grid__detail {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o08);
|
||||
border-radius: 16px;
|
||||
.explorer__panel {
|
||||
height: max(540px, calc(100vh - 318px));
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.empty {
|
||||
.explorer--section .explorer__panel {
|
||||
height: 600px;
|
||||
}
|
||||
|
||||
.explorer__grid {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 292px) minmax(0, 1fr);
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.explorer__grid--info {
|
||||
grid-template-columns: 292px minmax(0, 1fr) 258px;
|
||||
}
|
||||
|
||||
.pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.pane + .pane {
|
||||
border-left: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.pane--tree {
|
||||
background: var(--surface-sunk);
|
||||
}
|
||||
|
||||
.pane--main {
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.pane--info {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.pane-head {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 16px 16px 10px;
|
||||
}
|
||||
|
||||
.pane-title {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.pane-count {
|
||||
font-family: var(--font-num);
|
||||
font-size: 12px;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.pane-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.pane-body--info {
|
||||
padding: 22px 18px 24px;
|
||||
}
|
||||
|
||||
.info-group + .info-group {
|
||||
margin-top: 22px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.info-group__title {
|
||||
margin-bottom: 14px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.info-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 7px 0;
|
||||
}
|
||||
|
||||
.info-row dt {
|
||||
flex: none;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.info-row dd {
|
||||
min-width: 0;
|
||||
font-size: 12.5px;
|
||||
color: var(--ink-2);
|
||||
text-align: right;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.info-row dd.num {
|
||||
font-family: var(--font-num);
|
||||
font-size: 15px;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.info-empty {
|
||||
margin-top: 20px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--line);
|
||||
font-size: 12px;
|
||||
line-height: 1.65;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.state {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
padding: 40px 32px;
|
||||
}
|
||||
|
||||
.empty__orb {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
margin-bottom: 18px;
|
||||
.state__mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
margin-bottom: 16px;
|
||||
border-radius: 50%;
|
||||
background: conic-gradient(from 0deg, var(--c-lime), var(--c-purple), var(--c-teal), var(--c-coral), var(--c-lime));
|
||||
opacity: 0.85;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--line);
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.empty__title {
|
||||
font-family: var(--font-display);
|
||||
.state__title {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
color: var(--text2);
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
|
||||
.empty__text {
|
||||
font-size: 14px;
|
||||
.state__text {
|
||||
margin-top: 8px;
|
||||
max-width: 36ch;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: var(--muted);
|
||||
max-width: 320px;
|
||||
line-height: 1.55;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.confirm {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
background: var(--scrim);
|
||||
z-index: var(--z-modal);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
background: var(--scrim);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.confirm__card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
padding: 26px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-card-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.confirm__title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 18px;
|
||||
color: var(--text);
|
||||
margin: 0 0 8px;
|
||||
font-family: var(--font-serif);
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.confirm__msg {
|
||||
font-size: 14px;
|
||||
margin: 10px 0 22px;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.7;
|
||||
color: var(--muted);
|
||||
line-height: 1.55;
|
||||
margin: 0 0 20px;
|
||||
}
|
||||
|
||||
.confirm__actions {
|
||||
@@ -502,4 +700,118 @@ onUnmounted(() => {
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 1400px) {
|
||||
.explorer__grid--info {
|
||||
grid-template-columns: 264px minmax(0, 1fr) 236px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.explorer--page .explorer__panel {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.explorer--page .explorer__grid--info {
|
||||
grid-template-columns: 272px minmax(0, 1fr);
|
||||
grid-template-rows: minmax(520px, auto) auto;
|
||||
}
|
||||
|
||||
.explorer--page .pane--main {
|
||||
min-height: 520px;
|
||||
}
|
||||
|
||||
.explorer--page .pane--info {
|
||||
grid-column: 1 / -1;
|
||||
border-left: 0;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.explorer--page .pane-body--info {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 28px;
|
||||
align-items: start;
|
||||
overflow: visible;
|
||||
padding: 22px 24px 26px;
|
||||
}
|
||||
|
||||
.explorer--page .info-group + .info-group {
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
border-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.explorer__head {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.explorer__head .actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.explorer--page .explorer__grid,
|
||||
.explorer--page .explorer__grid--info {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto;
|
||||
}
|
||||
|
||||
.explorer--page .pane--tree {
|
||||
max-height: 360px;
|
||||
}
|
||||
|
||||
.explorer--page .pane + .pane {
|
||||
border-left: 0;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.explorer--page .pane--info {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.explorer__head {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.explorer__grid,
|
||||
.explorer__grid--info {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto;
|
||||
}
|
||||
|
||||
.explorer--section .explorer__panel {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.pane + .pane {
|
||||
border-left: 0;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.pane--tree {
|
||||
max-height: 340px;
|
||||
}
|
||||
|
||||
.pane--main {
|
||||
min-height: 460px;
|
||||
}
|
||||
|
||||
.explorer__head--section {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.explorer__head .actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -16,6 +16,7 @@ limitations under the License.
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import type { KnowledgeItem } from '@/types/knowledge'
|
||||
import { navIconSvg } from '@/components/layout/navIcons'
|
||||
|
||||
const props = defineProps<{
|
||||
sources: KnowledgeItem[]
|
||||
@@ -40,6 +41,23 @@ const filteredSources = computed(() => {
|
||||
(s) => s.name.toLowerCase().includes(q) || s.type.toLowerCase().includes(q),
|
||||
)
|
||||
})
|
||||
|
||||
function sourceIcon(item: KnowledgeItem): string {
|
||||
const t = (item.type || '').toLowerCase()
|
||||
if (t.includes('pdf') || t.includes('file')) return 'file'
|
||||
if (t.includes('sitemap')) return 'link'
|
||||
if (t.includes('web') || t.includes('site')) return 'globe'
|
||||
return 'folder'
|
||||
}
|
||||
|
||||
function typeLabel(item: KnowledgeItem): string {
|
||||
const t = (item.type || '').toLowerCase()
|
||||
if (t.includes('pdf') || t.includes('file')) return '文档'
|
||||
if (t.includes('sitemap')) return '站点地图'
|
||||
if (t.includes('web') || t.includes('site')) return '网页'
|
||||
if (t.includes('text') || t.includes('custom')) return '手动页面'
|
||||
return item.type || '来源'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -50,31 +68,47 @@ const filteredSources = computed(() => {
|
||||
<h3 class="modal__title">关联已有知识</h3>
|
||||
<p class="modal__sub">把组织中的知识关联到该智能体,供其使用。</p>
|
||||
</div>
|
||||
<button class="icon-btn" type="button" aria-label="关闭" @click="emit('close')">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18" /></svg>
|
||||
<button class="icon-btn sm modal__close" type="button" aria-label="关闭" @click="emit('close')">
|
||||
<span v-html="navIconSvg('x', 16, 'icon-sm')"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="search">
|
||||
<input v-model="query" class="text-input" type="text" placeholder="搜索知识来源…" />
|
||||
<div class="search-field">
|
||||
<div class="search-field__inner">
|
||||
<span class="search-field__ico" v-html="navIconSvg('search', 15, 'icon-sm')"></span>
|
||||
<input
|
||||
v-model="query"
|
||||
class="form-input"
|
||||
type="text"
|
||||
placeholder="搜索知识来源…"
|
||||
aria-label="搜索知识来源"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="body">
|
||||
<div v-if="loading" class="state">正在加载知识来源…</div>
|
||||
<div v-else-if="error" class="state state--error">{{ error }}</div>
|
||||
<div v-if="loading" class="state">
|
||||
<span class="state__mark" v-html="navIconSvg('clock', 20, 'icon')"></span>
|
||||
<p>正在加载知识来源…</p>
|
||||
</div>
|
||||
<div v-else-if="error" class="state state--error">
|
||||
<span class="state__mark" v-html="navIconSvg('shield', 20, 'icon')"></span>
|
||||
<p>{{ error }}</p>
|
||||
</div>
|
||||
<div v-else-if="!filteredSources.length" class="state">
|
||||
{{ query ? '没有匹配的来源。' : '组织中还没有可关联的知识。' }}
|
||||
<span class="state__mark" v-html="navIconSvg('folder', 20, 'icon')"></span>
|
||||
<p>{{ query ? '没有匹配的来源。' : '组织中还没有可关联的知识。' }}</p>
|
||||
</div>
|
||||
<ul v-else class="list">
|
||||
<li v-for="item in filteredSources" :key="item.id" class="row">
|
||||
<span class="row__mark" v-html="navIconSvg(sourceIcon(item), 16, 'icon')"></span>
|
||||
<div class="row__main">
|
||||
<span class="row__name">{{ item.name }}</span>
|
||||
<span class="row__type">{{ item.type }}</span>
|
||||
<span class="row__type">{{ typeLabel(item) }} · {{ item.pages?.length ?? 0 }} 页</span>
|
||||
</div>
|
||||
<button
|
||||
v-if="linkedIds.has(item.id)"
|
||||
class="btn btn--linked"
|
||||
class="btn btn-ghost btn-sm row__action"
|
||||
type="button"
|
||||
:disabled="busyIds.has(item.id)"
|
||||
@click="emit('unlink', item.id)"
|
||||
@@ -83,7 +117,7 @@ const filteredSources = computed(() => {
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="btn btn--primary"
|
||||
class="btn btn-primary btn-sm row__action"
|
||||
type="button"
|
||||
:disabled="busyIds.has(item.id)"
|
||||
@click="emit('link', item.id)"
|
||||
@@ -95,7 +129,7 @@ const filteredSources = computed(() => {
|
||||
</div>
|
||||
|
||||
<div class="foot">
|
||||
<button class="btn btn--ghost" type="button" @click="emit('close')">完成</button>
|
||||
<button class="btn btn-ghost" type="button" @click="emit('close')">完成</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -105,131 +139,158 @@ const filteredSources = computed(() => {
|
||||
.scrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
background: var(--scrim);
|
||||
z-index: var(--z-modal);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
padding: 48px 20px;
|
||||
overflow-y: auto;
|
||||
background: var(--scrim);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.modal {
|
||||
width: 560px;
|
||||
max-width: 100%;
|
||||
background: var(--bg2);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.4);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-card-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 20px 24px 16px;
|
||||
border-bottom: 1px solid var(--o08);
|
||||
padding: 22px 26px 18px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.modal__title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 19px;
|
||||
color: var(--text);
|
||||
margin: 0 0 4px;
|
||||
font-family: var(--font-serif);
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.modal__sub {
|
||||
font-size: 13px;
|
||||
margin-top: 4px;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.6;
|
||||
color: var(--muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 9px;
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o10);
|
||||
.modal__close {
|
||||
margin-top: 2px;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background: var(--o08);
|
||||
color: var(--text2);
|
||||
.search-field {
|
||||
padding: 18px 26px 4px;
|
||||
}
|
||||
|
||||
.search {
|
||||
padding: 16px 24px 4px;
|
||||
.search-field__inner {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.text-input {
|
||||
width: 100%;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: 11px;
|
||||
padding: 12px 14px;
|
||||
font-size: 14px;
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
font-family: var(--font-sans);
|
||||
.search-field__ico {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: inline-flex;
|
||||
color: var(--faint);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.text-input:focus {
|
||||
border-color: var(--accent-border, var(--accent-ink));
|
||||
.search-field .form-input {
|
||||
padding-left: 38px;
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: 12px 24px 4px;
|
||||
min-height: 180px;
|
||||
max-height: 340px;
|
||||
padding: 14px 26px 6px;
|
||||
min-height: 200px;
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 160px;
|
||||
font-size: 13.5px;
|
||||
color: var(--muted);
|
||||
gap: 10px;
|
||||
min-height: 180px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.state__mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--line);
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.state--error {
|
||||
color: var(--c-coral);
|
||||
color: var(--vermilion);
|
||||
}
|
||||
|
||||
.state--error .state__mark {
|
||||
background: var(--vermilion-soft);
|
||||
border-color: var(--vermilion-line);
|
||||
color: var(--vermilion);
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 11px 13px;
|
||||
border-radius: 11px;
|
||||
border: 1px solid var(--o10);
|
||||
background: var(--o04);
|
||||
gap: 11px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
transition:
|
||||
border-color var(--t-fast),
|
||||
background-color var(--t-fast);
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
background: var(--surface-2);
|
||||
border-color: var(--line-2);
|
||||
}
|
||||
|
||||
.row__mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
flex: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--line);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.row__main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -237,72 +298,48 @@ const filteredSources = computed(() => {
|
||||
}
|
||||
|
||||
.row__name {
|
||||
font-size: 13.5px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text2);
|
||||
word-break: break-word;
|
||||
color: var(--ink-2);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.row__type {
|
||||
font-size: 11.5px;
|
||||
color: var(--muted2);
|
||||
text-transform: capitalize;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.row__action {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.row__action.btn-ghost:hover:not(:disabled) {
|
||||
background: var(--vermilion-soft);
|
||||
border-color: var(--vermilion-line);
|
||||
color: var(--vermilion);
|
||||
}
|
||||
|
||||
.foot {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 9px;
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid var(--o08);
|
||||
background: var(--surface);
|
||||
padding: 16px 26px;
|
||||
border-top: 1px solid var(--line);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.scrim {
|
||||
padding: 20px 14px 40px;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn--primary {
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
}
|
||||
|
||||
.btn--primary:hover:not(:disabled) {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
|
||||
.btn--linked {
|
||||
background: var(--o05);
|
||||
border-color: var(--o12);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.btn--linked:hover:not(:disabled) {
|
||||
background: var(--coral-bg);
|
||||
border-color: var(--coral-border);
|
||||
color: var(--c-coral);
|
||||
}
|
||||
|
||||
.btn--ghost {
|
||||
background: transparent;
|
||||
border-color: var(--o12);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.btn--ghost:hover:not(:disabled) {
|
||||
background: var(--o05);
|
||||
.modal__head,
|
||||
.search-field,
|
||||
.body,
|
||||
.foot {
|
||||
padding-left: 18px;
|
||||
padding-right: 18px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -17,6 +17,7 @@ limitations under the License.
|
||||
import { computed } from 'vue'
|
||||
import type { KnowledgeLinkedAgent, KnowledgeSubPage } from '@/types/knowledge'
|
||||
import type { SourceStatus } from '@/composables/useKnowledgeExplorer'
|
||||
import { navIconSvg } from '@/components/layout/navIcons'
|
||||
|
||||
const props = defineProps<{
|
||||
page: KnowledgeSubPage
|
||||
@@ -27,21 +28,25 @@ const props = defineProps<{
|
||||
deleting: boolean
|
||||
}>()
|
||||
|
||||
// First-letter avatar for an agent chip.
|
||||
const initial = (name: string) => (name.trim()[0] || '?').toUpperCase()
|
||||
|
||||
defineEmits<{
|
||||
(e: 'edit'): void
|
||||
(e: 'delete'): void
|
||||
}>()
|
||||
|
||||
const statusText: Record<SourceStatus, string> = {
|
||||
queued: '等待中',
|
||||
queued: '排队中',
|
||||
crawling: '抓取中',
|
||||
synced: '已同步',
|
||||
synced: '已索引',
|
||||
error: '需要同步',
|
||||
}
|
||||
|
||||
const statusChip: Record<SourceStatus, string> = {
|
||||
queued: 'chip-quiet',
|
||||
crawling: 'chip-brass',
|
||||
synced: 'chip-ai',
|
||||
error: 'chip-wait',
|
||||
}
|
||||
|
||||
const paragraphs = computed(() =>
|
||||
(props.page.content || '')
|
||||
.split(/\n{2,}/)
|
||||
@@ -51,81 +56,119 @@ const paragraphs = computed(() =>
|
||||
|
||||
const href = computed(() => {
|
||||
const url = props.page.url || ''
|
||||
if (!url) return ''
|
||||
return /^https?:/i.test(url) ? url : `https://${url}`
|
||||
})
|
||||
|
||||
const updatedLabel = computed(() => {
|
||||
if (!props.page.updated_at) return '未知'
|
||||
const d = new Date(props.page.updated_at)
|
||||
return Number.isNaN(d.getTime()) ? '未知' : d.toLocaleDateString()
|
||||
function timeAgo(iso: string | null): string {
|
||||
if (!iso) return '未知'
|
||||
const date = new Date(iso)
|
||||
if (Number.isNaN(date.getTime())) return '未知'
|
||||
const diff = Date.now() - date.getTime()
|
||||
const minute = 60_000
|
||||
const hour = 60 * minute
|
||||
const day = 24 * hour
|
||||
if (diff < 0) return date.toLocaleDateString()
|
||||
if (diff < hour) return `${Math.max(1, Math.floor(diff / minute))} 分钟前`
|
||||
if (diff < day) return `${Math.floor(diff / hour)} 小时前`
|
||||
if (diff < 2 * day) return '昨天'
|
||||
if (diff < 30 * day) return `${Math.floor(diff / day)} 天前`
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const dayOfMonth = String(date.getDate()).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${month}-${dayOfMonth}`
|
||||
}
|
||||
|
||||
const updatedLabel = computed(() => timeAgo(props.page.updated_at))
|
||||
|
||||
const wordLabel = computed(() => (props.page.word_count || 0).toLocaleString())
|
||||
|
||||
// First-letter avatar for an agent chip.
|
||||
const initial = (name: string) => (name.trim()[0] || '?').toUpperCase()
|
||||
|
||||
const sourceTypeLabel = computed(() => {
|
||||
const t = (props.sourceType || '').toLowerCase()
|
||||
if (t.includes('pdf') || t.includes('file')) return '文档'
|
||||
if (t.includes('sitemap')) return '站点地图'
|
||||
if (t.includes('web') || t.includes('site')) return '网页'
|
||||
if (t.includes('text') || t.includes('custom')) return '手动页面'
|
||||
return props.sourceType || '来源'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="detail">
|
||||
<div class="detail__head">
|
||||
<div class="crumbs">
|
||||
<span class="crumbs__src" :title="sourceName">{{ sourceName }}</span>
|
||||
<span class="crumbs__sep">/</span>
|
||||
<span class="crumbs__type">{{ sourceType }}</span>
|
||||
</div>
|
||||
|
||||
<div class="detail__title-row">
|
||||
<div class="detail__title-wrap">
|
||||
<h2 class="detail__title">{{ page.title }}</h2>
|
||||
<a class="detail__url" :href="href" target="_blank" rel="noopener noreferrer">
|
||||
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.8"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
||||
<path d="M15 3h6v6" /><path d="M10 14 21 3" />
|
||||
</svg>
|
||||
<span class="detail__url-text">{{ page.url }}</span>
|
||||
</a>
|
||||
<div class="detail__title-wrap">
|
||||
<div class="detail__kicker">
|
||||
<span>来源</span>
|
||||
<span class="detail__kicker-name" :title="sourceName">{{ sourceName }}</span>
|
||||
<span class="detail__kicker-sep">·</span>
|
||||
<span>{{ sourceTypeLabel }}</span>
|
||||
</div>
|
||||
<div class="detail__actions">
|
||||
<button class="btn btn--ghost" type="button" @click="$emit('edit')">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M12 20h9" /><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z" />
|
||||
</svg>
|
||||
编辑
|
||||
</button>
|
||||
<button class="btn btn--danger" type="button" :disabled="deleting" title="删除页面"
|
||||
@click="$emit('delete')">
|
||||
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M4 7h16" /><path d="M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
|
||||
<path d="M6 7l1 13a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1l1-13" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<h2 class="detail__title">{{ page.title }}</h2>
|
||||
|
||||
<a
|
||||
v-if="page.url"
|
||||
class="detail__url"
|
||||
:href="href"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
:title="page.url"
|
||||
>
|
||||
<span v-html="navIconSvg('link', 13, 'icon-xs')"></span>
|
||||
<span class="detail__url-text">{{ page.url }}</span>
|
||||
</a>
|
||||
|
||||
<div class="preview-meta">
|
||||
<span class="chip chip-sq" :class="statusChip[status]">
|
||||
<i class="d"></i>{{ statusText[status] }}
|
||||
</span>
|
||||
<i class="preview-meta__sep" aria-hidden="true"></i>
|
||||
<span><span class="num">{{ wordLabel }}</span> 词</span>
|
||||
<i class="preview-meta__sep" aria-hidden="true"></i>
|
||||
<span>{{ page.chunk_count }} 个片段</span>
|
||||
<i class="preview-meta__sep" aria-hidden="true"></i>
|
||||
<span>更新于 {{ updatedLabel }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="agents && agents.length" class="usedby">
|
||||
<span class="usedby__label">使用方</span>
|
||||
<span v-for="agent in agents" :key="agent.id" class="agent-pill" :title="agent.name">
|
||||
<span class="agent-pill__avatar">{{ initial(agent.name) }}</span>
|
||||
<span class="agent-pill__name">{{ agent.name }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="meta">
|
||||
<span class="meta__item">{{ page.word_count }} 词</span>
|
||||
<span class="meta__sep"></span>
|
||||
<span class="meta__item">更新于 {{ updatedLabel }}</span>
|
||||
<span class="meta__sep"></span>
|
||||
<span class="pill" :class="`pill--${status}`">
|
||||
<span class="pill__dot"></span>{{ statusText[status] }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="agents && agents.length" class="usedby">
|
||||
<span class="usedby__label">使用方</span>
|
||||
<span v-for="agent in agents" :key="agent.id" class="agent-chip" :title="agent.name">
|
||||
<span class="agent-chip__avatar">{{ initial(agent.name) }}</span>
|
||||
<span class="agent-chip__name">{{ agent.name }}</span>
|
||||
</span>
|
||||
<div class="detail__actions">
|
||||
<button class="btn btn-ghost btn-sm" type="button" @click="$emit('edit')">
|
||||
<span v-html="navIconSvg('edit', 14, 'icon-sm')"></span>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
class="icon-btn sm detail__del"
|
||||
type="button"
|
||||
:disabled="deleting"
|
||||
title="删除页面"
|
||||
aria-label="删除页面"
|
||||
@click="$emit('delete')"
|
||||
>
|
||||
<span v-html="navIconSvg('trash', 15, 'icon-sm')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="doc">
|
||||
<p v-for="(para, i) in paragraphs" :key="i">{{ para }}</p>
|
||||
<div v-if="paragraphs.length === 0" class="doc__empty">
|
||||
该页面还没有内容,点击 <strong>编辑</strong> 添加内容。
|
||||
<article class="doc">
|
||||
<template v-if="paragraphs.length">
|
||||
<p v-for="(para, i) in paragraphs" :key="i">{{ para }}</p>
|
||||
</template>
|
||||
<div v-else class="doc__empty">
|
||||
<span class="doc__empty-mark" v-html="navIconSvg('file', 20, 'icon')"></span>
|
||||
<p class="doc__empty-title">该页面还没有内容</p>
|
||||
<p class="doc__empty-text">点击「编辑」添加内容;保存后会重新生成向量。</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -138,37 +181,11 @@ const updatedLabel = computed(() => {
|
||||
}
|
||||
|
||||
.detail__head {
|
||||
padding: 20px 24px 16px;
|
||||
border-bottom: 1px solid var(--o07);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.crumbs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--muted2);
|
||||
margin-bottom: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.crumbs__src {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 60%;
|
||||
}
|
||||
|
||||
.crumbs__sep { color: var(--faint); }
|
||||
.crumbs__type { color: var(--text3); }
|
||||
|
||||
.detail__title-row {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
gap: 18px;
|
||||
padding: 24px 28px 20px;
|
||||
}
|
||||
|
||||
.detail__title-wrap {
|
||||
@@ -176,13 +193,42 @@ const updatedLabel = computed(() => {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.detail__kicker {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
margin-bottom: 10px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.detail__kicker-name {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 11.5px;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--ink-2);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 26ch;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.detail__kicker-sep {
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.detail__title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 22px;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--text);
|
||||
margin: 0 0 6px;
|
||||
font-family: var(--font-serif);
|
||||
font-size: 25px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1.35;
|
||||
color: var(--ink);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@@ -190,188 +236,191 @@ const updatedLabel = computed(() => {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--c-teal);
|
||||
text-decoration: none;
|
||||
max-width: 100%;
|
||||
margin-top: 8px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11.5px;
|
||||
color: var(--celadon);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.detail__url:hover .detail__url-text {
|
||||
border-bottom-color: var(--celadon-line);
|
||||
}
|
||||
|
||||
.detail__url-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
border-bottom: 1px solid transparent;
|
||||
}
|
||||
|
||||
.detail__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
gap: 6px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 8px 14px;
|
||||
border-radius: 9px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--o12);
|
||||
background: var(--o05);
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.btn:hover { background: var(--o08); }
|
||||
|
||||
.btn--danger {
|
||||
width: 34px;
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
height: 34px;
|
||||
.detail__del {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.btn--danger:hover:not(:disabled) {
|
||||
background: var(--coral-bg);
|
||||
color: var(--c-coral);
|
||||
border-color: var(--coral-border);
|
||||
.detail__del:hover:not(:disabled) {
|
||||
background: var(--vermilion-soft);
|
||||
border-color: var(--vermilion-line);
|
||||
color: var(--vermilion);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.55;
|
||||
.detail__del:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.meta {
|
||||
.preview-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 14px;
|
||||
flex-wrap: wrap;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--muted2);
|
||||
align-items: center;
|
||||
gap: 8px 12px;
|
||||
margin-top: 14px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.meta__sep {
|
||||
.preview-meta__sep {
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
border-radius: 50%;
|
||||
background: var(--faint);
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
border: 1px solid var(--teal-border);
|
||||
background: var(--teal-bg);
|
||||
color: var(--c-teal);
|
||||
}
|
||||
|
||||
.pill__dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
.preview-meta .num {
|
||||
font-size: 13px;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
|
||||
.usedby {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 14px;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.usedby__label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
font-size: 9.5px;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted2);
|
||||
color: var(--faint);
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.agent-chip {
|
||||
.agent-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 10px 3px 3px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--o12);
|
||||
background: var(--o05);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text2);
|
||||
max-width: 180px;
|
||||
gap: 5px;
|
||||
max-width: 170px;
|
||||
padding: 2px 8px 2px 2px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--surface-2);
|
||||
font-size: 11.5px;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
|
||||
.agent-chip__avatar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
.agent-pill__avatar {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: var(--teal-bg);
|
||||
color: var(--c-teal);
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
background: var(--celadon-soft);
|
||||
color: var(--celadon);
|
||||
border: 1px solid var(--celadon-line);
|
||||
font-family: var(--font-serif);
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.agent-chip__name {
|
||||
.agent-pill__name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pill--crawling {
|
||||
border-color: var(--warning-border, var(--o12));
|
||||
background: var(--warning-bg);
|
||||
color: var(--warning-color);
|
||||
}
|
||||
|
||||
.pill--queued {
|
||||
border-color: var(--purple-border, var(--o12));
|
||||
background: var(--purple-bg, var(--o06));
|
||||
color: var(--c-purple);
|
||||
}
|
||||
|
||||
.pill--error {
|
||||
border-color: var(--coral-border);
|
||||
background: var(--coral-bg);
|
||||
color: var(--c-coral);
|
||||
}
|
||||
|
||||
.doc {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 24px 28px;
|
||||
color: var(--text3);
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
font-family: var(--font-sans);
|
||||
padding: 24px 28px 36px;
|
||||
border-top: 1px solid var(--line);
|
||||
font-size: 14px;
|
||||
line-height: 1.95;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
|
||||
.doc p {
|
||||
margin: 0 0 14px;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.doc p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.doc__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
color: var(--muted2);
|
||||
font-size: 14px;
|
||||
padding: 40px 0;
|
||||
padding: 48px 20px;
|
||||
}
|
||||
|
||||
.doc__empty-mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
margin-bottom: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--line);
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.doc__empty-title {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
|
||||
.doc__empty-text {
|
||||
margin-top: 6px;
|
||||
font-size: 12.5px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.detail__head {
|
||||
flex-direction: column;
|
||||
padding: 20px 18px 16px;
|
||||
}
|
||||
|
||||
.detail__actions {
|
||||
align-self: stretch;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.detail__title {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.doc {
|
||||
padding: 20px 18px 28px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -15,6 +15,7 @@ limitations under the License.
|
||||
-->
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { navIconSvg } from '@/components/layout/navIcons'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -42,36 +43,40 @@ const canSave = computed(() => props.content.trim().length > 0 && !props.saving)
|
||||
<template>
|
||||
<div class="editor">
|
||||
<div class="editor__body">
|
||||
<label class="editor__label" for="kb-page-title">页面标题</label>
|
||||
<input
|
||||
id="kb-page-title"
|
||||
class="editor__title"
|
||||
:value="title"
|
||||
type="text"
|
||||
placeholder="页面标题"
|
||||
@input="emit('update:title', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<div class="editor__field">
|
||||
<label class="editor__label" for="kb-page-title">页面标题</label>
|
||||
<input
|
||||
id="kb-page-title"
|
||||
class="form-input editor__title"
|
||||
:value="title"
|
||||
type="text"
|
||||
placeholder="页面标题"
|
||||
@input="emit('update:title', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template v-if="showUrl">
|
||||
<div v-if="showUrl" class="editor__field">
|
||||
<label class="editor__label" for="kb-page-url">来源 URL(可选)</label>
|
||||
<input
|
||||
id="kb-page-url"
|
||||
class="editor__title editor__url"
|
||||
class="form-input editor__url"
|
||||
:value="url"
|
||||
type="url"
|
||||
placeholder="https://example.com/page"
|
||||
@input="emit('update:url', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<label class="editor__label" for="kb-page-content">页面内容</label>
|
||||
<textarea
|
||||
id="kb-page-content"
|
||||
class="editor__content"
|
||||
:value="content"
|
||||
placeholder="在此输入页面内容,段落之间留一个空行。"
|
||||
@input="emit('update:content', ($event.target as HTMLTextAreaElement).value)"
|
||||
></textarea>
|
||||
<div class="editor__field editor__field--grow">
|
||||
<label class="editor__label" for="kb-page-content">页面内容</label>
|
||||
<textarea
|
||||
id="kb-page-content"
|
||||
class="form-input editor__content"
|
||||
:value="content"
|
||||
placeholder="在此输入页面内容,段落之间留一个空行。"
|
||||
@input="emit('update:content', ($event.target as HTMLTextAreaElement).value)"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor__foot">
|
||||
@@ -79,8 +84,11 @@ const canSave = computed(() => props.content.trim().length > 0 && !props.saving)
|
||||
<span class="editor__status-dot"></span>编辑中——保存后重新生成向量
|
||||
</span>
|
||||
<div class="editor__buttons">
|
||||
<button class="btn btn--ghost" type="button" :disabled="saving" @click="emit('cancel')">取消</button>
|
||||
<button class="btn btn--primary" type="button" :disabled="!canSave" @click="emit('save')">
|
||||
<button class="btn btn-ghost" type="button" :disabled="saving" @click="emit('cancel')">
|
||||
取消
|
||||
</button>
|
||||
<button class="btn btn-primary" type="button" :disabled="!canSave" @click="emit('save')">
|
||||
<span v-if="!saving" v-html="navIconSvg('check', 15, 'icon-sm')"></span>
|
||||
{{ saving ? '保存中…' : submitLabel }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -98,119 +106,107 @@ const canSave = computed(() => props.content.trim().length > 0 && !props.saving)
|
||||
|
||||
.editor__body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 18px 24px;
|
||||
padding: 24px 28px 22px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.editor__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.editor__field--grow {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.editor__label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted2);
|
||||
margin-bottom: 8px;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.editor__title {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: 9px;
|
||||
outline: none;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 18px;
|
||||
color: var(--text);
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 16px;
|
||||
font-family: var(--font-serif);
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1.4;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.editor__url {
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 400;
|
||||
font-size: 13px;
|
||||
color: var(--c-teal);
|
||||
}
|
||||
|
||||
.editor__title:focus,
|
||||
.editor__content:focus {
|
||||
border-color: var(--accent-border, var(--accent-ink));
|
||||
padding: 11px 14px;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
|
||||
.editor__content {
|
||||
flex: 1;
|
||||
min-height: 320px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: 12px;
|
||||
padding: 16px 18px;
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
color: var(--text3);
|
||||
outline: none;
|
||||
min-height: 280px;
|
||||
padding: 14px 16px;
|
||||
font-size: 14px;
|
||||
line-height: 1.85;
|
||||
color: var(--ink-2);
|
||||
resize: vertical;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.editor__foot {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 18px;
|
||||
border-top: 1px solid var(--o10);
|
||||
background: var(--bg2);
|
||||
flex-shrink: 0;
|
||||
gap: 14px;
|
||||
padding: 14px 22px;
|
||||
border-top: 1px solid var(--line);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.editor__status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
font-size: 12.5px;
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.editor__status-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
flex: none;
|
||||
border-radius: 50%;
|
||||
background: var(--c-coral);
|
||||
background: var(--brass);
|
||||
}
|
||||
|
||||
.editor__buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
gap: 8px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 9px 18px;
|
||||
border-radius: 9px;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
@media (max-width: 720px) {
|
||||
.editor__body {
|
||||
padding: 20px 18px;
|
||||
}
|
||||
|
||||
.editor__foot {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding: 14px 18px;
|
||||
}
|
||||
|
||||
.editor__buttons {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn--ghost {
|
||||
background: transparent;
|
||||
border-color: var(--o12);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.btn--ghost:hover:not(:disabled) { background: var(--o05); }
|
||||
|
||||
.btn--primary {
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
}
|
||||
|
||||
.btn--primary:hover:not(:disabled) { filter: brightness(1.05); }
|
||||
</style>
|
||||
@@ -44,7 +44,9 @@ function pct(used: number, limit: number | null): number {
|
||||
return Math.max(6, Math.min(100, Math.round((used / limit) * 100)))
|
||||
}
|
||||
|
||||
const sourcesOver = computed(() => maxSources.value !== null && props.sourceCount > maxSources.value)
|
||||
const sourcesOver = computed(
|
||||
() => maxSources.value !== null && props.sourceCount > maxSources.value,
|
||||
)
|
||||
const subpagesOver = computed(
|
||||
() => maxSubpages.value !== null && props.largestSubpageCount > maxSubpages.value,
|
||||
)
|
||||
@@ -55,60 +57,52 @@ const subpagesOver = computed(
|
||||
<div class="meter" :class="{ 'meter--over': sourcesOver }">
|
||||
<div class="meter__head">
|
||||
<span class="meter__label">知识来源</span>
|
||||
<span class="meter__value">
|
||||
{{ sourceCount }}<template v-if="maxSources !== null"> / {{ maxSources }}</template>
|
||||
<span class="meter__value num">
|
||||
{{ sourceCount }}<small v-if="maxSources !== null">/{{ maxSources }}</small>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="maxSources !== null" class="meter__track">
|
||||
<div class="meter__fill" :style="{ width: pct(sourceCount, maxSources) + '%' }"></div>
|
||||
<span class="meter__fill" :style="{ width: pct(sourceCount, maxSources) + '%' }"></span>
|
||||
</div>
|
||||
<div class="meter__note">
|
||||
<template v-if="sourcesOver">已超出套餐的来源数量上限,升级后可关联更多来源。</template>
|
||||
<p class="meter__note">
|
||||
<template v-if="sourcesOver">已超出套餐上限,升级后可关联更多来源。</template>
|
||||
<template v-else-if="maxSources !== null">已关联 {{ sourceCount }} / {{ maxSources }} 个来源。</template>
|
||||
<template v-else>已关联 {{ sourceCount }} 个来源。</template>
|
||||
</div>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="meter" :class="{ 'meter--over': subpagesOver }">
|
||||
<div class="meter__head">
|
||||
<span class="meter__label">每个来源的子页面</span>
|
||||
<span class="meter__value">
|
||||
<template v-if="maxSubpages !== null">最多 {{ maxSubpages }}</template>
|
||||
<template v-else>—</template>
|
||||
<span class="meter__label">单个来源的子页面</span>
|
||||
<span class="meter__value num">
|
||||
{{ largestSubpageCount }}<small v-if="maxSubpages !== null">/{{ maxSubpages }}</small>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="maxSubpages !== null" class="meter__track">
|
||||
<div class="meter__fill meter__fill--alt"
|
||||
:style="{ width: pct(largestSubpageCount, maxSubpages) + '%' }"></div>
|
||||
<span
|
||||
class="meter__fill meter__fill--alt"
|
||||
:style="{ width: pct(largestSubpageCount, maxSubpages) + '%' }"
|
||||
></span>
|
||||
</div>
|
||||
<div class="meter__note">
|
||||
<p class="meter__note">
|
||||
<template v-if="largestSourceName">
|
||||
最大来源:{{ largestSourceName }} — {{ largestSubpageCount }}<template v-if="maxSubpages !== null"> / {{ maxSubpages }}</template> 个子页面
|
||||
最大来源:{{ largestSourceName }}<template v-if="maxSubpages !== null">(上限 {{ maxSubpages }})</template>
|
||||
</template>
|
||||
<template v-else>还没有来源。</template>
|
||||
</div>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.meters {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.meters {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.meter {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o08);
|
||||
border-radius: 14px;
|
||||
padding: 16px 18px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.meter__head {
|
||||
@@ -116,56 +110,70 @@ const subpagesOver = computed(
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 11px;
|
||||
}
|
||||
|
||||
.meter__label {
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text2);
|
||||
font-size: 12.5px;
|
||||
color: var(--muted);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.meter__value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text3);
|
||||
font-family: var(--font-num);
|
||||
font-size: 20px;
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--ink);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.meter--over .meter__value {
|
||||
color: var(--c-coral);
|
||||
.meter__value small {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.meter__track {
|
||||
height: 7px;
|
||||
border-radius: 999px;
|
||||
background: var(--o07);
|
||||
height: 3px;
|
||||
margin-top: 10px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--line);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.meter__fill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: var(--c-teal);
|
||||
transition: width 0.3s ease;
|
||||
border-radius: inherit;
|
||||
background: var(--ink);
|
||||
transition: width var(--t-med);
|
||||
}
|
||||
|
||||
.meter__fill--alt {
|
||||
background: var(--c-purple);
|
||||
}
|
||||
|
||||
.meter--over .meter__fill {
|
||||
background: var(--c-coral);
|
||||
background: var(--celadon);
|
||||
}
|
||||
|
||||
.meter__note {
|
||||
font-size: 12.5px;
|
||||
margin-top: 9px;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--muted);
|
||||
margin-top: 10px;
|
||||
line-height: 1.5;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.meter--over .meter__value,
|
||||
.meter--over .meter__note {
|
||||
color: var(--c-coral);
|
||||
color: var(--vermilion);
|
||||
}
|
||||
|
||||
.meter--over .meter__fill {
|
||||
background: var(--vermilion);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.meter__fill {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -15,80 +15,158 @@ limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts" name="AppSidebar">
|
||||
import { computed } from 'vue'
|
||||
import BrandMark from '@/components/common/BrandMark.vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useNavItems, navIconSvg } from './navItems'
|
||||
|
||||
import SidebarToggle from './SidebarToggle.vue'
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
isCollapsed: boolean
|
||||
userName?: string
|
||||
userRole?: string
|
||||
isOnline?: boolean
|
||||
statusUpdating?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'toggle'): void
|
||||
(e: 'navigate'): void
|
||||
(e: 'logout'): void
|
||||
(e: 'toggle-status'): void
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const { navItems } = useNavItems()
|
||||
const { navGroups } = useNavItems()
|
||||
|
||||
const isActiveRoute = computed(() => (path?: string) => path ? route.path === path : false)
|
||||
|
||||
const handleNavigation = () => {
|
||||
emit('navigate')
|
||||
}
|
||||
|
||||
// 配置 is a disclosure group: it mirrors the prototype's collapsible
|
||||
// settings block and keeps the eight settings links out of the first
|
||||
// impression of the sidebar.
|
||||
const settingsOpen = ref(true)
|
||||
const isSettingsGroup = (section: string) => section === '配置'
|
||||
|
||||
const showUserMenu = ref(false)
|
||||
const toggleUserMenu = () => {
|
||||
if (props.isCollapsed) return
|
||||
showUserMenu.value = !showUserMenu.value
|
||||
}
|
||||
|
||||
const initial = computed(() => (props.userName || '·').trim().slice(0, 1))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="sidebar" :class="{ 'collapsed': isCollapsed }">
|
||||
<!-- Logo -->
|
||||
<div class="sidebar-header">
|
||||
<div class="logo-container">
|
||||
<div class="logo-mark" aria-hidden="true">
|
||||
<div class="dot"></div>
|
||||
<div class="dot"></div>
|
||||
<div class="dot"></div>
|
||||
</div>
|
||||
<span v-if="!isCollapsed" class="logo-text">ReplyPier</span>
|
||||
<aside class="sidebar" :class="{ collapsed: isCollapsed }">
|
||||
<div class="brand">
|
||||
<BrandMark :size="34" />
|
||||
<div v-if="!isCollapsed" class="brand-text">
|
||||
<span class="wordmark">Chatter<i>Mate</i></span>
|
||||
<span class="brand-sub">智能客服工作台</span>
|
||||
</div>
|
||||
<SidebarToggle :isCollapsed="isCollapsed" @toggle="emit('toggle')" />
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="sidebar-nav">
|
||||
<div v-for="(item, index) in navItems" :key="index">
|
||||
<!-- Section Header -->
|
||||
<div v-if="item.section" class="nav-section nav-section-heading" :class="{ 'collapsed': isCollapsed }">
|
||||
<span v-if="!isCollapsed">{{ item.section }}</span>
|
||||
<nav class="nav">
|
||||
<template v-for="group in navGroups" :key="group.section">
|
||||
<div v-if="isSettingsGroup(group.section)" class="nav-group" :class="{ open: settingsOpen }">
|
||||
<button
|
||||
v-if="!isCollapsed"
|
||||
class="nav-label nav-group-trigger"
|
||||
type="button"
|
||||
:aria-expanded="settingsOpen"
|
||||
@click="settingsOpen = !settingsOpen"
|
||||
>
|
||||
<span>{{ group.en }}</span>
|
||||
<em>{{ group.section }}</em>
|
||||
<span class="chev" v-html="navIconSvg('chevronRight', 14)"></span>
|
||||
</button>
|
||||
<div v-else class="nav-label"><span></span></div>
|
||||
|
||||
<div class="nav-sub">
|
||||
<div>
|
||||
<router-link
|
||||
v-for="item in group.items.filter((i) => i.to)"
|
||||
:key="item.to"
|
||||
:to="(item.to as string)"
|
||||
class="nav-item"
|
||||
:aria-current="isActiveRoute(item.to) ? 'page' : undefined"
|
||||
:title="isCollapsed ? item.label : undefined"
|
||||
@click="handleNavigation"
|
||||
>
|
||||
<span class="nav-icon" v-html="navIconSvg(item.icon, 18)"></span>
|
||||
<span>{{ item.label }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nav Item -->
|
||||
<router-link v-else-if="item.to" :to="item.to" class="nav-item"
|
||||
:class="{ 'active': isActiveRoute(item.to) }"
|
||||
:title="isCollapsed ? item.label : undefined"
|
||||
@click="handleNavigation">
|
||||
<span class="nav-bar" aria-hidden="true"></span>
|
||||
<span class="nav-icon" v-html="navIconSvg(item.icon)"></span>
|
||||
<span v-if="!isCollapsed" class="nav-label">{{ item.label }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="nav-label">
|
||||
<span>{{ group.section }}</span>
|
||||
<em>{{ group.en }}</em>
|
||||
</div>
|
||||
<router-link
|
||||
v-for="item in group.items.filter((i) => i.to)"
|
||||
:key="item.to"
|
||||
:to="(item.to as string)"
|
||||
class="nav-item"
|
||||
:aria-current="isActiveRoute(item.to) ? 'page' : undefined"
|
||||
:title="isCollapsed ? item.label : undefined"
|
||||
@click="handleNavigation"
|
||||
>
|
||||
<span class="nav-icon" v-html="navIconSvg(item.icon, 18)"></span>
|
||||
<span>{{ item.label }}</span>
|
||||
</router-link>
|
||||
</template>
|
||||
</template>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-foot">
|
||||
<button class="me" type="button" @click="toggleUserMenu" :aria-expanded="showUserMenu">
|
||||
<span class="avatar ink" style="--a: 34px">
|
||||
{{ initial }}
|
||||
<i class="presence-dot" :style="{ background: isOnline ? 'var(--celadon)' : 'var(--faint)' }"></i>
|
||||
</span>
|
||||
<div v-if="!isCollapsed">
|
||||
<div class="me-name">{{ userName || '未登录' }}</div>
|
||||
<div class="me-role">{{ userRole || '客服' }}</div>
|
||||
</div>
|
||||
<span v-if="!isCollapsed" class="me-chev" v-html="navIconSvg('chevronRight', 14)"></span>
|
||||
</button>
|
||||
|
||||
<div v-if="showUserMenu && !isCollapsed" class="user-menu">
|
||||
<button class="user-menu-item" type="button" :disabled="statusUpdating" @click="emit('toggle-status')">
|
||||
<span class="me-role" style="margin: 0">接待状态</span>
|
||||
<span class="chip" :class="isOnline ? 'chip-ai' : 'chip-quiet'">
|
||||
<i class="d"></i>{{ isOnline ? '在线' : '离线' }}
|
||||
</span>
|
||||
</button>
|
||||
<button class="user-menu-item" type="button" @click="emit('logout')">退出登录</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sidebar {
|
||||
width: 252px;
|
||||
background: var(--bg2);
|
||||
border-right: 1px solid var(--o07);
|
||||
width: 244px;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
flex: none;
|
||||
background: var(--paper-2);
|
||||
border-right: 1px solid var(--line);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: width .22s ease;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
transition: width 0.22s var(--ease);
|
||||
position: relative;
|
||||
height: 100vh;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
@@ -96,189 +174,305 @@ const handleNavigation = () => {
|
||||
width: 76px;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 20px 14px;
|
||||
border-bottom: 1px solid var(--o06);
|
||||
/* ── Brand ─────────────────────────────────────────── */
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Collapsed header: stack logo above the toggle, both centered */
|
||||
.sidebar.collapsed .sidebar-header {
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
padding: 18px 0;
|
||||
}
|
||||
|
||||
.logo-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
gap: 12px;
|
||||
padding: 22px 18px 20px 22px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 3-dot logo mark */
|
||||
.logo-mark {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: var(--accent-solid);
|
||||
border-radius: 10px 10px 10px 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 4.5px;
|
||||
height: 4.5px;
|
||||
background: var(--on-accent);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-family: var(--font-display);
|
||||
font-weight: var(--font-weight-bold);
|
||||
letter-spacing: var(--tracking-display);
|
||||
font-size: 18px;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
flex: 1;
|
||||
padding: 18px 14px;
|
||||
.brand-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.brand-sub {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
letter-spacing: 0.32em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .brand {
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 20px 0 14px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* ── Nav ───────────────────────────────────────────── */
|
||||
.nav {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
/* Hide the scrollbar so it doesn't squeeze the centered icons when collapsed */
|
||||
padding: 6px 14px 14px;
|
||||
/* The rail centers icons; a scrollbar would squeeze them off-centre */
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.sidebar-nav::-webkit-scrollbar {
|
||||
.nav::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Collapsed: tighten side padding so icons sit dead-centre in the 76px rail */
|
||||
.sidebar.collapsed .sidebar-nav {
|
||||
padding: 18px 8px;
|
||||
.sidebar.collapsed .nav {
|
||||
padding: 6px 12px 14px;
|
||||
}
|
||||
|
||||
/* Typography comes from the shared .nav-section-heading (components.css) */
|
||||
.nav-section {
|
||||
padding: 0 13px;
|
||||
margin: 4px 0 12px;
|
||||
.nav-label {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 18px 10px 8px;
|
||||
font-size: 11.5px;
|
||||
color: var(--muted);
|
||||
letter-spacing: 0.24em;
|
||||
background: none;
|
||||
border: 0;
|
||||
cursor: default;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* Collapsed: section label becomes an invisible spacer, no divider line */
|
||||
.nav-section.collapsed {
|
||||
height: 16px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
.nav-group-trigger {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nav-label em {
|
||||
font-style: normal;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9.5px;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--faint);
|
||||
margin-right: auto;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.nav-group .chev {
|
||||
color: var(--faint);
|
||||
display: inline-flex;
|
||||
transition: transform var(--t-med);
|
||||
}
|
||||
|
||||
.nav-group.open .chev {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.nav-sub {
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
transition: grid-template-rows var(--t-med);
|
||||
}
|
||||
|
||||
.nav-sub > div {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.nav-group.open .nav-sub {
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 13px;
|
||||
padding: 11px 13px;
|
||||
margin-bottom: 4px;
|
||||
color: var(--muted);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14.5px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
text-decoration: none;
|
||||
border-radius: var(--radius-btn);
|
||||
transition: background-color var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
|
||||
/* Collapsed: center the icon, drop the gap */
|
||||
.sidebar.collapsed .nav-item {
|
||||
padding: 11px;
|
||||
gap: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.nav-item:hover:not(.active) {
|
||||
background: var(--o04);
|
||||
gap: 12px;
|
||||
height: 38px;
|
||||
padding: 0 10px;
|
||||
margin: 1px 0;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text2);
|
||||
font-size: 14px;
|
||||
position: relative;
|
||||
text-decoration: none;
|
||||
transition: background-color var(--t-fast), color var(--t-fast);
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: color-mix(in srgb, var(--surface) 60%, transparent);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.nav-item:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--ring-focus);
|
||||
box-shadow: var(--ring);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: var(--accent-bg-12);
|
||||
color: var(--accent-ink);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
/* Lime accent bar on the active item */
|
||||
.nav-bar {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 9px;
|
||||
bottom: 9px;
|
||||
width: 3px;
|
||||
border-radius: 3px;
|
||||
background: var(--accent-solid);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.nav-item.active .nav-bar {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Hide the active bar when collapsed */
|
||||
.sidebar.collapsed .nav-item.active .nav-bar {
|
||||
opacity: 0;
|
||||
.nav-item[aria-current='page'] {
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
font-weight: 600;
|
||||
box-shadow: var(--shadow-sm), 0 0 0 1px var(--line);
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: inherit;
|
||||
color: var(--muted);
|
||||
transition: color var(--t-fast);
|
||||
}
|
||||
|
||||
.nav-icon :deep(svg) {
|
||||
width: 19px;
|
||||
height: 19px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
.nav-item:hover .nav-icon {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.nav-item[aria-current='page'] .nav-icon {
|
||||
color: var(--vermilion);
|
||||
}
|
||||
|
||||
.nav-sub .nav-item {
|
||||
padding-left: 40px;
|
||||
height: 34px;
|
||||
font-size: 13.5px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* ── Rail ──────────────────────────────────────────── */
|
||||
.sidebar.collapsed .nav-label {
|
||||
padding: 0;
|
||||
height: 21px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .nav-label > * {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .nav-label::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
top: 10px;
|
||||
height: 1px;
|
||||
background: var(--line-2);
|
||||
}
|
||||
|
||||
.sidebar.collapsed .nav-item {
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
height: 42px;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .nav-sub .nav-item {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .nav-item > span:last-child,
|
||||
.sidebar.collapsed .nav-group .chev {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── Footer / current user ─────────────────────────── */
|
||||
.sidebar-foot {
|
||||
position: relative;
|
||||
padding: 14px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.me {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border-radius: var(--radius-md);
|
||||
background: none;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background-color var(--t-fast);
|
||||
}
|
||||
|
||||
.me:hover {
|
||||
background: color-mix(in srgb, var(--surface) 60%, transparent);
|
||||
}
|
||||
|
||||
.me-name {
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Small laptops (1025px - 1280px) */
|
||||
@media (max-width: 1280px) and (min-width: 1025px) {
|
||||
.sidebar {
|
||||
width: 248px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sidebar.collapsed {
|
||||
width: 76px;
|
||||
}
|
||||
.me-role {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* Tablets and below - Overlay mode */
|
||||
.me-chev {
|
||||
margin-left: auto;
|
||||
color: var(--faint);
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .me {
|
||||
justify-content: center;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .me-chev {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.user-menu {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
right: 14px;
|
||||
bottom: calc(100% - 6px);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: 6px;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.user-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 9px 10px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--text);
|
||||
font-size: 13.5px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.user-menu-item:hover {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.user-menu-item:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Overlay mode (tablets and below) ───────────────── */
|
||||
@media (max-width: 1024px) {
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
@@ -286,67 +480,68 @@ const handleNavigation = () => {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
z-index: 1000;
|
||||
box-shadow: 8px 0 32px rgba(0,0,0,.4);
|
||||
width: 272px;
|
||||
max-width: 85vw;
|
||||
box-shadow: var(--shadow-lg);
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.sidebar.collapsed {
|
||||
transform: translateX(-100%);
|
||||
width: 252px;
|
||||
/* An off-canvas panel must not keep painting its shadow over the page.
|
||||
Sliding it out moves the box, not the shadow's reach: with an 8px
|
||||
offset and 32px blur, roughly 24px of it still lands on the page's
|
||||
left edge, above the content at z-index 1000. On the dark theme that
|
||||
is black on near-black and invisible, which is why it went unnoticed;
|
||||
on the light theme it reads as a grey band down the side. */
|
||||
width: 272px;
|
||||
max-width: 85vw;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* In overlay mode the panel is full-width — restore expanded layout */
|
||||
.sidebar.collapsed .sidebar-header {
|
||||
/* The off-canvas panel is full-width when it slides in, so restore the
|
||||
expanded layout regardless of the collapse flag. */
|
||||
.sidebar.collapsed .brand {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
padding: 22px 20px;
|
||||
justify-content: flex-start;
|
||||
padding: 22px 18px 20px 22px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .brand-text,
|
||||
.sidebar.collapsed .me > div,
|
||||
.sidebar.collapsed .me-chev {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .nav-label {
|
||||
padding: 18px 10px 8px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .nav-label > * {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .nav-label::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .nav-item {
|
||||
padding: 11px 13px;
|
||||
gap: 13px;
|
||||
justify-content: flex-start;
|
||||
padding: 0 10px;
|
||||
height: 38px;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .nav-item > span:last-child {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .nav-sub .nav-item {
|
||||
padding-left: 40px;
|
||||
height: 34px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
width: 280px;
|
||||
max-width: 85vw;
|
||||
}
|
||||
|
||||
.sidebar.collapsed {
|
||||
width: 280px;
|
||||
max-width: 85vw;
|
||||
}
|
||||
}
|
||||
|
||||
/* Very small mobile */
|
||||
@media (max-width: 480px) {
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
}
|
||||
|
||||
.sidebar,
|
||||
.sidebar.collapsed {
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
}
|
||||
|
||||
.nav-section {
|
||||
padding: var(--space-md) var(--space-lg) var(--space-xs);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@@ -40,17 +40,63 @@ export const NAV_ICONS: Record<string, string> = {
|
||||
moon: '<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>',
|
||||
sun: '<circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>',
|
||||
monitor: '<rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/>',
|
||||
// ── added with the 墨与瓷 redesign ──
|
||||
mail: '<rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>',
|
||||
lock: '<rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
|
||||
eye: '<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/>',
|
||||
arrowRight: '<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>',
|
||||
arrowUpRight: '<path d="M7 7h10v10"/><path d="M7 17 17 7"/>',
|
||||
star: '<path d="M11.52 2.3a.53.53 0 0 1 .95 0l2.31 4.68a2.12 2.12 0 0 0 1.6 1.16l5.17.76a.53.53 0 0 1 .29.9l-3.74 3.64a2.12 2.12 0 0 0-.61 1.88l.88 5.14a.53.53 0 0 1-.77.56l-4.62-2.43a2.12 2.12 0 0 0-1.97 0L6.4 21.01a.53.53 0 0 1-.77-.56l.88-5.14a2.12 2.12 0 0 0-.61-1.88L2.16 9.8a.53.53 0 0 1 .29-.9l5.17-.76a2.12 2.12 0 0 0 1.6-1.16z"/>',
|
||||
book: '<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>',
|
||||
transfer: '<path d="m16 3 4 4-4 4"/><path d="M20 7H4"/><path d="m8 21-4-4 4-4"/><path d="M4 17h16"/>',
|
||||
sparkles: '<path d="m12 3-1.9 5.8a2 2 0 0 1-1.3 1.3L3 12l5.8 1.9a2 2 0 0 1 1.3 1.3L12 21l1.9-5.8a2 2 0 0 1 1.3-1.3L21 12l-5.8-1.9a2 2 0 0 1-1.3-1.3Z"/>',
|
||||
check: '<path d="M20 6 9 17l-5-5"/>',
|
||||
checks: '<path d="M18 6 7 17l-5-5"/><path d="m22 10-7.5 7.5L13 16"/>',
|
||||
clock: '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
|
||||
paperclip: '<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/>',
|
||||
send: '<path d="M14.54 21.69a.5.5 0 0 0 .94-.02L21.97 2.69a.5.5 0 0 0-.65-.65L2.33 8.53a.5.5 0 0 0-.02.94l7.93 3.18a2 2 0 0 1 1.11 1.11z"/><path d="m21.85 2.15-10.94 10.94"/>',
|
||||
filter: '<path d="M3 6h18"/><path d="M7 12h10"/><path d="M10 18h4"/>',
|
||||
headset: '<path d="M3 14h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-7a9 9 0 0 1 18 0v7a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3"/>',
|
||||
panel: '<rect x="3" y="3" width="18" height="18" rx="2"/><path d="M15 3v18"/>',
|
||||
x: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
|
||||
plus: '<path d="M5 12h14"/><path d="M12 5v14"/>',
|
||||
chevronDown: '<path d="m6 9 6 6 6-6"/>',
|
||||
chevronsUpDown: '<path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/>',
|
||||
help: '<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/>',
|
||||
settings: '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
|
||||
sliders: '<line x1="4" x2="4" y1="21" y2="14"/><line x1="4" x2="4" y1="10" y2="3"/><line x1="12" x2="12" y1="21" y2="12"/><line x1="12" x2="12" y1="8" y2="3"/><line x1="20" x2="20" y1="21" y2="16"/><line x1="20" x2="20" y1="12" y2="3"/><line x1="2" x2="6" y1="14" y2="14"/><line x1="10" x2="14" y1="8" y2="8"/><line x1="18" x2="22" y1="16" y2="16"/>',
|
||||
globe: '<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/>',
|
||||
file: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/>',
|
||||
link: '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
|
||||
refresh: '<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/>',
|
||||
upload: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" x2="12" y1="3" y2="15"/>',
|
||||
folder: '<path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z"/>',
|
||||
edit: '<path d="M12 20h9"/><path d="M16.38 3.62a1 1 0 0 1 3 3L7.37 18.64a2 2 0 0 1-.86.5l-2.87.84a.5.5 0 0 1-.62-.62l.84-2.87a2 2 0 0 1 .5-.86z"/>',
|
||||
trash: '<path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/>',
|
||||
shield: '<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/>',
|
||||
zap: '<path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"/>',
|
||||
code: '<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>',
|
||||
quote: '<path d="M16 3a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2 1 1 0 0 1 1 1v1a2 2 0 0 1-2 2 1 1 0 0 0-1 1v2a1 1 0 0 0 1 1 6 6 0 0 0 6-6V5a2 2 0 0 0-2-2z"/><path d="M5 3a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2 1 1 0 0 1 1 1v1a2 2 0 0 1-2 2 1 1 0 0 0-1 1v2a1 1 0 0 0 1 1 6 6 0 0 0 6-6V5a2 2 0 0 0-2-2z"/>',
|
||||
search: '<circle cx="11" cy="11" r="7.5"/><path d="m20.5 20.5-4.2-4.2"/>',
|
||||
message: '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
|
||||
grid: '<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/>',
|
||||
cpu: '<rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6" rx="1"/><path d="M15 2v2"/><path d="M15 20v2"/><path d="M2 15h2"/><path d="M2 9h2"/><path d="M20 15h2"/><path d="M20 9h2"/><path d="M9 2v2"/><path d="M9 20v2"/>',
|
||||
building: '<rect x="4" y="2" width="16" height="20" rx="2"/><path d="M9 22v-4h6v4"/><path d="M8 6h.01"/><path d="M16 6h.01"/><path d="M12 6h.01"/><path d="M12 10h.01"/><path d="M12 14h.01"/><path d="M16 10h.01"/><path d="M16 14h.01"/><path d="M8 10h.01"/><path d="M8 14h.01"/>',
|
||||
plug: '<path d="M12 22v-5"/><path d="M9 8V2"/><path d="M15 8V2"/><path d="M18 8v5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V8Z"/>',
|
||||
copy: '<rect x="8" y="8" width="14" height="14" rx="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>',
|
||||
users: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
|
||||
}
|
||||
|
||||
// Icons are static — cache built strings so hot paths (bottom nav re-renders
|
||||
// on every route change) don't rebuild them.
|
||||
const svgCache = new Map<string, string>()
|
||||
|
||||
export const navIconSvg = (name?: string, size = 19) => {
|
||||
const key = `${name}:${size}`
|
||||
export const navIconSvg = (name?: string, size = 19, className?: string) => {
|
||||
const key = `${name}:${size}:${className ?? ''}`
|
||||
let svg = svgCache.get(key)
|
||||
if (!svg) {
|
||||
svg = `<svg viewBox="0 0 24 24" width="${size}" height="${size}" fill="none" stroke="currentColor" stroke-width="${name === 'analytics' ? 2 : 1.7}" stroke-linecap="round" stroke-linejoin="round">${name ? (NAV_ICONS[name] || '') : ''}</svg>`
|
||||
const cls = className ? ` class="${className}"` : ''
|
||||
svg = `<svg${cls} viewBox="0 0 24 24" width="${size}" height="${size}" fill="none" stroke="currentColor" stroke-width="${name === 'analytics' ? 2 : 1.7}" stroke-linecap="round" stroke-linejoin="round">${name ? (NAV_ICONS[name] || '') : ''}</svg>`
|
||||
svgCache.set(key, svg)
|
||||
}
|
||||
return svg
|
||||
|
||||
@@ -34,6 +34,8 @@ export interface NavItem {
|
||||
|
||||
export interface NavGroup {
|
||||
section: string
|
||||
/** Latin subtitle shown beside the Chinese section heading */
|
||||
en?: string
|
||||
items: NavItem[]
|
||||
}
|
||||
|
||||
@@ -54,20 +56,9 @@ export function useNavItems() {
|
||||
const navGroups = computed<NavGroup[]>(() =>
|
||||
[
|
||||
{
|
||||
section: '主菜单',
|
||||
section: '接待',
|
||||
en: 'Reception',
|
||||
items: [
|
||||
{
|
||||
to: '/ai-agents',
|
||||
icon: 'agents',
|
||||
label: 'AI 客服',
|
||||
show: canAccessPath('/ai-agents'),
|
||||
},
|
||||
{
|
||||
to: '/human-agents',
|
||||
icon: 'humans',
|
||||
label: '人工客服',
|
||||
show: canAccessPath('/human-agents'),
|
||||
},
|
||||
{
|
||||
to: '/conversations',
|
||||
icon: 'inbox',
|
||||
@@ -86,6 +77,18 @@ export function useNavItems() {
|
||||
label: '联系人',
|
||||
show: canAccessPath('/people'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
section: '智能',
|
||||
en: 'Intelligence',
|
||||
items: [
|
||||
{
|
||||
to: '/ai-agents',
|
||||
icon: 'agents',
|
||||
label: 'AI 客服',
|
||||
show: canAccessPath('/ai-agents'),
|
||||
},
|
||||
{
|
||||
to: '/knowledge',
|
||||
icon: 'knowledge',
|
||||
@@ -98,6 +101,18 @@ export function useNavItems() {
|
||||
label: '帮助中心',
|
||||
show: canAccessPath('/faq'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
section: '团队',
|
||||
en: 'Team',
|
||||
items: [
|
||||
{
|
||||
to: '/human-agents',
|
||||
icon: 'humans',
|
||||
label: '人工客服',
|
||||
show: canAccessPath('/human-agents'),
|
||||
},
|
||||
{
|
||||
to: '/analytics',
|
||||
icon: 'analytics',
|
||||
@@ -107,7 +122,8 @@ export function useNavItems() {
|
||||
],
|
||||
},
|
||||
{
|
||||
section: '设置',
|
||||
section: '配置',
|
||||
en: 'Settings',
|
||||
items: [
|
||||
{
|
||||
to: '/settings/organization',
|
||||
|
||||
@@ -21,7 +21,7 @@ export type ThemeMode = 'dark' | 'light' | 'system'
|
||||
|
||||
const STORAGE_KEY = 'rp-theme'
|
||||
|
||||
const stored = (typeof localStorage !== 'undefined' && localStorage.getItem(STORAGE_KEY)) || 'dark'
|
||||
const stored = (typeof localStorage !== 'undefined' && localStorage.getItem(STORAGE_KEY)) || 'light'
|
||||
const mode = ref<ThemeMode>(stored === 'light' || stored === 'system' ? (stored as ThemeMode) : 'dark')
|
||||
|
||||
const prefersDark = (): boolean =>
|
||||
|
||||
@@ -17,13 +17,14 @@ limitations under the License.
|
||||
import { computed } from 'vue'
|
||||
import type { AgentCustomization } from '../types/widget'
|
||||
import { isColorDark } from '../types/widget'
|
||||
import { resolveAvatarImage } from '../utils/orb'
|
||||
import { resolveWidgetUploadUrl } from '../webclient/widget-env'
|
||||
|
||||
// Themed colours now come from the shared design tokens (widget-theme.ts →
|
||||
// `--rp-*` vars set on `.chat-container`). These computeds return `var(--rp-*)`
|
||||
// references so the widget's existing inline styles resolve to the token values,
|
||||
// while user overrides are folded into the vars by `themeCssVars()`.
|
||||
export function useWidgetStyles(customization: { value: AgentCustomization }) {
|
||||
export function useWidgetStyles(customization: { value: AgentCustomization }, agentName: { value: string } = { value: '' }) {
|
||||
const chatStyles = computed(() => ({
|
||||
backgroundColor: 'var(--rp-card)',
|
||||
color: 'var(--rp-text)'
|
||||
@@ -55,7 +56,7 @@ export function useWidgetStyles(customization: { value: AgentCustomization }) {
|
||||
|
||||
// Signed S3/CDN URLs pass through; local paths are resolved against the
|
||||
// runtime API origin (photo_url already carries the /api/v1 prefix).
|
||||
const photoUrl = computed(() => resolveWidgetUploadUrl(customization.value.photo_url))
|
||||
const photoUrl = computed(() => resolveWidgetUploadUrl(resolveAvatarImage(agentName.value, customization.value)))
|
||||
|
||||
const shadowStyle = computed(() => {
|
||||
const bgColor = customization.value.chat_background_color || '#ffffff'
|
||||
@@ -74,4 +75,4 @@ export function useWidgetStyles(customization: { value: AgentCustomization }) {
|
||||
photoUrl,
|
||||
shadowStyle
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,6 @@ limitations under the License.
|
||||
* value as the pre-JS default.
|
||||
*/
|
||||
export const THEME_COLORS = {
|
||||
dark: '#0B0C10',
|
||||
light: '#EEF0F4',
|
||||
dark: '#121211',
|
||||
light: '#F4F1EB',
|
||||
} as const
|
||||
@@ -37,7 +37,7 @@ import { fab } from '@fortawesome/free-brands-svg-icons'
|
||||
/* Add icons to the library */
|
||||
library.add(fas, far, fab)
|
||||
|
||||
const savedTheme = localStorage.getItem('rp-theme') ?? 'dark'
|
||||
const savedTheme = localStorage.getItem('rp-theme') ?? 'light'
|
||||
const resolvedTheme = savedTheme === 'system'
|
||||
? (window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
|
||||
: savedTheme
|
||||
|
||||
@@ -14,91 +14,56 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
// Procedural "aurora" gradient orbs used as an avatar alternative to an uploaded photo.
|
||||
// Colors are hardcoded hex (not CSS vars) so the orb renders identically in the dashboard,
|
||||
// the preview panel, and the isolated widget bundle (which has no access to app design tokens).
|
||||
import boat from '../assets/avatars/paper-boat.svg?raw'
|
||||
import letter from '../assets/avatars/paper-letter.svg?raw'
|
||||
import pinwheel from '../assets/avatars/paper-pinwheel.svg?raw'
|
||||
import book from '../assets/avatars/paper-book.svg?raw'
|
||||
import plane from '../assets/avatars/paper-plane.svg?raw'
|
||||
import kite from '../assets/avatars/paper-kite.svg?raw'
|
||||
import heart from '../assets/avatars/paper-heart.svg?raw'
|
||||
import house from '../assets/avatars/paper-house.svg?raw'
|
||||
import leaf from '../assets/avatars/paper-leaf.svg?raw'
|
||||
import inkBoat from '../assets/avatars/paper-ink-boat.svg?raw'
|
||||
|
||||
interface OrbPalette {
|
||||
stops: string
|
||||
glow: string
|
||||
}
|
||||
// Keep the orb exports and stored variant indexes compatible with existing agents.
|
||||
// The artwork is now the same flat paper-craft SVG family in every render path.
|
||||
const PAPER_AVATARS = [boat, letter, pinwheel, book, plane, kite, heart, house, leaf, inkBoat]
|
||||
export const PAPER_AVATAR_NAMES = ['纸舟', '信笺', '风车', '书页', '纸翼', '风筝', '折心', '纸屋', '新叶', '墨舟']
|
||||
export const ORB_PALETTE_COUNT = PAPER_AVATARS.length
|
||||
|
||||
// Each entry is a visibly distinct colored orb the user can choose from.
|
||||
const ORB_PALETTES: OrbPalette[] = [
|
||||
{ stops: '#9D8CFF, #5FE3D6, #C9F24E', glow: 'rgba(157,140,255,0.45)' }, // aurora (default)
|
||||
{ stops: '#FF8A73, #9D8CFF, #5FE3D6', glow: 'rgba(255,138,115,0.40)' }, // coral
|
||||
{ stops: '#5FE3D6, #C9F24E, #9D8CFF', glow: 'rgba(95,227,214,0.40)' }, // teal
|
||||
{ stops: '#C9F24E, #5FE3D6, #FF8A73', glow: 'rgba(201,242,78,0.35)' }, // lime
|
||||
{ stops: '#6EA8FF, #9D8CFF, #5FE3D6', glow: 'rgba(110,168,255,0.42)' }, // blue
|
||||
{ stops: '#FF7AC6, #9D8CFF, #6EA8FF', glow: 'rgba(255,122,198,0.42)' }, // pink
|
||||
{ stops: '#FF8A73, #FFC857, #FF7AC6', glow: 'rgba(255,200,87,0.40)' }, // sunset
|
||||
{ stops: '#7C5CFF, #B388FF, #5FE3D6', glow: 'rgba(124,92,255,0.45)' }, // violet
|
||||
{ stops: '#0EA5A5, #5FE3D6, #C9F24E', glow: 'rgba(14,165,165,0.40)' }, // emerald
|
||||
{ stops: '#F34611, #FF8A73, #FFC857', glow: 'rgba(243,70,17,0.38)' }, // ember
|
||||
]
|
||||
|
||||
// Number of selectable orb variants (for rendering the picker grid).
|
||||
export const ORB_PALETTE_COUNT = ORB_PALETTES.length
|
||||
|
||||
// Stable palette index from a seed (agent name) so the same agent always gets the same orb
|
||||
// when no variant has been explicitly chosen.
|
||||
export const orbIndexForSeed = (seed: string): number =>
|
||||
(seed || '').split('').reduce((a, c) => a + c.charCodeAt(0), 0) % ORB_PALETTES.length
|
||||
(seed || '').split('').reduce((a, c) => a + c.charCodeAt(0), 0) % ORB_PALETTE_COUNT
|
||||
|
||||
// Inline style object for a specific orb variant by index.
|
||||
export const getOrbStyleAt = (index: number): Record<string, string> => {
|
||||
const p = ORB_PALETTES[((index % ORB_PALETTES.length) + ORB_PALETTES.length) % ORB_PALETTES.length]
|
||||
return {
|
||||
background: `
|
||||
radial-gradient(circle at 32% 28%, rgba(255,255,255,0.22) 0%, transparent 42%),
|
||||
radial-gradient(circle at 68% 72%, rgba(0,0,0,0.25) 0%, transparent 38%),
|
||||
radial-gradient(ellipse at 50% 50%, ${p.stops})
|
||||
`.trim(),
|
||||
boxShadow: `0 4px 28px ${p.glow}, inset 0 1px 0 rgba(255,255,255,0.15)`,
|
||||
borderRadius: '50%',
|
||||
}
|
||||
const variantIndex = (seed: string, variant: unknown): number => {
|
||||
const index = typeof variant === 'number' && Number.isFinite(variant)
|
||||
? Math.trunc(variant) : orbIndexForSeed(seed)
|
||||
return ((index % ORB_PALETTE_COUNT) + ORB_PALETTE_COUNT) % ORB_PALETTE_COUNT
|
||||
}
|
||||
|
||||
// Inline style for the orb, keyed by a seed (used as the stable default).
|
||||
export const orbSvgDataUri = (seed: string, variant?: unknown): string =>
|
||||
`data:image/svg+xml,${encodeURIComponent(PAPER_AVATARS[variantIndex(seed, variant)])}`
|
||||
|
||||
export const getOrbStyleAt = (index: number): Record<string, string> => ({
|
||||
background: `url("${orbSvgDataUri('', index)}") center / cover no-repeat`,
|
||||
borderRadius: '50%',
|
||||
})
|
||||
|
||||
export const getOrbStyle = (seed: string): Record<string, string> => getOrbStyleAt(orbIndexForSeed(seed))
|
||||
|
||||
// Resolve the orb style for an agent: an explicitly chosen variant wins, otherwise
|
||||
// fall back to the seed-derived default. `variant` is whatever is stored in
|
||||
// customization_metadata.orb_variant (may be undefined / non-numeric).
|
||||
export const resolveOrbStyle = (seed: string, variant: unknown): Record<string, string> => {
|
||||
const idx = typeof variant === 'number' && Number.isFinite(variant) ? variant : orbIndexForSeed(seed)
|
||||
return getOrbStyleAt(idx)
|
||||
}
|
||||
export const resolveOrbStyle = (seed: string, variant: unknown): Record<string, string> =>
|
||||
getOrbStyleAt(variantIndex(seed, variant))
|
||||
|
||||
// Render an orb variant as a self-contained SVG data URI. Storing this in
|
||||
// `photo_url` lets every existing avatar render site (agent list, widget header,
|
||||
// previews) show the orb with no orb-specific logic — they just render the image.
|
||||
export const orbSvgDataUri = (seed: string, variant?: unknown): string => {
|
||||
const idx = typeof variant === 'number' && Number.isFinite(variant) ? variant : orbIndexForSeed(seed)
|
||||
const p = ORB_PALETTES[((idx % ORB_PALETTES.length) + ORB_PALETTES.length) % ORB_PALETTES.length]
|
||||
const [c1, c2, c3] = p.stops.split(',').map((s) => s.trim())
|
||||
const svg =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">' +
|
||||
'<defs>' +
|
||||
'<radialGradient id="o" cx="50%" cy="50%" r="70%">' +
|
||||
`<stop offset="0%" stop-color="${c1}"/>` +
|
||||
`<stop offset="50%" stop-color="${c2}"/>` +
|
||||
`<stop offset="100%" stop-color="${c3}"/>` +
|
||||
'</radialGradient>' +
|
||||
'<radialGradient id="h" cx="32%" cy="28%" r="45%">' +
|
||||
'<stop offset="0%" stop-color="#ffffff" stop-opacity="0.45"/>' +
|
||||
'<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>' +
|
||||
'</radialGradient>' +
|
||||
'<radialGradient id="s" cx="68%" cy="72%" r="42%">' +
|
||||
'<stop offset="0%" stop-color="#000000" stop-opacity="0.3"/>' +
|
||||
'<stop offset="100%" stop-color="#000000" stop-opacity="0"/>' +
|
||||
'</radialGradient>' +
|
||||
'</defs>' +
|
||||
'<circle cx="50" cy="50" r="50" fill="url(#o)"/>' +
|
||||
'<circle cx="50" cy="50" r="50" fill="url(#h)"/>' +
|
||||
'<circle cx="50" cy="50" r="50" fill="url(#s)"/>' +
|
||||
'</svg>'
|
||||
return `data:image/svg+xml,${encodeURIComponent(svg)}`
|
||||
// Regenerate old stored orb data URIs from metadata without changing uploaded photos.
|
||||
// Callers resolve returned local upload paths against their own runtime API origin.
|
||||
export const resolveAvatarImage = (seed: string, customization?: {
|
||||
photo_url?: string | null
|
||||
customization_metadata?: Record<string, unknown> | null
|
||||
}): string => {
|
||||
const meta = customization?.customization_metadata
|
||||
if (meta?.avatar_style === 'orb' || !customization?.photo_url) {
|
||||
return orbSvgDataUri(seed, meta?.orb_variant)
|
||||
}
|
||||
return customization.photo_url
|
||||
}
|
||||
|
||||
// Terminal-style avatar mark: a monospace ">" prompt on a dark, FULL-BLEED tile tinted
|
||||
@@ -106,10 +71,10 @@ export const orbSvgDataUri = (seed: string, variant?: unknown): string => {
|
||||
// avatar container at every render site — circular (header/message) or square — with no
|
||||
// transparent corners. Stored in `photo_url` exactly like the orb, so every avatar
|
||||
// render site shows it with no mark-specific logic.
|
||||
export const TERMINAL_MARK_ACCENT = '#C9F24E'
|
||||
export const TERMINAL_MARK_ACCENT = '#8BB49C'
|
||||
// Only accept simple, safe colour tokens (hex / rgb[a] / hsl[a] / named) before
|
||||
// embedding into the SVG, so a stray quote in a stored value can't break out of the
|
||||
// attribute. Anything else falls back to the Terminal lime.
|
||||
// attribute. Anything else falls back to the muted celadon.
|
||||
const SAFE_COLOR = /^(#[0-9a-f]{3,8}|rgba?\([\d.,\s%]+\)|hsla?\([\d.,\s%]+\)|[a-z]+)$/i
|
||||
export const terminalMarkSvgDataUri = (accent: string = TERMINAL_MARK_ACCENT): string => {
|
||||
const c = accent && SAFE_COLOR.test(accent.trim()) ? accent.trim() : TERMINAL_MARK_ACCENT
|
||||
|
||||
@@ -21,6 +21,8 @@ import AnalyticsDashboard from '@/components/analytics/AnalyticsDashboard.vue'
|
||||
|
||||
<template>
|
||||
<DashboardLayout>
|
||||
<AnalyticsDashboard />
|
||||
<div class="page-inner">
|
||||
<AnalyticsDashboard />
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
</template>
|
||||
@@ -15,7 +15,7 @@ limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed, watch } from 'vue'
|
||||
import { ref, onMounted, onBeforeUnmount, computed, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import DashboardLayout from '@/layouts/DashboardLayout.vue'
|
||||
import ConversationsList from '@/components/conversations/ConversationsList.vue'
|
||||
@@ -40,6 +40,20 @@ const initialSessionId = ref<string | null>(
|
||||
typeof route.query.session === 'string' ? route.query.session : null
|
||||
)
|
||||
|
||||
// The details column is a real third column above ~1280px; narrower desktop
|
||||
// widths open it as an overlay instead, so a chat never gets squeezed into a
|
||||
// sliver. Selecting a chat opens the panel automatically only where it fits.
|
||||
const DETAILS_AUTO_QUERY = '(min-width: 1281px)'
|
||||
const detailsAutoOpen = ref(
|
||||
typeof window !== 'undefined' && window.matchMedia
|
||||
? window.matchMedia(DETAILS_AUTO_QUERY).matches
|
||||
: true
|
||||
)
|
||||
let detailsMediaQuery: MediaQueryList | null = null
|
||||
const syncDetailsAutoOpen = (event: MediaQueryListEvent) => {
|
||||
detailsAutoOpen.value = event.matches
|
||||
}
|
||||
|
||||
// On mobile the ?session= query drives which pane is visible (list vs chat) so
|
||||
// the hardware back button walks chat → list. It's also kept reactive for push
|
||||
// deep-links into an already-open app.
|
||||
@@ -116,6 +130,11 @@ const loadingAgents = ref(false)
|
||||
const selectedChatInfo = ref<ChatDetail | null>(null)
|
||||
const showChatInfo = ref(false)
|
||||
|
||||
// The details column/overlay is only offered while a chat is loaded.
|
||||
const detailsVisible = computed(
|
||||
() => !isMobile.value && showChatInfo.value && !!selectedChatInfo.value
|
||||
)
|
||||
|
||||
// Ref for ConversationsList component
|
||||
const conversationsListRef = ref<InstanceType<typeof ConversationsList> | null>(null)
|
||||
|
||||
@@ -123,6 +142,21 @@ const conversationsListRef = ref<InstanceType<typeof ConversationsList> | null>(
|
||||
const loadedCount = computed(() => conversations.value?.length || 0)
|
||||
const totalItems = computed(() => totalCount.value || loadedCount.value)
|
||||
|
||||
// The queue heading states what the current tab holds. While more pages exist
|
||||
// the count is an honest lower bound ("20+"), not the loaded page pretending to
|
||||
// be the total.
|
||||
const queueCountLabel = computed(() => {
|
||||
if (loading.value && conversations.value.length === 0) return '正在加载…'
|
||||
const count = `${loadedCount.value}${hasMore.value ? '+' : ''}`
|
||||
return statusFilter.value === 'closed' ? `${count} 个已结束` : `${count} 个进行中`
|
||||
})
|
||||
|
||||
const tabCount = computed(() =>
|
||||
loading.value && conversations.value.length === 0
|
||||
? '…'
|
||||
: `${loadedCount.value}${hasMore.value ? '+' : ''}`
|
||||
)
|
||||
|
||||
const loadConversations = async (page = 1, loadMore = false) => {
|
||||
error.value = ''
|
||||
|
||||
@@ -259,6 +293,17 @@ onMounted(() => {
|
||||
loadConversations(1)
|
||||
loadUsers()
|
||||
loadAgents()
|
||||
|
||||
if (typeof window !== 'undefined' && window.matchMedia) {
|
||||
detailsMediaQuery = window.matchMedia(DETAILS_AUTO_QUERY)
|
||||
detailsAutoOpen.value = detailsMediaQuery.matches
|
||||
detailsMediaQuery.addEventListener('change', syncDetailsAutoOpen)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
detailsMediaQuery?.removeEventListener('change', syncDetailsAutoOpen)
|
||||
detailsMediaQuery = null
|
||||
})
|
||||
|
||||
const handleChatUpdated = (chatDetail: ChatDetail) => {
|
||||
@@ -298,8 +343,9 @@ const handleChatUpdated = (chatDetail: ChatDetail) => {
|
||||
|
||||
const handleChatSelected = (chatDetail: ChatDetail) => {
|
||||
selectedChatInfo.value = chatDetail
|
||||
// Only auto-show if not already visible
|
||||
if (!showChatInfo.value) {
|
||||
// Auto-open only where the column has room; on narrower desktops the header
|
||||
// toggle opens it as an overlay instead.
|
||||
if (!showChatInfo.value && detailsAutoOpen.value) {
|
||||
showChatInfo.value = true
|
||||
}
|
||||
}
|
||||
@@ -343,7 +389,7 @@ const handleChatBack = () => {
|
||||
}
|
||||
|
||||
// Handle chat closed from ChatInfoPanel
|
||||
const handleChatClosed = (_sessionId?: string) => {
|
||||
const handleChatClosed = () => {
|
||||
showChatInfo.value = false
|
||||
selectedChatInfo.value = null
|
||||
if (conversationsListRef.value) {
|
||||
@@ -358,249 +404,317 @@ const handleChatClosed = (_sessionId?: string) => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DashboardLayout :hideHeader="true">
|
||||
<DashboardLayout :flush="true">
|
||||
<div class="conversations-page" :class="{ 'mobile-chat-open': mobileChatOpen }">
|
||||
<header class="page-header">
|
||||
<div class="header-content">
|
||||
<h1>会话</h1>
|
||||
<div class="header-actions">
|
||||
<!-- Queue heading: the top of the 344px column. On desktop it overlays the
|
||||
wall above the scrolling queue so the thread header can start at the
|
||||
very top; on phones it joins the normal flow. -->
|
||||
<header class="page-header queue-head">
|
||||
<div class="inbox-title">
|
||||
<h1>会话</h1>
|
||||
<span class="queue-count">{{ queueCountLabel }}</span>
|
||||
<div class="head-actions">
|
||||
<button
|
||||
v-if="whatsappAccounts.length"
|
||||
class="icon-btn sm bordered new-chat-btn"
|
||||
title="新建 WhatsApp 会话"
|
||||
aria-label="新建 WhatsApp 会话"
|
||||
@click="showNewConversation = true"
|
||||
>
|
||||
<font-awesome-icon :icon="['fab', 'whatsapp']" />
|
||||
</button>
|
||||
<ConversationFilters
|
||||
:showFilters="showFilters"
|
||||
:filterValues="filterValues"
|
||||
:users="users"
|
||||
:agents="agents"
|
||||
:loadingUsers="loadingUsers"
|
||||
:loadingAgents="loadingAgents"
|
||||
@toggle="toggleFilters"
|
||||
@apply="handleApplyFilters"
|
||||
@clear="handleClearFilters"
|
||||
@update:filterValues="filterValues = $event"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="seg" role="tablist" aria-label="会话状态">
|
||||
<button
|
||||
v-if="whatsappAccounts.length"
|
||||
class="new-conversation-btn"
|
||||
@click="showNewConversation = true"
|
||||
role="tab"
|
||||
:aria-selected="statusFilter === 'open'"
|
||||
@click="updateFilter('open')"
|
||||
>
|
||||
<font-awesome-icon :icon="['fab', 'whatsapp']" />
|
||||
新建会话
|
||||
进行中
|
||||
<span v-if="statusFilter === 'open'" class="count">{{ tabCount }}</span>
|
||||
</button>
|
||||
<ConversationFilters
|
||||
:showFilters="showFilters"
|
||||
:filterValues="filterValues"
|
||||
:users="users"
|
||||
:agents="agents"
|
||||
:loadingUsers="loadingUsers"
|
||||
:loadingAgents="loadingAgents"
|
||||
@toggle="toggleFilters"
|
||||
@apply="handleApplyFilters"
|
||||
@clear="handleClearFilters"
|
||||
@update:filterValues="filterValues = $event"
|
||||
/>
|
||||
|
||||
<button
|
||||
@click="toggleChatInfo"
|
||||
class="info-toggle-btn"
|
||||
:class="{ active: showChatInfo }"
|
||||
aria-label="切换会话信息"
|
||||
:disabled="!selectedChatInfo"
|
||||
<button
|
||||
role="tab"
|
||||
:aria-selected="statusFilter === 'closed'"
|
||||
@click="updateFilter('closed')"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="12" y1="16" x2="12" y2="12"/>
|
||||
<line x1="12" y1="8" x2="12.01" y2="8"/>
|
||||
</svg>
|
||||
已结束
|
||||
<span v-if="statusFilter === 'closed'" class="count">{{ tabCount }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Promo usage nudge for the workspace owner (phones, list pane only).
|
||||
In the mobile flow it sits between the queue heading and the queue;
|
||||
a desktop grid child would steal the single bounded row. -->
|
||||
<PromoInboxCard
|
||||
v-if="isMobile && !mobileChatOpen && hasEnterpriseModule"
|
||||
compact
|
||||
class="promo-inbox-card"
|
||||
/>
|
||||
|
||||
<NewWhatsAppConversation
|
||||
v-if="showNewConversation"
|
||||
:accounts="whatsappAccounts"
|
||||
@close="showNewConversation = false"
|
||||
@started="onConversationStarted"
|
||||
/>
|
||||
|
||||
<div class="main-content" :class="{ 'with-details': detailsVisible }">
|
||||
<ConversationsList
|
||||
ref="conversationsListRef"
|
||||
:conversations="conversations"
|
||||
:loading="loading"
|
||||
:error="error"
|
||||
:status-filter="statusFilter"
|
||||
:has-more="hasMore"
|
||||
:loading-more="loading && currentPage > 1"
|
||||
:loaded-count="loadedCount"
|
||||
:total-count="totalItems"
|
||||
:show-chat-info="showChatInfo && !!selectedChatInfo"
|
||||
:initial-session-id="initialSessionId"
|
||||
:mobile-pane="mobileChatOpen ? 'chat' : 'list'"
|
||||
@refresh="loadConversations(1)"
|
||||
@update-filter="updateFilter"
|
||||
@load-more="loadMoreConversations"
|
||||
@chat-updated="handleChatUpdated"
|
||||
@chat-selected="handleChatSelected"
|
||||
@select="openSession"
|
||||
@back="handleChatBack"
|
||||
@info="toggleChatInfo"
|
||||
@clear-unread="() => {}"
|
||||
/>
|
||||
|
||||
<ChatInfoPanel
|
||||
v-if="isMobile ? mobileInfoOpen : detailsVisible"
|
||||
:class="{ 'mobile-fullscreen': mobileInfoOpen }"
|
||||
:chatInfo="selectedChatInfo"
|
||||
:users="users"
|
||||
@close="closeChatInfo"
|
||||
@refresh="loadConversations(1)"
|
||||
@chatUpdated="handleChatUpdated"
|
||||
@chatClosed="handleChatClosed"
|
||||
/>
|
||||
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<NewWhatsAppConversation
|
||||
v-if="showNewConversation"
|
||||
:accounts="whatsappAccounts"
|
||||
@close="showNewConversation = false"
|
||||
@started="onConversationStarted"
|
||||
/>
|
||||
|
||||
<!-- Promo usage nudge for the workspace owner (phones, list pane only).
|
||||
Above the grid: a grid child would take its single bounded row and
|
||||
push the list into an unbounded one (see .main-content). -->
|
||||
<PromoInboxCard
|
||||
v-if="isMobile && !mobileChatOpen && hasEnterpriseModule"
|
||||
compact
|
||||
class="promo-inbox-card"
|
||||
/>
|
||||
|
||||
<div class="main-content">
|
||||
<ConversationsList
|
||||
ref="conversationsListRef"
|
||||
:conversations="conversations"
|
||||
:loading="loading"
|
||||
:error="error"
|
||||
:status-filter="statusFilter"
|
||||
:has-more="hasMore"
|
||||
:loading-more="loading && currentPage > 1"
|
||||
:loaded-count="loadedCount"
|
||||
:total-count="totalItems"
|
||||
:show-chat-info="showChatInfo && !!selectedChatInfo"
|
||||
:initial-session-id="initialSessionId"
|
||||
:mobile-pane="mobileChatOpen ? 'chat' : 'list'"
|
||||
@refresh="loadConversations(1)"
|
||||
@update-filter="updateFilter"
|
||||
@load-more="loadMoreConversations"
|
||||
@chat-updated="handleChatUpdated"
|
||||
@chat-selected="handleChatSelected"
|
||||
@select="openSession"
|
||||
@back="handleChatBack"
|
||||
@info="openMobileInfo"
|
||||
@clear-unread="() => {}"
|
||||
/>
|
||||
|
||||
<ChatInfoPanel
|
||||
v-if="isMobile ? mobileInfoOpen : showChatInfo"
|
||||
:class="{ 'mobile-fullscreen': mobileInfoOpen }"
|
||||
:chatInfo="selectedChatInfo"
|
||||
:users="users"
|
||||
@close="closeChatInfo"
|
||||
@refresh="loadConversations(1)"
|
||||
@chatUpdated="handleChatUpdated"
|
||||
@chatClosed="handleChatClosed"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* The workspace owns its padding and its three columns. Widths live on the
|
||||
page so both the heading and the queue can read the same values. */
|
||||
.conversations-page {
|
||||
--queue-w: 344px;
|
||||
--queue-head-h: 120px;
|
||||
--details-w: 316px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
background: var(--paper);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.promo-inbox-card {
|
||||
flex-shrink: 0;
|
||||
margin: 0 16px 8px;
|
||||
}
|
||||
|
||||
.conversations-page {
|
||||
/* ── Queue heading ─────────────────────────────────── */
|
||||
.queue-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
gap: 12px;
|
||||
padding: 20px 20px 14px;
|
||||
background: var(--paper);
|
||||
border-right: 1px solid var(--line);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.inbox-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.inbox-title h1 {
|
||||
flex: none;
|
||||
font-family: var(--font-serif);
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
line-height: 1.2;
|
||||
color: var(--ink);
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.queue-count {
|
||||
min-width: 0;
|
||||
font-size: 12.5px;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.head-actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.new-chat-btn {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.new-chat-btn :deep(svg) {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
.queue-head .seg {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.queue-head .seg button {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
padding: 0 9px;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
/* ── Three columns ─────────────────────────────────── */
|
||||
.main-content {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 350px;
|
||||
/* Bound the implicit row: an auto row is content-sized, so the list/chat
|
||||
panes inherit no height limit and their inner overflow never scrolls. */
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background: var(--bg);
|
||||
transition: all 0.3s ease;
|
||||
background: var(--paper);
|
||||
}
|
||||
|
||||
.main-content:not(:has(.chat-info-sidebar)) {
|
||||
grid-template-columns: 1fr;
|
||||
.main-content.with-details {
|
||||
grid-template-columns: minmax(0, 1fr) var(--details-w);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
padding: calc(14px + var(--safe-top)) var(--space-lg) 14px;
|
||||
border-bottom: 1px solid var(--o08);
|
||||
background: var(--bg2);
|
||||
/* The heading is pinned above the queue column, matching its width; the queue
|
||||
itself reserves the same height at the top of its scroll pane. */
|
||||
@media (min-width: 769px) {
|
||||
.queue-head {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: var(--queue-w);
|
||||
height: var(--queue-head-h);
|
||||
z-index: 5;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
@media (max-width: 1440px) {
|
||||
.conversations-page {
|
||||
--queue-w: 332px;
|
||||
}
|
||||
}
|
||||
|
||||
.header-content h1 {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--text);
|
||||
@media (max-width: 1280px) {
|
||||
.conversations-page {
|
||||
--queue-w: 316px;
|
||||
--details-w: 300px;
|
||||
}
|
||||
|
||||
/* Not enough width for three columns: the details open as an overlay and
|
||||
leave the thread its full width. */
|
||||
.main-content.with-details {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.main-content.with-details > .chat-info-sidebar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: min(320px, 86vw);
|
||||
height: auto;
|
||||
border-left: 1px solid var(--line);
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: 30;
|
||||
}
|
||||
}
|
||||
|
||||
.new-conversation-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 9px 14px;
|
||||
border-radius: var(--radius-btn, 8px);
|
||||
border: none;
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
@media (max-width: 1024px) {
|
||||
.conversations-page {
|
||||
--queue-w: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.info-toggle-btn {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border: 1px solid var(--o10);
|
||||
border-radius: 10px;
|
||||
background: var(--o05);
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.info-toggle-btn:hover {
|
||||
background: var(--o10);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.info-toggle-btn.active {
|
||||
background: rgba(201,242,78,.12);
|
||||
color: var(--accent-ink);
|
||||
border-color: rgba(201,242,78,.3);
|
||||
}
|
||||
|
||||
.info-toggle-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.info-toggle-btn:disabled:hover {
|
||||
background: var(--o05);
|
||||
color: var(--muted);
|
||||
border-color: var(--o10);
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
/* ── Mobile: one pane at a time ────────────────────── */
|
||||
@media (max-width: 768px) {
|
||||
.header-content {
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
align-items: flex-start;
|
||||
.conversations-page {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
align-self: flex-end;
|
||||
.queue-head {
|
||||
position: static;
|
||||
width: auto;
|
||||
height: auto;
|
||||
padding: 14px 16px 12px;
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
grid-template-columns: 1fr !important;
|
||||
.inbox-title h1 {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
/* Full-screen chat pane: the chat's own header replaces the page header */
|
||||
.conversations-page.mobile-chat-open .page-header {
|
||||
.main-content,
|
||||
.main-content.with-details {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
/* The full-screen chat replaces the list heading */
|
||||
.conversations-page.mobile-chat-open .queue-head {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* The desktop info toggle lives in the page header; on mobile it's in the chat */
|
||||
.info-toggle-btn {
|
||||
display: none;
|
||||
.promo-inbox-card {
|
||||
margin: 0 12px 6px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.page-header {
|
||||
padding: var(--space-md);
|
||||
.queue-head {
|
||||
padding: 12px 12px 10px;
|
||||
}
|
||||
|
||||
.header-content h1 {
|
||||
|
||||
.inbox-title h1 {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.queue-count {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@@ -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 .rp-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 `.rp-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>
|
||||
@@ -22,13 +22,12 @@ import { userService } from '@/services/user'
|
||||
const organizationId = computed(() => userService.getCurrentUser()?.organization_id ?? '')
|
||||
|
||||
const description =
|
||||
'这里汇集 AI 智能体学习的所有页面。你可以查看并编辑已提取的内容、添加自己的页面,' +
|
||||
'并决定哪些内容保留在知识库中。'
|
||||
'AI 依据这里的内容作答。导入网页、PDF 或手写页面;改动会在索引后立即生效。'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DashboardLayout>
|
||||
<div class="knowledge-view">
|
||||
<div class="page-inner knowledge-view">
|
||||
<KnowledgeExplorer
|
||||
v-if="organizationId"
|
||||
mode="org"
|
||||
@@ -38,14 +37,28 @@ const description =
|
||||
:organization-id="organizationId"
|
||||
:show-plan-meters="true"
|
||||
/>
|
||||
|
||||
<div v-else class="knowledge-empty">
|
||||
<div class="eyebrow">Knowledge · 智能</div>
|
||||
<h1 class="title">知识库</h1>
|
||||
<p class="lede">
|
||||
无法加载知识库:当前账号还没有归属的组织。请确认登录信息,或联系管理员把你加入组织。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.knowledge-view {
|
||||
max-width: 1240px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 24px 60px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.knowledge-empty {
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
|
||||
.knowledge-empty .lede {
|
||||
margin-top: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -20,6 +20,7 @@ import { ref, onMounted, computed, onUnmounted, watch, nextTick } from 'vue'
|
||||
import {
|
||||
isValidEmail} from '../types/widget'
|
||||
import { renderMarkdown } from './markdown'
|
||||
import BrandMark from '../components/common/BrandMark.vue'
|
||||
import AskAiPanel from './AskAiPanel.vue'
|
||||
import NewChatConfirm from './NewChatConfirm.vue'
|
||||
import { resolveOrbStyle } from '../utils/orb'
|
||||
@@ -370,7 +371,7 @@ const {
|
||||
headerBorderStyles,
|
||||
photoUrl,
|
||||
shadowStyle
|
||||
} = useWidgetStyles(customization)
|
||||
} = useWidgetStyles(customization, agentName)
|
||||
|
||||
// File input ref - must be defined before useWidgetFiles
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
@@ -1624,7 +1625,7 @@ const isAskAnythingStyle = computed(() => {
|
||||
return customization.value.chat_style === 'ASK_ANYTHING' || isAuroraStyle.value
|
||||
})
|
||||
|
||||
// Use the generated aurora orb when the user explicitly picked "orb", or as the Aurora
|
||||
// Use the paper-craft avatar when the user explicitly picked "orb", or as the Aurora
|
||||
// fallback when no profile photo is set. A selected profile picture always takes precedence.
|
||||
const orbMeta = computed(() => customization.value.customization_metadata as Record<string, unknown> | undefined)
|
||||
const useOrbAvatar = computed(() => {
|
||||
@@ -1911,12 +1912,7 @@ const askAiHotkey = computed(() => parentDisplay.value?.hotkey !== false)
|
||||
该聊天 Widget 尚未配置,请联系网站管理员开启聊天支持。
|
||||
</p>
|
||||
<div class="widget-unavailable-footer">
|
||||
<svg class="replypier-logo-small" width="14" height="14" viewBox="0 0 60 60" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19 3H41A16 16 0 0 1 57 19V41A16 16 0 0 1 41 57H9A6 6 0 0 1 3 51V19A16 16 0 0 1 19 3Z" fill="#C9F24E"/>
|
||||
<circle cx="19.7" cy="30" r="4.3" fill="#0B0C10"/>
|
||||
<circle cx="30" cy="30" r="4.3" fill="#0B0C10"/>
|
||||
<circle cx="40.3" cy="30" r="4.3" fill="#0B0C10"/>
|
||||
</svg>
|
||||
<BrandMark class="replypier-logo-small" :size="20" />
|
||||
<a class="rp-powered-link" href="https://replypier.site" target="_blank" rel="noopener"><span class="rp-powered-prefix">由 </span><strong class="rp-brand">ReplyPier</strong><span class="rp-powered-prefix"> 提供支持</span></a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2078,12 +2074,7 @@ const askAiHotkey = computed(() => parentDisplay.value?.hotkey !== false)
|
||||
|
||||
<!-- Powered by footer for welcome message -->
|
||||
<div class="powered-by-welcome" :style="messageNameStyles">
|
||||
<svg class="replypier-logo" width="16" height="16" viewBox="0 0 60 60" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19 3H41A16 16 0 0 1 57 19V41A16 16 0 0 1 41 57H9A6 6 0 0 1 3 51V19A16 16 0 0 1 19 3Z" fill="#C9F24E"/>
|
||||
<circle cx="19.7" cy="30" r="4.3" fill="#0B0C10"/>
|
||||
<circle cx="30" cy="30" r="4.3" fill="#0B0C10"/>
|
||||
<circle cx="40.3" cy="30" r="4.3" fill="#0B0C10"/>
|
||||
</svg>
|
||||
<BrandMark class="replypier-logo" :size="20" />
|
||||
<a class="rp-powered-link" href="https://replypier.site" target="_blank" rel="noopener"><span class="rp-powered-prefix">由 </span><strong class="rp-brand">ReplyPier</strong><span class="rp-powered-prefix"> 提供支持</span></a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2110,12 +2101,7 @@ const askAiHotkey = computed(() => parentDisplay.value?.hotkey !== false)
|
||||
</div>
|
||||
<!-- Powered by footer for landing page -->
|
||||
<div class="powered-by-landing" :style="messageNameStyles">
|
||||
<svg class="replypier-logo" width="16" height="16" viewBox="0 0 60 60" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19 3H41A16 16 0 0 1 57 19V41A16 16 0 0 1 41 57H9A6 6 0 0 1 3 51V19A16 16 0 0 1 19 3Z" fill="#C9F24E"/>
|
||||
<circle cx="19.7" cy="30" r="4.3" fill="#0B0C10"/>
|
||||
<circle cx="30" cy="30" r="4.3" fill="#0B0C10"/>
|
||||
<circle cx="40.3" cy="30" r="4.3" fill="#0B0C10"/>
|
||||
</svg>
|
||||
<BrandMark class="replypier-logo" :size="20" />
|
||||
<a class="rp-powered-link" href="https://replypier.site" target="_blank" rel="noopener"><span class="rp-powered-prefix">由 </span><strong class="rp-brand">ReplyPier</strong><span class="rp-powered-prefix"> 提供支持</span></a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2273,12 +2259,7 @@ const askAiHotkey = computed(() => parentDisplay.value?.hotkey !== false)
|
||||
</div>
|
||||
<!-- Powered by footer for form -->
|
||||
<div class="powered-by-landing" :style="messageNameStyles">
|
||||
<svg class="replypier-logo" width="16" height="16" viewBox="0 0 60 60" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19 3H41A16 16 0 0 1 57 19V41A16 16 0 0 1 41 57H9A6 6 0 0 1 3 51V19A16 16 0 0 1 19 3Z" fill="#C9F24E"/>
|
||||
<circle cx="19.7" cy="30" r="4.3" fill="#0B0C10"/>
|
||||
<circle cx="30" cy="30" r="4.3" fill="#0B0C10"/>
|
||||
<circle cx="40.3" cy="30" r="4.3" fill="#0B0C10"/>
|
||||
</svg>
|
||||
<BrandMark class="replypier-logo" :size="20" />
|
||||
<a class="rp-powered-link" href="https://replypier.site" target="_blank" rel="noopener"><span class="rp-powered-prefix">由 </span><strong class="rp-brand">ReplyPier</strong><span class="rp-powered-prefix"> 提供支持</span></a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2975,12 +2956,7 @@ const askAiHotkey = computed(() => parentDisplay.value?.hotkey !== false)
|
||||
|
||||
<!-- Powered by footer -->
|
||||
<div class="powered-by" :style="messageNameStyles">
|
||||
<svg class="replypier-logo" width="16" height="16" viewBox="0 0 60 60" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19 3H41A16 16 0 0 1 57 19V41A16 16 0 0 1 41 57H9A6 6 0 0 1 3 51V19A16 16 0 0 1 19 3Z" fill="#C9F24E"/>
|
||||
<circle cx="19.7" cy="30" r="4.3" fill="#0B0C10"/>
|
||||
<circle cx="30" cy="30" r="4.3" fill="#0B0C10"/>
|
||||
<circle cx="40.3" cy="30" r="4.3" fill="#0B0C10"/>
|
||||
</svg>
|
||||
<BrandMark class="replypier-logo" :size="20" />
|
||||
<a class="rp-powered-link" href="https://replypier.site" target="_blank" rel="noopener"><span class="rp-powered-prefix">由 </span><strong class="rp-brand">ReplyPier</strong><span class="rp-powered-prefix"> 提供支持</span></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import paperBoatSvg from '../assets/logo.svg?raw';
|
||||
|
||||
// @ts-nocheck
|
||||
// TypeScript declarations for global variables
|
||||
/** @type {string} */
|
||||
@@ -1625,17 +1627,13 @@ window.replypierConfig;
|
||||
// Footer
|
||||
const footer = document.createElement('div');
|
||||
footer.className = 'replypier-error-footer';
|
||||
const logoSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
logoSvg.setAttribute('width', '14');
|
||||
logoSvg.setAttribute('height', '14');
|
||||
logoSvg.setAttribute('viewBox', '0 0 60 60');
|
||||
logoSvg.setAttribute('fill', 'none');
|
||||
const logoPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
logoPath.setAttribute('d', 'M18 12 H42 A6 6 0 0 1 48 18 V42 A6 6 0 0 1 42 48 H18 A6 6 0 0 1 12 42 V18 A6 6 0 0 1 18 12 Z M17 30 a4 4 0 1 0 8 0 a4 4 0 1 0 -8 0 Z M26 30 a4 4 0 1 0 8 0 a4 4 0 1 0 -8 0 Z M35 30 a4 4 0 1 0 8 0 a4 4 0 1 0 -8 0 Z');
|
||||
logoPath.setAttribute('fill-rule', 'evenodd');
|
||||
logoPath.setAttribute('fill', 'currentColor');
|
||||
logoPath.setAttribute('opacity', '0.6');
|
||||
logoSvg.appendChild(logoPath);
|
||||
const logoSvg = document.createElement('span');
|
||||
// Trusted bundled vector: the same source used by the dashboard and widget.
|
||||
logoSvg.innerHTML = paperBoatSvg;
|
||||
const mark = logoSvg.querySelector('svg');
|
||||
mark.setAttribute('width', '20');
|
||||
mark.setAttribute('height', '16');
|
||||
mark.setAttribute('aria-hidden', 'true');
|
||||
const footerText = document.createElement('a');
|
||||
footerText.href = 'https://replypier.site';
|
||||
footerText.target = '_blank';
|
||||
|
||||