feat(ui): refresh dashboard and paper boat branding
This commit is contained in:
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,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -19,7 +19,7 @@ import { useRouter } from 'vue-router'
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import type { AgentWithCustomization, AgentCustomization } from '@/types/agent'
|
||||
import { getApiUrl, getWidgetUrl, resolveUploadUrl } from '@/config/api'
|
||||
import { ORB_PALETTE_COUNT, getOrbStyleAt, resolveOrbStyle, orbSvgDataUri, terminalMarkSvgDataUri } from '@/utils/orb'
|
||||
import { ORB_PALETTE_COUNT, PAPER_AVATAR_NAMES, getOrbStyleAt, resolveOrbStyle, orbSvgDataUri, terminalMarkSvgDataUri } from '@/utils/orb'
|
||||
|
||||
import KnowledgeExplorer from '@/components/knowledge/KnowledgeExplorer.vue'
|
||||
import AgentCustomizationView from './AgentCustomizationView.vue'
|
||||
@@ -64,7 +64,7 @@ const toggleAvatarPicker = () => {
|
||||
if (isUploading.value) return
|
||||
showAvatarPicker.value = !showAvatarPicker.value
|
||||
}
|
||||
// Aurora orb avatar (generated, no photo). Stored in customization_metadata so no
|
||||
// Paper-craft avatar (legacy orb storage key). Stored in customization_metadata so no
|
||||
// schema change is needed. Selecting a real picture resets this back to 'photo'.
|
||||
const orbCount = ORB_PALETTE_COUNT
|
||||
const orbMeta = computed(
|
||||
@@ -104,7 +104,7 @@ const selectOrbAvatar = async (variant: number) => {
|
||||
if (isUploading.value) return
|
||||
closeAvatarPicker()
|
||||
try {
|
||||
// Store the orb as an SVG data URI in photo_url so it propagates everywhere
|
||||
// Store the paper-craft avatar as an SVG data URI in photo_url so it propagates everywhere
|
||||
// (agent list, widget header, previews) via the normal avatar path.
|
||||
const orbUrl = orbSvgDataUri(agentData.value.name || '', variant)
|
||||
await persistAvatarMeta({ avatar_style: 'orb', orb_variant: variant }, orbUrl)
|
||||
@@ -118,7 +118,7 @@ const selectTerminalMark = async () => {
|
||||
if (isUploading.value) return
|
||||
closeAvatarPicker()
|
||||
try {
|
||||
// Tint the ">" mark with the agent's accent (falls back to the Terminal lime).
|
||||
// Tint the ">" mark with the agent's accent (falls back to the muted celadon).
|
||||
const accent = (agentData.value.customization?.accent_color as string | undefined) || undefined
|
||||
const markUrl = terminalMarkSvgDataUri(accent)
|
||||
await persistAvatarMeta({ avatar_style: 'terminal' }, markUrl)
|
||||
@@ -134,7 +134,7 @@ const selectPresetAvatar = async (url: string) => {
|
||||
optimisticPhoto.value = url
|
||||
try {
|
||||
await applyPresetAvatar(url)
|
||||
// A real picture was chosen — turn off the orb if it was on.
|
||||
// A real picture was chosen — turn off the paper-craft avatar if it was on.
|
||||
if (useOrbAvatar.value) await persistAvatarMeta({ avatar_style: 'photo' })
|
||||
} finally {
|
||||
optimisticPhoto.value = null
|
||||
@@ -873,8 +873,8 @@ onMounted(async () => {
|
||||
:class="{ active: useOrbAvatar && (currentOrbVariant ?? -1) === n - 1 }"
|
||||
:disabled="isUploading"
|
||||
@click="selectOrbAvatar(n - 1)"
|
||||
:aria-label="`光球 ${n}`"
|
||||
:title="`光球 ${n}`"
|
||||
:aria-label="PAPER_AVATAR_NAMES[n - 1]"
|
||||
:title="PAPER_AVATAR_NAMES[n - 1]"
|
||||
>
|
||||
<span class="avatar-orb-thumb" :style="getOrbStyleAt(n - 1)"></span>
|
||||
</button>
|
||||
@@ -887,7 +887,7 @@ onMounted(async () => {
|
||||
aria-label="终端标记"
|
||||
title="Terminal >"
|
||||
>
|
||||
<span class="avatar-terminal-thumb">></span>
|
||||
<img :src="terminalMarkSvgDataUri()" alt="">
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" class="avatar-picker-upload" :disabled="isUploading" @click="chooseUploadAvatar">
|
||||
@@ -1515,7 +1515,7 @@ onMounted(async () => {
|
||||
box-shadow: 0 0 0 2px var(--accent-bg-12);
|
||||
}
|
||||
|
||||
/* Aurora orb tile in the picker grid */
|
||||
/* Paper-craft tile; class names retain existing selectors. */
|
||||
.avatar-pick-orb {
|
||||
background: transparent;
|
||||
}
|
||||
@@ -1525,22 +1525,6 @@ onMounted(async () => {
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
}
|
||||
/* Terminal ">" prompt mark tile in the picker grid */
|
||||
.avatar-terminal-thumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
background: #0b0c10;
|
||||
box-shadow: inset 0 0 0 2px rgba(201, 242, 78, 0.55);
|
||||
color: #c9f24e;
|
||||
font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-weight: 700;
|
||||
font-size: 1.05rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.avatar-pick-orb.active {
|
||||
border-color: var(--accent-ink);
|
||||
box-shadow: 0 0 0 2px var(--accent-bg-12);
|
||||
@@ -1620,7 +1604,7 @@ onMounted(async () => {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* Generated aurora orb shown in place of the avatar image */
|
||||
/* Generated paper-craft artwork shown in place of an uploaded photo. */
|
||||
.agent-orb-avatar {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -3231,4 +3215,4 @@ input:checked + .slider:before {
|
||||
}
|
||||
|
||||
|
||||
</style>
|
||||
</style>
|
||||
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
@@ -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>
|
||||
@@ -40,17 +40,63 @@ export const NAV_ICONS: Record<string, string> = {
|
||||
moon: '<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>',
|
||||
sun: '<circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>',
|
||||
monitor: '<rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/>',
|
||||
// ── added with the 墨与瓷 redesign ──
|
||||
mail: '<rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>',
|
||||
lock: '<rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
|
||||
eye: '<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/>',
|
||||
arrowRight: '<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>',
|
||||
arrowUpRight: '<path d="M7 7h10v10"/><path d="M7 17 17 7"/>',
|
||||
star: '<path d="M11.52 2.3a.53.53 0 0 1 .95 0l2.31 4.68a2.12 2.12 0 0 0 1.6 1.16l5.17.76a.53.53 0 0 1 .29.9l-3.74 3.64a2.12 2.12 0 0 0-.61 1.88l.88 5.14a.53.53 0 0 1-.77.56l-4.62-2.43a2.12 2.12 0 0 0-1.97 0L6.4 21.01a.53.53 0 0 1-.77-.56l.88-5.14a2.12 2.12 0 0 0-.61-1.88L2.16 9.8a.53.53 0 0 1 .29-.9l5.17-.76a2.12 2.12 0 0 0 1.6-1.16z"/>',
|
||||
book: '<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>',
|
||||
transfer: '<path d="m16 3 4 4-4 4"/><path d="M20 7H4"/><path d="m8 21-4-4 4-4"/><path d="M4 17h16"/>',
|
||||
sparkles: '<path d="m12 3-1.9 5.8a2 2 0 0 1-1.3 1.3L3 12l5.8 1.9a2 2 0 0 1 1.3 1.3L12 21l1.9-5.8a2 2 0 0 1 1.3-1.3L21 12l-5.8-1.9a2 2 0 0 1-1.3-1.3Z"/>',
|
||||
check: '<path d="M20 6 9 17l-5-5"/>',
|
||||
checks: '<path d="M18 6 7 17l-5-5"/><path d="m22 10-7.5 7.5L13 16"/>',
|
||||
clock: '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
|
||||
paperclip: '<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/>',
|
||||
send: '<path d="M14.54 21.69a.5.5 0 0 0 .94-.02L21.97 2.69a.5.5 0 0 0-.65-.65L2.33 8.53a.5.5 0 0 0-.02.94l7.93 3.18a2 2 0 0 1 1.11 1.11z"/><path d="m21.85 2.15-10.94 10.94"/>',
|
||||
filter: '<path d="M3 6h18"/><path d="M7 12h10"/><path d="M10 18h4"/>',
|
||||
headset: '<path d="M3 14h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-7a9 9 0 0 1 18 0v7a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3"/>',
|
||||
panel: '<rect x="3" y="3" width="18" height="18" rx="2"/><path d="M15 3v18"/>',
|
||||
x: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
|
||||
plus: '<path d="M5 12h14"/><path d="M12 5v14"/>',
|
||||
chevronDown: '<path d="m6 9 6 6 6-6"/>',
|
||||
chevronsUpDown: '<path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/>',
|
||||
help: '<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/>',
|
||||
settings: '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
|
||||
sliders: '<line x1="4" x2="4" y1="21" y2="14"/><line x1="4" x2="4" y1="10" y2="3"/><line x1="12" x2="12" y1="21" y2="12"/><line x1="12" x2="12" y1="8" y2="3"/><line x1="20" x2="20" y1="21" y2="16"/><line x1="20" x2="20" y1="12" y2="3"/><line x1="2" x2="6" y1="14" y2="14"/><line x1="10" x2="14" y1="8" y2="8"/><line x1="18" x2="22" y1="16" y2="16"/>',
|
||||
globe: '<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/>',
|
||||
file: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/>',
|
||||
link: '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
|
||||
refresh: '<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/>',
|
||||
upload: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" x2="12" y1="3" y2="15"/>',
|
||||
folder: '<path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z"/>',
|
||||
edit: '<path d="M12 20h9"/><path d="M16.38 3.62a1 1 0 0 1 3 3L7.37 18.64a2 2 0 0 1-.86.5l-2.87.84a.5.5 0 0 1-.62-.62l.84-2.87a2 2 0 0 1 .5-.86z"/>',
|
||||
trash: '<path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/>',
|
||||
shield: '<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/>',
|
||||
zap: '<path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"/>',
|
||||
code: '<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>',
|
||||
quote: '<path d="M16 3a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2 1 1 0 0 1 1 1v1a2 2 0 0 1-2 2 1 1 0 0 0-1 1v2a1 1 0 0 0 1 1 6 6 0 0 0 6-6V5a2 2 0 0 0-2-2z"/><path d="M5 3a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2 1 1 0 0 1 1 1v1a2 2 0 0 1-2 2 1 1 0 0 0-1 1v2a1 1 0 0 0 1 1 6 6 0 0 0 6-6V5a2 2 0 0 0-2-2z"/>',
|
||||
search: '<circle cx="11" cy="11" r="7.5"/><path d="m20.5 20.5-4.2-4.2"/>',
|
||||
message: '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
|
||||
grid: '<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/>',
|
||||
cpu: '<rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6" rx="1"/><path d="M15 2v2"/><path d="M15 20v2"/><path d="M2 15h2"/><path d="M2 9h2"/><path d="M20 15h2"/><path d="M20 9h2"/><path d="M9 2v2"/><path d="M9 20v2"/>',
|
||||
building: '<rect x="4" y="2" width="16" height="20" rx="2"/><path d="M9 22v-4h6v4"/><path d="M8 6h.01"/><path d="M16 6h.01"/><path d="M12 6h.01"/><path d="M12 10h.01"/><path d="M12 14h.01"/><path d="M16 10h.01"/><path d="M16 14h.01"/><path d="M8 10h.01"/><path d="M8 14h.01"/>',
|
||||
plug: '<path d="M12 22v-5"/><path d="M9 8V2"/><path d="M15 8V2"/><path d="M18 8v5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V8Z"/>',
|
||||
copy: '<rect x="8" y="8" width="14" height="14" rx="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>',
|
||||
users: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
|
||||
}
|
||||
|
||||
// Icons are static — cache built strings so hot paths (bottom nav re-renders
|
||||
// on every route change) don't rebuild them.
|
||||
const svgCache = new Map<string, string>()
|
||||
|
||||
export const navIconSvg = (name?: string, size = 19) => {
|
||||
const key = `${name}:${size}`
|
||||
export const navIconSvg = (name?: string, size = 19, className?: string) => {
|
||||
const key = `${name}:${size}:${className ?? ''}`
|
||||
let svg = svgCache.get(key)
|
||||
if (!svg) {
|
||||
svg = `<svg viewBox="0 0 24 24" width="${size}" height="${size}" fill="none" stroke="currentColor" stroke-width="${name === 'analytics' ? 2 : 1.7}" stroke-linecap="round" stroke-linejoin="round">${name ? (NAV_ICONS[name] || '') : ''}</svg>`
|
||||
const cls = className ? ` class="${className}"` : ''
|
||||
svg = `<svg${cls} viewBox="0 0 24 24" width="${size}" height="${size}" fill="none" stroke="currentColor" stroke-width="${name === 'analytics' ? 2 : 1.7}" stroke-linecap="round" stroke-linejoin="round">${name ? (NAV_ICONS[name] || '') : ''}</svg>`
|
||||
svgCache.set(key, svg)
|
||||
}
|
||||
return svg
|
||||
|
||||
@@ -34,6 +34,8 @@ export interface NavItem {
|
||||
|
||||
export interface NavGroup {
|
||||
section: string
|
||||
/** Latin subtitle shown beside the Chinese section heading */
|
||||
en?: string
|
||||
items: NavItem[]
|
||||
}
|
||||
|
||||
@@ -54,20 +56,9 @@ export function useNavItems() {
|
||||
const navGroups = computed<NavGroup[]>(() =>
|
||||
[
|
||||
{
|
||||
section: '主菜单',
|
||||
section: '接待',
|
||||
en: 'Reception',
|
||||
items: [
|
||||
{
|
||||
to: '/ai-agents',
|
||||
icon: 'agents',
|
||||
label: 'AI 客服',
|
||||
show: canAccessPath('/ai-agents'),
|
||||
},
|
||||
{
|
||||
to: '/human-agents',
|
||||
icon: 'humans',
|
||||
label: '人工客服',
|
||||
show: canAccessPath('/human-agents'),
|
||||
},
|
||||
{
|
||||
to: '/conversations',
|
||||
icon: 'inbox',
|
||||
@@ -86,6 +77,18 @@ export function useNavItems() {
|
||||
label: '联系人',
|
||||
show: canAccessPath('/people'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
section: '智能',
|
||||
en: 'Intelligence',
|
||||
items: [
|
||||
{
|
||||
to: '/ai-agents',
|
||||
icon: 'agents',
|
||||
label: 'AI 客服',
|
||||
show: canAccessPath('/ai-agents'),
|
||||
},
|
||||
{
|
||||
to: '/knowledge',
|
||||
icon: 'knowledge',
|
||||
@@ -98,6 +101,18 @@ export function useNavItems() {
|
||||
label: '帮助中心',
|
||||
show: canAccessPath('/faq'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
section: '团队',
|
||||
en: 'Team',
|
||||
items: [
|
||||
{
|
||||
to: '/human-agents',
|
||||
icon: 'humans',
|
||||
label: '人工客服',
|
||||
show: canAccessPath('/human-agents'),
|
||||
},
|
||||
{
|
||||
to: '/analytics',
|
||||
icon: 'analytics',
|
||||
@@ -107,7 +122,8 @@ export function useNavItems() {
|
||||
],
|
||||
},
|
||||
{
|
||||
section: '设置',
|
||||
section: '配置',
|
||||
en: 'Settings',
|
||||
items: [
|
||||
{
|
||||
to: '/settings/organization',
|
||||
|
||||
Reference in new issue
Block a user