feat(ui): refresh dashboard and paper boat branding

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

No files matched your search

@@ -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(--cm-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="chattermate-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="chattermate-logo" :size="20" />
<span class="cm-powered-prefix">技术支持:</span><strong class="cm-brand">ChatterMate</strong>
</div>
</div>
@@ -710,12 +692,7 @@ const handleInitiationClick = () => {
<!-- Add powered by footer -->
<div class="powered-by" :style="messageNameStyles">
<svg class="chattermate-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="chattermate-logo" :size="20" />
<span class="cm-powered-prefix">技术支持:</span><strong class="cm-brand">ChatterMate</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,
},
{
+11 -27
View File
@@ -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">&gt;</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>
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -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>
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -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>
File diff suppressed because it is too large. Load diff
@@ -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>
File diff suppressed because it is too large. Load diff
+404 -209
View File
@@ -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">ChatterMate</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>
+49 -3
View File
@@ -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
+30 -14
View File
@@ -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',