feat(agent): 检索工具、模型接入与结构化回复
Agent 按组织与知识源范围调用检索工具,密钥加密保存,回复含来源与转人工意图。
This commit is contained in:
1 parent
c7011040c3
commit
2dd062787d
65 files changed
+33409
No files matched your search
@@ -0,0 +1,750 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import type { Agent } from '@/types/agent'
|
||||
import { agentService } from '@/services/agent'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
const props = defineProps<{
|
||||
agent: Agent
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update', agent: Agent): void
|
||||
}>()
|
||||
|
||||
// Create a ref for the agent
|
||||
const agentRef = ref(props.agent)
|
||||
|
||||
// Import the composable
|
||||
import { useAgentAdvanced } from '@/composables/useAgentAdvanced'
|
||||
|
||||
// Use the composable
|
||||
const {
|
||||
localSettings,
|
||||
isLoading,
|
||||
error,
|
||||
hasUnsavedChanges,
|
||||
toggleRateLimiting,
|
||||
updateLocalValue,
|
||||
saveRateLimitSettings
|
||||
} = useAgentAdvanced(agentRef)
|
||||
|
||||
// Handle successful updates
|
||||
const handleUpdate = (updatedAgent: Agent) => {
|
||||
emit('update', updatedAgent)
|
||||
}
|
||||
|
||||
// Handle toggle rate limiting
|
||||
const handleToggleRateLimiting = async () => {
|
||||
try {
|
||||
const updatedAgent = await toggleRateLimiting()
|
||||
handleUpdate(updatedAgent)
|
||||
} catch (err) {
|
||||
// Error is handled in the composable
|
||||
}
|
||||
}
|
||||
|
||||
// Handle slider value changes
|
||||
const handleValueChange = (type: 'overallLimitPerIp' | 'requestsPerSec', event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
if (!target) return
|
||||
|
||||
updateLocalValue(type, target.value)
|
||||
}
|
||||
|
||||
// Handle save settings
|
||||
const handleSaveSettings = async () => {
|
||||
try {
|
||||
const updatedAgent = await saveRateLimitSettings()
|
||||
handleUpdate(updatedAgent)
|
||||
} catch (err) {
|
||||
// Error is handled in the composable
|
||||
}
|
||||
}
|
||||
|
||||
// Handle cancel - restore local rate-limit values from the agent reference
|
||||
const handleCancel = () => {
|
||||
localSettings.value = {
|
||||
...localSettings.value,
|
||||
overallLimitPerIp: String(agentRef.value.overall_limit_per_ip || 100),
|
||||
requestsPerSec: String(agentRef.value.requests_per_sec || 1)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle attachments setting toggle
|
||||
const updateAttachmentsSetting = async (enabled: boolean) => {
|
||||
try {
|
||||
// Call API to save the setting
|
||||
const updatedAgent = await agentService.updateAgent(agentRef.value.id, {
|
||||
allow_attachments: enabled
|
||||
})
|
||||
|
||||
// Update local reference
|
||||
agentRef.value.allow_attachments = updatedAgent.allow_attachments
|
||||
|
||||
// Emit update to parent
|
||||
emit('update', updatedAgent)
|
||||
|
||||
// Show success toast
|
||||
toast.success('附件设置已更新', {
|
||||
duration: 2000
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Failed to update attachments setting:', err)
|
||||
toast.error('更新附件设置失败', {
|
||||
duration: 2000
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Allowed file type categories
|
||||
const fileTypeCategories = [
|
||||
{ value: 'images', label: '图片', description: 'JPG, PNG, GIF, WebP' },
|
||||
{ value: 'documents', label: 'PDF 文档', description: 'PDF 文件' },
|
||||
{ value: 'office', label: 'Office 文件', description: 'DOC, DOCX, XLS, XLSX' },
|
||||
{ value: 'text', label: '文本文件', description: 'TXT, CSV' }
|
||||
]
|
||||
|
||||
// Check if a category is selected
|
||||
const isCategorySelected = (category: string): boolean => {
|
||||
const types = agentRef.value.allowed_attachment_types
|
||||
// If null or empty, all types are allowed
|
||||
if (!types || types.length === 0) return true
|
||||
return types.includes(category)
|
||||
}
|
||||
|
||||
// Toggle a file type category
|
||||
const toggleFileTypeCategory = async (category: string) => {
|
||||
try {
|
||||
let currentTypes = agentRef.value.allowed_attachment_types || []
|
||||
|
||||
// If currently empty (all allowed), start with all categories
|
||||
if (currentTypes.length === 0) {
|
||||
currentTypes = fileTypeCategories.map(c => c.value)
|
||||
}
|
||||
|
||||
let newTypes: string[]
|
||||
if (currentTypes.includes(category)) {
|
||||
// Remove category (but ensure at least one remains)
|
||||
newTypes = currentTypes.filter(t => t !== category)
|
||||
if (newTypes.length === 0) {
|
||||
toast.error('至少需要允许一种文件类型', { duration: 2000 })
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Add category
|
||||
newTypes = [...currentTypes, category]
|
||||
}
|
||||
|
||||
// If all categories selected, set to null (allow all)
|
||||
const finalTypes = newTypes.length === fileTypeCategories.length ? null : newTypes
|
||||
|
||||
const updatedAgent = await agentService.updateAgent(agentRef.value.id, {
|
||||
allowed_attachment_types: finalTypes
|
||||
})
|
||||
|
||||
agentRef.value.allowed_attachment_types = updatedAgent.allowed_attachment_types
|
||||
emit('update', updatedAgent)
|
||||
|
||||
toast.success('允许的文件类型已更新', { duration: 2000 })
|
||||
} catch (err) {
|
||||
console.error('Failed to update allowed file types:', err)
|
||||
toast.error('更新允许的文件类型失败', { duration: 2000 })
|
||||
}
|
||||
}
|
||||
|
||||
// Handle token authentication setting toggle
|
||||
const updateTokenAuthSetting = async (enabled: boolean) => {
|
||||
try {
|
||||
// Call API to save the setting
|
||||
const updatedAgent = await agentService.updateAgent(agentRef.value.id, {
|
||||
require_token_auth: enabled
|
||||
})
|
||||
|
||||
// Update local reference
|
||||
agentRef.value.require_token_auth = updatedAgent.require_token_auth
|
||||
|
||||
// Emit update to parent
|
||||
emit('update', updatedAgent)
|
||||
|
||||
// Show success toast
|
||||
toast.success(`组件 Token 认证已${enabled ? '开启' : '关闭'}`, {
|
||||
duration: 2000
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Failed to update token auth setting:', err)
|
||||
toast.error('更新 Token 认证设置失败', {
|
||||
duration: 2000
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="advanced-tab">
|
||||
<div class="page-header">
|
||||
<h3 class="page-title">高级设置</h3>
|
||||
<p class="page-description">
|
||||
配置客服的限流、安全及其他技术选项。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Error message -->
|
||||
<div v-if="error" class="error-message">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div class="settings-grid">
|
||||
<!-- Rate Limiting Card -->
|
||||
<div class="settings-card" :class="{ 'loading': isLoading }">
|
||||
<div class="card-top">
|
||||
<div class="card-lead">
|
||||
<div class="card-icon icon-info">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 8V12L15 15M21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="card-meta">
|
||||
<div class="card-title">限流</div>
|
||||
<div class="card-desc">限制每位用户的请求数,防止客服被滥用。</div>
|
||||
</div>
|
||||
</div>
|
||||
<label class="switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="localSettings.enableRateLimiting"
|
||||
@change="handleToggleRateLimiting"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
<span class="slider" :class="{ 'enabled': localSettings.enableRateLimiting }"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="localSettings.enableRateLimiting" class="card-extra">
|
||||
<div class="num-fields">
|
||||
<div class="num-field">
|
||||
<label class="num-label">最大请求数</label>
|
||||
<input
|
||||
type="number"
|
||||
v-model="localSettings.overallLimitPerIp"
|
||||
@input="(e) => handleValueChange('overallLimitPerIp', e)"
|
||||
min="10"
|
||||
max="1000"
|
||||
step="10"
|
||||
class="num-input"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
</div>
|
||||
<div class="num-field">
|
||||
<label class="num-label">时间窗口(秒)</label>
|
||||
<input
|
||||
type="number"
|
||||
v-model="localSettings.requestsPerSec"
|
||||
@input="(e) => handleValueChange('requestsPerSec', e)"
|
||||
min="1"
|
||||
max="10"
|
||||
step="1"
|
||||
class="num-input"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Token Authentication Card -->
|
||||
<div class="settings-card">
|
||||
<div class="card-top">
|
||||
<div class="card-lead">
|
||||
<div class="card-icon icon-coral">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 15V17M6 21H18C19.1046 21 20 20.1046 20 19V13C20 11.8954 19.1046 11 18 11H6C4.89543 11 4 11.8954 4 13V19C4 20.1046 4.89543 21 6 21ZM16 11V7C16 4.79086 14.2091 3 12 3C9.79086 3 8 4.79086 8 7V11H16Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="card-meta">
|
||||
<div class="card-title">组件认证</div>
|
||||
<div class="card-desc">组件访问需通过服务端 Token 认证。</div>
|
||||
</div>
|
||||
</div>
|
||||
<label class="switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="agentRef.require_token_auth"
|
||||
@change="(e) => updateTokenAuthSetting((e.target as HTMLInputElement).checked)"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
<span class="slider" :class="{ 'enabled': agentRef.require_token_auth }"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="card-extra">
|
||||
<div v-if="agentRef.require_token_auth" class="locked-note">
|
||||
<span class="locked-note-icon">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 15V17M6 21H18C19.1046 21 20 20.1046 20 19V13C20 11.8954 19.1046 11 18 11H6C4.89543 11 4 11.8954 4 13V19C4 20.1046 4.89543 21 6 21ZM16 11V7C16 4.79086 14.2091 3 12 3C9.79086 3 8 4.79086 8 7V11H16Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span>
|
||||
<strong>设置:</strong>在 <strong>设置 → 组件应用</strong> 中创建 API Key,然后从
|
||||
后端调用 <code>/api/v1/generate-token</code>。
|
||||
</span>
|
||||
</div>
|
||||
<div v-else class="info-pill">
|
||||
<span class="info-pill-icon">ⓘ</span>
|
||||
<span>允许匿名访问</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File Attachments Card -->
|
||||
<div class="settings-card">
|
||||
<div class="card-top">
|
||||
<div class="card-lead">
|
||||
<div class="card-icon icon-teal">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M21.44 11.05L12.25 20.24C11.1242 21.3658 9.59723 21.9983 8.00502 21.9983C6.41282 21.9983 4.88586 21.3658 3.76002 20.24C2.63419 19.1142 2.00171 17.5872 2.00171 15.995C2.00171 14.4028 2.63419 12.8758 3.76002 11.75L12.33 3.18C13.0806 2.42975 14.0991 2.00892 15.16 2.00892C16.221 2.00892 17.2395 2.42975 17.99 3.18C18.7403 3.93053 19.1611 4.94903 19.1611 6.01C19.1611 7.07097 18.7403 8.08947 17.99 8.84L9.41002 17.41C9.03476 17.7853 8.52557 17.9961 7.99502 17.9961C7.46448 17.9961 6.95529 17.7853 6.58002 17.41C6.20476 17.0347 5.99393 16.5256 5.99393 15.995C5.99393 15.4644 6.20476 14.9553 6.58002 14.58L15.07 6.1" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="card-meta">
|
||||
<div class="card-title">文件附件</div>
|
||||
<div class="card-desc">允许用户在与人工客服会话时上传文件。</div>
|
||||
</div>
|
||||
</div>
|
||||
<label class="switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="agentRef.allow_attachments"
|
||||
@change="(e) => updateAttachmentsSetting((e.target as HTMLInputElement).checked)"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
<span class="slider" :class="{ 'enabled': agentRef.allow_attachments }"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="agentRef.allow_attachments" class="card-extra">
|
||||
<div class="file-types-grid">
|
||||
<button
|
||||
v-for="category in fileTypeCategories"
|
||||
:key="category.value"
|
||||
class="file-type-chip"
|
||||
:class="{ 'selected': isCategorySelected(category.value) }"
|
||||
@click="toggleFileTypeCategory(category.value)"
|
||||
>
|
||||
<svg v-if="isCategorySelected(category.value)" width="14" height="14" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M5 13L9 17L19 7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<span>{{ category.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="extra-note">
|
||||
人工客服接入后,客户即可上传文件。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="card-extra">
|
||||
<p class="extra-note">开启后,人工客服会话中可上传文件。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save bar -->
|
||||
<div class="save-bar">
|
||||
<button
|
||||
class="btn-ghost"
|
||||
type="button"
|
||||
@click="handleCancel"
|
||||
:disabled="isLoading || !hasUnsavedChanges"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
class="btn-primary"
|
||||
type="button"
|
||||
@click="handleSaveSettings"
|
||||
:disabled="isLoading || !hasUnsavedChanges"
|
||||
>
|
||||
保存修改
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.advanced-tab {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
padding: 0 var(--space-lg);
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
/* Page Header */
|
||||
.page-header {
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin: 0 0 6px 0;
|
||||
}
|
||||
|
||||
.page-description {
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Settings Grid */
|
||||
.settings-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
/* Settings Card */
|
||||
.settings-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o08);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 22px 24px;
|
||||
}
|
||||
|
||||
.settings-card.loading {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Card top row */
|
||||
.card-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.card-lead {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--radius-btn);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card-icon.icon-info {
|
||||
background: color-mix(in srgb, var(--c-info) 14%, transparent);
|
||||
color: var(--c-info);
|
||||
}
|
||||
|
||||
.card-icon.icon-coral {
|
||||
background: var(--coral-bg);
|
||||
color: var(--c-coral);
|
||||
}
|
||||
|
||||
.card-icon.icon-teal {
|
||||
background: color-mix(in srgb, var(--c-teal) 14%, transparent);
|
||||
color: var(--c-teal);
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 16.5px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
font-size: 13.5px;
|
||||
color: var(--muted);
|
||||
margin-top: 4px;
|
||||
max-width: 520px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Card extra (revealed area) */
|
||||
.card-extra {
|
||||
margin-top: 18px;
|
||||
padding-top: 18px;
|
||||
border-top: 1px solid var(--o06);
|
||||
}
|
||||
|
||||
/* Toggle Switch (46x26) */
|
||||
.switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 46px;
|
||||
height: 26px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
inset: 0;
|
||||
background: var(--toggle-track-off);
|
||||
transition: background 0.15s;
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
|
||||
.slider.enabled {
|
||||
background: var(--toggle-on-teal);
|
||||
}
|
||||
|
||||
.slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
left: 3px;
|
||||
top: 3px;
|
||||
background: var(--toggle-knob);
|
||||
transition: transform 0.15s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
input:checked + .slider:before {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
/* Number fields (rate limiting) */
|
||||
.num-fields {
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.num-field {
|
||||
flex: 1;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.num-label {
|
||||
display: block;
|
||||
font-size: 12.5px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
|
||||
.num-input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 11px 13px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
font-family: var(--font-mono);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.num-input:focus {
|
||||
border-color: var(--accent-ink);
|
||||
}
|
||||
|
||||
/* Widget auth - locked note */
|
||||
.locked-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.locked-note-icon {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
color: var(--c-teal);
|
||||
}
|
||||
|
||||
.locked-note code {
|
||||
background: var(--o06);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9em;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Widget auth - info pill */
|
||||
.info-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 7px 13px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o08);
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.info-pill-icon {
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* File Types Grid */
|
||||
.file-types-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.file-type-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: var(--radius-pill);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.file-type-chip:hover {
|
||||
border-color: var(--accent-ink);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.file-type-chip.selected {
|
||||
background: var(--accent-bg-08);
|
||||
border-color: var(--accent-ink);
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
/* Descriptive extra text */
|
||||
.extra-note {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.file-types-grid + .extra-note {
|
||||
margin-top: var(--space-md);
|
||||
}
|
||||
|
||||
/* Save bar */
|
||||
.save-bar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding: 18px 0 4px;
|
||||
}
|
||||
|
||||
.btn-ghost,
|
||||
.btn-primary {
|
||||
padding: 13px 22px;
|
||||
border-radius: var(--radius-btn);
|
||||
font-family: var(--font-display);
|
||||
font-size: 14.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o14);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 13px 26px;
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled),
|
||||
.btn-ghost:hover:not(:disabled) {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
|
||||
.btn-ghost:disabled,
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Error Message */
|
||||
.error-message {
|
||||
padding: var(--space-md);
|
||||
margin-bottom: var(--space-lg);
|
||||
background: var(--coral-bg);
|
||||
color: var(--c-coral);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.advanced-tab {
|
||||
padding: 0 var(--space-md);
|
||||
}
|
||||
|
||||
.settings-card {
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
.card-top {
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,437 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { leadCaptureService } from '@/services/leadCapture'
|
||||
import crmService, { type CrmConnection } from '@/services/crm'
|
||||
import type { LeadCaptureConfig, LeadField } from '@/types/leadCapture'
|
||||
|
||||
const props = defineProps<{ agentId: string }>()
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
|
||||
const enabled = ref(false)
|
||||
const requireConsent = ref(true)
|
||||
const guidance = ref('')
|
||||
const fields = ref<LeadField[]>([])
|
||||
const assignmentMode = ref('none')
|
||||
const crmSyncTarget = ref('none')
|
||||
const slackNotifyEnabled = ref(false)
|
||||
|
||||
// Org-level CRM connections, to warn when the chosen target isn't connected.
|
||||
// Loaded best-effort: a 403 (plan) or error just means no warning is shown.
|
||||
const crmConnections = ref<CrmConnection[]>([])
|
||||
const crmTargetUnconnected = computed(() =>
|
||||
crmSyncTarget.value !== 'none' &&
|
||||
!crmConnections.value.some(c => c.provider === crmSyncTarget.value && c.status === 'active')
|
||||
)
|
||||
const crmTargetName = computed(() =>
|
||||
({ hubspot: 'HubSpot', pipedrive: 'Pipedrive' } as Record<string, string>)[crmSyncTarget.value] || crmSyncTarget.value
|
||||
)
|
||||
|
||||
const STANDARD_FIELDS: { key: string; label: string }[] = [
|
||||
{ key: 'email', label: '邮箱' },
|
||||
{ key: 'name', label: '姓名' },
|
||||
{ key: 'company', label: '公司' },
|
||||
{ key: 'phone', label: '电话' },
|
||||
]
|
||||
|
||||
// --- Fields ---
|
||||
function isStandardEnabled(key: string) {
|
||||
return fields.value.some(f => f.standard && f.key === key && f.enabled)
|
||||
}
|
||||
function toggleStandard(key: string) {
|
||||
const existing = fields.value.find(f => f.standard && f.key === key)
|
||||
if (existing) existing.enabled = !existing.enabled
|
||||
else fields.value.push({ key, standard: true, enabled: true, required: key === 'email' })
|
||||
}
|
||||
function isRequired(key: string) {
|
||||
return !!fields.value.find(f => f.key === key)?.required
|
||||
}
|
||||
// Toggle a field's "required" flag. Email is always required (the minimum needed
|
||||
// to record a lead) and cannot be made optional.
|
||||
function toggleRequired(key: string) {
|
||||
if (key === 'email') return
|
||||
const f = fields.value.find(x => x.key === key)
|
||||
if (f) f.required = !f.required
|
||||
}
|
||||
const customFields = computed(() => fields.value.filter(f => !f.standard))
|
||||
const newFieldLabel = ref('')
|
||||
const newFieldOptions = ref('')
|
||||
function addCustomField() {
|
||||
const label = newFieldLabel.value.trim()
|
||||
if (!label) { toast.error('请输入字段名称'); return }
|
||||
// Slug from the label; fall back to an index when the label has no a–z0–9 chars
|
||||
// (e.g. non-Latin scripts) so the key is never a bare "custom_". Ensure uniqueness
|
||||
// since record_lead_capture keys off field.key and duplicates would overwrite.
|
||||
let slug = label.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '')
|
||||
let key = 'custom_' + (slug || 'field')
|
||||
if (fields.value.some(f => f.key === key)) {
|
||||
let n = 2
|
||||
while (fields.value.some(f => f.key === `${key}_${n}`)) n++
|
||||
key = `${key}_${n}`
|
||||
}
|
||||
const field: LeadField = { key, standard: false, enabled: true, required: false, label }
|
||||
const opts = newFieldOptions.value.split(',').map(s => s.trim()).filter(Boolean)
|
||||
if (opts.length) field.options = opts
|
||||
fields.value.push(field)
|
||||
newFieldLabel.value = ''; newFieldOptions.value = ''
|
||||
}
|
||||
function removeCustomField(key: string) {
|
||||
const i = fields.value.findIndex(f => f.key === key && !f.standard)
|
||||
if (i >= 0) fields.value.splice(i, 1)
|
||||
}
|
||||
function seedDefaultsIfEmpty() {
|
||||
if (fields.value.length === 0) {
|
||||
fields.value = [
|
||||
{ key: 'email', standard: true, enabled: true, required: true },
|
||||
{ key: 'name', standard: true, enabled: true, required: false },
|
||||
{ key: 'company', standard: true, enabled: false, required: false },
|
||||
{ key: 'phone', standard: true, enabled: false, required: false },
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// --- Preview derivations ---
|
||||
const enabledFieldLabels = computed(() =>
|
||||
fields.value.filter(f => f.enabled).map(f => {
|
||||
if (f.standard) return (STANDARD_FIELDS.find(s => s.key === f.key)?.label) || f.key
|
||||
return f.label || f.key
|
||||
})
|
||||
)
|
||||
const firstAskLabel = computed(() => (enabledFieldLabels.value[0] || 'email').toLowerCase())
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const cfg = await leadCaptureService.getConfig(props.agentId)
|
||||
enabled.value = !!cfg.enabled
|
||||
requireConsent.value = cfg.require_consent !== false
|
||||
guidance.value = cfg.guidance || ''
|
||||
fields.value = cfg.fields || []
|
||||
assignmentMode.value = cfg.assignment_mode || 'none'
|
||||
crmSyncTarget.value = cfg.crm_sync_target || 'none'
|
||||
slackNotifyEnabled.value = !!cfg.slack_notify_enabled
|
||||
seedDefaultsIfEmpty()
|
||||
} catch {
|
||||
toast.error('加载线索收集设置失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
try {
|
||||
crmConnections.value = await crmService.listConnections()
|
||||
} catch {
|
||||
crmConnections.value = [] // plan-gated or unavailable — just no warning
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
const updated: LeadCaptureConfig = await leadCaptureService.updateConfig(props.agentId, {
|
||||
enabled: enabled.value,
|
||||
require_consent: requireConsent.value,
|
||||
guidance: guidance.value.trim() || null,
|
||||
fields: fields.value,
|
||||
assignment_mode: assignmentMode.value as any,
|
||||
assignment_target_user_id: null,
|
||||
crm_sync_target: crmSyncTarget.value as any,
|
||||
slack_notify_enabled: slackNotifyEnabled.value,
|
||||
})
|
||||
enabled.value = !!updated.enabled
|
||||
toast.success('线索收集设置已保存')
|
||||
} catch {
|
||||
toast.error('保存线索收集设置失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="lead-capture-tab" v-if="!loading">
|
||||
<div class="lc-header">
|
||||
<div>
|
||||
<h3 class="lc-title">线索收集</h3>
|
||||
<p class="lc-sub">开启后,客服会在对话中收集联系方式——先解决问题,再在合适时机询问。无需表单。</p>
|
||||
</div>
|
||||
<button class="lc-save" :disabled="saving" @click="save">{{ saving ? '保存中…' : '保存修改' }}</button>
|
||||
</div>
|
||||
|
||||
<div class="lc-grid">
|
||||
<!-- LEFT: config -->
|
||||
<div class="lc-left">
|
||||
<!-- Master toggle -->
|
||||
<section class="lc-card">
|
||||
<div class="lc-toggle-row">
|
||||
<div>
|
||||
<div class="lc-toggle-title">启用线索收集</div>
|
||||
<div class="lc-toggle-desc">由客服判断何时开口(类似转人工),并以对话方式收集信息。</div>
|
||||
</div>
|
||||
<button class="lc-switch" :class="{ on: enabled }" @click="enabled = !enabled" :aria-pressed="enabled">
|
||||
<span class="lc-knob"></span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<template v-if="enabled">
|
||||
<!-- What to collect -->
|
||||
<section class="lc-card">
|
||||
<h4 class="lc-card-title">收集哪些信息</h4>
|
||||
<p class="lc-card-sub">客服会在对话中询问这些信息,并以结构化数据上报。也可以自行添加。</p>
|
||||
<div class="lc-chips">
|
||||
<button
|
||||
v-for="sf in STANDARD_FIELDS" :key="sf.key"
|
||||
class="lc-chip" :class="{ on: isStandardEnabled(sf.key) }"
|
||||
@click="toggleStandard(sf.key)"
|
||||
>
|
||||
<span class="lc-cbox" :class="{ on: isStandardEnabled(sf.key) }">
|
||||
<svg v-if="isStandardEnabled(sf.key)" viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5" /></svg>
|
||||
</span>
|
||||
<span class="lc-chip-label">{{ sf.label }}</span>
|
||||
<span v-if="sf.key === 'email'" class="lc-req">必填</span>
|
||||
<span
|
||||
v-else-if="isStandardEnabled(sf.key)"
|
||||
class="lc-req-toggle" :class="{ on: isRequired(sf.key) }"
|
||||
@click.stop="toggleRequired(sf.key)"
|
||||
:title="isRequired(sf.key) ? '必填——点击改为选填' : '选填——点击改为必填'"
|
||||
>{{ isRequired(sf.key) ? '必填' : '选填' }}</span>
|
||||
</button>
|
||||
<span v-for="cf in customFields" :key="cf.key" class="lc-chip on custom">
|
||||
<span class="lc-cbox purple on">
|
||||
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5" /></svg>
|
||||
</span>
|
||||
<span class="lc-chip-label">{{ cf.label }}</span>
|
||||
<span v-if="cf.options && cf.options.length" class="lc-type" :title="cf.options.join(', ')">{{ cf.options.length }} 个选项</span>
|
||||
<span
|
||||
class="lc-req-toggle" :class="{ on: cf.required }"
|
||||
@click="toggleRequired(cf.key)"
|
||||
:title="cf.required ? '必填——点击改为选填' : '选填——点击改为必填'"
|
||||
>{{ cf.required ? '必填' : '选填' }}</span>
|
||||
<button class="lc-chip-x" title="移除" @click="removeCustomField(cf.key)">✕</button>
|
||||
</span>
|
||||
</div>
|
||||
<p class="lc-chip-hint">点击字段上的 <b>选填 / 必填</b>,控制客服是否必须拿到它才记录线索。邮箱始终必填。</p>
|
||||
<div class="lc-custom-add">
|
||||
<input class="lc-input" v-model="newFieldLabel" placeholder="自定义字段名称(例如:团队规模)" />
|
||||
<input class="lc-input" v-model="newFieldOptions" placeholder="可选值,用逗号分隔(选填)" />
|
||||
<button class="lc-add" @click="addCustomField">+ 添加字段</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Behaviour: consent + guidance -->
|
||||
<section class="lc-card">
|
||||
<h4 class="lc-card-title">如何询问</h4>
|
||||
<div class="lc-toggle-row bordered">
|
||||
<div>
|
||||
<div class="lc-toggle-title">要求用户同意 <span class="lc-badge">GDPR</span></div>
|
||||
<div class="lc-toggle-desc">记录线索前,客服必须获得用户的明确同意。推荐开启。</div>
|
||||
</div>
|
||||
<button class="lc-switch" :class="{ on: requireConsent }" @click="requireConsent = !requireConsent" :aria-pressed="requireConsent">
|
||||
<span class="lc-knob"></span>
|
||||
</button>
|
||||
</div>
|
||||
<label class="lc-guidance-label">引导说明 <span class="lc-optional">选填</span></label>
|
||||
<textarea
|
||||
class="lc-input lc-textarea"
|
||||
v-model="guidance"
|
||||
rows="3"
|
||||
placeholder="引导何时、如何询问——例如“优先处理定价页访客;非工作时间更主动一些”。"
|
||||
></textarea>
|
||||
</section>
|
||||
|
||||
<!-- When a lead qualifies (CRM sync live; routing coming soon) -->
|
||||
<section class="lc-card">
|
||||
<h4 class="lc-card-title">线索合格后</h4>
|
||||
<p class="lc-card-sub">把合格线索直接推送到你的 CRM。分配与 Slack 提醒将在后续版本推出。</p>
|
||||
<div class="lc-route-row">
|
||||
<span>同步到 CRM</span>
|
||||
<select class="lc-input lc-input-sm" v-model="crmSyncTarget">
|
||||
<option value="none">不同步</option>
|
||||
<option value="hubspot">HubSpot</option>
|
||||
<option value="pipedrive">Pipedrive</option>
|
||||
<option value="salesforce" disabled>Salesforce(即将推出)</option>
|
||||
</select>
|
||||
</div>
|
||||
<p v-if="crmTargetUnconnected" class="lc-crm-warning">
|
||||
{{ crmTargetName }} 尚未连接——线索不会同步,直到你
|
||||
<RouterLink to="/settings/integrations">在集成设置中连接它</RouterLink>。
|
||||
</p>
|
||||
<div class="lc-route-row">
|
||||
<span>分配给 <span class="lc-soon">即将推出</span></span>
|
||||
<select class="lc-input lc-input-sm" v-model="assignmentMode" disabled>
|
||||
<option value="none">不分配——仅记录</option>
|
||||
<option value="sales_team">销售团队</option>
|
||||
<option value="round_robin">轮询分配</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="lc-route-row">
|
||||
<span>Slack 提醒 <span class="lc-soon">即将推出</span></span>
|
||||
<input type="checkbox" v-model="slackNotifyEnabled" disabled />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<div v-else class="lc-off">
|
||||
线索收集已关闭——该客服只回答问题。开启后即可在对话中收集线索。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT: live preview (conversational) -->
|
||||
<div class="lc-right">
|
||||
<div class="lc-preview-head">
|
||||
<span class="lc-preview-tag">实时预览</span>
|
||||
<span class="lc-preview-mode">{{ enabled ? '含线索收集' : '仅回答问题' }}</span>
|
||||
</div>
|
||||
<div class="lc-pv-widget">
|
||||
<div class="lc-pv-topbar">
|
||||
<span class="lc-pv-logo"><i></i><i></i><i></i></span>
|
||||
<span>
|
||||
<span class="lc-pv-name">销售助手</span>
|
||||
<span class="lc-pv-online"><i></i>在线</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="lc-pv-body">
|
||||
<div class="lc-pv-msg user">你们能对接 Shopify 吗?每周约 800 单。</div>
|
||||
<div class="lc-pv-msg bot">可以——Shopify 一键安装,我还能在聊天里实时查询订单。</div>
|
||||
<template v-if="enabled">
|
||||
<div class="lc-pv-msg bot">很乐意帮你配置店铺——方便留下你的{{ firstAskLabel }},让专员跟进吗?</div>
|
||||
<div class="lc-pv-msg user">好的——jane@acme.com</div>
|
||||
<div v-if="requireConsent" class="lc-pv-msg bot">好的——可以让人通过这个方式联系你吗?</div>
|
||||
<div v-if="requireConsent" class="lc-pv-msg user">可以,没问题。</div>
|
||||
<div class="lc-pv-msg bot">太好了——我会让专员通过 jane@acme.com 联系你。👍</div>
|
||||
</template>
|
||||
<div v-else class="lc-pv-tagline">线索收集关闭 · 仅回答问题</div>
|
||||
</div>
|
||||
<div class="lc-pv-inputbar">
|
||||
<span class="lc-pv-typebox">输入消息…</span>
|
||||
<span class="lc-pv-send">↑</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="enabled" class="lc-pv-captured">
|
||||
<div class="lc-pv-captured-label">已记录为线索 →</div>
|
||||
<div class="lc-pv-chips">
|
||||
<span v-for="l in enabledFieldLabels" :key="l" class="lc-pv-fchip">{{ l }}</span>
|
||||
<span class="lc-pv-fchip summary">+ AI 摘要</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="lc-loading">正在加载线索收集设置…</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.lead-capture-tab { display: flex; flex-direction: column; gap: 18px; padding: 0 var(--space-lg); max-width: 1208px; container-type: inline-size; }
|
||||
.lc-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
|
||||
.lc-title { font-size: 20px; font-weight: 600; margin: 0 0 4px; }
|
||||
.lc-sub { font-size: 14px; color: var(--muted); margin: 0; max-width: 560px; line-height: 1.5; }
|
||||
.lc-save { flex-shrink: 0; padding: 10px 18px; background: var(--accent-solid); color: var(--on-accent-solid); border: none; border-radius: 10px; font-size: 14px; font-weight: 600; cursor: pointer; }
|
||||
.lc-save:disabled { opacity: .6; cursor: default; }
|
||||
|
||||
.lc-grid { display: grid; grid-template-columns: 1fr; gap: 24px; align-items: start; }
|
||||
.lc-left { display: flex; flex-direction: column; gap: 18px; min-width: 0; }
|
||||
.lc-right { min-width: 0; }
|
||||
@container (min-width: 880px) {
|
||||
.lc-grid { grid-template-columns: minmax(0, 1fr) 360px; }
|
||||
.lc-right { position: sticky; top: 20px; }
|
||||
}
|
||||
|
||||
.lc-card { background: var(--surface); border: 1px solid var(--border-color); border-radius: 14px; padding: 20px; }
|
||||
.lc-card-title { font-size: 15px; font-weight: 600; margin: 0 0 4px; display: flex; align-items: center; gap: 8px; }
|
||||
.lc-card-sub { font-size: 13px; color: var(--muted); margin: 0 0 14px; }
|
||||
|
||||
/* Toggle rows */
|
||||
.lc-toggle-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
||||
.lc-toggle-row.bordered { padding-bottom: 16px; margin-bottom: 14px; border-bottom: 1px solid var(--border-color); }
|
||||
.lc-toggle-title { font-weight: 600; font-size: 15px; display: flex; align-items: center; gap: 8px; }
|
||||
.lc-toggle-desc { font-size: 13px; color: var(--muted); margin-top: 3px; max-width: 460px; line-height: 1.45; }
|
||||
.lc-switch { flex-shrink: 0; width: 46px; height: 26px; border-radius: 999px; border: none; background: var(--o12); padding: 3px; cursor: pointer; transition: background .16s ease; }
|
||||
.lc-switch.on { background: var(--accent-solid); }
|
||||
.lc-knob { display: block; width: 20px; height: 20px; border-radius: 50%; background: #fff; transition: transform .16s ease; box-shadow: 0 1px 2px rgba(0,0,0,.3); }
|
||||
.lc-switch.on .lc-knob { transform: translateX(20px); }
|
||||
.lc-badge { font-size: 9px; letter-spacing: .05em; padding: 3px 7px; border-radius: 999px; background: var(--purple-bg); color: var(--c-purple); font-weight: 600; }
|
||||
|
||||
.lc-input { width: 100%; margin-top: 10px; padding: 10px 12px; border: 1px solid var(--border-color); border-radius: 9px; font-size: 14px; background: var(--bg); color: var(--text); }
|
||||
.lc-input-sm { width: auto; min-width: 120px; }
|
||||
.lc-textarea { resize: vertical; line-height: 1.5; }
|
||||
.lc-guidance-label { display: block; font-size: 13px; font-weight: 500; color: var(--text3); margin-top: 4px; }
|
||||
.lc-optional { font-size: 11px; font-weight: 400; color: var(--muted2); margin-left: 4px; }
|
||||
.lc-add { margin-top: 6px; padding: 8px 14px; background: transparent; border: 1px dashed var(--border-color); border-radius: 9px; font-size: 13px; cursor: pointer; color: var(--muted); }
|
||||
|
||||
/* Field chips */
|
||||
.lc-chips { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.lc-chip { display: inline-flex; align-items: center; gap: 8px; padding: 8px 13px 8px 9px; border-radius: 12px; border: 1px solid var(--border-color); background: var(--o05); font-size: 13.5px; cursor: pointer; color: var(--text); line-height: 1; }
|
||||
.lc-chip.on { border-color: var(--accent-border); background: var(--accent-bg-08); }
|
||||
.lc-chip.custom { cursor: default; border-color: var(--purple-border, var(--o12)); background: var(--purple-bg); }
|
||||
.lc-cbox { width: 18px; height: 18px; flex-shrink: 0; border-radius: 5px; border: 1.5px solid var(--o25); background: transparent; display: flex; align-items: center; justify-content: center; color: var(--on-accent-solid); }
|
||||
.lc-cbox.on { background: var(--accent-solid); border-color: var(--accent-solid); }
|
||||
.lc-cbox.purple.on { background: var(--c-purple); border-color: var(--c-purple); color: #fff; }
|
||||
.lc-chip-label { font-weight: 500; }
|
||||
.lc-type { font-size: 10px; padding: 2px 6px; border-radius: 5px; background: var(--o08); color: var(--muted); }
|
||||
.lc-req { font-size: 10.5px; color: var(--c-coral); }
|
||||
/* Clickable required/optional pill on each enabled field. */
|
||||
.lc-req-toggle { font-size: 10px; font-weight: 600; letter-spacing: .02em; padding: 2px 7px; border-radius: 999px; cursor: pointer; background: var(--o08); color: var(--muted); border: 1px solid transparent; user-select: none; }
|
||||
.lc-req-toggle:hover { border-color: var(--o25); }
|
||||
.lc-req-toggle.on { background: color-mix(in srgb, var(--c-coral) 16%, transparent); color: var(--c-coral); }
|
||||
.lc-chip-hint { font-size: 12px; color: var(--muted2); margin: 12px 0 0; line-height: 1.45; }
|
||||
.lc-chip-x { border: none; background: none; cursor: pointer; color: var(--muted); padding: 0 2px; font-size: 12px; }
|
||||
.lc-custom-add { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-top: 14px; }
|
||||
.lc-custom-add .lc-input { margin-top: 0; flex: 1; min-width: 160px; }
|
||||
|
||||
.lc-route-row { display: flex; align-items: center; justify-content: space-between; padding: 12px 0; border-bottom: 1px solid var(--border-color); font-size: 14px; }
|
||||
.lc-route-row:last-child { border-bottom: none; }
|
||||
.lc-soon { font-size: 10px; padding: 2px 8px; border-radius: 999px; background: var(--o08); color: var(--muted); font-weight: 500; }
|
||||
.lc-crm-warning { font-size: 12.5px; color: var(--c-coral); background: var(--coral-bg); border: 1px solid var(--coral-border); border-radius: var(--radius-md); padding: 8px 12px; margin: -4px 0 12px; }
|
||||
.lc-crm-warning a { color: inherit; font-weight: 600; }
|
||||
.lc-off { background: var(--o05); border: 1px dashed var(--border-color); border-radius: 14px; padding: 24px; text-align: center; font-size: 14px; color: var(--muted); }
|
||||
.lc-loading { padding: 40px; text-align: center; color: var(--muted); }
|
||||
|
||||
input[type="checkbox"] { appearance: none; -webkit-appearance: none; width: 18px; height: 18px; margin: 0; flex-shrink: 0; border-radius: 5px; border: 1.5px solid var(--o25); background: transparent; cursor: pointer; position: relative; }
|
||||
input[type="checkbox"]:checked { background: var(--accent-solid); border-color: var(--accent-solid); }
|
||||
input[type="checkbox"]:checked::after { content: ''; position: absolute; left: 5px; top: 2px; width: 4px; height: 8px; border: solid var(--on-accent-solid); border-width: 0 2px 2px 0; transform: rotate(45deg); }
|
||||
input[type="checkbox"]:disabled { opacity: .5; cursor: default; }
|
||||
|
||||
/* Live preview */
|
||||
.lc-preview-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
|
||||
.lc-preview-tag { font-family: monospace; font-size: 11px; letter-spacing: .07em; color: var(--muted2); }
|
||||
.lc-preview-mode { font-size: 12px; color: var(--muted2); }
|
||||
.lc-pv-widget { background: var(--surface); border: 1px solid var(--border-color); border-radius: 16px; overflow: hidden; display: flex; flex-direction: column; min-height: 380px; }
|
||||
.lc-pv-topbar { display: flex; align-items: center; gap: 11px; padding: 14px 15px; border-bottom: 1px solid var(--border-color); }
|
||||
.lc-pv-logo { width: 32px; height: 32px; border-radius: 10px 10px 10px 3px; background: var(--accent-solid); display: flex; align-items: center; justify-content: center; gap: 2.5px; flex-shrink: 0; }
|
||||
.lc-pv-logo i { width: 3.5px; height: 3.5px; border-radius: 50%; background: var(--on-accent-solid); }
|
||||
.lc-pv-name { display: block; font-weight: 600; font-size: 13.5px; color: var(--text); }
|
||||
.lc-pv-online { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--c-teal, #0E8C8C); margin-top: 2px; }
|
||||
.lc-pv-online i { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
||||
.lc-pv-body { flex: 1; padding: 16px 14px; display: flex; flex-direction: column; gap: 10px; overflow-y: auto; }
|
||||
.lc-pv-msg { max-width: 88%; padding: 9px 12px; font-size: 12.5px; line-height: 1.5; }
|
||||
.lc-pv-msg.user { align-self: flex-end; background: var(--o08); border-radius: 14px 14px 4px 14px; color: var(--text2); }
|
||||
.lc-pv-msg.bot { align-self: flex-start; background: var(--o05); border: 1px solid var(--border-color); border-radius: 14px 14px 14px 4px; color: var(--text2); }
|
||||
.lc-pv-tagline { align-self: center; margin-top: 6px; padding: 8px 14px; border-radius: 999px; background: var(--o05); border: 1px solid var(--border-color); font-size: 11.5px; color: var(--muted); }
|
||||
.lc-pv-inputbar { padding: 11px 13px; border-top: 1px solid var(--border-color); display: flex; align-items: center; gap: 9px; }
|
||||
.lc-pv-typebox { flex: 1; padding: 9px 12px; background: var(--bg); border: 1px solid var(--border-color); border-radius: 10px; font-size: 12.5px; color: var(--muted2); }
|
||||
.lc-pv-send { width: 36px; height: 36px; border-radius: 10px; background: var(--accent-bg-12); display: flex; align-items: center; justify-content: center; color: var(--accent-ink); font-size: 15px; flex-shrink: 0; }
|
||||
.lc-pv-captured { margin-top: 14px; background: var(--surface); border: 1px solid var(--border-color); border-radius: 13px; padding: 14px 16px; }
|
||||
.lc-pv-captured-label { font-family: monospace; font-size: 10px; letter-spacing: .07em; color: var(--muted2); margin-bottom: 9px; }
|
||||
.lc-pv-chips { display: flex; flex-wrap: wrap; gap: 7px; }
|
||||
.lc-pv-fchip { padding: 5px 11px; border-radius: 999px; background: var(--accent-bg-08); border: 1px solid var(--accent-border); font-size: 11.5px; color: var(--text3); }
|
||||
.lc-pv-fchip.summary { background: var(--purple-bg); border-color: var(--purple-border, var(--o12)); }
|
||||
</style>
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,655 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import type { Agent } from '@/types/agent'
|
||||
import { buildWidgetEmbed } from '@/utils/widgetEmbed'
|
||||
|
||||
interface Widget {
|
||||
id: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
widget: {
|
||||
type: Object as () => Widget | null,
|
||||
required: true
|
||||
},
|
||||
widgetUrl: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
widgetLoading: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
agent: {
|
||||
type: Object as () => Agent,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['copy-widget-code', 'copy-iframe-code', 'copy-backend-code'])
|
||||
|
||||
// Check if token authentication is required
|
||||
const requiresTokenAuth = computed(() => {
|
||||
return props.agent?.require_token_auth ?? false
|
||||
})
|
||||
|
||||
// The exact snippet the Copy button produces (see buildWidgetEmbed) — rendered
|
||||
// in the preview so what you see always matches what you copy.
|
||||
const embedCode = computed(() => buildWidgetEmbed(props.widget?.id ?? '', requiresTokenAuth.value))
|
||||
|
||||
const copyWidgetCode = () => {
|
||||
emit('copy-widget-code')
|
||||
}
|
||||
|
||||
const copyIframeCode = () => {
|
||||
emit('copy-iframe-code')
|
||||
}
|
||||
|
||||
const copyBackendCode = () => {
|
||||
emit('copy-backend-code')
|
||||
}
|
||||
|
||||
// Check if this is an "Ask Anything" style agent
|
||||
const isAskAnythingStyle = computed(() => {
|
||||
return props.agent?.customization?.chat_style === 'ASK_ANYTHING'
|
||||
})
|
||||
|
||||
// Per-page overrides + JS control examples. Placement defaults live in the
|
||||
// Customization tab; these snippets are for developers who need more.
|
||||
const configExampleCode = `<script>
|
||||
window.replypierConfig = {
|
||||
displayMode: 'sidebar-right', // 'floating' | 'sidebar-left' | 'sidebar-right' | 'search-bar'
|
||||
position: { side: 'right', bottom: 20, offset: 20 },
|
||||
width: 400, // floating window size
|
||||
height: 560,
|
||||
launcher: false, // hide the built-in launcher
|
||||
trigger: '#my-chat-button', // your own element opens the chat
|
||||
zIndex: 999999
|
||||
};
|
||||
<\/script>`
|
||||
|
||||
const jsApiExampleCode = `// From your own code:
|
||||
ReplyPier.open(); // open({ message: 'Hi!' }) prefills the input
|
||||
ReplyPier.close();
|
||||
ReplyPier.toggle();
|
||||
ReplyPier.on('unread', (count) => { /* badge your own button */ });
|
||||
ReplyPier.on('ready', () => { /* widget loaded */ });
|
||||
|
||||
// Single-page apps — switch identity without reloading the page:
|
||||
ReplyPier.identify(token); // after login (token from your backend)
|
||||
ReplyPier.logout(); // after logout: back to anonymous
|
||||
ReplyPier.destroy(); // remove the widget from the page
|
||||
ReplyPier.reload(); // rebuild it (also brings back a destroyed widget)
|
||||
|
||||
// Or with no JavaScript at all:
|
||||
<button data-replypier-open>与我们聊天</button>`
|
||||
|
||||
const copyText = (text: string) => {
|
||||
if (!navigator.clipboard) {
|
||||
toast.error('剪贴板不可用——请手动复制代码')
|
||||
return
|
||||
}
|
||||
navigator.clipboard.writeText(text)
|
||||
.then(() => toast.success('已复制到剪贴板'))
|
||||
.catch(() => toast.error('复制失败——请手动复制代码'))
|
||||
}
|
||||
|
||||
// Generate iframe URL
|
||||
const iframeUrl = computed(() => {
|
||||
if (!props.widget?.id) return ''
|
||||
return `${props.widgetUrl}/api/v1/widgets/${props.widget.id}/data`
|
||||
})
|
||||
|
||||
// Generate iframe embed code
|
||||
const iframeEmbedCode = computed(() => {
|
||||
if (!props.widget?.id) return ''
|
||||
return `<iframe src="${iframeUrl.value}" width="100%" height="600" frameborder="0" title="AI 助手" allow="clipboard-write"></iframe>`
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="widget-tab">
|
||||
<section class="detail-section">
|
||||
<h3 class="section-title">组件集成</h3>
|
||||
<p class="section-description">
|
||||
复制下面的代码粘贴到 HTML 中,即可把客服加到任意网站。
|
||||
</p>
|
||||
|
||||
<div class="widget-info">
|
||||
<h4 class="widget-section-title">嵌入代码</h4>
|
||||
<div v-if="widgetLoading" class="loading-indicator">
|
||||
<div class="loading-spinner"></div>
|
||||
正在加载组件信息…
|
||||
</div>
|
||||
<div v-else-if="widget" class="widget-code-section">
|
||||
<!-- Token-based authentication (when require_token_auth is enabled) -->
|
||||
<template v-if="requiresTokenAuth">
|
||||
<div class="auth-badge">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 15V17M6 21H18C19.1046 21 20 20.1046 20 19V13C20 11.8954 19.1046 11 18 11H6C4.89543 11 4 11.8954 4 13V19C4 20.1046 4.89543 21 6 21ZM16 11V7C16 4.79086 14.2091 3 12 3C9.79086 3 8 4.79086 8 7V11H16Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
需要 Token 认证
|
||||
</div>
|
||||
|
||||
<p class="code-description">
|
||||
该客服要求 Token 认证。加载组件前,需由你的后端调用 ReplyPier API 生成 Token。
|
||||
</p>
|
||||
|
||||
<!-- Step 1: Get API Key -->
|
||||
<div class="setup-step">
|
||||
<div class="step-header">
|
||||
<span class="step-number">1</span>
|
||||
<h5 class="step-title">获取 API Key</h5>
|
||||
</div>
|
||||
<p class="step-description">
|
||||
前往 <strong>设置 > 组件应用</strong> 创建组件应用并获取 API Key。
|
||||
请妥善保管,只能在服务端使用。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Backend Token Generation -->
|
||||
<div class="setup-step">
|
||||
<div class="step-header">
|
||||
<span class="step-number">2</span>
|
||||
<h5 class="step-title">生成 Token(服务端)</h5>
|
||||
</div>
|
||||
<p class="step-description">
|
||||
创建一个后端接口,调用 ReplyPier API 生成 Token:
|
||||
</p>
|
||||
<div class="code-block">
|
||||
<pre><code>// Your backend endpoint (e.g., /api/chat-token)
|
||||
const response = await fetch('{{ widgetUrl }}/api/v1/generate-token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer YOUR_API_KEY' // From Widget Apps
|
||||
},
|
||||
body: JSON.stringify({
|
||||
widget_id: '{{ widget.id }}',
|
||||
customer_email: 'user@example.com', // Optional
|
||||
ttl_seconds: 3600 // Token validity (1 hour)
|
||||
})
|
||||
});
|
||||
|
||||
const { data } = await response.json();
|
||||
// Return data.token to your frontend</code></pre>
|
||||
<button class="copy-button" @click="copyBackendCode" title="复制后端代码">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 4V16C8 17.1046 8.89543 18 10 18H18C19.1046 18 20 17.1046 20 16V7.41421C20 6.88378 19.7893 6.37507 19.4142 6L16 2.58579C15.6249 2.21071 15.1162 2 14.5858 2H10C8.89543 2 8 2.89543 8 4Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M16 18V20C16 21.1046 15.1046 22 14 22H6C4.89543 22 4 21.1046 4 20V8C4 6.89543 4.89543 6 6 6H8" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Frontend Integration -->
|
||||
<div class="setup-step">
|
||||
<div class="step-header">
|
||||
<span class="step-number">3</span>
|
||||
<h5 class="step-title">加入网站(客户端)</h5>
|
||||
</div>
|
||||
<p class="step-description">
|
||||
把这段代码加入 HTML,并将 <code>/api/replypier</code> 换成你的后端接口:
|
||||
</p>
|
||||
<div class="code-block">
|
||||
<pre><code>{{ embedCode }}</code></pre>
|
||||
<button class="copy-button" @click="copyWidgetCode" title="复制到剪贴板">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 4V16C8 17.1046 8.89543 18 10 18H18C19.1046 18 20 17.1046 20 16V7.41421C20 6.88378 19.7893 6.37507 19.4142 6L16 2.58579C15.6249 2.21071 15.1162 2 14.5858 2H10C8.89543 2 8 2.89543 8 4Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M16 18V20C16 21.1046 15.1046 22 14 22H6C4.89543 22 4 21.1046 4 20V8C4 6.89543 4.89543 6 6 6H8" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box warning">
|
||||
<div class="info-icon">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 9V13M12 17H12.01M5.07183 19H18.9282C20.4678 19 21.4301 17.3333 20.6603 16L13.7321 4C12.9623 2.66667 11.0377 2.66667 10.2679 4L3.33975 16C2.56995 17.3333 3.53223 19 5.07183 19Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="info-content">
|
||||
<p><strong>安全提示:</strong>切勿在前端代码中暴露 API Key,Token 生成必须在服务端完成。</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Simple integration (no token auth required) -->
|
||||
<template v-else>
|
||||
<p class="code-description">
|
||||
把这段代码加到网站 HTML 中,放在 <code></body></code> 标签之前:
|
||||
</p>
|
||||
<div class="code-block">
|
||||
<pre><code>{{ embedCode }}</code></pre>
|
||||
<button class="copy-button" @click="copyWidgetCode" title="复制到剪贴板">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 4V16C8 17.1046 8.89543 18 10 18H18C19.1046 18 20 17.1046 20 16V7.41421C20 6.88378 19.7893 6.37507 19.4142 6L16 2.58579C15.6249 2.21071 15.1162 2 14.5858 2H10C8.89543 2 8 2.89543 8 4Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M16 18V20C16 21.1046 15.1046 22 14 22H6C4.89543 22 4 21.1046 4 20V8C4 6.89543 4.89543 6 6 6H8" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="info-box">
|
||||
<div class="info-icon">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2"/>
|
||||
<path d="M12 16V12M12 8H12.01" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="info-content">
|
||||
<p>组件会以聊天按钮形式出现在网站右下角,无需任何认证配置。</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Per-page overrides + JS API -->
|
||||
<div class="widget-info">
|
||||
<h4 class="widget-section-title">自定义与控制(选填)</h4>
|
||||
<div class="widget-code-section">
|
||||
<p class="code-description">
|
||||
位置、尺寸和启动器显隐在 <strong>自定义</strong> 标签页中设置。
|
||||
如需在特定页面覆盖,请在加载脚本前设置 <code>window.replypierConfig</code>:
|
||||
</p>
|
||||
<div class="code-block">
|
||||
<pre><code>{{ configExampleCode }}</code></pre>
|
||||
<button class="copy-button" @click="copyText(configExampleCode)" title="复制到剪贴板">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 4V16C8 17.1046 8.89543 18 10 18H18C19.1046 18 20 17.1046 20 16V7.41421C20 6.88378 19.7893 6.37507 19.4142 6L16 2.58579C15.6249 2.21071 15.1162 2 14.5858 2H10C8.89543 2 8 2.89543 8 4Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M16 18V20C16 21.1046 15.1046 22 14 22H6C4.89543 22 4 21.1046 4 20V8C4 6.89543 4.89543 6 6 6H8" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p class="code-description">
|
||||
用自己的按钮打开、关闭并响应组件:
|
||||
</p>
|
||||
<div class="code-block">
|
||||
<pre><code>{{ jsApiExampleCode }}</code></pre>
|
||||
<button class="copy-button" @click="copyText(jsApiExampleCode)" title="复制到剪贴板">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 4V16C8 17.1046 8.89543 18 10 18H18C19.1046 18 20 17.1046 20 16V7.41421C20 6.88378 19.7893 6.37507 19.4142 6L16 2.58579C15.6249 2.21071 15.1162 2 14.5858 2H10C8.89543 2 8 2.89543 8 4Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M16 18V20C16 21.1046 15.1046 22 14 22H6C4.89543 22 4 21.1046 4 20V8C4 6.89543 4.89543 6 6 6H8" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Iframe Integration Section for Ask Anything Style -->
|
||||
<div v-if="isAskAnythingStyle && !requiresTokenAuth" class="widget-info">
|
||||
<h4 class="widget-section-title">Iframe 集成</h4>
|
||||
<div v-if="widgetLoading" class="loading-indicator">
|
||||
<div class="loading-spinner"></div>
|
||||
正在加载 iframe 信息…
|
||||
</div>
|
||||
<div v-else-if="widget" class="widget-code-section">
|
||||
<p class="code-description">
|
||||
对于“随便问”风格的客服,可以把聊天界面直接嵌入页面:
|
||||
</p>
|
||||
|
||||
<!-- Iframe Preview -->
|
||||
<div class="iframe-preview-section">
|
||||
<h5 class="preview-title">预览</h5>
|
||||
<div class="iframe-preview-container">
|
||||
<iframe
|
||||
:src="iframeUrl"
|
||||
class="iframe-preview"
|
||||
frameborder="0"
|
||||
title="AI 助手预览"
|
||||
allow="clipboard-write"
|
||||
></iframe>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Iframe Code -->
|
||||
<div class="code-block">
|
||||
<pre><code>{{ iframeEmbedCode }}</code></pre>
|
||||
<button class="copy-button" @click="copyIframeCode" title="复制 iframe 代码">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 4V16C8 17.1046 8.89543 18 10 18H18C19.1046 18 20 17.1046 20 16V7.41421C20 6.88378 19.7893 6.37507 19.4142 6L16 2.58579C15.6249 2.21071 15.1162 2 14.5858 2H10C8.89543 2 8 2.89543 8 4Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M16 18V20C16 21.1046 15.1046 22 14 22H6C4.89543 22 4 21.1046 4 20V8C4 6.89543 4.89543 6 6 6H8" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<div class="info-icon">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="info-content">
|
||||
<p><strong>提示:</strong>非常适合嵌入帮助中心、联系表单或专属支持页面。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.widget-tab {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
padding: 0 var(--space-lg);
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
.section-description {
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
margin-bottom: 22px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.widget-section-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.widget-info {
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-xl);
|
||||
width: 100%;
|
||||
border: 1px solid var(--o08);
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
.widget-code-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-lg);
|
||||
}
|
||||
|
||||
/* Auth badge for token authentication */
|
||||
.auth-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
background: var(--accent-bg-12);
|
||||
color: var(--accent-ink);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border-radius: var(--radius-chip);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
width: fit-content;
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.code-description {
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.code-description code {
|
||||
background: var(--o06);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9em;
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
/* Setup steps */
|
||||
.setup-step {
|
||||
background: var(--bg-deep);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-lg);
|
||||
border: 1px solid var(--o08);
|
||||
}
|
||||
|
||||
.step-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.step-number {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
border-radius: 50%;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.step-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.step-description {
|
||||
color: var(--muted);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.6;
|
||||
margin: 0 0 var(--space-md) 0;
|
||||
padding-left: calc(28px + var(--space-md));
|
||||
}
|
||||
|
||||
.step-description strong {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Code block styling */
|
||||
.code-block {
|
||||
position: relative;
|
||||
background: var(--bg-deep);
|
||||
border: 1px solid var(--o10);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.code-block pre {
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.code-block code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: var(--code);
|
||||
white-space: pre;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.code-block .copy-button {
|
||||
position: absolute;
|
||||
top: var(--space-sm);
|
||||
right: var(--space-sm);
|
||||
background: var(--o06);
|
||||
border: 1px solid var(--o14);
|
||||
border-radius: var(--radius-chip);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.code-block .copy-button:hover {
|
||||
background: var(--o14);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.copy-button {
|
||||
background: var(--o06);
|
||||
border: 1px solid var(--o14);
|
||||
padding: var(--space-sm);
|
||||
border-radius: var(--radius-chip);
|
||||
cursor: pointer;
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.copy-button:hover {
|
||||
background: var(--o14);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.loading-indicator {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--muted);
|
||||
padding: var(--space-xl);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-sm);
|
||||
background: var(--bg-deep);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid var(--o10);
|
||||
border-radius: 50%;
|
||||
border-top-color: var(--accent-ink);
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Info box */
|
||||
.info-box {
|
||||
display: flex;
|
||||
background: var(--bg-deep);
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
border-radius: var(--radius-md);
|
||||
border-left: 3px solid var(--accent-ink);
|
||||
gap: var(--space-md);
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.info-box.warning {
|
||||
border-left-color: var(--c-teal);
|
||||
background: var(--teal-bg);
|
||||
}
|
||||
|
||||
.info-box .info-icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
.info-box.warning .info-icon {
|
||||
color: var(--c-teal);
|
||||
}
|
||||
|
||||
.info-content p {
|
||||
margin: 0;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.info-content strong {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Iframe integration styles */
|
||||
.iframe-preview-section {
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.preview-title {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
margin-bottom: var(--space-md);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.iframe-preview-container {
|
||||
background: var(--bg-deep);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-md);
|
||||
border: 1px solid var(--o08);
|
||||
}
|
||||
|
||||
.iframe-preview {
|
||||
width: 100%;
|
||||
height: 450px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg2);
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.widget-tab {
|
||||
padding: 0 var(--space-md);
|
||||
}
|
||||
|
||||
.widget-info {
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
.step-description {
|
||||
padding-left: 0;
|
||||
margin-top: var(--space-sm);
|
||||
}
|
||||
|
||||
.code-block pre {
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.code-block code {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.iframe-preview {
|
||||
height: 350px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,864 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useAgentWorkflow } from '@/composables/useAgentWorkflow'
|
||||
import type { AgentWithCustomization } from '@/types/agent'
|
||||
import WorkflowBuilder from '../workflow/WorkflowBuilder.vue'
|
||||
import { workflowCacheStorage } from '@/utils/storage'
|
||||
|
||||
const props = defineProps<{
|
||||
agent: AgentWithCustomization
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'toggle-fullscreen', isFullscreen: boolean): void
|
||||
}>()
|
||||
|
||||
const {
|
||||
workflow,
|
||||
workflowLoading,
|
||||
workflowError,
|
||||
createWorkflowLoading,
|
||||
hasWorkflow,
|
||||
fetchWorkflow,
|
||||
createWorkflow,
|
||||
updateWorkflow,
|
||||
deleteWorkflow
|
||||
} = useAgentWorkflow(props.agent.id)
|
||||
|
||||
// Create workflow form state
|
||||
const showCreateForm = ref(false)
|
||||
const workflowName = ref('')
|
||||
const workflowDescription = ref('')
|
||||
|
||||
// Edit workflow form state
|
||||
const showEditForm = ref(false)
|
||||
const editWorkflowName = ref('')
|
||||
const editWorkflowDescription = ref('')
|
||||
|
||||
// Workflow builder state
|
||||
const showWorkflowBuilder = ref(false)
|
||||
|
||||
// Computed properties
|
||||
const workflowStatus = computed(() => {
|
||||
if (!workflow.value) return 'No workflow'
|
||||
return workflow.value.status || 'Draft'
|
||||
})
|
||||
|
||||
const workflowStatusColor = computed(() => {
|
||||
const status = workflowStatus.value.toLowerCase()
|
||||
switch (status) {
|
||||
case 'published':
|
||||
return 'success'
|
||||
case 'draft':
|
||||
return 'warning'
|
||||
case 'archived':
|
||||
return 'muted'
|
||||
default:
|
||||
return 'muted'
|
||||
}
|
||||
})
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const handleCreateWorkflow = async () => {
|
||||
if (!workflowName.value.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await createWorkflow({
|
||||
name: workflowName.value.trim(),
|
||||
description: workflowDescription.value.trim() || undefined
|
||||
})
|
||||
|
||||
// Reset form
|
||||
workflowName.value = ''
|
||||
workflowDescription.value = ''
|
||||
showCreateForm.value = false
|
||||
} catch (error) {
|
||||
// Error handling is done in the composable
|
||||
}
|
||||
}
|
||||
|
||||
const handleEditWorkflow = () => {
|
||||
if (!workflow.value) return
|
||||
|
||||
// Pre-populate form with current values
|
||||
editWorkflowName.value = workflow.value.name
|
||||
editWorkflowDescription.value = workflow.value.description || ''
|
||||
showEditForm.value = true
|
||||
showCreateForm.value = false // Ensure create form is hidden
|
||||
}
|
||||
|
||||
const handleUpdateWorkflow = async () => {
|
||||
if (!workflow.value || !editWorkflowName.value.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await updateWorkflow(workflow.value.id, {
|
||||
name: editWorkflowName.value.trim(),
|
||||
description: editWorkflowDescription.value.trim() || undefined
|
||||
})
|
||||
|
||||
// Reset form
|
||||
editWorkflowName.value = ''
|
||||
editWorkflowDescription.value = ''
|
||||
showEditForm.value = false
|
||||
} catch (error) {
|
||||
// Error handling is done in the composable
|
||||
}
|
||||
}
|
||||
|
||||
const cancelEditForm = () => {
|
||||
editWorkflowName.value = ''
|
||||
editWorkflowDescription.value = ''
|
||||
showEditForm.value = false
|
||||
}
|
||||
|
||||
const handleDeleteWorkflow = async () => {
|
||||
if (!workflow.value) return
|
||||
|
||||
if (confirm('确定要删除该工作流吗?此操作无法撤销。')) {
|
||||
try {
|
||||
await deleteWorkflow(workflow.value.id)
|
||||
} catch (error) {
|
||||
// Error handling is done in the composable
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
emit('toggle-fullscreen', true)
|
||||
}
|
||||
|
||||
const openWorkflowBuilder = () => {
|
||||
// Clear existing cache and reload fresh data when opening workflow builder
|
||||
if (workflow.value) {
|
||||
workflowCacheStorage.clearWorkflowCache(workflow.value.id)
|
||||
}
|
||||
showWorkflowBuilder.value = true
|
||||
}
|
||||
|
||||
const closeWorkflowBuilder = () => {
|
||||
showWorkflowBuilder.value = false
|
||||
}
|
||||
|
||||
const handleWorkflowSave = () => {
|
||||
// Refresh workflow data after saving
|
||||
fetchWorkflow()
|
||||
showWorkflowBuilder.value = false
|
||||
}
|
||||
|
||||
const cancelCreateForm = () => {
|
||||
workflowName.value = ''
|
||||
workflowDescription.value = ''
|
||||
showCreateForm.value = false
|
||||
}
|
||||
|
||||
// Fetch workflow on mount
|
||||
onMounted(() => {
|
||||
fetchWorkflow()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="workflow-tab">
|
||||
<section class="detail-section">
|
||||
<div class="wf-header">
|
||||
<h3 class="section-title">工作流编排</h3>
|
||||
<p class="section-description">
|
||||
创建并管理对话工作流,用拖拽式编排器设计自定义对话流程。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="workflowLoading && !hasWorkflow" class="loading-container">
|
||||
<div class="loading-spinner"></div>
|
||||
<p class="loading-text">正在加载工作流…</p>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="workflowError && !hasWorkflow" class="error-container">
|
||||
<div class="error-icon">⚠️</div>
|
||||
<p class="error-message">{{ workflowError }}</p>
|
||||
<button class="retry-button" @click="fetchWorkflow">重试</button>
|
||||
</div>
|
||||
|
||||
<!-- No Workflow State -->
|
||||
<div v-else-if="!hasWorkflow && !showCreateForm && !showEditForm" class="no-workflow-container">
|
||||
<div class="no-workflow-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="6" height="6"/>
|
||||
<rect x="15" y="3" width="6" height="6"/>
|
||||
<rect x="9" y="15" width="6" height="6"/>
|
||||
<path d="M6 9v3a3 3 0 0 0 3 3h6a3 3 0 0 0 3-3V9"/>
|
||||
<path d="M12 15v-3"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h4 class="no-workflow-title">尚未创建工作流</h4>
|
||||
<p class="no-workflow-description">
|
||||
创建工作流,用拖拽界面设计自定义对话流程。
|
||||
</p>
|
||||
<button class="create-workflow-button" @click="showCreateForm = true; showEditForm = false">
|
||||
+ 创建工作流
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Create Workflow Form -->
|
||||
<div v-else-if="showCreateForm" class="create-form-container">
|
||||
<div class="form-header">
|
||||
<h4 class="form-title">创建工作流</h4>
|
||||
<button class="close-form-button" @click="cancelCreateForm" aria-label="关闭">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleCreateWorkflow" class="create-form">
|
||||
<div class="form-group">
|
||||
<label for="workflow-name" class="form-label">工作流名称 <span class="req">*</span></label>
|
||||
<input
|
||||
id="workflow-name"
|
||||
v-model="workflowName"
|
||||
type="text"
|
||||
class="form-input"
|
||||
placeholder="输入工作流名称"
|
||||
required
|
||||
:disabled="createWorkflowLoading"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="workflow-description" class="form-label">描述</label>
|
||||
<textarea
|
||||
id="workflow-description"
|
||||
v-model="workflowDescription"
|
||||
class="form-textarea"
|
||||
placeholder="描述工作流的用途(选填)"
|
||||
rows="3"
|
||||
:disabled="createWorkflowLoading"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="cancel-button"
|
||||
@click="cancelCreateForm"
|
||||
:disabled="createWorkflowLoading"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="submit-button"
|
||||
:disabled="!workflowName.trim() || createWorkflowLoading"
|
||||
>
|
||||
<div v-if="createWorkflowLoading" class="button-spinner"></div>
|
||||
<span v-else>创建工作流</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Edit Workflow Form -->
|
||||
<div v-else-if="showEditForm" class="create-form-container">
|
||||
<div class="form-header">
|
||||
<h4 class="form-title">重命名工作流</h4>
|
||||
<button class="close-form-button" @click="cancelEditForm" aria-label="关闭">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleUpdateWorkflow" class="create-form">
|
||||
<div class="form-group">
|
||||
<label for="edit-workflow-name" class="form-label">工作流名称 <span class="req">*</span></label>
|
||||
<input
|
||||
id="edit-workflow-name"
|
||||
v-model="editWorkflowName"
|
||||
type="text"
|
||||
class="form-input"
|
||||
placeholder="输入工作流名称"
|
||||
required
|
||||
:disabled="workflowLoading"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="edit-workflow-description" class="form-label">描述</label>
|
||||
<textarea
|
||||
id="edit-workflow-description"
|
||||
v-model="editWorkflowDescription"
|
||||
class="form-textarea"
|
||||
placeholder="描述工作流的用途(选填)"
|
||||
rows="3"
|
||||
:disabled="workflowLoading"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="cancel-button"
|
||||
@click="cancelEditForm"
|
||||
:disabled="workflowLoading"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="submit-button"
|
||||
:disabled="!editWorkflowName.trim() || workflowLoading"
|
||||
>
|
||||
<div v-if="workflowLoading" class="button-spinner"></div>
|
||||
<span v-else>保存修改</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Existing Workflow -->
|
||||
<div v-else-if="hasWorkflow && !showEditForm" class="workflow-container">
|
||||
<div class="workflow-card">
|
||||
<div class="workflow-info">
|
||||
<div class="workflow-main">
|
||||
<span class="workflow-name">{{ workflow!.name }}</span>
|
||||
<span class="workflow-status" :class="`status-${workflowStatusColor}`">
|
||||
{{ workflowStatus }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="workflow!.description" class="workflow-description">
|
||||
{{ workflow!.description }}
|
||||
</p>
|
||||
<div class="workflow-meta">
|
||||
<span class="workflow-date">创建于 {{ formatDate(workflow!.created_at) }}</span>
|
||||
<span class="workflow-separator">·</span>
|
||||
<span class="workflow-date">更新于 {{ formatDate(workflow!.updated_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="workflow-actions">
|
||||
<button class="action-button primary" @click="openWorkflowBuilder">
|
||||
⛶ 打开编排器
|
||||
</button>
|
||||
|
||||
<button class="action-button secondary" @click="handleEditWorkflow">
|
||||
✎ 重命名
|
||||
</button>
|
||||
|
||||
<button class="action-button danger" @click="handleDeleteWorkflow">
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Workflow Builder -->
|
||||
<WorkflowBuilder
|
||||
v-if="showWorkflowBuilder && workflow"
|
||||
:workflow="workflow"
|
||||
@close="closeWorkflowBuilder"
|
||||
@save="handleWorkflowSave"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.workflow-tab {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
padding: 0 var(--space-lg);
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.wf-header {
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 20px;
|
||||
color: var(--text);
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
.section-description {
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
max-width: 600px;
|
||||
line-height: 1.55;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Loading State */
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-xl);
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--o08);
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid var(--o10);
|
||||
border-radius: 50%;
|
||||
border-top-color: var(--accent-ink);
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
color: var(--muted);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Error State */
|
||||
.error-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-xl);
|
||||
background: var(--coral-bg);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--error-color);
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
font-size: 2rem;
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: var(--error-color);
|
||||
font-size: var(--text-sm);
|
||||
margin-bottom: var(--space-md);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.retry-button {
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
background: var(--error-color);
|
||||
color: var(--on-dark);
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
font-size: var(--text-sm);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.retry-button:hover {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
|
||||
/* No Workflow State */
|
||||
.no-workflow-container {
|
||||
border: 1px solid var(--o08);
|
||||
border-radius: var(--radius-card);
|
||||
padding: 64px 30px;
|
||||
background: var(--surface);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.no-workflow-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin: 0 auto 14px;
|
||||
color: var(--muted);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.no-workflow-icon svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.no-workflow-title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 20px;
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.no-workflow-description {
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
max-width: 420px;
|
||||
margin: 6px 0 20px;
|
||||
}
|
||||
|
||||
.create-workflow-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: 12px 22px;
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
border: none;
|
||||
border-radius: var(--radius-btn);
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 600;
|
||||
font-size: 14.5px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.create-workflow-button:hover {
|
||||
filter: brightness(1.05);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Create / Rename Form */
|
||||
.create-form-container {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o08);
|
||||
border-radius: var(--radius-card);
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.form-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.form-title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 19px;
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.close-form-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o10);
|
||||
border-radius: 9px;
|
||||
color: var(--muted);
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.close-form-button:hover {
|
||||
background: var(--o10);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.create-form {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.form-group:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 13.5px;
|
||||
color: var(--text3);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.form-label .req {
|
||||
color: var(--c-coral);
|
||||
}
|
||||
|
||||
.form-input,
|
||||
.form-textarea {
|
||||
width: 100%;
|
||||
padding: 13px 15px;
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: var(--radius-btn);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 14.5px;
|
||||
font-family: var(--font-sans);
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.form-input:focus,
|
||||
.form-textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-ink);
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
resize: vertical;
|
||||
line-height: 1.5;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
.cancel-button,
|
||||
.submit-button {
|
||||
border-radius: var(--radius-btn);
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 600;
|
||||
font-size: 14.5px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.cancel-button {
|
||||
padding: 12px 22px;
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o14);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.cancel-button:hover {
|
||||
background: var(--o10);
|
||||
}
|
||||
|
||||
.submit-button {
|
||||
padding: 12px 24px;
|
||||
background: var(--accent-solid);
|
||||
border: none;
|
||||
color: var(--on-accent-solid);
|
||||
}
|
||||
|
||||
.submit-button:hover:not(:disabled) {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
|
||||
.submit-button:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.button-spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid transparent;
|
||||
border-top: 2px solid currentColor;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
/* Workflow Card */
|
||||
.workflow-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.workflow-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o08);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.workflow-info {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.workflow-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.workflow-name {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 17px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.workflow-description {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
margin: 6px 0 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.workflow-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.workflow-status {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
padding: 3px 9px;
|
||||
border-radius: var(--radius-pill);
|
||||
letter-spacing: 0.06em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.workflow-status.status-success {
|
||||
background: color-mix(in srgb, var(--success-color) 15%, transparent);
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.workflow-status.status-warning {
|
||||
background: color-mix(in srgb, var(--warning-color) 15%, transparent);
|
||||
color: var(--warning-color);
|
||||
}
|
||||
|
||||
.workflow-status.status-muted {
|
||||
background: var(--o05);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.workflow-date {
|
||||
color: var(--muted2);
|
||||
font-size: 12.5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.workflow-separator {
|
||||
color: var(--muted2);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.workflow-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
border-radius: 10px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 13.5px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.action-button.primary {
|
||||
padding: 10px 18px;
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
border: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.action-button.primary:hover {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
|
||||
.action-button.secondary {
|
||||
padding: 10px 16px;
|
||||
background: var(--o05);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--o14);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.action-button.secondary:hover {
|
||||
background: var(--o10);
|
||||
}
|
||||
|
||||
.action-button.danger {
|
||||
padding: 10px 16px;
|
||||
background: transparent;
|
||||
color: var(--c-coral);
|
||||
border: 1px solid var(--coral-border);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.action-button.danger:hover {
|
||||
background: color-mix(in srgb, var(--c-coral) 12%, transparent);
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 768px) {
|
||||
.workflow-card {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.workflow-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,358 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Agent } from '@/types/agent'
|
||||
import { useAgentCreate } from '@/composables/useAgentCreate'
|
||||
|
||||
const {
|
||||
agentName,
|
||||
useWorkflow,
|
||||
isCreating,
|
||||
error,
|
||||
createAgent
|
||||
} = useAgentCreate()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
(e: 'created', agent: Agent): void
|
||||
(e: 'openWorkflowBuilder', agent: Agent): void
|
||||
}>()
|
||||
|
||||
const handleCreateAgent = async () => {
|
||||
const newAgent = await createAgent()
|
||||
if (newAgent) {
|
||||
emit('created', newAgent)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="modal-overlay">
|
||||
<div class="modal-content" role="dialog" aria-modal="true" aria-labelledby="create-agent-title">
|
||||
<div class="modal-header">
|
||||
<h3 id="create-agent-title">创建客服</h3>
|
||||
<button type="button" class="close-button" aria-label="关闭" @click="$emit('close')">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form @submit.prevent="handleCreateAgent">
|
||||
<div class="form-group">
|
||||
<label for="agent-name">客服名称</label>
|
||||
<input
|
||||
id="agent-name"
|
||||
type="text"
|
||||
v-model="agentName"
|
||||
placeholder="例如:客户支持客服"
|
||||
:disabled="isCreating"
|
||||
required
|
||||
>
|
||||
<div class="help-text">
|
||||
为客服起个名字,访客会看到这个名称。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group mode-selection">
|
||||
<label>客服模式</label>
|
||||
<div class="mode-options">
|
||||
<div
|
||||
class="mode-option"
|
||||
:class="{ 'selected': !useWorkflow }"
|
||||
@click="useWorkflow = false"
|
||||
>
|
||||
<div class="mode-icon">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 3l1.9 4.6L18.5 9l-4.6 1.4L12 15l-1.9-4.6L5.5 9l4.6-1.4z"/>
|
||||
<path d="M19 14l.7 1.8L21.5 16.5l-1.8.7L19 19l-.7-1.8L16.5 16.5l1.8-.7z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="mode-details">
|
||||
<h4>AI 自动模式</h4>
|
||||
<p>让 AI 依据指令自然应答</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mode-option"
|
||||
:class="{ 'selected': useWorkflow }"
|
||||
@click="useWorkflow = true"
|
||||
>
|
||||
<div class="mode-icon">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="6" cy="6" r="2.4"/>
|
||||
<circle cx="6" cy="18" r="2.4"/>
|
||||
<circle cx="18" cy="9" r="2.4"/>
|
||||
<path d="M8.4 6H13a3 3 0 0 1 3 3M6 8.4v7.2"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="mode-details">
|
||||
<h4>工作流编排</h4>
|
||||
<p>拖拽搭建自定义对话流程</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="cancel-button"
|
||||
@click="$emit('close')"
|
||||
:disabled="isCreating"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="create-button"
|
||||
:disabled="isCreating"
|
||||
>
|
||||
{{ isCreating ? '创建中…' : '创建客服' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(255, 255, 255, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o10);
|
||||
border-radius: 24px;
|
||||
width: 90%;
|
||||
max-width: 600px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.2);
|
||||
animation: modal-appear 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes modal-appear {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--space-lg) var(--space-lg) var(--space-md);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
color: var(--text-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: var(--space-xs);
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: var(--space-md);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--background-soft);
|
||||
color: var(--text-color);
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px var(--primary-soft);
|
||||
}
|
||||
|
||||
.help-text {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: var(--space-xs);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background-color: var(--error-soft);
|
||||
color: var(--error-color);
|
||||
padding: var(--space-md);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--space-lg);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.mode-options {
|
||||
display: flex;
|
||||
gap: var(--space-md);
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.mode-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-md);
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.mode-option.selected {
|
||||
border-color: var(--primary-color);
|
||||
background-color: var(--primary-soft);
|
||||
}
|
||||
|
||||
.mode-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o10);
|
||||
color: var(--muted);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.mode-option.selected .mode-icon {
|
||||
background: var(--accent-bg-08);
|
||||
border-color: var(--accent-border);
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
.mode-details {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mode-details h4 {
|
||||
margin: 0 0 var(--space-xs);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.mode-details p {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-md);
|
||||
margin-top: var(--space-xl);
|
||||
}
|
||||
|
||||
.cancel-button {
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--background-soft);
|
||||
color: var(--text-color);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.cancel-button:hover {
|
||||
background: var(--background-muted);
|
||||
}
|
||||
|
||||
.create-button {
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.create-button:hover {
|
||||
background: var(--primary-dark);
|
||||
}
|
||||
|
||||
.create-button:disabled,
|
||||
.cancel-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.mode-options {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.mode-option {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,227 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch, defineAsyncComponent } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useEnterpriseFeatures } from '@/composables/useEnterpriseFeatures'
|
||||
import { notifySetupChanged } from '@/utils/setupEvents'
|
||||
|
||||
// Lazy load components
|
||||
const AISetup = defineAsyncComponent(() => import('../ai/AISetup.vue'))
|
||||
const AgentList = defineAsyncComponent(() => import('../agent/AgentList.vue'))
|
||||
const AIAgentOnboarding = defineAsyncComponent(() => import('./onboarding/AIAgentOnboarding.vue'))
|
||||
|
||||
import { useAgentStorage } from '@/utils/storage'
|
||||
import { aiService } from '@/services/ai'
|
||||
import { agentService } from '@/services/agent'
|
||||
import { permissionChecks } from '@/utils/permissions'
|
||||
import { AxiosError } from 'axios'
|
||||
|
||||
const agentStorage = useAgentStorage()
|
||||
|
||||
const error = ref<string | null>(null)
|
||||
const isLoading = ref(false)
|
||||
const isAISetupMode = ref(false)
|
||||
// First-run guided setup: shown when the org has no agents (and didn't skip)
|
||||
const showOnboarding = ref(false)
|
||||
|
||||
const props = defineProps<{
|
||||
model: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'toggle-fullscreen', isFullscreen: boolean): void
|
||||
}>()
|
||||
|
||||
const checkAIConfig = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
await aiService.getOrganizationConfig()
|
||||
isAISetupMode.value = false
|
||||
await fetchAgents()
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof AxiosError && err.response?.status === 404) {
|
||||
// No AI config found
|
||||
isAISetupMode.value = true
|
||||
} else {
|
||||
error.value = '检查 AI 配置失败'
|
||||
console.error(err)
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fetchAgents = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
const agents = await agentService.getOrganizationAgents()
|
||||
if (agents.length > 0) {
|
||||
agentStorage.setAgents(agents)
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = '获取客服列表失败'
|
||||
console.error(err)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Handle fullscreen toggle from agent list
|
||||
const handleFullscreenToggle = (isFullscreen: boolean) => {
|
||||
emit('toggle-fullscreen', isFullscreen)
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
// Links into setup carry ?setup=1: reopen the wizard where the org left off.
|
||||
const openWizardIfAsked = () => {
|
||||
if (route.query.setup === '1' && permissionChecks.canManageAgents()) showOnboarding.value = true
|
||||
}
|
||||
watch(() => route.query.setup, openWizardIfAsked)
|
||||
|
||||
// Onboarding wizard finished (or was skipped) — refresh and show the list
|
||||
const handleOnboardingComplete = async () => {
|
||||
showOnboarding.value = false
|
||||
isAISetupMode.value = false
|
||||
if (route.query.setup) router.replace({ query: { ...route.query, setup: undefined } })
|
||||
notifySetupChanged()
|
||||
await fetchAgents()
|
||||
}
|
||||
|
||||
const resumeOnboarding = () => {
|
||||
showOnboarding.value = true
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
// Authoritative check: does this org have any agents yet?
|
||||
const agents = await agentService.getOrganizationAgents()
|
||||
if (agents.length > 0) {
|
||||
agentStorage.setAgents(agents)
|
||||
openWizardIfAsked()
|
||||
return
|
||||
}
|
||||
// No agents yet → show the guided first-agent setup, but only to
|
||||
// someone who can actually create one. A view-only role was being
|
||||
// dropped into a creation wizard whose every step would 403.
|
||||
showOnboarding.value = permissionChecks.canManageAgents()
|
||||
} catch (err) {
|
||||
error.value = '加载客服列表失败'
|
||||
console.error(err)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="chat-window">
|
||||
<div class="messages">
|
||||
<div v-if="isLoading" class="loading-container">
|
||||
<div class="loader"></div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="error-message">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="showOnboarding" class="onboarding-container">
|
||||
<AIAgentOnboarding @complete="handleOnboardingComplete" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="isAISetupMode" class="setup-messages">
|
||||
<div class="ai-provider-setup">
|
||||
<h3>配置 AI 服务商</h3>
|
||||
<div class="ai-setup-container">
|
||||
<AISetup @ai-setup-complete="checkAIConfig" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="agent-list-container">
|
||||
<AgentList @toggle-fullscreen="handleFullscreenToggle" @resume-onboarding="resumeOnboarding" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chat-window {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
margin: 0 auto;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.messages {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.ai-provider-setup {
|
||||
margin-bottom: var(--space-xl);
|
||||
padding: var(--space-xl);
|
||||
}
|
||||
|
||||
.ai-provider-setup h3 {
|
||||
margin-bottom: var(--space-lg);
|
||||
color: var(--text);
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ai-setup-container {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
padding: var(--space-md);
|
||||
background: var(--error-bg);
|
||||
color: var(--error-color);
|
||||
border-radius: var(--radius-lg);
|
||||
text-align: center;
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.loader {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 3px solid var(--o10);
|
||||
border-bottom-color: var(--accent-ink);
|
||||
border-radius: 50%;
|
||||
animation: rotation 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rotation {
|
||||
0% { transform: rotate(0deg) }
|
||||
100% { transform: rotate(360deg) }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,186 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import robotIcon from '@/assets/robot.png'
|
||||
import userIcon from '@/assets/user.png'
|
||||
|
||||
defineProps<{
|
||||
role: 'user' | 'bot' | 'error' | 'agent'
|
||||
content: string
|
||||
agent?: string
|
||||
avatar?: string
|
||||
isLoading?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="message" :class="role">
|
||||
<div class="avatar" :class="role">
|
||||
<template v-if="role === 'user'">
|
||||
<img :src="userIcon" alt="用户" class="user-icon" />
|
||||
</template>
|
||||
<template v-else-if="role === 'bot'">
|
||||
<img :src="avatar || robotIcon" :alt="agent || 'AI 助手'" class="robot-icon" />
|
||||
</template>
|
||||
<template v-else>
|
||||
⚠️
|
||||
</template>
|
||||
</div>
|
||||
<div class="bubble">
|
||||
<template v-if="isLoading && role === 'bot'">
|
||||
<div class="typing-indicator">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ content }}
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.message {
|
||||
display: flex;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-sm);
|
||||
margin-bottom: var(--space-md);
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
.message.user {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.avatar.assistant {
|
||||
background: var(--primary-soft);
|
||||
padding: var(--space-xs);
|
||||
}
|
||||
|
||||
.robot-icon {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.avatar.user {
|
||||
background: var(--background-soft);
|
||||
padding: var(--space-xs);
|
||||
}
|
||||
|
||||
.user-icon {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
max-width: 70%;
|
||||
padding: var(--space-md);
|
||||
border-radius: var(--radius-lg);
|
||||
line-height: 1.5;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.message.assistant .bubble {
|
||||
background: #313131;
|
||||
border-top-left-radius: 4px;
|
||||
/* Slightly darker light black background */
|
||||
border-top-left-radius: 4px;
|
||||
}
|
||||
|
||||
.message.user .bubble {
|
||||
background: var(--accent-solid);
|
||||
color: black;
|
||||
border-top-right-radius: 4px;
|
||||
}
|
||||
|
||||
.message.error .bubble {
|
||||
background: var(--error-soft, #fef2f2);
|
||||
color: var(--error-color, #dc2626);
|
||||
border: 1px solid var(--error-color, #dc2626);
|
||||
}
|
||||
|
||||
.avatar.error {
|
||||
background: var(--error-soft, #fef2f2);
|
||||
color: var(--error-color, #dc2626);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.typing-indicator {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: var(--space-xs);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.typing-indicator span {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: var(--primary-color, var(--accent-solid));
|
||||
border-radius: 50%;
|
||||
animation: typing 1.4s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.typing-indicator span:nth-child(1) {
|
||||
animation-delay: -0.32s;
|
||||
}
|
||||
|
||||
.typing-indicator span:nth-child(2) {
|
||||
animation-delay: -0.16s;
|
||||
}
|
||||
|
||||
@keyframes typing {
|
||||
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
transform: scale(0);
|
||||
}
|
||||
|
||||
40% {
|
||||
transform: scale(1.0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,328 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { userService } from '@/services/user'
|
||||
import { agentService } from '@/services/agent'
|
||||
import { widgetService } from '@/services/widget'
|
||||
import { useOnboardingState, type OnboardingStepIndex } from '@/composables/useOnboardingState'
|
||||
import { useEnterpriseFeatures } from '@/composables/useEnterpriseFeatures'
|
||||
import type { Agent } from '@/types/agent'
|
||||
import StepCreate from './steps/StepCreate.vue'
|
||||
import StepTeach from './steps/StepTeach.vue'
|
||||
import StepTest from './steps/StepTest.vue'
|
||||
import StepLaunch from './steps/StepLaunch.vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
// user finished the wizard or skipped — parent refreshes agents and renders the list
|
||||
(e: 'complete'): void
|
||||
}>()
|
||||
|
||||
const onboarding = useOnboardingState()
|
||||
const { loadModule, moduleImports } = useEnterpriseFeatures()
|
||||
|
||||
// The wizard step to reopen at for each server-side stage
|
||||
const WIZARD_STEP_FOR: Record<string, OnboardingStepIndex> = {
|
||||
create: 0, teach: 1, test: 2, launch: 3, live: 3,
|
||||
}
|
||||
|
||||
/** Where the server says the org is, when the enterprise module tracks it. */
|
||||
const serverProgress = async () => {
|
||||
try {
|
||||
const module = await loadModule(moduleImports.setupService)
|
||||
return (await module?.setupService?.getProgress()) ?? null
|
||||
} catch (err) {
|
||||
console.error('Failed to load setup progress:', err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This browser's run if it has one, else where the server says the org is
|
||||
* (enterprise module only): another device, cleared storage, a link.
|
||||
*/
|
||||
const resumePoint = async () => {
|
||||
const record = onboarding.get(orgId)
|
||||
if (record.agentId) return record
|
||||
const server = await serverProgress()
|
||||
if (!server?.agent_id || !(server.step in WIZARD_STEP_FOR)) return record
|
||||
return onboarding.patch(orgId, {
|
||||
agentId: server.agent_id,
|
||||
widgetId: server.widget_id,
|
||||
currentStep: WIZARD_STEP_FOR[server.step],
|
||||
})
|
||||
}
|
||||
|
||||
const orgId = userService.getCurrentUser()?.organization_id || ''
|
||||
const steps = ['创建', '培训', '测试', '上线']
|
||||
|
||||
const currentStep = ref<OnboardingStepIndex>(0)
|
||||
const agentId = ref<string | null>(null)
|
||||
const agentName = ref<string>('')
|
||||
const agentType = ref<string>('customer_support')
|
||||
const widgetId = ref<string | null>(null)
|
||||
// The agent created in this run — so returning to Create edits it instead of
|
||||
// creating a duplicate.
|
||||
const createdAgent = ref<Agent | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
// Resume an in-progress run, from this browser or from the server
|
||||
const record = await resumePoint()
|
||||
agentId.value = record.agentId
|
||||
agentName.value = record.agentName || ''
|
||||
widgetId.value = record.widgetId
|
||||
// Can't resume past Create without an agent
|
||||
currentStep.value = record.agentId ? record.currentStep : 0
|
||||
// Restore the agent so Create can prefill + update on a resumed run
|
||||
if (record.agentId) {
|
||||
try {
|
||||
createdAgent.value = await agentService.getAgentById(record.agentId)
|
||||
agentType.value = createdAgent.value?.agent_type || agentType.value
|
||||
agentName.value = agentName.value || createdAgent.value?.display_name || createdAgent.value?.name || ''
|
||||
} catch (err) {
|
||||
// The stored agent no longer exists (e.g. deleted) — start the run fresh
|
||||
// instead of leaving later steps pointing at a dead agent id.
|
||||
console.error('Failed to load onboarding agent, restarting:', err)
|
||||
createdAgent.value = null
|
||||
agentId.value = null
|
||||
currentStep.value = 0
|
||||
onboarding.patch(orgId, { agentId: null, widgetId: null, currentStep: 0, completedSteps: [] })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const goTo = (step: OnboardingStepIndex) => {
|
||||
currentStep.value = step
|
||||
onboarding.goTo(orgId, step)
|
||||
}
|
||||
|
||||
const handleWidgetCreated = (id: string) => {
|
||||
widgetId.value = id
|
||||
onboarding.patch(orgId, { widgetId: id })
|
||||
}
|
||||
|
||||
// The widget exists from the start, not only once they reach Test: someone
|
||||
// who stops at Teach can still be sent their install code. Test creates it
|
||||
// if this failed.
|
||||
const ensureWidget = async (agent: Agent) => {
|
||||
if (widgetId.value) return
|
||||
try {
|
||||
handleWidgetCreated((await widgetService.createAgentWidget(agent.id, agentName.value)).id)
|
||||
} catch (err) {
|
||||
console.error('Failed to create the widget with the agent:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreated = async (agent: Agent) => {
|
||||
createdAgent.value = agent
|
||||
agentId.value = agent.id
|
||||
agentName.value = agent.display_name || agent.name
|
||||
agentType.value = agent.agent_type
|
||||
await ensureWidget(agent)
|
||||
onboarding.patch(orgId, {
|
||||
agentId: agent.id,
|
||||
agentName: agentName.value,
|
||||
currentStep: 1,
|
||||
completedSteps: [0],
|
||||
})
|
||||
currentStep.value = 1
|
||||
}
|
||||
|
||||
const completeAndAdvance = (step: OnboardingStepIndex, next: OnboardingStepIndex) => {
|
||||
onboarding.completeStep(orgId, step, next)
|
||||
currentStep.value = next
|
||||
}
|
||||
|
||||
const handleSkip = () => {
|
||||
onboarding.skip(orgId)
|
||||
emit('complete')
|
||||
}
|
||||
|
||||
const handleFinish = () => {
|
||||
onboarding.completeStep(orgId, 3)
|
||||
emit('complete')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="onboarding">
|
||||
<header class="onb-header">
|
||||
<h1 class="onb-title">让你的第一个 AI 客服上线吧</h1>
|
||||
<p class="onb-sub">四步搞定,无需写代码,也无需配置模型——AI 部分交给我们。</p>
|
||||
</header>
|
||||
|
||||
<!-- Stepper -->
|
||||
<ol class="stepper" aria-label="设置进度">
|
||||
<li
|
||||
v-for="(label, i) in steps"
|
||||
:key="label"
|
||||
class="stepper-item"
|
||||
:class="{
|
||||
active: currentStep === i,
|
||||
done: currentStep > i,
|
||||
}"
|
||||
>
|
||||
<span class="stepper-dot">
|
||||
<template v-if="currentStep > i">✓</template>
|
||||
<template v-else>{{ i + 1 }}</template>
|
||||
</span>
|
||||
<span class="stepper-label">{{ label }}</span>
|
||||
<span v-if="i < steps.length - 1" class="stepper-line" aria-hidden="true"></span>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<!-- Active step -->
|
||||
<div class="onb-card">
|
||||
<StepCreate
|
||||
v-if="currentStep === 0"
|
||||
:key="createdAgent ? createdAgent.id : 'new'"
|
||||
:existing-agent="createdAgent"
|
||||
@created="handleCreated"
|
||||
@skip="handleSkip"
|
||||
/>
|
||||
<StepTeach
|
||||
v-else-if="currentStep === 1 && agentId"
|
||||
:agent-id="agentId"
|
||||
:organization-id="orgId"
|
||||
@next="completeAndAdvance(1, 2)"
|
||||
@back="goTo(0)"
|
||||
/>
|
||||
<StepTest
|
||||
v-else-if="currentStep === 2 && agentId"
|
||||
:agent-id="agentId"
|
||||
:agent-name="agentName"
|
||||
:widget-id="widgetId"
|
||||
@widget-created="handleWidgetCreated"
|
||||
@next="completeAndAdvance(2, 3)"
|
||||
@back="goTo(1)"
|
||||
/>
|
||||
<StepLaunch
|
||||
v-else-if="currentStep === 3"
|
||||
:widget-id="widgetId"
|
||||
:agent-type="agentType"
|
||||
@finish="handleFinish"
|
||||
@back="goTo(2)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.onboarding {
|
||||
max-width: 920px;
|
||||
margin: 0 auto;
|
||||
padding: 8px 0 40px;
|
||||
}
|
||||
|
||||
.onb-header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.onb-title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 32px;
|
||||
letter-spacing: -0.03em;
|
||||
margin: 0 0 8px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.onb-sub {
|
||||
font-size: 16px;
|
||||
color: var(--muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.stepper {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin: 0 0 34px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.stepper-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.stepper-dot {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid var(--o14);
|
||||
background: var(--surface);
|
||||
color: var(--muted2);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-mono);
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.stepper-item.active .stepper-dot {
|
||||
background: var(--accent-solid);
|
||||
border-color: var(--accent-ink);
|
||||
color: var(--on-accent-solid);
|
||||
}
|
||||
|
||||
.stepper-item.done .stepper-dot {
|
||||
background: var(--accent-bg-12);
|
||||
border-color: var(--accent-border);
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
.stepper-label {
|
||||
font-size: 14.5px;
|
||||
font-weight: 500;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stepper-item.active .stepper-label {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.stepper-line {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--o12);
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.onb-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o08);
|
||||
border-radius: var(--radius-card);
|
||||
padding: 34px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.stepper-label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,928 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, defineAsyncComponent, onMounted } from 'vue'
|
||||
import { AxiosError } from 'axios'
|
||||
import { aiService } from '@/services/ai'
|
||||
import { agentService } from '@/services/agent'
|
||||
import { leadCaptureService } from '@/services/leadCapture'
|
||||
import { useEnterpriseFeatures } from '@/composables/useEnterpriseFeatures'
|
||||
import { subscriptionStorage } from '@/utils/storage'
|
||||
import type { Agent } from '@/types/agent'
|
||||
|
||||
// Lazy-load the provider setup form — only needed on OSS builds without ReplyPier AI
|
||||
const AISetup = defineAsyncComponent(() => import('@/components/ai/AISetup.vue'))
|
||||
|
||||
const { hasEnterpriseModule } = useEnterpriseFeatures()
|
||||
|
||||
// Bundled preset avatars (frontend/src/assets/avatars/avatar-*.png)
|
||||
const avatarModules = import.meta.glob('@/assets/avatars/avatar-*.png', {
|
||||
eager: true,
|
||||
query: '?url',
|
||||
import: 'default',
|
||||
}) as Record<string, string>
|
||||
const presetAvatars = Object.keys(avatarModules).sort().map((k) => avatarModules[k])
|
||||
|
||||
// When set, Create edits this agent (returning to the step) instead of
|
||||
// creating a new one — avoids duplicate agents / plan-limit errors.
|
||||
const props = defineProps<{
|
||||
existingAgent?: Agent | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'created', agent: Agent): void
|
||||
(e: 'skip'): void
|
||||
}>()
|
||||
|
||||
const agentTypes = [
|
||||
{ value: 'customer_support', label: '客户支持' },
|
||||
{ value: 'sales', label: '销售' },
|
||||
{ value: 'tech_support', label: '技术支持' },
|
||||
{ value: 'general', label: '通用' },
|
||||
] as const
|
||||
|
||||
// Sensible starter instructions per agent type — prefilled so setup is quick
|
||||
const TYPE_DEFAULTS: Record<string, string> = {
|
||||
customer_support: '简洁、友好、有同理心。依据知识库回答问题,账单或账户问题转交人工客服。',
|
||||
sales: '热情且乐于助人。突出产品优势,解答价格问题,引导访客预约演示或开始试用。在合适的时机收集线索信息。',
|
||||
tech_support: '清晰且耐心。依据文档引导用户逐步排查问题。必要时索取报错信息或截图,无法解决的问题转交人工。',
|
||||
general: '乐于助人、简洁友好。依据知识库准确回答问题,需要专人处理时转交人工。',
|
||||
}
|
||||
const DEFAULT_VALUES = Object.values(TYPE_DEFAULTS)
|
||||
|
||||
// Prefill from the existing agent when returning to this step
|
||||
const existingInstructions = Array.isArray(props.existingAgent?.instructions)
|
||||
? props.existingAgent!.instructions.join('\n')
|
||||
: ''
|
||||
|
||||
const agentName = ref(props.existingAgent?.display_name || props.existingAgent?.name || '')
|
||||
const selectedType = ref<string>(props.existingAgent?.agent_type || 'customer_support')
|
||||
const instructions = ref<string>(existingInstructions || TYPE_DEFAULTS[selectedType.value] || '')
|
||||
|
||||
const isSubmitting = ref(false)
|
||||
const isGenerating = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
// Lead capture is a Pro-plan feature — hide/disable it where the enterprise module
|
||||
// is present and the plan doesn't include it (mirrors the Lead Capture tab gate).
|
||||
const leadCaptureLocked = computed(() =>
|
||||
hasEnterpriseModule &&
|
||||
(!subscriptionStorage.hasFeature('lead_capture') || !subscriptionStorage.isSubscriptionActive())
|
||||
)
|
||||
|
||||
// Lead capture — a simple on/off here; the agent collects details in conversation.
|
||||
// Refined later in the agent's Lead Capture tab (consent, guidance, custom fields).
|
||||
// Default off when locked so we never try to save it on a non-Pro plan.
|
||||
const leadEnabled = ref(!leadCaptureLocked.value)
|
||||
const LEAD_FIELDS = [
|
||||
{ key: 'name', label: '姓名' },
|
||||
{ key: 'email', label: '邮箱' },
|
||||
{ key: 'company', label: '公司' },
|
||||
{ key: 'phone', label: '电话' },
|
||||
]
|
||||
const leadFields = ref<Record<string, boolean>>({ name: true, email: true, company: true, phone: false })
|
||||
|
||||
// Persist the chosen lead-capture config onto the freshly-created/updated agent.
|
||||
// Non-fatal: the agent is already created and this is fully editable in its tab.
|
||||
const applyLeadCapture = async (agentId: string) => {
|
||||
// Skip entirely on non-Pro plans — the backend gate would 403.
|
||||
if (leadCaptureLocked.value) return
|
||||
try {
|
||||
const fields = LEAD_FIELDS
|
||||
.filter(f => leadFields.value[f.key])
|
||||
.map(f => ({ key: f.key, standard: true, enabled: true, required: f.key === 'email' }))
|
||||
await leadCaptureService.updateConfig(agentId, {
|
||||
enabled: leadEnabled.value,
|
||||
require_consent: true,
|
||||
guidance: null,
|
||||
fields,
|
||||
assignment_mode: 'none',
|
||||
assignment_target_user_id: null,
|
||||
crm_sync_target: 'none',
|
||||
slack_notify_enabled: false,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Failed to apply lead capture config:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// When returning to edit an existing agent, prefill from its saved config.
|
||||
onMounted(async () => {
|
||||
if (!props.existingAgent) return
|
||||
try {
|
||||
const cfg = await leadCaptureService.getConfig(props.existingAgent.id)
|
||||
leadEnabled.value = !!cfg.enabled
|
||||
if (cfg.fields && cfg.fields.length) {
|
||||
const on = new Set(cfg.fields.filter(f => f.enabled).map(f => f.key))
|
||||
leadFields.value = {
|
||||
name: on.has('name'), email: on.has('email'),
|
||||
company: on.has('company'), phone: on.has('phone'),
|
||||
}
|
||||
}
|
||||
} catch { /* keep defaults */ }
|
||||
})
|
||||
|
||||
// Avatar: a chosen preset URL, or a custom uploaded file.
|
||||
// For an existing agent, start with nothing selected so we keep its current
|
||||
// photo unless the user actually picks a new one.
|
||||
const selectedAvatar = ref<string | null>(props.existingAgent ? null : (presetAvatars[0] ?? null))
|
||||
const customAvatarFile = ref<File | null>(null)
|
||||
const customAvatarPreview = ref<string | null>(null)
|
||||
const avatarFileInput = ref<HTMLInputElement | null>(null)
|
||||
const avatarTouched = ref(false)
|
||||
|
||||
const selectPreset = (url: string) => {
|
||||
selectedAvatar.value = url
|
||||
avatarTouched.value = true
|
||||
customAvatarFile.value = null
|
||||
if (customAvatarPreview.value) {
|
||||
URL.revokeObjectURL(customAvatarPreview.value)
|
||||
customAvatarPreview.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const triggerAvatarUpload = () => avatarFileInput.value?.click()
|
||||
|
||||
const onAvatarFile = (e: Event) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0]
|
||||
if (!file) return
|
||||
customAvatarFile.value = file
|
||||
avatarTouched.value = true
|
||||
if (customAvatarPreview.value) URL.revokeObjectURL(customAvatarPreview.value)
|
||||
customAvatarPreview.value = URL.createObjectURL(file)
|
||||
selectedAvatar.value = null
|
||||
}
|
||||
|
||||
// Apply the chosen avatar to the freshly-created agent via the photo endpoint.
|
||||
// Presets are bundled assets, so fetch them into a File first. Non-fatal.
|
||||
const applyAvatar = async (agentId: string) => {
|
||||
try {
|
||||
let file: File | null = customAvatarFile.value
|
||||
if (!file && selectedAvatar.value) {
|
||||
const res = await fetch(selectedAvatar.value)
|
||||
const blob = await res.blob()
|
||||
file = new File([blob], 'avatar.png', { type: blob.type || 'image/png' })
|
||||
}
|
||||
if (file) return await agentService.uploadAgentPhoto(agentId, file)
|
||||
} catch (err) {
|
||||
console.error('Failed to set agent avatar:', err)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Switch type and refresh the prefilled instructions, but never clobber text
|
||||
// the user has actually written/edited.
|
||||
const selectType = (type: string) => {
|
||||
selectedType.value = type
|
||||
const current = instructions.value.trim()
|
||||
if (current === '' || DEFAULT_VALUES.includes(current)) {
|
||||
instructions.value = TYPE_DEFAULTS[type] ?? ''
|
||||
}
|
||||
}
|
||||
|
||||
const generateWithAI = async () => {
|
||||
if (isGenerating.value) return
|
||||
error.value = ''
|
||||
isGenerating.value = true
|
||||
try {
|
||||
const typeLabel = agentTypes.find(t => t.value === selectedType.value)?.label ?? 'support'
|
||||
const prompt = `A ${typeLabel} AI agent${agentName.value.trim() ? ` named "${agentName.value.trim()}"` : ''}. Write clear instructions describing how it should talk to customers and when to hand off to a human.`
|
||||
const generated = await agentService.generateInstructions(prompt, instructions.value.trim() ? [instructions.value.trim()] : undefined)
|
||||
if (generated.length) instructions.value = generated.join('\n')
|
||||
} catch (err: any) {
|
||||
error.value = err?.response?.data?.detail || '无法生成指令,请重试。'
|
||||
} finally {
|
||||
isGenerating.value = false
|
||||
}
|
||||
}
|
||||
// OSS-only: when ReplyPier AI isn't available we must collect a provider key first
|
||||
const showAiGate = ref(false)
|
||||
|
||||
const ensureAIConfig = async (): Promise<boolean> => {
|
||||
try {
|
||||
await aiService.getOrganizationConfig()
|
||||
return true
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError && err.response?.status === 404) {
|
||||
if (hasEnterpriseModule) {
|
||||
// Managed ReplyPier AI — zero setup, backend injects the key
|
||||
await aiService.setupAI({
|
||||
model_type: 'REPLYPIER',
|
||||
model_name: 'replypier',
|
||||
api_key: '',
|
||||
})
|
||||
return true
|
||||
}
|
||||
// OSS: route into the existing provider setup form as a gate
|
||||
showAiGate.value = true
|
||||
return false
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
const instructionList = () =>
|
||||
instructions.value.trim()
|
||||
? [instructions.value.trim()]
|
||||
: ['我是 AI 助手,随时为你解答问题。']
|
||||
|
||||
const createAgent = async () => {
|
||||
let agent: Agent
|
||||
if (props.existingAgent) {
|
||||
// Returning to this step — update the existing agent, don't create another
|
||||
agent = await agentService.updateAgent(props.existingAgent.id, {
|
||||
display_name: agentName.value.trim(),
|
||||
instructions: instructionList(),
|
||||
})
|
||||
if (avatarTouched.value) {
|
||||
const customization = await applyAvatar(agent.id)
|
||||
if (customization) agent.customization = customization
|
||||
} else if (props.existingAgent.customization) {
|
||||
agent.customization = props.existingAgent.customization
|
||||
}
|
||||
} else {
|
||||
agent = await agentService.createAgent({
|
||||
name: agentName.value.trim(),
|
||||
display_name: agentName.value.trim(),
|
||||
agent_type: selectedType.value,
|
||||
instructions: instructionList(),
|
||||
is_active: true,
|
||||
use_workflow: false,
|
||||
})
|
||||
const customization = await applyAvatar(agent.id)
|
||||
if (customization) agent.customization = customization
|
||||
}
|
||||
await applyLeadCapture(agent.id)
|
||||
emit('created', agent)
|
||||
}
|
||||
|
||||
const handleContinue = async () => {
|
||||
if (!agentName.value.trim()) {
|
||||
error.value = '请先为客服起个名字。'
|
||||
return
|
||||
}
|
||||
error.value = ''
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const ready = await ensureAIConfig()
|
||||
if (!ready) return // OSS gate is now showing; createAgent runs after it completes
|
||||
await createAgent()
|
||||
} catch (err: any) {
|
||||
error.value = err?.response?.data?.detail || '创建客服失败,请重试。'
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Fired after the OSS provider form saves a config
|
||||
const onAiConfigured = async () => {
|
||||
showAiGate.value = false
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
await createAgent()
|
||||
} catch (err: any) {
|
||||
error.value = err?.response?.data?.detail || '创建客服失败,请重试。'
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="step">
|
||||
<!-- OSS provider-key gate -->
|
||||
<div v-if="showAiGate" class="ai-gate">
|
||||
<p class="ai-gate-note">先接入 AI 服务商为客服提供能力,随后即可创建。</p>
|
||||
<AISetup @ai-setup-complete="onAiConfigured" />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<header class="step-head">
|
||||
<h2 class="step-title">创建你的客服</h2>
|
||||
<p class="step-sub">给它起个名字、设定人设,之后还能随时调整。</p>
|
||||
</header>
|
||||
|
||||
<div class="field">
|
||||
<label class="field-label" for="onb-agent-name">客服名称</label>
|
||||
<input
|
||||
id="onb-agent-name"
|
||||
v-model="agentName"
|
||||
class="text-input"
|
||||
type="text"
|
||||
placeholder="例如:客户支持客服"
|
||||
:disabled="isSubmitting"
|
||||
@keydown.enter.prevent="handleContinue"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field-label">类型</label>
|
||||
<div class="type-picker">
|
||||
<button
|
||||
v-for="type in agentTypes"
|
||||
:key="type.value"
|
||||
type="button"
|
||||
class="type-chip"
|
||||
:class="{ selected: selectedType === type.value }"
|
||||
:disabled="isSubmitting || !!existingAgent"
|
||||
@click="selectType(type.value)"
|
||||
>
|
||||
{{ type.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field-label">头像</label>
|
||||
<div class="avatar-grid">
|
||||
<button
|
||||
v-for="(url, i) in presetAvatars"
|
||||
:key="i"
|
||||
type="button"
|
||||
class="avatar-thumb"
|
||||
:class="{ selected: selectedAvatar === url }"
|
||||
:disabled="isSubmitting"
|
||||
@click="selectPreset(url)"
|
||||
:aria-label="`头像 ${i + 1}`"
|
||||
>
|
||||
<img :src="url" alt="" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="avatar-thumb upload"
|
||||
:class="{ selected: !!customAvatarPreview }"
|
||||
:disabled="isSubmitting"
|
||||
@click="triggerAvatarUpload"
|
||||
title="上传自定义头像"
|
||||
aria-label="上传自定义头像"
|
||||
>
|
||||
<img v-if="customAvatarPreview" :src="customAvatarPreview" alt="" />
|
||||
<span v-else class="upload-plus">+</span>
|
||||
</button>
|
||||
<input ref="avatarFileInput" type="file" accept="image/*" hidden @change="onAvatarFile" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div class="field-label-row">
|
||||
<label class="field-label" for="onb-instructions">人设与指令</label>
|
||||
<button
|
||||
type="button"
|
||||
class="ai-generate-btn"
|
||||
:disabled="isSubmitting || isGenerating"
|
||||
@click="generateWithAI"
|
||||
>
|
||||
<span class="ai-icon">✨</span>
|
||||
{{ isGenerating ? '生成中…' : 'AI 生成' }}
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
id="onb-instructions"
|
||||
v-model="instructions"
|
||||
class="text-input textarea"
|
||||
rows="3"
|
||||
placeholder="简洁友好,账单问题转交人工。"
|
||||
:disabled="isSubmitting"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="field lead-field">
|
||||
<div class="lead-enable-row">
|
||||
<div>
|
||||
<label class="field-label">线索收集 <span v-if="leadCaptureLocked" class="lead-pro">Pro</span></label>
|
||||
<p class="lead-sub">
|
||||
<template v-if="leadCaptureLocked">在对话中收集并筛选线索,升级到 Pro 即可启用。</template>
|
||||
<template v-else>客服会在对话中收集联系方式——先解决问题,再在合适的时机开口询问。</template>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="lead-switch"
|
||||
:class="{ on: leadEnabled }"
|
||||
:disabled="isSubmitting || leadCaptureLocked"
|
||||
:aria-pressed="leadEnabled"
|
||||
@click="leadEnabled = !leadEnabled"
|
||||
>
|
||||
<span class="lead-knob"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="leadEnabled && !leadCaptureLocked" class="lead-details">
|
||||
<div class="lead-details-label">收集字段</div>
|
||||
<div class="lead-chips">
|
||||
<button
|
||||
v-for="f in LEAD_FIELDS"
|
||||
:key="f.key"
|
||||
type="button"
|
||||
class="lead-chip"
|
||||
:class="{ on: leadFields[f.key] }"
|
||||
:disabled="isSubmitting"
|
||||
@click="leadFields[f.key] = !leadFields[f.key]"
|
||||
>
|
||||
<span class="lead-check">{{ leadFields[f.key] ? '✔' : '' }}</span>{{ f.label }}
|
||||
<span v-if="f.key === 'email'" class="lead-req">必填</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ai-note">
|
||||
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><circle cx="12" cy="12" r="8" /></svg>
|
||||
<span>
|
||||
由 ReplyPier AI 驱动,开箱即用。想用自己的模型?稍后可在
|
||||
<strong>AI 配置 → 高级</strong> 中设置。
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="step-error" role="alert">{{ error }}</p>
|
||||
|
||||
<div class="step-actions">
|
||||
<button type="button" class="btn-text" :disabled="isSubmitting" @click="emit('skip')">暂时跳过</button>
|
||||
<button type="button" class="btn-accent" :disabled="isSubmitting" @click="handleContinue">
|
||||
{{ isSubmitting ? '创建中…' : '继续' }}
|
||||
<span v-if="!isSubmitting" class="arrow">→</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.step-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.step-title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 22px;
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.step-sub {
|
||||
font-size: 14.5px;
|
||||
color: var(--muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-size: 13.5px;
|
||||
font-weight: 500;
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
.field-label-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.ai-generate-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: var(--accent-bg-08);
|
||||
border: 1px solid var(--accent-border);
|
||||
border-radius: var(--radius-pill);
|
||||
color: var(--accent-ink);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.ai-generate-btn:hover:not(:disabled) {
|
||||
background: var(--accent-bg-12);
|
||||
}
|
||||
|
||||
.ai-generate-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.ai-icon {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.text-input {
|
||||
width: 100%;
|
||||
padding: 14px 16px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: var(--radius-input);
|
||||
color: var(--text);
|
||||
font-size: 15px;
|
||||
font-family: var(--font-sans);
|
||||
outline: none;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.text-input:focus {
|
||||
border-color: var(--accent-ink);
|
||||
box-shadow: var(--ring-focus);
|
||||
}
|
||||
|
||||
.textarea {
|
||||
line-height: 1.5;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.type-picker {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.avatar-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.avatar-thumb {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
background: var(--o05);
|
||||
border: 2px solid var(--o12);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: var(--transition-fast);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.avatar-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.avatar-thumb:hover:not(:disabled) {
|
||||
border-color: var(--o25);
|
||||
}
|
||||
|
||||
.avatar-thumb.selected {
|
||||
border-color: var(--accent-ink);
|
||||
box-shadow: 0 0 0 3px var(--accent-bg-12);
|
||||
}
|
||||
|
||||
.avatar-thumb:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.avatar-thumb.upload {
|
||||
border-style: dashed;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.upload-plus {
|
||||
font-size: 22px;
|
||||
font-weight: 300;
|
||||
line-height: 1;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.type-chip {
|
||||
padding: 11px 18px;
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: var(--radius-pill);
|
||||
color: var(--text);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.type-chip.selected {
|
||||
background: var(--accent-bg-08);
|
||||
border-color: var(--accent-border);
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
.type-chip:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.ai-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 12px 14px;
|
||||
background: var(--purple-bg);
|
||||
border: 1px solid var(--purple-border);
|
||||
border-radius: 11px;
|
||||
font-size: 13px;
|
||||
color: var(--c-purple-fg, var(--text3));
|
||||
}
|
||||
|
||||
.ai-note svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ai-note strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.step-error {
|
||||
margin: 0;
|
||||
font-size: 13.5px;
|
||||
color: var(--error-color);
|
||||
}
|
||||
|
||||
.step-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--muted2);
|
||||
font-size: 14.5px;
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-text:hover:not(:disabled) {
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
.btn-accent {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 14px 26px;
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
border: none;
|
||||
border-radius: var(--radius-btn);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-accent:hover:not(:disabled) {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
|
||||
.btn-accent:disabled,
|
||||
.btn-text:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.ai-gate-note {
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
margin: 0 0 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Lead capture */
|
||||
.lead-reco {
|
||||
font-size: 12.5px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.lead-sub {
|
||||
font-size: 13px;
|
||||
color: var(--muted2);
|
||||
margin: -2px 0 4px;
|
||||
line-height: 1.5;
|
||||
max-width: 460px;
|
||||
}
|
||||
|
||||
.lead-enable-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.lead-switch {
|
||||
flex-shrink: 0;
|
||||
width: 46px;
|
||||
height: 26px;
|
||||
border-radius: 999px;
|
||||
border: none;
|
||||
background: var(--o12);
|
||||
padding: 3px;
|
||||
cursor: pointer;
|
||||
transition: background 0.16s ease;
|
||||
}
|
||||
|
||||
.lead-switch.on {
|
||||
background: var(--accent-solid);
|
||||
}
|
||||
|
||||
.lead-switch:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.lead-knob {
|
||||
display: block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
transition: transform 0.16s ease;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.lead-switch.on .lead-knob {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
.lead-details {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.lead-modes {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.lead-mode {
|
||||
text-align: left;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 14px;
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: var(--radius-lg, 14px);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.lead-mode.selected {
|
||||
border-color: var(--accent-border);
|
||||
background: var(--accent-bg-08);
|
||||
}
|
||||
|
||||
.lead-mode:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.lead-mode-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.lead-radio {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--o25);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.lead-radio.on {
|
||||
border-color: var(--accent-ink);
|
||||
background: var(--accent-ink);
|
||||
box-shadow: inset 0 0 0 3px var(--surface, var(--bg));
|
||||
}
|
||||
|
||||
.lead-badge {
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 3px 7px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--accent-bg-12);
|
||||
color: var(--accent-ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.lead-mode-title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 14.5px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.lead-mode-desc {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.lead-details {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.lead-details-label {
|
||||
font-size: 12.5px;
|
||||
font-weight: 500;
|
||||
color: var(--text3);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.lead-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.lead-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 9px 14px;
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: var(--radius-md, 10px);
|
||||
color: var(--text);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.lead-chip.on {
|
||||
background: var(--accent-bg-08);
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
|
||||
.lead-chip:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.lead-check {
|
||||
width: 12px;
|
||||
font-size: 11px;
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
.lead-req {
|
||||
font-size: 10.5px;
|
||||
color: var(--c-coral, #e0653f);
|
||||
margin-left: 2px;
|
||||
}
|
||||
.lead-pro {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: .03em;
|
||||
padding: 2px 7px;
|
||||
border-radius: 999px;
|
||||
margin-left: 6px;
|
||||
background: var(--purple-bg, rgba(124, 58, 237, .14));
|
||||
color: var(--c-purple, #7c3aed);
|
||||
vertical-align: middle;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,286 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { buildWidgetEmbed } from '@/utils/widgetEmbed'
|
||||
import { WIDGET_INSTALL_GUIDE_URL } from '@/config/docs'
|
||||
import { useEnterpriseFeatures } from '@/composables/useEnterpriseFeatures'
|
||||
|
||||
const props = defineProps<{
|
||||
widgetId: string | null
|
||||
agentType: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'finish'): void
|
||||
(e: 'back'): void
|
||||
}>()
|
||||
|
||||
const widgetSnippet = computed(() =>
|
||||
props.widgetId ? buildWidgetEmbed(props.widgetId, false) : '',
|
||||
)
|
||||
|
||||
// Where the widget is live, or blocked (enterprise module; nothing in open source)
|
||||
const { enterpriseComponent, moduleImports } = useEnterpriseFeatures()
|
||||
const WidgetInstallStatus = enterpriseComponent(moduleImports.widgetInstallStatus)
|
||||
|
||||
// Built in the browser and handed to the user's own mail app: nothing is sent
|
||||
// from our servers, so it can't be used to mail arbitrary addresses.
|
||||
const developerMailto = computed(() => {
|
||||
if (!widgetSnippet.value) return ''
|
||||
const body = [
|
||||
'你好,',
|
||||
'',
|
||||
'能否把我们的 ReplyPier 聊天组件加到网站上?请在每个页面的 </body> 前粘贴这段代码:',
|
||||
'',
|
||||
widgetSnippet.value,
|
||||
'',
|
||||
`指南:${WIDGET_INSTALL_GUIDE_URL}`,
|
||||
].join('\n')
|
||||
const params = new URLSearchParams({ subject: '把 ReplyPier 聊天组件加到网站', body })
|
||||
return `mailto:?${params.toString().replace(/\+/g, '%20')}`
|
||||
})
|
||||
|
||||
// The button itself says it worked, for a moment; a failure gets a toast.
|
||||
const COPIED_FOR_MS = 2000
|
||||
const copied = ref(false)
|
||||
let copiedTimer: ReturnType<typeof setTimeout> | undefined
|
||||
onBeforeUnmount(() => clearTimeout(copiedTimer))
|
||||
|
||||
const copySnippet = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(widgetSnippet.value)
|
||||
copied.value = true
|
||||
clearTimeout(copiedTimer)
|
||||
copiedTimer = setTimeout(() => (copied.value = false), COPIED_FOR_MS)
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err)
|
||||
toast.error('复制到剪贴板失败')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="step">
|
||||
<header class="step-head">
|
||||
<h2 class="step-title">正式上线</h2>
|
||||
<p class="step-sub">把这段代码嵌入网站,即可开始与访客聊天。</p>
|
||||
</header>
|
||||
|
||||
<div class="snippet">
|
||||
<pre class="code-block">{{ widgetSnippet }}</pre>
|
||||
<button type="button" class="copy-btn" :disabled="!widgetSnippet" @click="copySnippet">
|
||||
<font-awesome-icon :icon="copied ? 'fa-solid fa-check' : 'fa-regular fa-copy'" aria-hidden="true" />
|
||||
<span aria-live="polite">{{ copied ? '已复制' : '复制' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="snippet-actions">
|
||||
<a v-if="developerMailto" class="action-btn" :href="developerMailto">
|
||||
<font-awesome-icon icon="fa-regular fa-envelope" aria-hidden="true" />
|
||||
邮件发给开发
|
||||
</a>
|
||||
<a class="action-btn" :href="WIDGET_INSTALL_GUIDE_URL" target="_blank" rel="noopener">
|
||||
<font-awesome-icon icon="fa-solid fa-book-open" aria-hidden="true" />
|
||||
安装指南
|
||||
<font-awesome-icon icon="fa-solid fa-arrow-up-right-from-square" class="external" aria-hidden="true" />
|
||||
<span class="sr-only">(在新标签页打开)</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<WidgetInstallStatus :widget-id="widgetId" />
|
||||
|
||||
<div class="step-actions">
|
||||
<button type="button" class="btn-ghost" @click="emit('back')">返回</button>
|
||||
<button type="button" class="btn-accent" @click="emit('finish')">完成并进入后台 ✓</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.step-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.step-title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 22px;
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.step-sub {
|
||||
font-size: 14.5px;
|
||||
color: var(--muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.snippet {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--o10);
|
||||
border-radius: var(--radius-input);
|
||||
/* Room on the right for the Copy button over the first line */
|
||||
padding: 18px 96px 18px 18px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--accent-ink);
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 12px;
|
||||
background: var(--o06);
|
||||
border: 1px solid var(--o14);
|
||||
border-radius: 8px;
|
||||
color: var(--text);
|
||||
font-size: 12.5px;
|
||||
font-weight: 500;
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
/* On a phone the code needs every pixel of width: Copy goes above it instead */
|
||||
@media (max-width: 480px) {
|
||||
.code-block {
|
||||
padding: 52px 14px 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.copy-btn:hover:not(:disabled) {
|
||||
background: var(--o10);
|
||||
}
|
||||
|
||||
.copy-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.snippet-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 11px 16px;
|
||||
background: var(--o06);
|
||||
border: 1px solid var(--o14);
|
||||
border-radius: var(--radius-btn);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
font-family: var(--font-sans);
|
||||
text-decoration: none;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: var(--o10);
|
||||
}
|
||||
|
||||
.copy-btn:focus-visible,
|
||||
.action-btn:focus-visible {
|
||||
outline: 2px solid var(--accent-ink);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.action-btn .external {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.step-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
padding: 14px 22px;
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o14);
|
||||
border-radius: var(--radius-btn);
|
||||
color: var(--text);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
background: var(--o10);
|
||||
}
|
||||
|
||||
.btn-accent {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 14px 28px;
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
border: none;
|
||||
border-radius: var(--radius-btn);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-accent:hover {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,564 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useKnowledgeManagement } from '@/composables/useKnowledgeManagement'
|
||||
import { useStagedSourceCap } from '@/composables/useStagedSourceCap'
|
||||
import { useCrawlCheck } from '@/composables/useCrawlCheck'
|
||||
import TeachTextFallback from './TeachTextFallback.vue'
|
||||
import type { QueueItem } from '@/types/knowledge'
|
||||
import { organizationService } from '@/services/organization'
|
||||
|
||||
const props = defineProps<{
|
||||
agentId: string
|
||||
organizationId: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'next'): void
|
||||
(e: 'back'): void
|
||||
}>()
|
||||
|
||||
const {
|
||||
newUrl,
|
||||
urls,
|
||||
files,
|
||||
fileInput,
|
||||
isUploading,
|
||||
uploadProgress,
|
||||
uploadError,
|
||||
urlFormError,
|
||||
knowledgeItems,
|
||||
orgTotalCount,
|
||||
queueItems,
|
||||
fetchQueueItems,
|
||||
handleUrlAdd,
|
||||
removeUrl,
|
||||
triggerFileInput,
|
||||
handleUrlUpload,
|
||||
handleFileUpload,
|
||||
fetchKnowledge,
|
||||
withScheme,
|
||||
} = useKnowledgeManagement(props.agentId, props.organizationId)
|
||||
|
||||
const urlInput = ref<HTMLInputElement | null>(null)
|
||||
const pasteButton = ref<HTMLButtonElement | null>(null)
|
||||
|
||||
const advancing = ref(false)
|
||||
// Sending a batch with a website in it: the server checks it can be reached.
|
||||
const reading = ref(false)
|
||||
const textSaving = ref(false)
|
||||
// Sending the sources, or waiting to hear whether they could be read: the
|
||||
// batch is in flight, so nothing on the step may change it.
|
||||
const busy = computed(() => advancing.value || isUploading.value || textSaving.value)
|
||||
|
||||
// A website from the last attempt that failed fast (e.g. a domain that doesn't
|
||||
// resolve), shown with a way round it instead of moving on with nothing learned
|
||||
const crawlFailure = ref<QueueItem | null>(null)
|
||||
const showTextFallback = ref(false)
|
||||
const { firstFailure, cancel: stopCrawlCheck } = useCrawlCheck(fetchQueueItems, queueItems)
|
||||
onBeforeUnmount(stopCrawlCheck)
|
||||
|
||||
const setFailure = (failure: QueueItem | null) => {
|
||||
crawlFailure.value = failure
|
||||
showTextFallback.value = false
|
||||
}
|
||||
|
||||
// Nothing new to send: Continue would move on without the site that failed.
|
||||
const nothingStaged = computed(() => !urls.value.length && !files.value.length && !newUrl.value.trim())
|
||||
const continueLabel = computed(() => {
|
||||
if (reading.value) return '正在读取网站…'
|
||||
if (advancing.value) return '添加中…'
|
||||
return crawlFailure.value && nothingStaged.value ? '跳过并继续' : '继续'
|
||||
})
|
||||
|
||||
// Crawls still running count against the cap too (the server counts them).
|
||||
const crawling = computed(() =>
|
||||
queueItems.value.filter((q) => q.status === 'pending' || q.status === 'processing'),
|
||||
)
|
||||
const inUseCount = computed(() => orgTotalCount.value + crawling.value.length)
|
||||
const { fitToCap } = useStagedSourceCap(inUseCount, urls, files)
|
||||
|
||||
// A newly staged source makes the last failure history: Continue sends it and
|
||||
// checks again. `capError` is set when the plan had no room and nothing stayed.
|
||||
const afterStaging = (capError: string | null) => {
|
||||
urlFormError.value = capError
|
||||
if (capError) return
|
||||
uploadError.value = null
|
||||
setFailure(null)
|
||||
}
|
||||
|
||||
// Stage the typed URL, then keep the batch within the plan's source cap —
|
||||
// over the cap the server rejects the whole batch, signup domain included.
|
||||
const stageUrl = () => {
|
||||
const before = urls.value.length
|
||||
handleUrlAdd()
|
||||
if (urls.value.length > before) afterStaging(fitToCap('urls'))
|
||||
}
|
||||
|
||||
// Picked PDFs join the list like URLs do, rather than replacing the last pick.
|
||||
const stageFiles = (event: Event) => {
|
||||
const input = event.target as HTMLInputElement
|
||||
const picked = Array.from(input.files ?? [])
|
||||
// Cleared so picking the same file again (after removing it) still registers.
|
||||
input.value = ''
|
||||
if (!picked.length) return
|
||||
const fresh = picked.filter((f) => !files.value.some((s) => s.name === f.name && s.size === f.size))
|
||||
if (!fresh.length) {
|
||||
urlFormError.value = '该 PDF 已在列表中。'
|
||||
return
|
||||
}
|
||||
files.value = [...files.value, ...fresh]
|
||||
afterStaging(fitToCap('files', fresh.length))
|
||||
}
|
||||
|
||||
// Errors about the batch no longer describe it once a source is taken out. The
|
||||
// × that had focus is gone with its row, so focus goes back to the address box.
|
||||
const afterUnstaging = () => {
|
||||
urlFormError.value = null
|
||||
uploadError.value = null
|
||||
urlInput.value?.focus()
|
||||
}
|
||||
|
||||
const unstageUrl = (index: number) => {
|
||||
removeUrl(index)
|
||||
afterUnstaging()
|
||||
}
|
||||
|
||||
const unstageFile = (index: number) => {
|
||||
files.value.splice(index, 1)
|
||||
afterUnstaging()
|
||||
}
|
||||
|
||||
// The site they gave us at signup is almost always what they want indexed
|
||||
// first. Stage it as a source rather than dropping it in the input: sitting in
|
||||
// the box it looks like a placeholder, and nothing says you must press
|
||||
// "Add" for it to count. In the list it reads as added, with an × to
|
||||
// drop it.
|
||||
const prefillFromOrgDomain = async () => {
|
||||
try {
|
||||
const org = await organizationService.getOrganization(props.organizationId)
|
||||
if (!org?.domain || newUrl.value || urls.value.length) return
|
||||
const domainUrl = withScheme(org.domain)
|
||||
// Back from a later step, the domain may still be crawling — not indexed
|
||||
// yet, so the dedupe below can't see it. Staging it again would re-send it.
|
||||
// If it already failed, suggesting it again would only fail again. The
|
||||
// queue lists newest first, so this is its latest attempt.
|
||||
const last = queueItems.value.find((q) => q.source === domainUrl)
|
||||
if (last && last.status !== 'completed') return
|
||||
// The server only accepts websites with a full domain (acme.com, not
|
||||
// localhost): don't suggest one it will refuse.
|
||||
if (!new URL(domainUrl).hostname.includes('.')) return
|
||||
|
||||
newUrl.value = domainUrl
|
||||
stageUrl()
|
||||
// stageUrl flags an already-indexed URL, or a plan with no room left. That
|
||||
// is worth saying when a person typed it, but not for a suggestion they
|
||||
// never asked for — it just ends up not staged.
|
||||
urlFormError.value = null
|
||||
} catch {
|
||||
// A missing prefill is not worth blocking or alarming the user over.
|
||||
}
|
||||
}
|
||||
|
||||
// Back on this step with nothing learned yet and the last site unreadable
|
||||
// (e.g. via "Add your website or some text" on the Test step): pick up where
|
||||
// they left off, ways round it included.
|
||||
const restoreLastFailure = () => {
|
||||
if (knowledgeItems.value.length || crawling.value.length || urls.value.length) return
|
||||
const [newest] = queueItems.value
|
||||
if (newest?.status === 'failed' && newest.source_type === 'website') setFailure(newest)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// Prefill after the fetch so the dedupe check can see what is already indexed.
|
||||
await fetchKnowledge()
|
||||
await prefillFromOrgDomain()
|
||||
restoreLastFailure()
|
||||
})
|
||||
|
||||
// Flush any staged URLs/files, then advance — but only when the server took
|
||||
// them. Ingestion itself runs async in the background; we don't wait on it.
|
||||
const handleContinue = async () => {
|
||||
// A URL sitting in the box (the prefill, or one they typed without pressing
|
||||
// "Add") reads as accepted. Stage it rather than silently dropping it.
|
||||
if (newUrl.value.trim()) {
|
||||
stageUrl()
|
||||
if (urlFormError.value) return
|
||||
}
|
||||
|
||||
advancing.value = true
|
||||
setFailure(null)
|
||||
let failed: QueueItem | null = null
|
||||
let filesAccepted = true
|
||||
try {
|
||||
const sentUrls = [...urls.value]
|
||||
reading.value = sentUrls.length > 0
|
||||
// On failure the error is on screen and the batch stays staged to fix.
|
||||
if (!(await handleUrlUpload())) return
|
||||
filesAccepted = await handleFileUpload()
|
||||
// The websites went in even if a PDF didn't: say now if one already failed.
|
||||
if (sentUrls.length) failed = await firstFailure(sentUrls)
|
||||
} finally {
|
||||
advancing.value = false
|
||||
reading.value = false
|
||||
}
|
||||
|
||||
if (!failed) {
|
||||
if (filesAccepted) emit('next') // else the PDF error is on screen
|
||||
return
|
||||
}
|
||||
setFailure(failed)
|
||||
// Focus was on the button, disabled while it read the site. Trying another
|
||||
// address is the likeliest next move; the ways round it follow in tab order.
|
||||
await nextTick()
|
||||
urlInput.value?.focus()
|
||||
}
|
||||
|
||||
const openTextFallback = () => {
|
||||
showTextFallback.value = true
|
||||
}
|
||||
|
||||
const closeTextFallback = async () => {
|
||||
showTextFallback.value = false
|
||||
await nextTick()
|
||||
pasteButton.value?.focus()
|
||||
}
|
||||
|
||||
const handleTextAdded = () => {
|
||||
setFailure(null)
|
||||
emit('next')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="step">
|
||||
<header class="step-head">
|
||||
<h2 class="step-title">让它了解你的业务</h2>
|
||||
<p class="step-sub">放一个网址或 PDF——ReplyPier 会自动读取并建立索引。</p>
|
||||
</header>
|
||||
|
||||
<div class="source-input">
|
||||
<input
|
||||
ref="urlInput"
|
||||
v-model="newUrl"
|
||||
class="text-input mono"
|
||||
type="text"
|
||||
aria-label="网站地址"
|
||||
placeholder="https://docs.yourcompany.com"
|
||||
:disabled="busy"
|
||||
@keydown.enter.prevent="stageUrl"
|
||||
/>
|
||||
<!-- "Add" acts on the URL beside it, so the object is implicit. The PDF
|
||||
button opens a file picker, a different action — it keeps its own
|
||||
label rather than reading as a second way to add the same thing. -->
|
||||
<button type="button" class="btn-soft" :disabled="busy" @click="stageUrl">添加</button>
|
||||
<button type="button" class="btn-soft" :disabled="busy" @click="triggerFileInput">上传 PDF</button>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept="application/pdf"
|
||||
multiple
|
||||
hidden
|
||||
@change="stageFiles"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p v-if="urlFormError" class="step-error" role="alert">{{ urlFormError }}</p>
|
||||
|
||||
<!-- Staged sources (this batch) -->
|
||||
<ul v-if="urls.length || files.length" class="source-list" aria-label="待添加">
|
||||
<li v-for="(url, i) in urls" :key="`u-${url}`" class="source-row">
|
||||
<font-awesome-icon icon="fa-solid fa-globe" class="source-icon" aria-hidden="true" />
|
||||
<span class="source-name">{{ url }}</span>
|
||||
<button type="button" class="source-remove" :disabled="busy" :aria-label="`移除 ${url}`" @click="unstageUrl(i)">
|
||||
<font-awesome-icon icon="fa-solid fa-xmark" aria-hidden="true" />
|
||||
</button>
|
||||
</li>
|
||||
<li v-for="(file, i) in files" :key="`f-${file.name}-${file.size}`" class="source-row">
|
||||
<font-awesome-icon icon="fa-solid fa-file-pdf" class="source-icon" aria-hidden="true" />
|
||||
<span class="source-name">{{ file.name }}</span>
|
||||
<button type="button" class="source-remove" :disabled="busy" :aria-label="`移除 ${file.name}`" @click="unstageFile(i)">
|
||||
<font-awesome-icon icon="fa-solid fa-xmark" aria-hidden="true" />
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Already indexed for this agent -->
|
||||
<ul v-if="knowledgeItems.length" class="source-list indexed" aria-label="已添加">
|
||||
<li v-for="item in knowledgeItems" :key="item.id" class="source-row">
|
||||
<font-awesome-icon icon="fa-solid fa-check" class="source-icon done" aria-hidden="true" />
|
||||
<span class="source-name">{{ item.name }}</span>
|
||||
<span class="source-tag">已索引</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div
|
||||
v-if="isUploading"
|
||||
class="progress"
|
||||
role="progressbar"
|
||||
aria-label="上传进度"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
:aria-valuenow="uploadProgress"
|
||||
>
|
||||
<div class="progress-bar" :style="{ width: `${uploadProgress}%` }"></div>
|
||||
</div>
|
||||
|
||||
<p v-if="uploadError" class="step-error" role="alert">{{ uploadError }}</p>
|
||||
|
||||
<div v-if="crawlFailure" class="crawl-failure">
|
||||
<p class="step-error" role="alert">无法读取 {{ crawlFailure.source }}。{{ crawlFailure.reason }}</p>
|
||||
<TeachTextFallback
|
||||
v-if="showTextFallback"
|
||||
:agent-id="agentId"
|
||||
:organization-id="organizationId"
|
||||
@added="handleTextAdded"
|
||||
@cancel="closeTextFallback"
|
||||
@busy="textSaving = $event"
|
||||
/>
|
||||
<div v-else class="failure-actions">
|
||||
<button ref="pasteButton" type="button" class="btn-soft" :disabled="busy" @click="openTextFallback">
|
||||
改为粘贴文本
|
||||
</button>
|
||||
<button type="button" class="btn-soft" :disabled="busy" @click="triggerFileInput">上传 PDF</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step-actions">
|
||||
<button type="button" class="btn-ghost" :disabled="busy" @click="emit('back')">返回</button>
|
||||
<button type="button" class="btn-accent" :disabled="busy" @click="handleContinue">
|
||||
{{ continueLabel }}
|
||||
<span v-if="!advancing" class="arrow" aria-hidden="true">→</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped src="./step-controls.css"></style>
|
||||
<style scoped>
|
||||
.step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.step-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.step-title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 22px;
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.step-sub {
|
||||
font-size: 14.5px;
|
||||
color: var(--muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.source-input {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* On a phone the address gets its own row rather than a sliver beside the buttons */
|
||||
@media (max-width: 520px) {
|
||||
.source-input {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.source-input .text-input {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.text-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 14px 16px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: var(--radius-input);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
font-family: var(--font-sans);
|
||||
outline: none;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.text-input.mono {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.text-input:focus {
|
||||
border-color: var(--accent-ink);
|
||||
box-shadow: var(--ring-focus);
|
||||
}
|
||||
|
||||
.source-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.source-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 11px 14px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o08);
|
||||
border-radius: var(--radius-input);
|
||||
}
|
||||
|
||||
.source-icon {
|
||||
flex-shrink: 0;
|
||||
width: 14px;
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.source-icon.done {
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
.source-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 13.5px;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.source-tag {
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
.source-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--muted2);
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
.source-remove:hover:not(:disabled) {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.source-remove:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.progress {
|
||||
height: 6px;
|
||||
background: var(--o08);
|
||||
border-radius: var(--radius-pill);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
background: var(--accent-solid);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.crawl-failure {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: var(--radius-input);
|
||||
}
|
||||
|
||||
.failure-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.step-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
padding: 14px 22px;
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o14);
|
||||
border-radius: var(--radius-btn);
|
||||
color: var(--text);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-ghost:hover:not(:disabled) {
|
||||
background: var(--o10);
|
||||
}
|
||||
|
||||
.btn-accent {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 14px 26px;
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
border: none;
|
||||
border-radius: var(--radius-btn);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-accent:hover:not(:disabled) {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
|
||||
.btn-accent:disabled,
|
||||
.btn-ghost:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
font-size: 17px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,482 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { marked } from 'marked'
|
||||
import { sanitizeHtml } from '@/utils/sanitize'
|
||||
import { widgetService } from '@/services/widget'
|
||||
import { knowledgeService } from '@/services/knowledge'
|
||||
import { useOnboardingTestChat } from '@/composables/useOnboardingTestChat'
|
||||
import { newestPerSource } from '@/composables/useCrawlCheck'
|
||||
import type { QueueItem } from '@/types/knowledge'
|
||||
|
||||
const props = defineProps<{
|
||||
agentId: string
|
||||
agentName: string
|
||||
widgetId: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'next'): void
|
||||
(e: 'back'): void
|
||||
(e: 'widget-created', widgetId: string): void
|
||||
}>()
|
||||
|
||||
const {
|
||||
messages,
|
||||
loading,
|
||||
connecting,
|
||||
connectionError,
|
||||
start,
|
||||
send,
|
||||
cleanup,
|
||||
} = useOnboardingTestChat()
|
||||
|
||||
const input = ref('')
|
||||
const setupError = ref('')
|
||||
const scrollEl = ref<HTMLElement | null>(null)
|
||||
|
||||
// Knowledge ingestion runs in the background. While it's queued/processing the
|
||||
// agent can still answer general questions; we poll the queue and let the user
|
||||
// know it'll get smarter once indexing finishes.
|
||||
const knowledgeProcessing = ref(false)
|
||||
const knowledgeJustFinished = ref(false)
|
||||
// Nothing indexed and nothing indexing: answers can only be generic
|
||||
const noKnowledge = ref(false)
|
||||
// The newest source that failed, to say why when nothing was learned. A crawl
|
||||
// often fails long after Teach moved on: this is where the user finds out.
|
||||
const failure = ref<QueueItem | null>(null)
|
||||
let knowledgePoll: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const checkKnowledgeStatus = async () => {
|
||||
try {
|
||||
const data = await knowledgeService.getAgentQueueItems(props.agentId)
|
||||
const items = (data?.queue_items ?? []) as QueueItem[]
|
||||
const pending = items.filter(i => i.status === 'pending' || i.status === 'processing').length
|
||||
failure.value = newestPerSource(items).find(i => i.status === 'failed') ?? null
|
||||
const wasProcessing = knowledgeProcessing.value
|
||||
knowledgeProcessing.value = pending > 0
|
||||
if (wasProcessing && pending === 0) {
|
||||
stopKnowledgePoll()
|
||||
// Finished is not the same as learned: every source may have failed
|
||||
await checkHasKnowledge()
|
||||
knowledgeJustFinished.value = !noKnowledge.value
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to check knowledge status:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const checkHasKnowledge = async () => {
|
||||
if (knowledgeProcessing.value) return
|
||||
try {
|
||||
const page = await knowledgeService.getKnowledgeByAgent(props.agentId, 1, 1)
|
||||
noKnowledge.value = !(page?.pagination?.total_count || page?.knowledge?.length)
|
||||
} catch (err) {
|
||||
console.error('Failed to check agent knowledge:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const stopKnowledgePoll = () => {
|
||||
if (knowledgePoll) {
|
||||
clearInterval(knowledgePoll)
|
||||
knowledgePoll = null
|
||||
}
|
||||
}
|
||||
|
||||
const ensureWidget = async (): Promise<string | null> => {
|
||||
if (props.widgetId) return props.widgetId
|
||||
try {
|
||||
const widget = await widgetService.createAgentWidget(props.agentId, props.agentName)
|
||||
emit('widget-created', widget.id)
|
||||
return widget.id
|
||||
} catch (err: any) {
|
||||
setupError.value = err?.response?.data?.detail || '测试会话准备失败。'
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const widgetId = await ensureWidget()
|
||||
if (widgetId) await start(widgetId)
|
||||
// Watch the knowledge queue and poll while anything is still indexing
|
||||
await checkKnowledgeStatus()
|
||||
await checkHasKnowledge()
|
||||
if (knowledgeProcessing.value) {
|
||||
knowledgePoll = setInterval(checkKnowledgeStatus, 5000)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cleanup()
|
||||
stopKnowledgePoll()
|
||||
})
|
||||
|
||||
const scrollToBottom = () => {
|
||||
nextTick(() => {
|
||||
if (scrollEl.value) scrollEl.value.scrollTop = scrollEl.value.scrollHeight
|
||||
})
|
||||
}
|
||||
|
||||
watch(() => messages.value.length, scrollToBottom)
|
||||
watch(loading, scrollToBottom)
|
||||
|
||||
const handleSend = () => {
|
||||
const text = input.value.trim()
|
||||
if (!text || loading.value) return
|
||||
send(text)
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
const formatMessage = (content: string) => sanitizeHtml(marked(content) as string)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="step">
|
||||
<header class="step-head">
|
||||
<h2 class="step-title">试一试</h2>
|
||||
<p class="step-sub">像客户一样提问,它的答复就是正式效果。</p>
|
||||
</header>
|
||||
|
||||
<!-- Knowledge indexing status -->
|
||||
<div v-if="knowledgeProcessing" class="kb-note processing">
|
||||
<span class="kb-spinner" aria-hidden="true"></span>
|
||||
<span>
|
||||
知识库仍在后台建立索引。客服现在可以先回答
|
||||
<strong>通用问题</strong>——索引完成后即可基于知识库作答,
|
||||
届时我们会通知你。
|
||||
</span>
|
||||
</div>
|
||||
<div v-else-if="knowledgeJustFinished" class="kb-note done">
|
||||
<span class="kb-check" aria-hidden="true">✓</span>
|
||||
<span>知识库已索引——客服现在可以基于它作答了。</span>
|
||||
</div>
|
||||
<div v-else-if="noKnowledge" class="kb-note empty" role="status">
|
||||
<span>
|
||||
<template v-if="failure">无法读取 {{ failure.source }}。{{ failure.reason }}</template>
|
||||
它还完全不了解你的业务,回答会比较通用。
|
||||
<button type="button" class="kb-link" @click="emit('back')">添加网站或文本</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="test-chat">
|
||||
<div v-if="connecting" class="chat-state">正在连接客服…</div>
|
||||
<div v-else-if="setupError || connectionError" class="chat-state error">
|
||||
{{ setupError || connectionError }}
|
||||
</div>
|
||||
|
||||
<div ref="scrollEl" class="chat-scroll">
|
||||
<div
|
||||
v-for="(msg, i) in messages"
|
||||
:key="i"
|
||||
class="bubble-row"
|
||||
:class="msg.message_type === 'user' ? 'from-user' : 'from-bot'"
|
||||
>
|
||||
<div class="bubble" v-html="formatMessage(msg.message || '')"></div>
|
||||
</div>
|
||||
<div v-if="loading" class="bubble-row from-bot">
|
||||
<div class="bubble typing">
|
||||
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chat-compose">
|
||||
<input
|
||||
v-model="input"
|
||||
class="text-input"
|
||||
type="text"
|
||||
placeholder="向客服提问…"
|
||||
:disabled="connecting || !!setupError"
|
||||
@keydown.enter.prevent="handleSend"
|
||||
/>
|
||||
<button type="button" class="send-btn" :disabled="connecting || loading || !!setupError" @click="handleSend" aria-label="发送">↑</button>
|
||||
</div>
|
||||
|
||||
<div class="step-actions">
|
||||
<button type="button" class="btn-ghost" @click="emit('back')">返回</button>
|
||||
<button type="button" class="btn-accent" @click="emit('next')">
|
||||
看起来不错 <span class="arrow">→</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.step-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.step-title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 22px;
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.step-sub {
|
||||
font-size: 14.5px;
|
||||
color: var(--muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.kb-note {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 11px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.kb-note.processing {
|
||||
background: var(--purple-bg);
|
||||
border: 1px solid var(--purple-border);
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
.kb-note.empty {
|
||||
border-color: var(--c-warn);
|
||||
}
|
||||
|
||||
.kb-link {
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
color: var(--accent-ink);
|
||||
font: inherit;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.kb-note.done {
|
||||
background: var(--accent-bg-08);
|
||||
border: 1px solid var(--accent-border);
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
.kb-note strong {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.kb-spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin-top: 2px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--o20);
|
||||
border-top-color: var(--c-purple);
|
||||
animation: kb-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.kb-check {
|
||||
margin-top: 1px;
|
||||
flex-shrink: 0;
|
||||
color: var(--accent-ink);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@keyframes kb-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.test-chat {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--o10);
|
||||
border-radius: var(--radius-input);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-state {
|
||||
padding: 12px 16px;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
border-bottom: 1px solid var(--o08);
|
||||
}
|
||||
|
||||
.chat-state.error {
|
||||
color: var(--error-color);
|
||||
}
|
||||
|
||||
.chat-scroll {
|
||||
height: 300px;
|
||||
overflow-y: auto;
|
||||
padding: 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.bubble-row {
|
||||
display: flex;
|
||||
max-width: 85%;
|
||||
}
|
||||
|
||||
.from-user {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.from-bot {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
padding: 10px 14px;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.5;
|
||||
border-radius: 14px 14px 14px 4px;
|
||||
}
|
||||
|
||||
.from-bot .bubble {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o08);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.from-user .bubble {
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
border-radius: 14px 14px 4px 14px;
|
||||
}
|
||||
|
||||
.typing {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--muted2);
|
||||
animation: bounce 1.4s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.dot:nth-child(1) { animation-delay: -0.32s; }
|
||||
.dot:nth-child(2) { animation-delay: -0.16s; }
|
||||
|
||||
@keyframes bounce {
|
||||
0%, 80%, 100% { transform: scale(0); }
|
||||
40% { transform: scale(1); }
|
||||
}
|
||||
|
||||
.chat-compose {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.text-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 14px 16px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: var(--radius-input);
|
||||
color: var(--text);
|
||||
font-size: 15px;
|
||||
font-family: var(--font-sans);
|
||||
outline: none;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.text-input:focus {
|
||||
border-color: var(--accent-ink);
|
||||
box-shadow: var(--ring-focus);
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
flex-shrink: 0;
|
||||
width: 50px;
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
border: none;
|
||||
border-radius: var(--radius-input);
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.send-btn:hover:not(:disabled) {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
|
||||
.step-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
padding: 14px 22px;
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o14);
|
||||
border-radius: var(--radius-btn);
|
||||
color: var(--text);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
background: var(--o10);
|
||||
}
|
||||
|
||||
.btn-accent {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 14px 26px;
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
border: none;
|
||||
border-radius: var(--radius-btn);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-accent:hover {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
|
||||
.send-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
font-size: 17px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,133 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { knowledgeService } from '@/services/knowledge'
|
||||
|
||||
const props = defineProps<{
|
||||
agentId: string
|
||||
organizationId: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'added'): void
|
||||
(e: 'cancel'): void
|
||||
// While the text is being saved the step around it must hold still.
|
||||
(e: 'busy', saving: boolean): void
|
||||
}>()
|
||||
|
||||
// Titles must be unique per org, and someone may paste more than once (or
|
||||
// already have an "About us"): stamp it so a second paste isn't refused.
|
||||
const titleNow = () =>
|
||||
`关于我们(${new Date().toLocaleString(undefined, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })})`
|
||||
const MIN_LENGTH = 40 // enough to answer something from; less is a typo
|
||||
|
||||
const textArea = ref<HTMLTextAreaElement | null>(null)
|
||||
const content = ref('')
|
||||
const saving = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const hasText = computed(() => content.value.trim().length > 0)
|
||||
|
||||
watch(saving, (value) => emit('busy', value))
|
||||
// An error is about what was there when they pressed the button.
|
||||
watch(content, () => {
|
||||
error.value = null
|
||||
})
|
||||
onMounted(() => textArea.value?.focus())
|
||||
|
||||
const add = async () => {
|
||||
if (saving.value || !hasText.value) return
|
||||
const text = content.value.trim()
|
||||
// Say why rather than leave the button dead with no reason given.
|
||||
if (text.length < MIN_LENGTH) {
|
||||
error.value = '请多写几句话,让它有内容可答。'
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
await knowledgeService.addText(props.organizationId, titleNow(), text, props.agentId)
|
||||
content.value = ''
|
||||
emit('added')
|
||||
} catch (err: any) {
|
||||
error.value = err?.message || '无法添加该文本'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const cancel = () => {
|
||||
if (!saving.value) emit('cancel')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Esc anywhere in the box (textarea or its buttons) backs out of it -->
|
||||
<div class="text-fallback" @keydown.esc.prevent="cancel">
|
||||
<textarea
|
||||
ref="textArea"
|
||||
v-model="content"
|
||||
class="text-area"
|
||||
rows="6"
|
||||
aria-label="关于你业务的内容"
|
||||
placeholder="粘贴客户常问的内容:卖什么、价格、配送、营业时间……"
|
||||
:disabled="saving"
|
||||
></textarea>
|
||||
<p v-if="error" class="step-error" role="alert">{{ error }}</p>
|
||||
<div class="fallback-actions">
|
||||
<button type="button" class="btn-soft" :disabled="!hasText || saving" @click="add">
|
||||
{{ saving ? '添加中…' : '添加文本' }}
|
||||
</button>
|
||||
<button type="button" class="btn-text" :disabled="saving" @click="cancel">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped src="./step-controls.css"></style>
|
||||
<style scoped>
|
||||
.text-fallback {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.text-area {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 14px 16px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: var(--radius-input);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
font-family: var(--font-sans);
|
||||
line-height: 1.5;
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.text-area:focus {
|
||||
border-color: var(--accent-ink);
|
||||
box-shadow: var(--ring-focus);
|
||||
}
|
||||
|
||||
.fallback-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
/* Controls shared by the Teach step and the pieces it is built from. Pulled in
|
||||
with <style scoped src> so each component keeps its own scope. */
|
||||
|
||||
.btn-soft {
|
||||
flex-shrink: 0;
|
||||
padding: 14px 18px;
|
||||
background: var(--o06);
|
||||
border: 1px solid var(--o14);
|
||||
border-radius: var(--radius-input);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-soft:hover:not(:disabled) {
|
||||
background: var(--o10);
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
padding: 14px 8px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--muted2);
|
||||
font-size: 14px;
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-text:hover:not(:disabled) {
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
.btn-soft:disabled,
|
||||
.btn-text:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.step-error {
|
||||
margin: 0;
|
||||
font-size: 13.5px;
|
||||
color: var(--error-color);
|
||||
}
|
||||
Reference in new issue
Block a user