3234 lines
97 KiB
Vue
3234 lines
97 KiB
Vue
<!--
|
||||
|
|
Copyright 2024-2026 ChatterMate
|
|||
|
|
|
|||
|
|
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 { useRouter } from 'vue-router'
|
|||
|
|
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
|||
|
|
import type { AgentWithCustomization, AgentCustomization } from '@/types/agent'
|
|||
|
|
import { getApiUrl, getWidgetUrl, resolveUploadUrl } from '@/config/api'
|
|||
|
|
import { ORB_PALETTE_COUNT, getOrbStyleAt, resolveOrbStyle, orbSvgDataUri, terminalMarkSvgDataUri } from '@/utils/orb'
|
|||
|
|
|
|||
|
|
import KnowledgeExplorer from '@/components/knowledge/KnowledgeExplorer.vue'
|
|||
|
|
import AgentCustomizationView from './AgentCustomizationView.vue'
|
|||
|
|
import AgentChatPreviewPanel from './AgentChatPreviewPanel.vue'
|
|||
|
|
import AgentIntegrationsTab from './AgentIntegrationsTab.vue'
|
|||
|
|
import AgentWidgetTab from './AgentWidgetTab.vue'
|
|||
|
|
|
|||
|
|
import AgentAdvancedTab from './AgentAdvancedTab.vue'
|
|||
|
|
import AgentInstructionsTab from './AgentInstructionsTab.vue'
|
|||
|
|
import AgentWorkflowTab from './AgentWorkflowTab.vue'
|
|||
|
|
import AgentMCPToolsTab from './AgentMCPToolsTab.vue'
|
|||
|
|
import AgentLeadCaptureTab from './AgentLeadCaptureTab.vue'
|
|||
|
|
import { Cropper, CircleStencil } from 'vue-advanced-cropper'
|
|||
|
|
import 'vue-advanced-cropper/dist/style.css'
|
|||
|
|
import { useAgentChat } from '@/composables/useAgentChat'
|
|||
|
|
import { useAgentDetail } from '@/composables/useAgentDetail'
|
|||
|
|
import { agentService } from '@/services/agent'
|
|||
|
|
import { toast } from 'vue-sonner'
|
|||
|
|
import { agentStorage, subscriptionStorage } from '@/utils/storage'
|
|||
|
|
import { useEnterpriseFeatures } from '@/composables/useEnterpriseFeatures'
|
|||
|
|
|
|||
|
|
const props = defineProps<{
|
|||
|
|
agent: AgentWithCustomization
|
|||
|
|
}>()
|
|||
|
|
|
|||
|
|
const { hasEnterpriseModule, enterpriseComponent, moduleImports } = useEnterpriseFeatures()
|
|||
|
|
const PromoGateNotice = enterpriseComponent(moduleImports.promoGateNotice)
|
|||
|
|
const agentData = ref({ ...props.agent })
|
|||
|
|
|
|||
|
|
// Avatar picker (preset avatars + upload)
|
|||
|
|
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])
|
|||
|
|
const showAvatarPicker = ref(false)
|
|||
|
|
// Show the chosen preset instantly while the upload finishes in the background
|
|||
|
|
const optimisticPhoto = ref<string | null>(null)
|
|||
|
|
const closeAvatarPicker = () => { showAvatarPicker.value = false }
|
|||
|
|
const toggleAvatarPicker = () => {
|
|||
|
|
if (isUploading.value) return
|
|||
|
|
showAvatarPicker.value = !showAvatarPicker.value
|
|||
|
|
}
|
|||
|
|
// Aurora orb avatar (generated, no photo). Stored in customization_metadata so no
|
|||
|
|
// schema change is needed. Selecting a real picture resets this back to 'photo'.
|
|||
|
|
const orbCount = ORB_PALETTE_COUNT
|
|||
|
|
const orbMeta = computed(
|
|||
|
|
() => (agentData.value.customization?.customization_metadata as Record<string, unknown> | undefined),
|
|||
|
|
)
|
|||
|
|
const useOrbAvatar = computed(() => orbMeta.value?.avatar_style === 'orb')
|
|||
|
|
const useTerminalMark = computed(() => orbMeta.value?.avatar_style === 'terminal')
|
|||
|
|
const currentOrbVariant = computed(() => orbMeta.value?.orb_variant)
|
|||
|
|
const orbStyle = computed(() => resolveOrbStyle(agentData.value.name || '', currentOrbVariant.value))
|
|||
|
|
// Orb whenever it was chosen explicitly, and whenever there is no picture at all
|
|||
|
|
// — the same rule the agent list uses, so the two views agree.
|
|||
|
|
const showOrbAvatar = computed(
|
|||
|
|
() => !optimisticPhoto.value && (useOrbAvatar.value || !agentData.value.customization?.photo_url),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
const persistAvatarMeta = async (patch: Record<string, unknown>, photoUrl?: string) => {
|
|||
|
|
const cust = agentData.value.customization
|
|||
|
|
if (!cust) return
|
|||
|
|
const meta = { ...(cust.customization_metadata ?? {}), ...patch }
|
|||
|
|
const updated = await agentService.updateCustomization(agentData.value.id, {
|
|||
|
|
...cust,
|
|||
|
|
...(photoUrl !== undefined ? { photo_url: photoUrl } : {}),
|
|||
|
|
customization_metadata: meta,
|
|||
|
|
})
|
|||
|
|
agentData.value.customization = updated
|
|||
|
|
// Reflect the new avatar in the live Chat-Customization preview immediately.
|
|||
|
|
// The form's props-watch is guarded by isInternalUpdate, so it won't re-emit
|
|||
|
|
// `preview` for this external change — sync previewCustomization directly.
|
|||
|
|
previewCustomization.value = {
|
|||
|
|
...previewCustomization.value,
|
|||
|
|
photo_url: updated.photo_url,
|
|||
|
|
customization_metadata: updated.customization_metadata,
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const selectOrbAvatar = async (variant: number) => {
|
|||
|
|
if (isUploading.value) return
|
|||
|
|
closeAvatarPicker()
|
|||
|
|
try {
|
|||
|
|
// Store the orb as an SVG data URI in photo_url so it propagates everywhere
|
|||
|
|
// (agent list, widget header, previews) via the normal avatar path.
|
|||
|
|
const orbUrl = orbSvgDataUri(agentData.value.name || '', variant)
|
|||
|
|
await persistAvatarMeta({ avatar_style: 'orb', orb_variant: variant }, orbUrl)
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to set orb avatar:', error)
|
|||
|
|
toast.error('更新头像失败')
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const selectTerminalMark = async () => {
|
|||
|
|
if (isUploading.value) return
|
|||
|
|
closeAvatarPicker()
|
|||
|
|
try {
|
|||
|
|
// Tint the ">" mark with the agent's accent (falls back to the Terminal lime).
|
|||
|
|
const accent = (agentData.value.customization?.accent_color as string | undefined) || undefined
|
|||
|
|
const markUrl = terminalMarkSvgDataUri(accent)
|
|||
|
|
await persistAvatarMeta({ avatar_style: 'terminal' }, markUrl)
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to set terminal mark avatar:', error)
|
|||
|
|
toast.error('更新头像失败')
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const selectPresetAvatar = async (url: string) => {
|
|||
|
|
if (isUploading.value) return
|
|||
|
|
closeAvatarPicker()
|
|||
|
|
optimisticPhoto.value = url
|
|||
|
|
try {
|
|||
|
|
await applyPresetAvatar(url)
|
|||
|
|
// A real picture was chosen — turn off the orb if it was on.
|
|||
|
|
if (useOrbAvatar.value) await persistAvatarMeta({ avatar_style: 'photo' })
|
|||
|
|
} finally {
|
|||
|
|
optimisticPhoto.value = null
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
const chooseUploadAvatar = () => {
|
|||
|
|
if (isUploading.value) return
|
|||
|
|
closeAvatarPicker()
|
|||
|
|
triggerFileUpload()
|
|||
|
|
}
|
|||
|
|
onMounted(() => window.addEventListener('click', closeAvatarPicker))
|
|||
|
|
onUnmounted(() => window.removeEventListener('click', closeAvatarPicker))
|
|||
|
|
|
|||
|
|
const activeTab = ref(props.agent.use_workflow ? 'workflow-builder' : 'agent') // Track the active tab: 'agent', 'integrations', etc.
|
|||
|
|
const isEditingHeader = ref(false)
|
|||
|
|
const editDisplayName = ref(agentData.value.display_name || agentData.value.name)
|
|||
|
|
const editIsActive = ref(agentData.value.is_active)
|
|||
|
|
const previewCustomization = ref<AgentCustomization>({
|
|||
|
|
id: agentData.value.customization?.id ?? 0,
|
|||
|
|
agent_id: agentData.value.id,
|
|||
|
|
chat_background_color: agentData.value.customization?.chat_background_color ?? '#F8F9FA',
|
|||
|
|
chat_text_color: agentData.value.customization?.chat_text_color ?? '#212529',
|
|||
|
|
chat_bubble_color: agentData.value.customization?.chat_bubble_color ?? '#E9ECEF',
|
|||
|
|
accent_color: agentData.value.customization?.accent_color ?? '#C9F24E',
|
|||
|
|
font_family: agentData.value.customization?.font_family ?? 'Inter, system-ui, sans-serif',
|
|||
|
|
photo_url: agentData.value.customization?.photo_url,
|
|||
|
|
icon_color: agentData.value.customization?.icon_color ?? '#6C757D',
|
|||
|
|
custom_css: agentData.value.customization?.custom_css,
|
|||
|
|
customization_metadata: agentData.value.customization?.customization_metadata ?? {},
|
|||
|
|
chat_style: agentData.value.customization?.chat_style ?? 'CHATBOT',
|
|||
|
|
widget_position: agentData.value.customization?.widget_position
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
|
|||
|
|
|
|||
|
|
// Resolve at runtime (window.APP_CONFIG) so the iframe embed + token endpoint
|
|||
|
|
// point at THIS install's backend on self-hosted deployments, not the values
|
|||
|
|
// baked at build time.
|
|||
|
|
const baseUrl = computed(() => {
|
|||
|
|
return getApiUrl()
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const widgetUrl = computed(() => {
|
|||
|
|
return getWidgetUrl()
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// Computed property to handle instructions as text
|
|||
|
|
const instructionsText = computed({
|
|||
|
|
get: () => agentData.value.instructions?.join('\n') || '',
|
|||
|
|
set: (value) => {
|
|||
|
|
agentData.value.instructions = value.split('\n').filter(line => line.trim())
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const iframeUrl = computed(() => {
|
|||
|
|
if (!widget.value?.id) return ''
|
|||
|
|
return `${baseUrl.value}/widgets/${widget.value.id}/data?widget_id=${widget.value.id}`
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// Computed property to check if chat style is ASK_ANYTHING for preview adjustments
|
|||
|
|
const isAskAnythingStyle = computed(() => {
|
|||
|
|
return previewCustomization.value.chat_style === 'ASK_ANYTHING'
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// Computed property for preview wrapper styles
|
|||
|
|
const previewWrapperStyles = computed(() => {
|
|||
|
|
const baseStyles = {
|
|||
|
|
background: 'transparent',
|
|||
|
|
borderRadius: 'var(--radius-lg)',
|
|||
|
|
overflow: 'hidden',
|
|||
|
|
height: '600px',
|
|||
|
|
flexShrink: '0',
|
|||
|
|
display: 'flex',
|
|||
|
|
alignItems: 'flex-start',
|
|||
|
|
justifyContent: 'center',
|
|||
|
|
boxShadow: 'none',
|
|||
|
|
border: 'none',
|
|||
|
|
maxWidth: '100%'
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (isAskAnythingStyle.value) {
|
|||
|
|
return {
|
|||
|
|
...baseStyles,
|
|||
|
|
width: '500px',
|
|||
|
|
minWidth: '500px'
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
...baseStyles,
|
|||
|
|
width: '480px'
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const emit = defineEmits<{
|
|||
|
|
(e: 'close'): void
|
|||
|
|
(e: 'toggle-fullscreen', isFullscreen: boolean): void
|
|||
|
|
}>()
|
|||
|
|
|
|||
|
|
const {
|
|||
|
|
fileInput,
|
|||
|
|
isUploading,
|
|||
|
|
showCropper,
|
|||
|
|
cropperImage,
|
|||
|
|
cropper,
|
|||
|
|
widget,
|
|||
|
|
widgetLoading,
|
|||
|
|
triggerFileUpload,
|
|||
|
|
handleFileUpload,
|
|||
|
|
handleCrop,
|
|||
|
|
cancelCrop,
|
|||
|
|
applyPresetAvatar,
|
|||
|
|
handleClose: handleCloseAgent,
|
|||
|
|
initializeWidget,
|
|||
|
|
copyWidgetCode: copyWidgetCodeFn,
|
|||
|
|
userGroups,
|
|||
|
|
selectedGroupIds,
|
|||
|
|
loadingGroups,
|
|||
|
|
fetchUserGroups,
|
|||
|
|
updateAgentGroups,
|
|||
|
|
// Jira integration
|
|||
|
|
jiraConnected,
|
|||
|
|
jiraLoading,
|
|||
|
|
createTicketEnabled,
|
|||
|
|
jiraProjects,
|
|||
|
|
jiraIssueTypes,
|
|||
|
|
selectedProject,
|
|||
|
|
selectedIssueType,
|
|||
|
|
loadingProjects,
|
|||
|
|
loadingIssueTypes,
|
|||
|
|
checkJiraStatus,
|
|||
|
|
toggleCreateTicket,
|
|||
|
|
saveJiraConfig,
|
|||
|
|
fetchAgentJiraConfig,
|
|||
|
|
handleProjectChange,
|
|||
|
|
handleIssueTypeChange,
|
|||
|
|
shopifyIntegrationEnabled,
|
|||
|
|
checkShopifyStatus,
|
|||
|
|
fetchAgentShopifyConfig,
|
|||
|
|
toggleShopifyIntegration,
|
|||
|
|
saveShopifyConfig,
|
|||
|
|
shopifyShopDomain
|
|||
|
|
} = useAgentDetail(agentData, emit)
|
|||
|
|
|
|||
|
|
const { cleanup } = useAgentChat(agentData.value.id)
|
|||
|
|
|
|||
|
|
const handlePreview = (customization: AgentCustomization) => {
|
|||
|
|
previewCustomization.value = {
|
|||
|
|
...customization,
|
|||
|
|
chat_style: customization.chat_style ?? 'CHATBOT',
|
|||
|
|
widget_position: customization.widget_position
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Also update the agentData customization to keep it in sync
|
|||
|
|
// This ensures when switching tabs, the latest saved data is preserved
|
|||
|
|
agentData.value.customization = {
|
|||
|
|
...customization,
|
|||
|
|
chat_style: customization.chat_style ?? 'CHATBOT',
|
|||
|
|
widget_position: customization.widget_position
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Update storage to keep data in sync
|
|||
|
|
agentStorage.updateAgent(agentData.value)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Handle chat style changes for message management in preview
|
|||
|
|
const handleChatStyleChange = (oldStyle: string, newStyle: string) => {
|
|||
|
|
console.log('Chat style changed in parent:', oldStyle, 'to', newStyle)
|
|||
|
|
// The actual message management is handled in AgentChatPreviewPanel
|
|||
|
|
// This handler is just for potential future use
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const photoUrl = computed(() => {
|
|||
|
|
// Optimistic preview while an upload is in flight
|
|||
|
|
if (optimisticPhoto.value) {
|
|||
|
|
return optimisticPhoto.value
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// No photo is handled by showOrbAvatar below, which renders the same orb the
|
|||
|
|
// agent list shows. It used to fall back to getAvatarUrl() — the retired
|
|||
|
|
// /avatars/*.svg artwork — so any agent created without a picture (the CLI
|
|||
|
|
// never sets one) showed the old illustration here while the list looked right.
|
|||
|
|
if (!agentData.value.customization?.photo_url) {
|
|||
|
|
return ''
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Absolute S3/CDN URLs pass through; local paths are resolved against the
|
|||
|
|
// runtime API origin.
|
|||
|
|
return resolveUploadUrl(agentData.value.customization.photo_url)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const handleClose = () => {
|
|||
|
|
handleCloseAgent(cleanup)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const copyWidgetCode = () => {
|
|||
|
|
copyWidgetCodeFn(agentData.value.require_token_auth)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const copyIframeCode = () => {
|
|||
|
|
if (!widget.value?.id) return
|
|||
|
|
const iframeCode = `<iframe src="${widgetUrl.value}/api/v1/widgets/${widget.value.id}/data" width="100%" height="600" frameborder="0" title="AI 助手" allow="clipboard-write"></iframe>`
|
|||
|
|
navigator.clipboard.writeText(iframeCode).then(() => {
|
|||
|
|
// Could show a toast notification here
|
|||
|
|
console.log('Iframe code copied to clipboard')
|
|||
|
|
}).catch(err => {
|
|||
|
|
console.error('Failed to copy iframe code: ', err)
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const copyBackendCode = () => {
|
|||
|
|
if (!widget.value?.id) return
|
|||
|
|
const backendCode = `// Your backend endpoint (e.g., /api/chat-token)
|
|||
|
|
const response = await fetch('${widgetUrl.value}/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.value.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`
|
|||
|
|
navigator.clipboard.writeText(backendCode).then(() => {
|
|||
|
|
console.log('Backend code copied to clipboard')
|
|||
|
|
}).catch(err => {
|
|||
|
|
console.error('Failed to copy backend code: ', err)
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Dialog state for knowledge tips
|
|||
|
|
const showTips = ref(false)
|
|||
|
|
|
|||
|
|
// Dialog state for upgrade modal
|
|||
|
|
const router = useRouter()
|
|||
|
|
const showUpgradeModal = ref(false)
|
|||
|
|
const upgradeModalType = ref<'workflow' | 'mcp' | 'advanced' | 'lead-capture'>('workflow')
|
|||
|
|
|
|||
|
|
const openTips = () => {
|
|||
|
|
showTips.value = true
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const closeTips = () => {
|
|||
|
|
showTips.value = false
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const closeUpgradeModal = () => {
|
|||
|
|
showUpgradeModal.value = false
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const handleUpgrade = () => {
|
|||
|
|
closeUpgradeModal()
|
|||
|
|
void router.push('/settings/subscription')
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** The feature the gate notice unlocks, by type - never derived from UI copy. */
|
|||
|
|
const gateFeatureLabel = computed(() => ({
|
|||
|
|
workflow: 'Workflow',
|
|||
|
|
mcp: 'MCP Tools',
|
|||
|
|
advanced: 'Advanced Settings',
|
|||
|
|
'lead-capture': 'Lead Capture',
|
|||
|
|
}[upgradeModalType.value] ?? 'this feature'))
|
|||
|
|
|
|||
|
|
// Tab switching function
|
|||
|
|
const switchTab = (tab: string) => {
|
|||
|
|
activeTab.value = tab
|
|||
|
|
|
|||
|
|
// Update URL with tab parameter
|
|||
|
|
const url = new URL(window.location.href)
|
|||
|
|
url.searchParams.set('tab', tab)
|
|||
|
|
window.history.replaceState({}, '', url.toString())
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Check if workflow feature is available in current plan
|
|||
|
|
const hasWorkflowFeature = computed(() => {
|
|||
|
|
return subscriptionStorage.hasFeature('workflow')
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// Check if subscription is active
|
|||
|
|
const isSubscriptionActive = computed(() => {
|
|||
|
|
return subscriptionStorage.isSubscriptionActive()
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// Determine if workflow is locked (feature not available or subscription not active)
|
|||
|
|
const isWorkflowLocked = computed(() => {
|
|||
|
|
// Only lock if enterprise module exists
|
|||
|
|
if (!hasEnterpriseModule) {
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
return !hasWorkflowFeature.value || !isSubscriptionActive.value
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// Check if MCP tools feature is available in current plan
|
|||
|
|
const hasMCPFeature = computed(() => {
|
|||
|
|
return subscriptionStorage.hasFeature('mcp_tools')
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// Determine if MCP tools is locked
|
|||
|
|
const isMCPLocked = computed(() => {
|
|||
|
|
// Only lock if enterprise module exists
|
|||
|
|
if (!hasEnterpriseModule) {
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
return !hasMCPFeature.value || !isSubscriptionActive.value
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// Check if advanced features are available in current plan
|
|||
|
|
const hasAdvancedFeature = computed(() => {
|
|||
|
|
return subscriptionStorage.hasFeature('advanced_settings')
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// Determine if advanced tab is locked
|
|||
|
|
const isAdvancedLocked = computed(() => {
|
|||
|
|
// Only lock if enterprise module exists
|
|||
|
|
if (!hasEnterpriseModule) {
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
return !hasAdvancedFeature.value || !isSubscriptionActive.value
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// AI Ticketing is a Pro-plan feature (the 'ai_ticketing' flag is true only for
|
|||
|
|
// Pro/Enterprise). Lock the per-agent toggle when the plan lacks it; OSS is
|
|||
|
|
// never locked.
|
|||
|
|
const hasTicketingFeature = computed(() => {
|
|||
|
|
return subscriptionStorage.hasFeature('ai_ticketing')
|
|||
|
|
})
|
|||
|
|
const isTicketingLocked = computed(() => {
|
|||
|
|
if (!hasEnterpriseModule) {
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
return !hasTicketingFeature.value || !isSubscriptionActive.value
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// Check if lead capture feature is available in current plan (Pro+ only)
|
|||
|
|
const hasLeadCaptureFeature = computed(() => {
|
|||
|
|
return subscriptionStorage.hasFeature('lead_capture')
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// Determine if lead capture is locked. Lead Capture is a Pro-plan feature — the
|
|||
|
|
// 'lead_capture' flag is true only for Pro/Enterprise (not Free/Base), matching the
|
|||
|
|
// backend gate (check_feature_availability). OSS deployments are never locked.
|
|||
|
|
const isLeadCaptureLocked = computed(() => {
|
|||
|
|
if (!hasEnterpriseModule) {
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
return !hasLeadCaptureFeature.value || !isSubscriptionActive.value
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// Computed properties for dynamic modal content
|
|||
|
|
const modalTitle = computed(() => {
|
|||
|
|
switch (upgradeModalType.value) {
|
|||
|
|
case 'workflow':
|
|||
|
|
return '解锁工作流功能'
|
|||
|
|
case 'mcp':
|
|||
|
|
return '解锁 MCP 工具'
|
|||
|
|
case 'advanced':
|
|||
|
|
return '解锁高级设置'
|
|||
|
|
case 'lead-capture':
|
|||
|
|
return '解锁线索收集'
|
|||
|
|
default:
|
|||
|
|
return '解锁高级功能'
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const modalDescription = computed(() => {
|
|||
|
|
switch (upgradeModalType.value) {
|
|||
|
|
case 'workflow':
|
|||
|
|
return '工作流模式支持用条件逻辑、多步骤和高级集成搭建复杂的自动化流程。'
|
|||
|
|
case 'mcp':
|
|||
|
|
return 'MCP 工具可接入模型上下文协议服务器,通过外部数据源和服务扩展客服能力。'
|
|||
|
|
case 'advanced':
|
|||
|
|
return '高级设置可精细控制客服行为、性能调优,并提供企业级配置选项。'
|
|||
|
|
case 'lead-capture':
|
|||
|
|
return '线索收集让客服从访客那里收集并筛选联系方式——支持触发条件、筛选问题和自定义字段——再把合格线索转给团队。'
|
|||
|
|
default:
|
|||
|
|
return '升级套餐即可使用高级功能,充分释放 AI 客服的潜力。'
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const modalFeatures = computed(() => {
|
|||
|
|
switch (upgradeModalType.value) {
|
|||
|
|
case 'workflow':
|
|||
|
|
return [
|
|||
|
|
'可视化工作流编排',
|
|||
|
|
'条件逻辑与分支',
|
|||
|
|
'高级集成',
|
|||
|
|
'自定义自动化流程'
|
|||
|
|
]
|
|||
|
|
case 'mcp':
|
|||
|
|
return [
|
|||
|
|
'接入 MCP 服务器',
|
|||
|
|
'外部数据集成',
|
|||
|
|
'自定义工具扩展',
|
|||
|
|
'基于协议的通信'
|
|||
|
|
]
|
|||
|
|
case 'advanced':
|
|||
|
|
return [
|
|||
|
|
'性能优化',
|
|||
|
|
'自定义系统提示词',
|
|||
|
|
'企业级配置',
|
|||
|
|
'高级调试工具'
|
|||
|
|
]
|
|||
|
|
default:
|
|||
|
|
return ['高级功能', '更强能力', '增强功能', '优先支持']
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// Determine if we should show workflow or general tab
|
|||
|
|
const showWorkflowTab = computed(() => {
|
|||
|
|
return agentData.value.use_workflow === true && hasWorkflowFeature.value
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// Handle fullscreen toggle from workflow tab
|
|||
|
|
const handleWorkflowFullscreenToggle = (isFullscreen: boolean) => {
|
|||
|
|
emit('toggle-fullscreen', isFullscreen)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Handle workflow toggle
|
|||
|
|
const handleToggleUseWorkflow = async () => {
|
|||
|
|
// Check if workflow feature is locked
|
|||
|
|
if (isWorkflowLocked.value) {
|
|||
|
|
// Only show upgrade modal if enterprise module exists
|
|||
|
|
if (hasEnterpriseModule) {
|
|||
|
|
upgradeModalType.value = 'workflow'
|
|||
|
|
showUpgradeModal.value = true
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
// If no enterprise module, just return without showing modal
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const newValue = !agentData.value.use_workflow
|
|||
|
|
const updatedAgent = await agentService.updateAgent(agentData.value.id, { use_workflow: newValue })
|
|||
|
|
|
|||
|
|
// Update the agent data to trigger reactivity
|
|||
|
|
Object.assign(agentData.value, updatedAgent)
|
|||
|
|
|
|||
|
|
// Update storage to keep data in sync
|
|||
|
|
agentStorage.updateAgent(updatedAgent)
|
|||
|
|
|
|||
|
|
// Switch to the appropriate tab based on the new mode
|
|||
|
|
if (newValue) {
|
|||
|
|
// Switched to workflow mode - go to workflow-builder tab
|
|||
|
|
activeTab.value = 'workflow-builder'
|
|||
|
|
} else {
|
|||
|
|
// Switched to AI mode - go to agent tab
|
|||
|
|
activeTab.value = 'agent'
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Update URL with new tab
|
|||
|
|
const url = new URL(window.location.href)
|
|||
|
|
url.searchParams.set('tab', activeTab.value)
|
|||
|
|
window.history.replaceState({}, '', url.toString())
|
|||
|
|
|
|||
|
|
// Force reactivity update
|
|||
|
|
await nextTick()
|
|||
|
|
|
|||
|
|
toast.success(`工作流模式已${newValue ? '开启' : '关闭'}`, {
|
|||
|
|
duration: 4000,
|
|||
|
|
closeButton: true
|
|||
|
|
})
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Error toggling workflow mode:', error)
|
|||
|
|
toast.error('更新工作流模式失败', {
|
|||
|
|
duration: 4000,
|
|||
|
|
closeButton: true
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Toggle native AI ticketing for this agent (per-agent switch; the org plan
|
|||
|
|
// gate still applies on top in the backend).
|
|||
|
|
const toggleTicketing = async () => {
|
|||
|
|
if (isTicketingLocked.value) return
|
|||
|
|
try {
|
|||
|
|
const newValue = !agentData.value.ticketing_enabled
|
|||
|
|
const updatedAgent = await agentService.updateAgent(agentData.value.id, { ticketing_enabled: newValue })
|
|||
|
|
Object.assign(agentData.value, updatedAgent)
|
|||
|
|
agentStorage.updateAgent(updatedAgent)
|
|||
|
|
toast.success(`已为该客服${newValue ? '开启' : '关闭'} AI 工单`, {
|
|||
|
|
duration: 4000,
|
|||
|
|
closeButton: true
|
|||
|
|
})
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Error toggling AI ticketing:', error)
|
|||
|
|
toast.error('更新 AI 工单设置失败', {
|
|||
|
|
duration: 4000,
|
|||
|
|
closeButton: true
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Handle add workflow
|
|||
|
|
|
|||
|
|
// Handle navigate to tab
|
|||
|
|
|
|||
|
|
// Handle save agent changes
|
|||
|
|
|
|||
|
|
// Handle advanced tab updates
|
|||
|
|
const handleAdvancedTabUpdate = async (updatedAgent: AgentWithCustomization) => {
|
|||
|
|
try {
|
|||
|
|
// The AgentAdvancedTab component already saves the setting via API
|
|||
|
|
// This handler updates the local agentData to ensure reactivity across all tabs
|
|||
|
|
|
|||
|
|
// Update the agent data to trigger reactivity
|
|||
|
|
Object.assign(agentData.value, updatedAgent)
|
|||
|
|
|
|||
|
|
// Update storage to keep data in sync
|
|||
|
|
agentStorage.updateAgent(updatedAgent)
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Error handling advanced settings update:', error)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Handle customization save
|
|||
|
|
const handleCustomizationSave = (updatedAgent: AgentWithCustomization) => {
|
|||
|
|
// Update the agent data to trigger reactivity
|
|||
|
|
Object.assign(agentData.value, updatedAgent)
|
|||
|
|
|
|||
|
|
// Update storage to keep data in sync
|
|||
|
|
agentStorage.updateAgent(updatedAgent)
|
|||
|
|
|
|||
|
|
// Update preview customization with all properties including chat_style
|
|||
|
|
if (updatedAgent.customization) {
|
|||
|
|
previewCustomization.value = {
|
|||
|
|
...updatedAgent.customization,
|
|||
|
|
chat_style: updatedAgent.customization.chat_style ?? 'CHATBOT',
|
|||
|
|
widget_position: updatedAgent.customization.widget_position
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Switch back to general tab
|
|||
|
|
switchTab('general')
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Handle save header changes
|
|||
|
|
const handleSaveHeader = async () => {
|
|||
|
|
try {
|
|||
|
|
const updatedAgent = await agentService.updateAgent(agentData.value.id, {
|
|||
|
|
display_name: editDisplayName.value,
|
|||
|
|
is_active: editIsActive.value
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// Update the agent data to trigger reactivity
|
|||
|
|
Object.assign(agentData.value, updatedAgent)
|
|||
|
|
|
|||
|
|
// Update storage to keep data in sync
|
|||
|
|
agentStorage.updateAgent(updatedAgent)
|
|||
|
|
|
|||
|
|
isEditingHeader.value = false
|
|||
|
|
|
|||
|
|
toast.success('客服已更新', {
|
|||
|
|
duration: 4000,
|
|||
|
|
closeButton: true
|
|||
|
|
})
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Error saving agent:', error)
|
|||
|
|
toast.error('保存客服修改失败', {
|
|||
|
|
duration: 4000,
|
|||
|
|
closeButton: true
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Handle cancel header edit
|
|||
|
|
const handleCancelHeaderEdit = () => {
|
|||
|
|
editDisplayName.value = agentData.value.display_name || agentData.value.name
|
|||
|
|
editIsActive.value = agentData.value.is_active
|
|||
|
|
isEditingHeader.value = false
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Handle status toggle
|
|||
|
|
const handleStatusToggle = async (event: Event) => {
|
|||
|
|
const newStatus = (event.target as HTMLInputElement).checked
|
|||
|
|
|
|||
|
|
if (isEditingHeader.value) {
|
|||
|
|
editIsActive.value = newStatus
|
|||
|
|
} else {
|
|||
|
|
// Directly update the agent status
|
|||
|
|
try {
|
|||
|
|
const updatedAgent = await agentService.updateAgent(agentData.value.id, {
|
|||
|
|
is_active: newStatus
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// Update the agent data to trigger reactivity
|
|||
|
|
agentData.value.is_active = newStatus
|
|||
|
|
|
|||
|
|
// Update storage to keep data in sync
|
|||
|
|
agentStorage.updateAgent(updatedAgent)
|
|||
|
|
|
|||
|
|
toast.success(`客服已${newStatus ? '启用' : '停用'}`, {
|
|||
|
|
duration: 3000,
|
|||
|
|
closeButton: true
|
|||
|
|
})
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Error updating agent status:', error)
|
|||
|
|
toast.error('更新客服状态失败', {
|
|||
|
|
duration: 4000,
|
|||
|
|
closeButton: true
|
|||
|
|
})
|
|||
|
|
// Revert the checkbox state on error
|
|||
|
|
;(event.target as HTMLInputElement).checked = agentData.value.is_active
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Handle save agent from tab
|
|||
|
|
const handleSaveAgentFromTab = async (data: any) => {
|
|||
|
|
try {
|
|||
|
|
// Convert instructions back to array format if it's a string
|
|||
|
|
let instructions = data.instructions
|
|||
|
|
if (typeof instructions === 'string') {
|
|||
|
|
instructions = instructions.split('\n').filter((line: string) => line.trim())
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const updatedAgent = await agentService.updateAgent(agentData.value.id, {
|
|||
|
|
instructions: instructions,
|
|||
|
|
guardrail_prompt: data.guardrailPrompt,
|
|||
|
|
guardrail_enabled: data.guardrailEnabled,
|
|||
|
|
transfer_to_human: data.transferToHuman,
|
|||
|
|
ai_replies_enabled: data.aiRepliesEnabled,
|
|||
|
|
channel_quick_replies: data.channelQuickReplies,
|
|||
|
|
ask_for_rating: data.askForRating,
|
|||
|
|
handoff_collect_email: data.handoffCollectEmail,
|
|||
|
|
handoff_collect_name: data.handoffCollectName
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// Update agent groups if changed
|
|||
|
|
if (data.selectedGroupIds && data.selectedGroupIds.length !== selectedGroupIds.value.length ||
|
|||
|
|
!data.selectedGroupIds.every((id: string) => selectedGroupIds.value.includes(id))) {
|
|||
|
|
await updateAgentGroups(data.selectedGroupIds)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Update the agent data to trigger reactivity
|
|||
|
|
Object.assign(agentData.value, updatedAgent)
|
|||
|
|
|
|||
|
|
// Update storage to keep data in sync
|
|||
|
|
agentStorage.updateAgent(updatedAgent)
|
|||
|
|
|
|||
|
|
toast.success('客服已更新', {
|
|||
|
|
duration: 4000,
|
|||
|
|
closeButton: true
|
|||
|
|
})
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Error saving agent:', error)
|
|||
|
|
toast.error('保存客服修改失败', {
|
|||
|
|
duration: 4000,
|
|||
|
|
closeButton: true
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
onMounted(async () => {
|
|||
|
|
initializeWidget()
|
|||
|
|
fetchUserGroups()
|
|||
|
|
|
|||
|
|
// First check Jira status, then fetch agent config
|
|||
|
|
await checkJiraStatus()
|
|||
|
|
await fetchAgentJiraConfig()
|
|||
|
|
|
|||
|
|
// Check Shopify status and fetch config
|
|||
|
|
await checkShopifyStatus()
|
|||
|
|
await fetchAgentShopifyConfig()
|
|||
|
|
|
|||
|
|
// Check if we should switch to knowledge tab based on URL query parameter
|
|||
|
|
const urlParams = new URLSearchParams(window.location.search)
|
|||
|
|
const tab = urlParams.get('tab')
|
|||
|
|
if (tab && ['agent', 'preview', 'knowledge', 'integrations', 'mcp-tools', 'widget', 'advanced', 'workflow-builder', 'customization'].includes(tab)) {
|
|||
|
|
activeTab.value = tab
|
|||
|
|
} else if (agentData.value.use_workflow === true) {
|
|||
|
|
// Show workflow builder tab by default when using workflow
|
|||
|
|
activeTab.value = 'workflow-builder'
|
|||
|
|
} else {
|
|||
|
|
// Show agent tab by default when NOT using workflow
|
|||
|
|
activeTab.value = 'agent'
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<template>
|
|||
|
|
<div class="agent-detail">
|
|||
|
|
<!-- Left Panel -->
|
|||
|
|
<div class="detail-panel">
|
|||
|
|
<div class="panel-header">
|
|||
|
|
<div class="header-layout">
|
|||
|
|
<button class="back-button" @click="handleClose" aria-label="返回客服列表">
|
|||
|
|
<svg class="back-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|||
|
|
<path d="M19 12H5m7-7-7 7 7 7"/>
|
|||
|
|
</svg>
|
|||
|
|
</button>
|
|||
|
|
<div class="agent-header">
|
|||
|
|
<div class="agent-avatar" :class="{ uploading: isUploading }" @click.stop="toggleAvatarPicker">
|
|||
|
|
<input type="file" ref="fileInput" accept="image/jpeg,image/png,image/webp" class="hidden"
|
|||
|
|
@change="handleFileUpload">
|
|||
|
|
<div class="agent-avatar-ring">
|
|||
|
|
<div v-if="showOrbAvatar" class="agent-orb-avatar" :class="{ 'opacity-50': isUploading }" :style="orbStyle"></div>
|
|||
|
|
<img v-else :src="photoUrl" :alt="agentData.name" :class="{ 'opacity-50': isUploading }">
|
|||
|
|
<div class="upload-overlay" v-if="!isUploading">
|
|||
|
|
<span>更换</span>
|
|||
|
|
</div>
|
|||
|
|
<div class="upload-overlay" v-else>
|
|||
|
|
<span>...</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<span class="avatar-edit-badge" aria-hidden="true">
|
|||
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20h4l10-10-4-4L4 16z"></path></svg>
|
|||
|
|
</span>
|
|||
|
|
|
|||
|
|
<!-- Preset avatar picker -->
|
|||
|
|
<div v-if="showAvatarPicker" class="avatar-picker" @click.stop>
|
|||
|
|
<div class="avatar-picker-title">选择头像</div>
|
|||
|
|
<div class="avatar-picker-grid">
|
|||
|
|
<button
|
|||
|
|
v-for="(url, i) in presetAvatars"
|
|||
|
|
:key="i"
|
|||
|
|
type="button"
|
|||
|
|
class="avatar-pick"
|
|||
|
|
:disabled="isUploading"
|
|||
|
|
@click="selectPresetAvatar(url)"
|
|||
|
|
:aria-label="`头像 ${i + 1}`"
|
|||
|
|
>
|
|||
|
|
<img :src="url" alt="">
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
v-for="n in orbCount"
|
|||
|
|
:key="`orb-${n}`"
|
|||
|
|
type="button"
|
|||
|
|
class="avatar-pick avatar-pick-orb"
|
|||
|
|
:class="{ active: useOrbAvatar && (currentOrbVariant ?? -1) === n - 1 }"
|
|||
|
|
:disabled="isUploading"
|
|||
|
|
@click="selectOrbAvatar(n - 1)"
|
|||
|
|
:aria-label="`光球 ${n}`"
|
|||
|
|
:title="`光球 ${n}`"
|
|||
|
|
>
|
|||
|
|
<span class="avatar-orb-thumb" :style="getOrbStyleAt(n - 1)"></span>
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
class="avatar-pick avatar-pick-orb"
|
|||
|
|
:class="{ active: useTerminalMark }"
|
|||
|
|
:disabled="isUploading"
|
|||
|
|
@click="selectTerminalMark"
|
|||
|
|
aria-label="终端标记"
|
|||
|
|
title="Terminal >"
|
|||
|
|
>
|
|||
|
|
<span class="avatar-terminal-thumb">></span>
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
<button type="button" class="avatar-picker-upload" :disabled="isUploading" @click="chooseUploadAvatar">
|
|||
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
|||
|
|
上传自定义头像
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="agent-info">
|
|||
|
|
<div class="name-section">
|
|||
|
|
<div v-if="!isEditingHeader" class="name-display">
|
|||
|
|
<h3>{{ agentData.display_name || agentData.name }}</h3>
|
|||
|
|
<button class="edit-icon-button" @click="isEditingHeader = true" title="编辑名称与状态">
|
|||
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|||
|
|
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
|
|||
|
|
<path d="m18.5 2.5 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
|
|||
|
|
</svg>
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
<div v-else class="name-edit">
|
|||
|
|
<input
|
|||
|
|
v-model="editDisplayName"
|
|||
|
|
class="name-input"
|
|||
|
|
placeholder="输入显示名称"
|
|||
|
|
@keyup.enter="handleSaveHeader"
|
|||
|
|
@keyup.escape="handleCancelHeaderEdit"
|
|||
|
|
>
|
|||
|
|
<div class="edit-actions">
|
|||
|
|
<button class="save-icon-button" @click="handleSaveHeader" title="保存">
|
|||
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|||
|
|
<polyline points="20,6 9,17 4,12"></polyline>
|
|||
|
|
</svg>
|
|||
|
|
</button>
|
|||
|
|
<button class="cancel-icon-button" @click="handleCancelHeaderEdit" title="取消">
|
|||
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|||
|
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
|||
|
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
|||
|
|
</svg>
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="status-and-mode">
|
|||
|
|
<div class="status-toggle">
|
|||
|
|
<label class="status-switch" :class="{ 'editing': isEditingHeader }">
|
|||
|
|
<input
|
|||
|
|
type="checkbox"
|
|||
|
|
:checked="isEditingHeader ? editIsActive : agentData.is_active"
|
|||
|
|
@change="handleStatusToggle"
|
|||
|
|
>
|
|||
|
|
<span class="status-slider"></span>
|
|||
|
|
</label>
|
|||
|
|
<span class="status-text" :class="{ online: (isEditingHeader ? editIsActive : agentData.is_active) }">{{ (isEditingHeader ? editIsActive : agentData.is_active) ? '在线' : '离线' }}</span>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Mode Selection -->
|
|||
|
|
<div class="mode-selection">
|
|||
|
|
<div class="mode-toggle">
|
|||
|
|
<button
|
|||
|
|
class="mode-button"
|
|||
|
|
:class="{ 'active': !agentData.use_workflow }"
|
|||
|
|
@click="!agentData.use_workflow || handleToggleUseWorkflow()"
|
|||
|
|
>
|
|||
|
|
<svg class="mode-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|||
|
|
<circle cx="12" cy="12" r="3"/>
|
|||
|
|
<path d="M12 1v6m0 6v6m11-7h-6m-6 0H1m15.5-3.5L19 4l-1.5 1.5M6.5 17.5L5 19l1.5 1.5m0-11L5 5l1.5 1.5m11 11L19 19l-1.5-1.5"/>
|
|||
|
|
</svg>
|
|||
|
|
<span class="mode-label">AI 模式</span>
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
class="mode-button"
|
|||
|
|
:class="{
|
|||
|
|
'active': agentData.use_workflow,
|
|||
|
|
'locked': isWorkflowLocked
|
|||
|
|
}"
|
|||
|
|
@click="agentData.use_workflow || handleToggleUseWorkflow()"
|
|||
|
|
:title="isWorkflowLocked ? '升级套餐以解锁工作流模式' : '切换到工作流模式'"
|
|||
|
|
>
|
|||
|
|
<svg class="mode-icon" 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>
|
|||
|
|
<span class="mode-label">工作流</span>
|
|||
|
|
<font-awesome-icon v-if="hasEnterpriseModule && isWorkflowLocked" icon="fa-solid fa-lock" class="lock-icon" />
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="panel-content">
|
|||
|
|
<div class="content-layout">
|
|||
|
|
<!-- Tab Navigation - Horizontal -->
|
|||
|
|
<div class="tabs-navigation horizontal">
|
|||
|
|
<button
|
|||
|
|
class="tab-button"
|
|||
|
|
:class="{ 'active': activeTab === 'agent' }"
|
|||
|
|
@click="switchTab('agent')"
|
|||
|
|
v-if="!showWorkflowTab"
|
|||
|
|
>
|
|||
|
|
客服
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
class="tab-button"
|
|||
|
|
:class="{
|
|||
|
|
'active': activeTab === 'workflow-builder',
|
|||
|
|
'locked': isWorkflowLocked
|
|||
|
|
}"
|
|||
|
|
@click="isWorkflowLocked ? (upgradeModalType = 'workflow', showUpgradeModal = true) : switchTab('workflow-builder')"
|
|||
|
|
:title="isWorkflowLocked ? '升级套餐以解锁工作流编排' : '工作流编排'"
|
|||
|
|
v-if="agentData.use_workflow || isWorkflowLocked"
|
|||
|
|
>
|
|||
|
|
工作流编排
|
|||
|
|
<font-awesome-icon v-if="hasEnterpriseModule && isWorkflowLocked" icon="fa-solid fa-lock" class="lock-icon-small" />
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
class="tab-button"
|
|||
|
|
:class="{ 'active': activeTab === 'customization' }"
|
|||
|
|
@click="switchTab('customization')"
|
|||
|
|
>
|
|||
|
|
聊天自定义
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
class="tab-button"
|
|||
|
|
:class="{ 'active': activeTab === 'preview' }"
|
|||
|
|
@click="switchTab('preview')"
|
|||
|
|
>
|
|||
|
|
测试与预览
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
class="tab-button"
|
|||
|
|
:class="{ 'active': activeTab === 'knowledge' }"
|
|||
|
|
@click="switchTab('knowledge')"
|
|||
|
|
>
|
|||
|
|
知识库
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
class="tab-button"
|
|||
|
|
:class="{ 'active': activeTab === 'integrations' }"
|
|||
|
|
@click="switchTab('integrations')"
|
|||
|
|
>
|
|||
|
|
集成
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
class="tab-button"
|
|||
|
|
:class="{ 'active': activeTab === 'lead-capture', 'locked': isLeadCaptureLocked }"
|
|||
|
|
@click="isLeadCaptureLocked ? (upgradeModalType = 'lead-capture', showUpgradeModal = true) : switchTab('lead-capture')"
|
|||
|
|
:title="isLeadCaptureLocked ? '升级套餐以解锁线索收集' : '线索收集'"
|
|||
|
|
>
|
|||
|
|
线索收集
|
|||
|
|
<font-awesome-icon v-if="hasEnterpriseModule && isLeadCaptureLocked" icon="fa-solid fa-lock" class="lock-icon-small" />
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
class="tab-button"
|
|||
|
|
:class="{ 'active': activeTab === 'mcp-tools', 'locked': isMCPLocked }"
|
|||
|
|
@click="isMCPLocked ? (upgradeModalType = 'mcp', showUpgradeModal = true) : switchTab('mcp-tools')"
|
|||
|
|
:title="isMCPLocked ? '升级套餐以解锁 MCP 工具' : 'MCP 工具'"
|
|||
|
|
>
|
|||
|
|
MCP 工具
|
|||
|
|
<font-awesome-icon v-if="hasEnterpriseModule && isMCPLocked" icon="fa-solid fa-lock" class="lock-icon-small" />
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
class="tab-button"
|
|||
|
|
:class="{ 'active': activeTab === 'widget' }"
|
|||
|
|
@click="switchTab('widget')"
|
|||
|
|
>
|
|||
|
|
组件
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
class="tab-button"
|
|||
|
|
:class="{ 'active': activeTab === 'advanced', 'locked': isAdvancedLocked }"
|
|||
|
|
@click="isAdvancedLocked ? (upgradeModalType = 'advanced', showUpgradeModal = true) : switchTab('advanced')"
|
|||
|
|
:title="isAdvancedLocked ? '升级套餐以解锁高级设置' : '高级'"
|
|||
|
|
>
|
|||
|
|
高级
|
|||
|
|
<font-awesome-icon v-if="hasEnterpriseModule && isAdvancedLocked" icon="fa-solid fa-lock" class="lock-icon-small" />
|
|||
|
|
</button>
|
|||
|
|
|
|||
|
|
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Tab Content -->
|
|||
|
|
<div class="tab-content-container">
|
|||
|
|
<!-- Agent Tab -->
|
|||
|
|
<div v-if="activeTab === 'agent'" class="tab-content">
|
|||
|
|
<AgentInstructionsTab
|
|||
|
|
:instructions="instructionsText"
|
|||
|
|
:guardrail-prompt="agentData.guardrail_prompt"
|
|||
|
|
:guardrail-enabled="agentData.guardrail_enabled ?? true"
|
|||
|
|
:transfer-to-human="agentData.transfer_to_human"
|
|||
|
|
:ai-replies-enabled="agentData.ai_replies_enabled ?? true"
|
|||
|
|
:channel-quick-replies="agentData.channel_quick_replies ?? false"
|
|||
|
|
:ask-for-rating="agentData.ask_for_rating"
|
|||
|
|
:handoff-collect-email="agentData.handoff_collect_email"
|
|||
|
|
:handoff-collect-name="agentData.handoff_collect_name"
|
|||
|
|
:user-groups="userGroups"
|
|||
|
|
:selected-group-ids="selectedGroupIds"
|
|||
|
|
:loading-groups="loadingGroups"
|
|||
|
|
:is-editing="true"
|
|||
|
|
:agent="agentData"
|
|||
|
|
@save-agent="handleSaveAgentFromTab"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
|
|||
|
|
|
|||
|
|
<!-- Preview Tab -->
|
|||
|
|
<div v-if="activeTab === 'preview'" class="tab-content">
|
|||
|
|
<div class="preview-container">
|
|||
|
|
<div class="preview-header">
|
|||
|
|
<h3 class="section-title">聊天预览</h3>
|
|||
|
|
<p class="section-description">
|
|||
|
|
这是访客看到的聊天组件效果,预览与真实界面完全一致。
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
<div class="preview-wrapper" :style="previewWrapperStyles">
|
|||
|
|
<!-- Token auth required notice -->
|
|||
|
|
<div v-if="agentData.require_token_auth" class="preview-unavailable">
|
|||
|
|
<div class="preview-unavailable-icon">
|
|||
|
|
<svg width="48" height="48" 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>
|
|||
|
|
<h4 class="preview-unavailable-title">预览不可用</h4>
|
|||
|
|
<p class="preview-unavailable-description">
|
|||
|
|
该客服要求组件应用认证。由于已启用 Token 认证,无法显示预览。
|
|||
|
|
</p>
|
|||
|
|
<p class="preview-unavailable-hint">
|
|||
|
|
如需测试,请使用组件应用的有效 API Key 在外部组件中访问,或在“设置”标签页中临时关闭“要求 Token 认证”。
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
<!-- Normal preview iframe -->
|
|||
|
|
<iframe
|
|||
|
|
v-else-if="widget?.id"
|
|||
|
|
:src="iframeUrl"
|
|||
|
|
class="widget-preview"
|
|||
|
|
frameborder="0"
|
|||
|
|
title="组件预览"
|
|||
|
|
allow="clipboard-write"
|
|||
|
|
></iframe>
|
|||
|
|
<div v-else class="loading-preview">
|
|||
|
|
正在加载组件预览…
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Knowledge Tab -->
|
|||
|
|
<div v-if="activeTab === 'knowledge'" class="tab-content">
|
|||
|
|
<div class="knowledge-tab-container">
|
|||
|
|
<KnowledgeExplorer
|
|||
|
|
mode="agent"
|
|||
|
|
variant="section"
|
|||
|
|
title="知识来源"
|
|||
|
|
description="该客服学习的所有页面。查看提取的内容,决定哪些作为回答依据。"
|
|||
|
|
:agent-id="agentData.id"
|
|||
|
|
:organization-id="agentData.organization_id"
|
|||
|
|
>
|
|||
|
|
<template #actions>
|
|||
|
|
<router-link to="/knowledge" class="kb-open-link">
|
|||
|
|
打开知识库
|
|||
|
|
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path><path d="M15 3h6v6"></path><path d="M10 14 21 3"></path></svg>
|
|||
|
|
</router-link>
|
|||
|
|
</template>
|
|||
|
|
</KnowledgeExplorer>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Integrations Tab -->
|
|||
|
|
<div v-if="activeTab === 'integrations'" class="tab-content">
|
|||
|
|
<AgentIntegrationsTab
|
|||
|
|
:agent-id="agentData.id"
|
|||
|
|
:ticketing-enabled="agentData.ticketing_enabled"
|
|||
|
|
:ticketing-locked="isTicketingLocked"
|
|||
|
|
:jira-connected="jiraConnected"
|
|||
|
|
:jira-loading="jiraLoading"
|
|||
|
|
:create-ticket-enabled="createTicketEnabled"
|
|||
|
|
:jira-projects="jiraProjects"
|
|||
|
|
:jira-issue-types="jiraIssueTypes"
|
|||
|
|
:selected-project="selectedProject"
|
|||
|
|
:selected-issue-type="selectedIssueType"
|
|||
|
|
:loading-projects="loadingProjects"
|
|||
|
|
:loading-issue-types="loadingIssueTypes"
|
|||
|
|
:shopify-integration-enabled="shopifyIntegrationEnabled"
|
|||
|
|
:shopify-shop-domain="shopifyShopDomain"
|
|||
|
|
@toggle-ticketing="toggleTicketing"
|
|||
|
|
@toggle-create-ticket="toggleCreateTicket"
|
|||
|
|
@handle-project-change="handleProjectChange"
|
|||
|
|
@handle-issue-type-change="handleIssueTypeChange"
|
|||
|
|
@save-jira-config="(config) => saveJiraConfig(config.projectKey, config.issueTypeId)"
|
|||
|
|
@toggle-shopify-integration="toggleShopifyIntegration"
|
|||
|
|
@save-shopify-config="saveShopifyConfig"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Lead Capture Tab -->
|
|||
|
|
<div v-if="activeTab === 'lead-capture'" class="tab-content">
|
|||
|
|
<AgentLeadCaptureTab
|
|||
|
|
:agent-id="agentData.id"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- MCP Tools Tab -->
|
|||
|
|
<div v-if="activeTab === 'mcp-tools'" class="tab-content">
|
|||
|
|
<AgentMCPToolsTab
|
|||
|
|
:agent-id="agentData.id"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Widget Tab -->
|
|||
|
|
<div v-if="activeTab === 'widget'" class="tab-content">
|
|||
|
|
<AgentWidgetTab
|
|||
|
|
:widget="widget"
|
|||
|
|
:widget-url="widgetUrl"
|
|||
|
|
:widget-loading="widgetLoading"
|
|||
|
|
:agent="agentData"
|
|||
|
|
@copy-widget-code="copyWidgetCode"
|
|||
|
|
@copy-iframe-code="copyIframeCode"
|
|||
|
|
@copy-backend-code="copyBackendCode"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Advanced Tab -->
|
|||
|
|
<div v-if="activeTab === 'advanced'" class="tab-content">
|
|||
|
|
<AgentAdvancedTab
|
|||
|
|
:agent="agentData"
|
|||
|
|
@update="handleAdvancedTabUpdate"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Workflow Builder Tab -->
|
|||
|
|
<div v-if="activeTab === 'workflow-builder'" class="tab-content">
|
|||
|
|
<AgentWorkflowTab
|
|||
|
|
:key="`workflow-${agentData.id}-${agentData.use_workflow}`"
|
|||
|
|
:agent="agentData"
|
|||
|
|
@toggle-fullscreen="handleWorkflowFullscreenToggle"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Customization Tab -->
|
|||
|
|
<div v-if="activeTab === 'customization'" class="tab-content">
|
|||
|
|
<div class="customization-tab-layout">
|
|||
|
|
<div class="customization-panel">
|
|||
|
|
<AgentCustomizationView
|
|||
|
|
:agent="agentData"
|
|||
|
|
@preview="handlePreview"
|
|||
|
|
@save="handleCustomizationSave"
|
|||
|
|
@cancel="() => switchTab('agent')"
|
|||
|
|
@chat-style-changed="handleChatStyleChange"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
<div class="customization-preview">
|
|||
|
|
<AgentChatPreviewPanel
|
|||
|
|
:is-active="agentData.is_active"
|
|||
|
|
:customization="previewCustomization"
|
|||
|
|
:agent-type="agentData.agent_type"
|
|||
|
|
:agent-name="agentData.display_name || agentData.name"
|
|||
|
|
:orb-seed="agentData.name"
|
|||
|
|
:agent-id="agentData.id"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Cropper Modal -->
|
|||
|
|
<div v-if="showCropper" class="cropper-modal">
|
|||
|
|
<div class="cropper-container">
|
|||
|
|
<Cropper ref="cropper" :src="cropperImage" :stencil-props="{
|
|||
|
|
aspectRatio: 1,
|
|||
|
|
previewClass: 'preview-circle'
|
|||
|
|
}" :default-size="{
|
|||
|
|
width: 250,
|
|||
|
|
height: 250
|
|||
|
|
}" :stencil-component="CircleStencil" image-restriction="stencil" background="var(--o05)" />
|
|||
|
|
<div class="cropper-actions">
|
|||
|
|
<button @click="handleCrop" :disabled="isUploading">
|
|||
|
|
{{ isUploading ? '上传中…' : '保存' }}
|
|||
|
|
</button>
|
|||
|
|
<button @click="cancelCrop">取消</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Knowledge Tips Dialog -->
|
|||
|
|
<div v-if="showTips" class="tips-dialog-overlay">
|
|||
|
|
<div class="tips-dialog">
|
|||
|
|
<div class="tips-dialog-header">
|
|||
|
|
<h3>知识管理提示</h3>
|
|||
|
|
<button class="close-button" @click="closeTips">×</button>
|
|||
|
|
</div>
|
|||
|
|
<div class="tips-dialog-content">
|
|||
|
|
<div class="tip-item">
|
|||
|
|
<div class="tip-icon">📄</div>
|
|||
|
|
<div class="tip-content">
|
|||
|
|
<h4>PDF 文档</h4>
|
|||
|
|
<p>上传 PDF 文件,让客服读取文档内容。适合手册、报告和结构化文档。</p>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="tip-item">
|
|||
|
|
<div class="tip-icon">🔗</div>
|
|||
|
|
<div class="tip-content">
|
|||
|
|
<h4>网页</h4>
|
|||
|
|
<p>添加希望客服引用的网页 URL,适合经常更新的动态信息。</p>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="tip-item">
|
|||
|
|
<div class="tip-icon">🔄</div>
|
|||
|
|
<div class="tip-content">
|
|||
|
|
<h4>关联已有来源</h4>
|
|||
|
|
<p>在多个客服间复用知识来源,保持回答一致。</p>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="tip-item">
|
|||
|
|
<div class="tip-icon">⚙️</div>
|
|||
|
|
<div class="tip-content">
|
|||
|
|
<h4>处理时间</h4>
|
|||
|
|
<p>知识来源为异步处理。较大的文档可能需要几分钟才能完成索引。</p>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="tips-dialog-footer">
|
|||
|
|
<button class="close-tips-button" @click="closeTips">知道了</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Upgrade Modal (only shown when enterprise module exists) -->
|
|||
|
|
<div v-if="hasEnterpriseModule && showUpgradeModal" class="upgrade-modal-overlay">
|
|||
|
|
<div class="upgrade-modal">
|
|||
|
|
<div class="upgrade-modal-header">
|
|||
|
|
<div class="upgrade-icon">
|
|||
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|||
|
|
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
|
|||
|
|
<path d="M2 17l10 5 10-5"/>
|
|||
|
|
<path d="M2 12l10 5 10-5"/>
|
|||
|
|
</svg>
|
|||
|
|
</div>
|
|||
|
|
<h3>{{ modalTitle }}</h3>
|
|||
|
|
<button class="close-button" @click="closeUpgradeModal">×</button>
|
|||
|
|
</div>
|
|||
|
|
<div class="upgrade-modal-content">
|
|||
|
|
<p class="upgrade-description">
|
|||
|
|
{{ modalDescription }}
|
|||
|
|
</p>
|
|||
|
|
<div class="upgrade-features">
|
|||
|
|
<div v-for="feature in modalFeatures" :key="feature" class="feature-item">
|
|||
|
|
<svg class="feature-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|||
|
|
<polyline points="20,6 9,17 4,12"/>
|
|||
|
|
</svg>
|
|||
|
|
<span>{{ feature }}</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<!-- Live promo offer, when one applies to this organization -->
|
|||
|
|
<PromoGateNotice :feature="gateFeatureLabel" @claim="closeUpgradeModal" />
|
|||
|
|
</div>
|
|||
|
|
<div class="upgrade-modal-footer">
|
|||
|
|
<button class="upgrade-button" @click="handleUpgrade">
|
|||
|
|
升级套餐
|
|||
|
|
<svg class="upgrade-arrow" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|||
|
|
<path d="M5 12h14m-7-7 7 7-7 7"/>
|
|||
|
|
</svg>
|
|||
|
|
</button>
|
|||
|
|
<button class="cancel-upgrade-button" @click="closeUpgradeModal">以后再说</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<style scoped>
|
|||
|
|
.agent-detail {
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
min-height: 100vh;
|
|||
|
|
height: auto;
|
|||
|
|
background: var(--bg);
|
|||
|
|
overflow-x: hidden;
|
|||
|
|
max-width: 100vw;
|
|||
|
|
box-sizing: border-box;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.detail-panel {
|
|||
|
|
flex: 1;
|
|||
|
|
background: var(--surface);
|
|||
|
|
border: 1px solid var(--o08);
|
|||
|
|
border-radius: var(--radius-card);
|
|||
|
|
overflow: visible;
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
min-height: calc(100vh - 2 * var(--space-sm));
|
|||
|
|
max-width: 100%;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.panel-header {
|
|||
|
|
padding: 26px 32px var(--space-md);
|
|||
|
|
background: transparent;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.header-layout {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: var(--space-md);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.back-button {
|
|||
|
|
display: inline-flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
width: 40px;
|
|||
|
|
height: 40px;
|
|||
|
|
background: var(--o05);
|
|||
|
|
border: 1px solid var(--o12);
|
|||
|
|
border-radius: var(--radius-btn);
|
|||
|
|
cursor: pointer;
|
|||
|
|
transition: all var(--transition-fast);
|
|||
|
|
color: var(--text3);
|
|||
|
|
flex-shrink: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.back-button:hover {
|
|||
|
|
background: var(--o10);
|
|||
|
|
color: var(--text);
|
|||
|
|
transform: translateX(-2px);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.back-icon {
|
|||
|
|
transition: transform 0.2s ease;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.back-button:hover .back-icon {
|
|||
|
|
transform: translateX(-1px);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.agent-header {
|
|||
|
|
display: flex;
|
|||
|
|
gap: var(--space-md);
|
|||
|
|
align-items: center;
|
|||
|
|
flex: 1;
|
|||
|
|
min-width: 0; /* Allow shrinking */
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.agent-avatar {
|
|||
|
|
position: relative;
|
|||
|
|
cursor: pointer;
|
|||
|
|
width: 64px;
|
|||
|
|
height: 64px;
|
|||
|
|
flex-shrink: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* Preset avatar picker popover */
|
|||
|
|
.avatar-picker {
|
|||
|
|
position: absolute;
|
|||
|
|
top: calc(100% + 10px);
|
|||
|
|
left: 0;
|
|||
|
|
z-index: 40;
|
|||
|
|
width: 296px;
|
|||
|
|
padding: 14px;
|
|||
|
|
background: var(--surface);
|
|||
|
|
border: 1px solid var(--o12);
|
|||
|
|
border-radius: var(--radius-md);
|
|||
|
|
box-shadow: var(--shadow-lg);
|
|||
|
|
cursor: default;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.avatar-picker-title {
|
|||
|
|
font-size: 12.5px;
|
|||
|
|
font-weight: 600;
|
|||
|
|
color: var(--text3);
|
|||
|
|
margin-bottom: 10px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.avatar-picker-grid {
|
|||
|
|
display: grid;
|
|||
|
|
grid-template-columns: repeat(6, 1fr);
|
|||
|
|
gap: 8px;
|
|||
|
|
margin-bottom: 12px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.avatar-pick {
|
|||
|
|
width: 100%;
|
|||
|
|
aspect-ratio: 1;
|
|||
|
|
padding: 0;
|
|||
|
|
border-radius: 50%;
|
|||
|
|
overflow: hidden;
|
|||
|
|
background: var(--o05);
|
|||
|
|
border: 2px solid var(--o12);
|
|||
|
|
cursor: pointer;
|
|||
|
|
transition: var(--transition-fast);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.avatar-pick img {
|
|||
|
|
width: 100%;
|
|||
|
|
height: 100%;
|
|||
|
|
object-fit: cover;
|
|||
|
|
display: block;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.avatar-pick:hover:not(:disabled) {
|
|||
|
|
border-color: var(--o25);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.avatar-pick:disabled,
|
|||
|
|
.avatar-picker-upload:disabled {
|
|||
|
|
opacity: 0.5;
|
|||
|
|
cursor: not-allowed;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.agent-avatar.uploading {
|
|||
|
|
cursor: progress;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.avatar-pick.selected {
|
|||
|
|
border-color: var(--accent-ink);
|
|||
|
|
box-shadow: 0 0 0 2px var(--accent-bg-12);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* Aurora orb tile in the picker grid */
|
|||
|
|
.avatar-pick-orb {
|
|||
|
|
background: transparent;
|
|||
|
|
}
|
|||
|
|
.avatar-orb-thumb {
|
|||
|
|
display: block;
|
|||
|
|
width: 100%;
|
|||
|
|
height: 100%;
|
|||
|
|
border-radius: 50%;
|
|||
|
|
}
|
|||
|
|
/* Terminal ">" prompt mark tile in the picker grid */
|
|||
|
|
.avatar-terminal-thumb {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
width: 100%;
|
|||
|
|
height: 100%;
|
|||
|
|
border-radius: 50%;
|
|||
|
|
background: #0b0c10;
|
|||
|
|
box-shadow: inset 0 0 0 2px rgba(201, 242, 78, 0.55);
|
|||
|
|
color: #c9f24e;
|
|||
|
|
font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|||
|
|
font-weight: 700;
|
|||
|
|
font-size: 1.05rem;
|
|||
|
|
line-height: 1;
|
|||
|
|
}
|
|||
|
|
.avatar-pick-orb.active {
|
|||
|
|
border-color: var(--accent-ink);
|
|||
|
|
box-shadow: 0 0 0 2px var(--accent-bg-12);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.avatar-picker-upload {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
gap: 8px;
|
|||
|
|
width: 100%;
|
|||
|
|
padding: 9px;
|
|||
|
|
background: var(--o05);
|
|||
|
|
border: 1px solid var(--o14);
|
|||
|
|
border-radius: var(--radius-input);
|
|||
|
|
color: var(--text);
|
|||
|
|
font-family: var(--font-sans);
|
|||
|
|
font-size: 13px;
|
|||
|
|
font-weight: 500;
|
|||
|
|
cursor: pointer;
|
|||
|
|
transition: var(--transition-fast);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.avatar-picker-upload:hover {
|
|||
|
|
background: var(--o10);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* Ring around the avatar */
|
|||
|
|
.agent-avatar-ring {
|
|||
|
|
width: 64px;
|
|||
|
|
height: 64px;
|
|||
|
|
border-radius: 50%;
|
|||
|
|
padding: 3px;
|
|||
|
|
background: var(--o04);
|
|||
|
|
border: 1px solid var(--o12);
|
|||
|
|
box-sizing: border-box;
|
|||
|
|
overflow: hidden;
|
|||
|
|
position: relative;
|
|||
|
|
transition: border-color 0.2s ease;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.agent-avatar:hover .agent-avatar-ring {
|
|||
|
|
border-color: var(--o20);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.hidden {
|
|||
|
|
display: none;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.upload-overlay {
|
|||
|
|
position: absolute;
|
|||
|
|
top: 3px;
|
|||
|
|
left: 3px;
|
|||
|
|
right: 3px;
|
|||
|
|
bottom: 3px;
|
|||
|
|
background: rgba(0, 0, 0, 0.6);
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
color: var(--toggle-knob);
|
|||
|
|
opacity: 0;
|
|||
|
|
transition: opacity 0.3s ease;
|
|||
|
|
border-radius: 50%;
|
|||
|
|
font-size: 0.7rem;
|
|||
|
|
font-weight: 500;
|
|||
|
|
text-align: center;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.agent-avatar:hover .upload-overlay {
|
|||
|
|
opacity: 1;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.agent-avatar img {
|
|||
|
|
width: 100%;
|
|||
|
|
height: 100%;
|
|||
|
|
object-fit: cover;
|
|||
|
|
border-radius: 50%;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* Generated aurora orb shown in place of the avatar image */
|
|||
|
|
.agent-orb-avatar {
|
|||
|
|
width: 100%;
|
|||
|
|
height: 100%;
|
|||
|
|
border-radius: 50%;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* Lime pencil edit badge */
|
|||
|
|
.avatar-edit-badge {
|
|||
|
|
position: absolute;
|
|||
|
|
right: -2px;
|
|||
|
|
bottom: -2px;
|
|||
|
|
width: 22px;
|
|||
|
|
height: 22px;
|
|||
|
|
border-radius: 50%;
|
|||
|
|
background: var(--surface);
|
|||
|
|
border: 1px solid var(--o20);
|
|||
|
|
color: var(--accent-ink);
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.agent-info {
|
|||
|
|
flex: 1;
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
gap: var(--space-sm);
|
|||
|
|
min-width: 0; /* Allow shrinking */
|
|||
|
|
overflow: hidden;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.agent-info h3 {
|
|||
|
|
font-family: var(--font-display);
|
|||
|
|
font-size: 1.625rem;
|
|||
|
|
font-weight: 700;
|
|||
|
|
letter-spacing: var(--tracking-display);
|
|||
|
|
color: var(--text);
|
|||
|
|
margin: 0;
|
|||
|
|
line-height: 1.2;
|
|||
|
|
white-space: nowrap;
|
|||
|
|
overflow: hidden;
|
|||
|
|
text-overflow: ellipsis;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.name-section {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
gap: var(--space-sm);
|
|||
|
|
min-width: 0;
|
|||
|
|
overflow: hidden;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.name-display {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: var(--space-sm);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.name-edit {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: var(--space-sm);
|
|||
|
|
flex: 1;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.name-input {
|
|||
|
|
flex: 1;
|
|||
|
|
padding: var(--space-sm) var(--space-md);
|
|||
|
|
border: 1px solid var(--o12);
|
|||
|
|
border-radius: var(--radius-input);
|
|||
|
|
font-family: var(--font-display);
|
|||
|
|
font-size: 1.5rem;
|
|||
|
|
font-weight: 700;
|
|||
|
|
letter-spacing: var(--tracking-display);
|
|||
|
|
background: var(--o05);
|
|||
|
|
color: var(--text);
|
|||
|
|
min-width: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.name-input:focus {
|
|||
|
|
outline: none;
|
|||
|
|
border-color: var(--accent-ink);
|
|||
|
|
box-shadow: var(--ring-focus);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.edit-actions {
|
|||
|
|
display: flex;
|
|||
|
|
gap: var(--space-xs);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.edit-icon-button {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
width: 32px;
|
|||
|
|
height: 32px;
|
|||
|
|
border: none;
|
|||
|
|
background: var(--background-soft);
|
|||
|
|
border-radius: var(--radius-md);
|
|||
|
|
color: var(--text-muted);
|
|||
|
|
cursor: pointer;
|
|||
|
|
transition: all 0.2s ease;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.edit-icon-button:hover {
|
|||
|
|
background: var(--background-muted);
|
|||
|
|
color: var(--text-color);
|
|||
|
|
transform: translateY(-1px);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.save-icon-button {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
width: 28px;
|
|||
|
|
height: 28px;
|
|||
|
|
border: none;
|
|||
|
|
background: var(--success-color);
|
|||
|
|
border-radius: var(--radius-sm);
|
|||
|
|
color: var(--toggle-knob);
|
|||
|
|
cursor: pointer;
|
|||
|
|
transition: all 0.2s ease;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.save-icon-button:hover {
|
|||
|
|
background: var(--success-dark);
|
|||
|
|
transform: translateY(-1px);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.cancel-icon-button {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
width: 28px;
|
|||
|
|
height: 28px;
|
|||
|
|
border: none;
|
|||
|
|
background: var(--background-muted);
|
|||
|
|
border-radius: var(--radius-sm);
|
|||
|
|
color: var(--text-muted);
|
|||
|
|
cursor: pointer;
|
|||
|
|
transition: all 0.2s ease;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.cancel-icon-button:hover {
|
|||
|
|
background: var(--error-color);
|
|||
|
|
color: var(--toggle-knob);
|
|||
|
|
transform: translateY(-1px);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.status-toggle {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: var(--space-sm);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.status-switch {
|
|||
|
|
position: relative;
|
|||
|
|
display: inline-block;
|
|||
|
|
width: 46px;
|
|||
|
|
height: 26px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.status-switch input {
|
|||
|
|
opacity: 0;
|
|||
|
|
width: 0;
|
|||
|
|
height: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.status-slider {
|
|||
|
|
position: absolute;
|
|||
|
|
cursor: pointer;
|
|||
|
|
top: 0;
|
|||
|
|
left: 0;
|
|||
|
|
right: 0;
|
|||
|
|
bottom: 0;
|
|||
|
|
background-color: var(--toggle-track-off);
|
|||
|
|
transition: .25s;
|
|||
|
|
border-radius: var(--radius-pill);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.status-slider:before {
|
|||
|
|
position: absolute;
|
|||
|
|
content: "";
|
|||
|
|
height: 20px;
|
|||
|
|
width: 20px;
|
|||
|
|
left: 3px;
|
|||
|
|
bottom: 3px;
|
|||
|
|
background-color: var(--toggle-knob);
|
|||
|
|
transition: .25s;
|
|||
|
|
border-radius: 50%;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.status-switch input:checked + .status-slider {
|
|||
|
|
background-color: var(--toggle-on-teal);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.status-switch input:checked + .status-slider:before {
|
|||
|
|
transform: translateX(20px);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.status-switch input:disabled + .status-slider {
|
|||
|
|
cursor: not-allowed;
|
|||
|
|
opacity: 0.7;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.status-switch.editing input:disabled + .status-slider {
|
|||
|
|
cursor: pointer;
|
|||
|
|
opacity: 1;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.status-switch:not(.editing) .status-slider:hover {
|
|||
|
|
opacity: 0.8;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
|
|||
|
|
.status-and-mode {
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: row;
|
|||
|
|
align-items: center;
|
|||
|
|
flex-wrap: wrap;
|
|||
|
|
gap: 16px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.status {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: var(--space-sm);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.status-indicator {
|
|||
|
|
width: 12px;
|
|||
|
|
height: 12px;
|
|||
|
|
border-radius: 50%;
|
|||
|
|
background: var(--error-color);
|
|||
|
|
position: relative;
|
|||
|
|
animation: pulse-offline 2s infinite;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.status-indicator.online {
|
|||
|
|
background: var(--success-color);
|
|||
|
|
animation: pulse-online 2s infinite;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.status-text {
|
|||
|
|
font-weight: 500;
|
|||
|
|
font-size: 14px;
|
|||
|
|
color: var(--muted2);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.status-text.online {
|
|||
|
|
color: var(--c-online);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* Mode Selection Styles — standalone solid pills */
|
|||
|
|
.mode-selection {
|
|||
|
|
margin-top: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.mode-toggle {
|
|||
|
|
display: inline-flex;
|
|||
|
|
gap: 6px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.mode-button {
|
|||
|
|
display: inline-flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 6px;
|
|||
|
|
padding: 7px 14px;
|
|||
|
|
background: var(--pill-idle-bg);
|
|||
|
|
border: none;
|
|||
|
|
border-radius: var(--radius-pill);
|
|||
|
|
cursor: pointer;
|
|||
|
|
transition: background var(--transition-fast), color var(--transition-fast), filter var(--transition-fast);
|
|||
|
|
font-size: 13px;
|
|||
|
|
font-weight: var(--font-weight-semibold);
|
|||
|
|
color: var(--pill-idle-fg);
|
|||
|
|
white-space: nowrap;
|
|||
|
|
position: relative;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.mode-button:hover:not(.active):not(.locked) {
|
|||
|
|
filter: brightness(1.2);
|
|||
|
|
color: var(--text3);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* AI Mode active — solid blue */
|
|||
|
|
.mode-button:first-child.active {
|
|||
|
|
background: var(--mode-ai-bg);
|
|||
|
|
color: var(--mode-ai-fg);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* Workflow active — solid purple */
|
|||
|
|
.mode-button:last-child.active {
|
|||
|
|
background: var(--mode-wf-bg);
|
|||
|
|
color: var(--mode-wf-fg);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.mode-icon {
|
|||
|
|
width: 14px;
|
|||
|
|
height: 14px;
|
|||
|
|
stroke-width: 2;
|
|||
|
|
flex-shrink: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.mode-label {
|
|||
|
|
font-size: 13px;
|
|||
|
|
font-weight: var(--font-weight-semibold);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.mode-button.locked {
|
|||
|
|
opacity: 0.6;
|
|||
|
|
/* the lock opens the upgrade dialog, so invite the click */
|
|||
|
|
cursor: pointer;
|
|||
|
|
position: relative;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.mode-button.locked:hover {
|
|||
|
|
filter: none;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.lock-icon {
|
|||
|
|
font-size: 10px;
|
|||
|
|
margin-left: var(--space-xs);
|
|||
|
|
opacity: 0.7;
|
|||
|
|
color: var(--warning-color);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@keyframes pulse-online {
|
|||
|
|
0% {
|
|||
|
|
box-shadow: 0 0 0 0 color-mix(in srgb, var(--success-color) 70%, transparent);
|
|||
|
|
}
|
|||
|
|
70% {
|
|||
|
|
box-shadow: 0 0 0 10px color-mix(in srgb, var(--success-color) 0%, transparent);
|
|||
|
|
}
|
|||
|
|
100% {
|
|||
|
|
box-shadow: 0 0 0 0 color-mix(in srgb, var(--success-color) 0%, transparent);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@keyframes pulse-offline {
|
|||
|
|
0% {
|
|||
|
|
box-shadow: 0 0 0 0 color-mix(in srgb, var(--error-color) 70%, transparent);
|
|||
|
|
}
|
|||
|
|
70% {
|
|||
|
|
box-shadow: 0 0 0 10px color-mix(in srgb, var(--error-color) 0%, transparent);
|
|||
|
|
}
|
|||
|
|
100% {
|
|||
|
|
box-shadow: 0 0 0 0 color-mix(in srgb, var(--error-color) 0%, transparent);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.detail-section {
|
|||
|
|
margin-bottom: var(--space-xl);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.detail-section h4 {
|
|||
|
|
margin-bottom: var(--space-md);
|
|||
|
|
color: var(--text-muted);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.instructions-textarea {
|
|||
|
|
width: 100%;
|
|||
|
|
min-height: 150px;
|
|||
|
|
padding: var(--space-sm);
|
|||
|
|
background: var(--background-soft);
|
|||
|
|
border: 1px solid var(--border-color);
|
|||
|
|
border-radius: var(--radius-lg);
|
|||
|
|
font-family: inherit;
|
|||
|
|
font-size: inherit;
|
|||
|
|
line-height: 1.5;
|
|||
|
|
resize: vertical;
|
|||
|
|
color: var(--text-color);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.instructions-textarea:read-only {
|
|||
|
|
background: var(--background-soft);
|
|||
|
|
cursor: default;
|
|||
|
|
opacity: 0.9;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.instructions-textarea:focus {
|
|||
|
|
outline: none;
|
|||
|
|
border-color: var(--primary-color);
|
|||
|
|
box-shadow: 0 0 0 2px var(--primary-soft);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.cropper-modal {
|
|||
|
|
position: fixed;
|
|||
|
|
top: 0;
|
|||
|
|
left: 0;
|
|||
|
|
right: 0;
|
|||
|
|
bottom: 0;
|
|||
|
|
background: rgba(0, 0, 0, 0.85);
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
z-index: 1000;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.cropper-container {
|
|||
|
|
background: var(--bg);
|
|||
|
|
padding: 2rem;
|
|||
|
|
border-radius: 12px;
|
|||
|
|
width: 90%;
|
|||
|
|
max-width: 600px;
|
|||
|
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.crop-area {
|
|||
|
|
position: relative;
|
|||
|
|
width: 100%;
|
|||
|
|
height: 400px;
|
|||
|
|
background: var(--bg2);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.cropper-actions {
|
|||
|
|
margin-top: 1rem;
|
|||
|
|
display: flex;
|
|||
|
|
gap: 1rem;
|
|||
|
|
justify-content: flex-end;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.cropper-actions button {
|
|||
|
|
padding: 0.5rem 1rem;
|
|||
|
|
border-radius: 4px;
|
|||
|
|
border: none;
|
|||
|
|
cursor: pointer;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.cropper-actions button:first-child {
|
|||
|
|
background: var(--accent-solid);
|
|||
|
|
color: var(--on-accent-solid);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.cropper-actions button:last-child {
|
|||
|
|
background: var(--background-soft);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.cropper-actions button:disabled {
|
|||
|
|
opacity: 0.7;
|
|||
|
|
cursor: not-allowed;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.preview-circle {
|
|||
|
|
border-radius: 50%;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
:deep(.vue-advanced-cropper__foreground) {
|
|||
|
|
border-radius: 50%;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
:deep(.vue-advanced-cropper__background) {
|
|||
|
|
background: var(--o05);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.widget-info {
|
|||
|
|
background: var(--background-soft);
|
|||
|
|
border-radius: var(--radius-lg);
|
|||
|
|
padding: var(--space-sm);
|
|||
|
|
width: 100%;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.widget-code {
|
|||
|
|
font-family: monospace;
|
|||
|
|
font-size: 0.85em;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.code-container {
|
|||
|
|
background: var(--background-alt);
|
|||
|
|
padding: var(--space-sm);
|
|||
|
|
border-radius: var(--radius-md);
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
gap: var(--space-sm);
|
|||
|
|
width: 100%;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.code-container code {
|
|||
|
|
font-size: 11px;
|
|||
|
|
color: var(--text-muted);
|
|||
|
|
white-space: normal;
|
|||
|
|
word-break: break-all;
|
|||
|
|
flex: 1;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.copy-button {
|
|||
|
|
background: transparent;
|
|||
|
|
border: none;
|
|||
|
|
padding: var(--space-xs);
|
|||
|
|
border-radius: var(--radius-sm);
|
|||
|
|
cursor: pointer;
|
|||
|
|
color: var(--text-muted);
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
flex-shrink: 0;
|
|||
|
|
transition: all 0.2s ease;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.copy-button:hover {
|
|||
|
|
background: var(--background-soft);
|
|||
|
|
color: var(--text-color);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.widget-code pre {
|
|||
|
|
display: none;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.section-header {
|
|||
|
|
display: flex;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
align-items: center;
|
|||
|
|
margin-bottom: var(--space-md);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.toggle-switch {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: var(--space-sm);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.toggle-label {
|
|||
|
|
font-size: 0.9em;
|
|||
|
|
color: var(--text-muted);
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 0.5rem;
|
|||
|
|
cursor: help;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.info-icon {
|
|||
|
|
font-size: 0.9em;
|
|||
|
|
color: var(--text-muted);
|
|||
|
|
opacity: 0.7;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.switch {
|
|||
|
|
position: relative;
|
|||
|
|
display: inline-block;
|
|||
|
|
width: 48px;
|
|||
|
|
height: 24px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.switch input {
|
|||
|
|
opacity: 0;
|
|||
|
|
width: 0;
|
|||
|
|
height: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.slider {
|
|||
|
|
position: absolute;
|
|||
|
|
cursor: pointer;
|
|||
|
|
top: 0;
|
|||
|
|
left: 0;
|
|||
|
|
right: 0;
|
|||
|
|
bottom: 0;
|
|||
|
|
background-color: var(--toggle-track-off);
|
|||
|
|
transition: .4s;
|
|||
|
|
border-radius: 24px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.slider:before {
|
|||
|
|
position: absolute;
|
|||
|
|
content: "";
|
|||
|
|
height: 18px;
|
|||
|
|
width: 18px;
|
|||
|
|
left: 3px;
|
|||
|
|
bottom: 3px;
|
|||
|
|
background-color: var(--toggle-knob);
|
|||
|
|
transition: .4s;
|
|||
|
|
border-radius: 50%;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
input:checked + .slider {
|
|||
|
|
background-color: var(--success-color);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
input:focus + .slider {
|
|||
|
|
box-shadow: 0 0 1px var(--primary-color);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
input:checked + .slider:before {
|
|||
|
|
transform: translateX(24px);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
:deep(.v-popper__inner) {
|
|||
|
|
white-space: pre-line;
|
|||
|
|
max-width: 300px;
|
|||
|
|
line-height: 1.4;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
:deep(.v-popper__arrow) {
|
|||
|
|
border-color: var(--background-alt);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
:deep(.v-popper__inner) {
|
|||
|
|
background: var(--background-alt);
|
|||
|
|
border: 1px solid var(--border-color);
|
|||
|
|
color: var(--text-color);
|
|||
|
|
padding: var(--space-sm);
|
|||
|
|
border-radius: var(--radius-md);
|
|||
|
|
font-size: 0.9em;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.transfer-section {
|
|||
|
|
margin-top: var(--space-lg);
|
|||
|
|
border-top: 1px solid var(--border-color);
|
|||
|
|
padding-top: var(--space-lg);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.transfer-toggle {
|
|||
|
|
margin-bottom: var(--space-lg);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.toggle-header {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
margin-bottom: var(--space-sm);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.transfer-groups {
|
|||
|
|
padding: var(--space-md);
|
|||
|
|
background: var(--background-soft);
|
|||
|
|
border-radius: var(--radius-md);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.helper-text {
|
|||
|
|
color: var(--text-muted);
|
|||
|
|
font-size: var(--text-sm);
|
|||
|
|
margin-bottom: var(--space-md);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.groups-list {
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
gap: var(--space-sm);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.group-item {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: var(--space-sm);
|
|||
|
|
cursor: pointer;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.no-groups-message {
|
|||
|
|
text-align: center;
|
|||
|
|
padding: var(--space-lg);
|
|||
|
|
background: var(--background-mute);
|
|||
|
|
border-radius: var(--radius-md);
|
|||
|
|
color: var(--text-muted);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.create-group-link {
|
|||
|
|
display: inline-flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: var(--space-sm);
|
|||
|
|
margin-top: var(--space-md);
|
|||
|
|
color: var(--primary-color);
|
|||
|
|
font-weight: 500;
|
|||
|
|
transition: opacity var(--transition-fast);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.create-group-link:hover {
|
|||
|
|
opacity: 0.8;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.create-group-link i {
|
|||
|
|
font-size: 0.8em;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.loading-groups {
|
|||
|
|
text-align: center;
|
|||
|
|
padding: var(--space-md);
|
|||
|
|
background: var(--background-mute);
|
|||
|
|
border-radius: var(--radius-md);
|
|||
|
|
color: var(--text-muted);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.preview-panel {
|
|||
|
|
background: var(--background-soft);
|
|||
|
|
border-radius: var(--radius-lg);
|
|||
|
|
overflow: hidden;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.widget-preview {
|
|||
|
|
width: 100%;
|
|||
|
|
max-width: 384px;
|
|||
|
|
height: 560px;
|
|||
|
|
border: none;
|
|||
|
|
background: none;
|
|||
|
|
border-radius: var(--radius-lg);
|
|||
|
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.preview-container {
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: row;
|
|||
|
|
gap: var(--space-xl);
|
|||
|
|
padding: var(--space-lg);
|
|||
|
|
height: 100%;
|
|||
|
|
/* Keep the description + preview grouped and centered instead of spread
|
|||
|
|
across ultrawide screens. */
|
|||
|
|
max-width: 1040px;
|
|||
|
|
margin: 0 auto;
|
|||
|
|
justify-content: center;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.preview-header {
|
|||
|
|
flex: 0 1 460px;
|
|||
|
|
max-width: 460px;
|
|||
|
|
overflow-y: auto;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.preview-title {
|
|||
|
|
font-size: 1.25rem;
|
|||
|
|
font-weight: 600;
|
|||
|
|
color: var(--text-color);
|
|||
|
|
margin-bottom: var(--space-sm);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.preview-wrapper {
|
|||
|
|
/* Width comes from the inline previewWrapperStyles (400px / 500px); don't
|
|||
|
|
let flex-grow stretch it. */
|
|||
|
|
flex: 0 0 auto;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: flex-start;
|
|||
|
|
justify-content: center;
|
|||
|
|
border-radius: var(--radius-lg);
|
|||
|
|
padding: var(--space-lg);
|
|||
|
|
padding-top: 0;
|
|||
|
|
transition: all 0.3s ease;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.loading-preview {
|
|||
|
|
color: var(--text-muted);
|
|||
|
|
text-align: center;
|
|||
|
|
padding: var(--space-xl);
|
|||
|
|
background: var(--background-alt);
|
|||
|
|
border-radius: var(--radius-lg);
|
|||
|
|
width: 100%;
|
|||
|
|
height: 100%;
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.loading-preview::before {
|
|||
|
|
content: "";
|
|||
|
|
width: 40px;
|
|||
|
|
height: 40px;
|
|||
|
|
margin-bottom: var(--space-md);
|
|||
|
|
border: 3px solid var(--border-color);
|
|||
|
|
border-radius: 50%;
|
|||
|
|
border-top-color: var(--primary-color);
|
|||
|
|
animation: loading-spinner 1s linear infinite;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@keyframes loading-spinner {
|
|||
|
|
to {
|
|||
|
|
transform: rotate(360deg);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* Preview unavailable state (when token auth is required) */
|
|||
|
|
.preview-unavailable {
|
|||
|
|
text-align: center;
|
|||
|
|
padding: var(--space-xl);
|
|||
|
|
background: var(--background-soft);
|
|||
|
|
border-radius: var(--radius-lg);
|
|||
|
|
width: 100%;
|
|||
|
|
max-width: 400px;
|
|||
|
|
margin: 0 auto;
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
gap: var(--space-md);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.preview-unavailable-icon {
|
|||
|
|
width: 80px;
|
|||
|
|
height: 80px;
|
|||
|
|
background: color-mix(in srgb, var(--warning-color) 10%, transparent);
|
|||
|
|
border-radius: 50%;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
color: var(--warning-color);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.preview-unavailable-title {
|
|||
|
|
font-size: var(--text-lg);
|
|||
|
|
font-weight: 600;
|
|||
|
|
color: var(--text-color);
|
|||
|
|
margin: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.preview-unavailable-description {
|
|||
|
|
color: var(--text-muted);
|
|||
|
|
margin: 0;
|
|||
|
|
line-height: 1.5;
|
|||
|
|
font-size: var(--text-sm);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.preview-unavailable-hint {
|
|||
|
|
color: var(--text-muted);
|
|||
|
|
margin: 0;
|
|||
|
|
line-height: 1.5;
|
|||
|
|
font-size: var(--text-xs);
|
|||
|
|
padding: var(--space-sm) var(--space-md);
|
|||
|
|
background: var(--background-mute);
|
|||
|
|
border-radius: var(--radius-md);
|
|||
|
|
max-width: 350px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.rating-toggle {
|
|||
|
|
margin-top: var(--space-lg);
|
|||
|
|
padding-top: var(--space-lg);
|
|||
|
|
border-top: 1px solid var(--border-color);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.ticket-toggle {
|
|||
|
|
margin-top: var(--space-lg);
|
|||
|
|
padding-top: var(--space-lg);
|
|||
|
|
border-top: 1px solid var(--border-color);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.jira-status {
|
|||
|
|
margin-top: var(--space-sm);
|
|||
|
|
padding: var(--space-sm);
|
|||
|
|
border-radius: var(--radius-md);
|
|||
|
|
font-size: var(--text-sm);
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: var(--space-xs);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.jira-status.loading {
|
|||
|
|
background-color: var(--background-mute);
|
|||
|
|
color: var(--text-muted);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.jira-status.connected {
|
|||
|
|
background-color: var(--success-light);
|
|||
|
|
color: var(--success);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.jira-status.not-connected {
|
|||
|
|
background-color: var(--warning-light);
|
|||
|
|
color: var(--warning);
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.connect-link {
|
|||
|
|
color: var(--primary-color);
|
|||
|
|
font-weight: 500;
|
|||
|
|
text-decoration: none;
|
|||
|
|
padding: var(--space-xs) var(--space-sm);
|
|||
|
|
background-color: var(--primary-soft);
|
|||
|
|
border-radius: var(--radius-full);
|
|||
|
|
transition: all var(--transition-fast);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.connect-link:hover {
|
|||
|
|
background-color: var(--accent-solid);
|
|||
|
|
color: var(--on-accent-solid);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.jira-config {
|
|||
|
|
margin-top: var(--space-md);
|
|||
|
|
padding: var(--space-md);
|
|||
|
|
background-color: var(--background-soft);
|
|||
|
|
border-radius: var(--radius-md);
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
gap: var(--space-md);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.form-group {
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
gap: var(--space-xs);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.form-group label {
|
|||
|
|
font-size: var(--text-sm);
|
|||
|
|
font-weight: 500;
|
|||
|
|
color: var(--text-muted);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.form-group select {
|
|||
|
|
padding: var(--space-sm);
|
|||
|
|
border: 1px solid var(--border-color);
|
|||
|
|
border-radius: var(--radius-md);
|
|||
|
|
background-color: var(--background-color);
|
|||
|
|
color: var(--text-color);
|
|||
|
|
font-size: var(--text-sm);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.form-group select:disabled {
|
|||
|
|
opacity: 0.7;
|
|||
|
|
cursor: not-allowed;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.loading-indicator {
|
|||
|
|
font-size: var(--text-sm);
|
|||
|
|
color: var(--text-muted);
|
|||
|
|
padding: var(--space-sm);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.save-config-btn {
|
|||
|
|
margin-top: var(--space-sm);
|
|||
|
|
padding: var(--space-sm) var(--space-md);
|
|||
|
|
background: linear-gradient(135deg, var(--accent-solid), var(--accent-solid));
|
|||
|
|
color: var(--on-accent-solid);
|
|||
|
|
border: none;
|
|||
|
|
border-radius: var(--radius-full);
|
|||
|
|
font-weight: 500;
|
|||
|
|
cursor: pointer;
|
|||
|
|
transition: all var(--transition-fast);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.save-config-btn:hover {
|
|||
|
|
filter: brightness(1.1);
|
|||
|
|
transform: translateY(-1px);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.save-config-btn:disabled {
|
|||
|
|
opacity: 0.7;
|
|||
|
|
cursor: not-allowed;
|
|||
|
|
filter: grayscale(0.5);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.agent-workflow-badge {
|
|||
|
|
margin-top: var(--space-sm);
|
|||
|
|
display: inline-flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: var(--space-xs);
|
|||
|
|
padding: var(--space-xs) var(--space-sm);
|
|||
|
|
background: var(--primary-soft);
|
|||
|
|
color: var(--primary-color);
|
|||
|
|
border-radius: var(--radius-full);
|
|||
|
|
font-size: 0.75rem;
|
|||
|
|
font-weight: 500;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.workflow-icon {
|
|||
|
|
font-size: 0.875rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tab-content {
|
|||
|
|
animation: fadeIn 0.4s ease;
|
|||
|
|
flex: 1;
|
|||
|
|
overflow: visible;
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
background: transparent;
|
|||
|
|
margin-top: var(--space-md);
|
|||
|
|
min-height: calc(100vh - 300px);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.content-layout {
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
min-height: 100%;
|
|||
|
|
flex: 1;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.panel-content {
|
|||
|
|
flex: 1;
|
|||
|
|
overflow: visible;
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
min-height: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tab-content-container {
|
|||
|
|
flex: 1;
|
|||
|
|
overflow: visible;
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
background: transparent;
|
|||
|
|
/* No horizontal padding: each tab's own root padding (24px) sets the inset
|
|||
|
|
so content aligns with the tab bar. */
|
|||
|
|
padding: var(--space-sm) 0 var(--space-lg);
|
|||
|
|
min-height: calc(100vh - 200px);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tab-button {
|
|||
|
|
padding: 14px 18px;
|
|||
|
|
background: transparent;
|
|||
|
|
border: none;
|
|||
|
|
cursor: pointer;
|
|||
|
|
transition: color 0.2s ease, border-color 0.2s ease;
|
|||
|
|
font-family: var(--font-sans);
|
|||
|
|
font-weight: var(--font-weight-medium);
|
|||
|
|
font-size: 14.5px;
|
|||
|
|
color: var(--muted);
|
|||
|
|
position: relative;
|
|||
|
|
text-align: center;
|
|||
|
|
white-space: nowrap;
|
|||
|
|
border-bottom: 2px solid transparent;
|
|||
|
|
flex-shrink: 0;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
gap: 7px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tabs-navigation {
|
|||
|
|
flex-shrink: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tabs-navigation.horizontal {
|
|||
|
|
display: flex;
|
|||
|
|
gap: 4px;
|
|||
|
|
border-bottom: 1px solid var(--o07);
|
|||
|
|
padding: 0 32px;
|
|||
|
|
margin: 0;
|
|||
|
|
overflow-x: auto;
|
|||
|
|
background: var(--bg2);
|
|||
|
|
scrollbar-width: none;
|
|||
|
|
-ms-overflow-style: none;
|
|||
|
|
align-items: flex-end;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tabs-navigation.horizontal::-webkit-scrollbar {
|
|||
|
|
display: none; /* Chrome, Safari and Opera */
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tab-button:hover {
|
|||
|
|
color: var(--text2);
|
|||
|
|
background: transparent;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tab-button.active {
|
|||
|
|
color: var(--accent-ink);
|
|||
|
|
font-weight: var(--font-weight-semibold);
|
|||
|
|
background: transparent;
|
|||
|
|
border-bottom-color: var(--accent-ink);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tabs-navigation.horizontal .tab-button.active::after {
|
|||
|
|
display: none;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tab-button.locked {
|
|||
|
|
opacity: 0.6;
|
|||
|
|
/* the lock opens the upgrade dialog, so invite the click */
|
|||
|
|
cursor: pointer;
|
|||
|
|
position: relative;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tab-button.locked:hover {
|
|||
|
|
color: var(--text-muted);
|
|||
|
|
background: var(--background-soft);
|
|||
|
|
transform: none;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.lock-icon-small {
|
|||
|
|
font-size: 12px;
|
|||
|
|
margin-left: var(--space-xs);
|
|||
|
|
opacity: 0.7;
|
|||
|
|
color: var(--warning-color);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.integration-section {
|
|||
|
|
border: 1px solid var(--border-color);
|
|||
|
|
border-radius: var(--radius-md);
|
|||
|
|
padding: var(--space-md);
|
|||
|
|
background-color: var(--background-soft);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@keyframes fadeIn {
|
|||
|
|
from {
|
|||
|
|
opacity: 0;
|
|||
|
|
transform: translateY(5px);
|
|||
|
|
}
|
|||
|
|
to {
|
|||
|
|
opacity: 1;
|
|||
|
|
transform: translateY(0);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.knowledge-tab-container {
|
|||
|
|
max-width: 1200px;
|
|||
|
|
margin: 0 auto;
|
|||
|
|
width: 100%;
|
|||
|
|
padding: 0 var(--space-lg);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.kb-open-link {
|
|||
|
|
display: inline-flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 7px;
|
|||
|
|
height: 47px;
|
|||
|
|
padding: 0 18px;
|
|||
|
|
border-radius: 11px;
|
|||
|
|
background: var(--o05);
|
|||
|
|
border: 1px solid var(--o12);
|
|||
|
|
color: var(--text2);
|
|||
|
|
font-size: 14px;
|
|||
|
|
font-weight: 600;
|
|||
|
|
text-decoration: none;
|
|||
|
|
white-space: nowrap;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.kb-open-link:hover {
|
|||
|
|
background: var(--o08);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.section-title {
|
|||
|
|
font-family: var(--font-display);
|
|||
|
|
font-size: 1.5rem;
|
|||
|
|
font-weight: 700;
|
|||
|
|
color: var(--text);
|
|||
|
|
margin-bottom: var(--space-lg);
|
|||
|
|
line-height: 1.3;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.section-description {
|
|||
|
|
color: var(--muted);
|
|||
|
|
font-size: 1rem;
|
|||
|
|
line-height: 1.6;
|
|||
|
|
margin-bottom: var(--space-xl);
|
|||
|
|
max-width: 600px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.knowledge-header {
|
|||
|
|
display: flex;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
align-items: center;
|
|||
|
|
margin-bottom: var(--space-md);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.knowledge-actions {
|
|||
|
|
display: flex;
|
|||
|
|
gap: var(--space-sm);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.knowledge-action-button {
|
|||
|
|
background: transparent;
|
|||
|
|
border: none;
|
|||
|
|
padding: var(--space-xs) var(--space-sm);
|
|||
|
|
cursor: pointer;
|
|||
|
|
color: var(--text-muted);
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: var(--space-xs);
|
|||
|
|
transition: all 0.2s ease;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.knowledge-action-button:hover {
|
|||
|
|
color: var(--text-color);
|
|||
|
|
background-color: var(--background-soft);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.icon {
|
|||
|
|
font-size: 1.25rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tips-dialog-overlay {
|
|||
|
|
position: fixed;
|
|||
|
|
top: 0;
|
|||
|
|
left: 0;
|
|||
|
|
right: 0;
|
|||
|
|
bottom: 0;
|
|||
|
|
background: rgba(0, 0, 0, 0.85);
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
z-index: 1000;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tips-dialog {
|
|||
|
|
background: var(--bg);
|
|||
|
|
padding: 2rem;
|
|||
|
|
border-radius: 12px;
|
|||
|
|
width: 90%;
|
|||
|
|
max-width: 600px;
|
|||
|
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tips-dialog-header {
|
|||
|
|
display: flex;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
align-items: center;
|
|||
|
|
margin-bottom: var(--space-md);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tips-dialog-header h3 {
|
|||
|
|
font-size: 1.25rem;
|
|||
|
|
font-weight: 600;
|
|||
|
|
color: var(--text-color);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.close-button {
|
|||
|
|
background: transparent;
|
|||
|
|
border: none;
|
|||
|
|
cursor: pointer;
|
|||
|
|
font-size: 1.25rem;
|
|||
|
|
color: var(--text-muted);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tips-dialog-content {
|
|||
|
|
margin-bottom: var(--space-md);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tip-item {
|
|||
|
|
display: flex;
|
|||
|
|
gap: var(--space-md);
|
|||
|
|
margin-bottom: var(--space-md);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tip-icon {
|
|||
|
|
font-size: 1.25rem;
|
|||
|
|
color: var(--text-muted);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tip-content {
|
|||
|
|
flex: 1;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tip-content h4 {
|
|||
|
|
font-size: 1rem;
|
|||
|
|
font-weight: 600;
|
|||
|
|
color: var(--text-color);
|
|||
|
|
margin-bottom: var(--space-xs);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tip-content p {
|
|||
|
|
color: var(--text-muted);
|
|||
|
|
font-size: 0.9rem;
|
|||
|
|
line-height: 1.5;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tips-dialog-footer {
|
|||
|
|
text-align: right;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.close-tips-button {
|
|||
|
|
padding: var(--space-sm) var(--space-md);
|
|||
|
|
background: var(--accent-solid);
|
|||
|
|
color: var(--on-accent-solid);
|
|||
|
|
border: none;
|
|||
|
|
border-radius: var(--radius-full);
|
|||
|
|
cursor: pointer;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.customization-tab-layout {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: flex-start;
|
|||
|
|
gap: var(--space-xl);
|
|||
|
|
padding: var(--space-lg);
|
|||
|
|
/* Group the options + preview and center them rather than letting the
|
|||
|
|
preview column stretch across ultrawide screens. */
|
|||
|
|
max-width: 1040px;
|
|||
|
|
margin: 0 auto;
|
|||
|
|
justify-content: center;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.customization-panel {
|
|||
|
|
flex: 0 0 460px;
|
|||
|
|
max-width: 480px;
|
|||
|
|
/* Match the chat preview window height (.chat-container is 600px) */
|
|||
|
|
height: 600px;
|
|||
|
|
max-height: 600px;
|
|||
|
|
overflow-y: auto;
|
|||
|
|
overflow-x: hidden;
|
|||
|
|
padding-right: var(--space-xs);
|
|||
|
|
scrollbar-width: thin;
|
|||
|
|
scrollbar-color: var(--o25) transparent;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.customization-panel::-webkit-scrollbar {
|
|||
|
|
width: 8px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.customization-panel::-webkit-scrollbar-track {
|
|||
|
|
background: transparent;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.customization-panel::-webkit-scrollbar-thumb {
|
|||
|
|
background-color: var(--o25);
|
|||
|
|
border-radius: 4px;
|
|||
|
|
transition: background-color 0.2s ease;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.customization-panel::-webkit-scrollbar-thumb:hover {
|
|||
|
|
background-color: var(--o25);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.customization-preview {
|
|||
|
|
flex: 0 1 auto;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: flex-start;
|
|||
|
|
justify-content: center;
|
|||
|
|
border-radius: var(--radius-lg);
|
|||
|
|
padding: var(--space-lg);
|
|||
|
|
padding-top: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* Responsive Design for smaller laptops */
|
|||
|
|
@media (max-width: 1600px) {
|
|||
|
|
.panel-header {
|
|||
|
|
padding: var(--space-md) var(--space-md) var(--space-sm);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.header-layout {
|
|||
|
|
gap: var(--space-sm);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.agent-header {
|
|||
|
|
gap: var(--space-sm);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.agent-avatar {
|
|||
|
|
width: 64px;
|
|||
|
|
height: 64px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.agent-avatar-ring {
|
|||
|
|
width: 64px;
|
|||
|
|
height: 64px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.agent-info h3 {
|
|||
|
|
font-size: 1.5rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tabs-navigation.horizontal {
|
|||
|
|
padding: 0 var(--space-lg);
|
|||
|
|
gap: 4px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tab-button {
|
|||
|
|
padding: 12px 16px;
|
|||
|
|
font-size: 14px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tab-content-container {
|
|||
|
|
padding: var(--space-sm) 0 var(--space-md);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@media (max-width: 1440px) {
|
|||
|
|
.detail-panel {
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.panel-header {
|
|||
|
|
padding: var(--space-lg) var(--space-lg) var(--space-sm);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.agent-info h3 {
|
|||
|
|
font-size: 1.375rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.status-and-mode {
|
|||
|
|
gap: var(--space-xs);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.status-text {
|
|||
|
|
font-size: 13px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tabs-navigation.horizontal {
|
|||
|
|
padding: 0 var(--space-lg);
|
|||
|
|
gap: 4px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tab-button {
|
|||
|
|
padding: 12px 14px;
|
|||
|
|
font-size: 13.5px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.customization-tab-layout {
|
|||
|
|
padding: var(--space-sm);
|
|||
|
|
gap: var(--space-md);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.customization-panel {
|
|||
|
|
/* Keep matching the 600px chat preview, even on laptop widths */
|
|||
|
|
height: 600px;
|
|||
|
|
max-height: 600px;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* Additional responsive for very compact screens */
|
|||
|
|
@media (max-width: 1200px) {
|
|||
|
|
.tabs-navigation.horizontal {
|
|||
|
|
padding: 0 4px;
|
|||
|
|
gap: 2px;
|
|||
|
|
min-height: 52px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tab-button {
|
|||
|
|
padding: var(--space-xs) 6px;
|
|||
|
|
font-size: 0.7rem;
|
|||
|
|
min-height: 36px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.detail-panel {
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* Upgrade Modal Styles */
|
|||
|
|
.upgrade-modal-overlay {
|
|||
|
|
position: fixed;
|
|||
|
|
top: 0;
|
|||
|
|
left: 0;
|
|||
|
|
right: 0;
|
|||
|
|
bottom: 0;
|
|||
|
|
background: rgba(0, 0, 0, 0.85);
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
z-index: 1000;
|
|||
|
|
backdrop-filter: blur(4px);
|
|||
|
|
/* The dialog is taller than a laptop viewport: without these its header
|
|||
|
|
and its own Upgrade button are cut off the screen with no way to reach
|
|||
|
|
them. Scroll the overlay and let the box start at the top when it
|
|||
|
|
cannot be centred. */
|
|||
|
|
overflow-y: auto;
|
|||
|
|
padding: var(--space-lg) var(--space-md);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.upgrade-modal {
|
|||
|
|
background: linear-gradient(135deg, var(--bg) 0%, var(--surface) 100%);
|
|||
|
|
border-radius: 16px;
|
|||
|
|
width: 90%;
|
|||
|
|
max-width: 500px;
|
|||
|
|
margin: auto;
|
|||
|
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
|||
|
|
overflow: hidden;
|
|||
|
|
animation: modalSlideIn 0.3s ease-out;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@keyframes modalSlideIn {
|
|||
|
|
from {
|
|||
|
|
opacity: 0;
|
|||
|
|
transform: translateY(-20px) scale(0.95);
|
|||
|
|
}
|
|||
|
|
to {
|
|||
|
|
opacity: 1;
|
|||
|
|
transform: translateY(0) scale(1);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.upgrade-modal-header {
|
|||
|
|
padding: var(--space-xl);
|
|||
|
|
text-align: center;
|
|||
|
|
position: relative;
|
|||
|
|
background: linear-gradient(135deg, var(--accent-solid), var(--accent-solid));
|
|||
|
|
color: var(--on-accent-solid);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.upgrade-icon {
|
|||
|
|
width: 48px;
|
|||
|
|
height: 48px;
|
|||
|
|
margin: 0 auto var(--space-md);
|
|||
|
|
background: color-mix(in srgb, var(--on-accent-solid) 20%, transparent);
|
|||
|
|
border-radius: 50%;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.upgrade-icon svg {
|
|||
|
|
width: 24px;
|
|||
|
|
height: 24px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.upgrade-modal-header h3 {
|
|||
|
|
font-size: 1.5rem;
|
|||
|
|
font-weight: 700;
|
|||
|
|
margin: 0;
|
|||
|
|
color: var(--on-accent-solid);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.upgrade-modal-header .close-button {
|
|||
|
|
position: absolute;
|
|||
|
|
top: var(--space-md);
|
|||
|
|
right: var(--space-md);
|
|||
|
|
background: color-mix(in srgb, var(--on-accent-solid) 20%, transparent);
|
|||
|
|
border: none;
|
|||
|
|
border-radius: 50%;
|
|||
|
|
width: 32px;
|
|||
|
|
height: 32px;
|
|||
|
|
color: var(--on-accent-solid);
|
|||
|
|
cursor: pointer;
|
|||
|
|
font-size: 1.25rem;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
transition: all 0.2s ease;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.upgrade-modal-header .close-button:hover {
|
|||
|
|
background: color-mix(in srgb, var(--on-accent-solid) 30%, transparent);
|
|||
|
|
transform: scale(1.1);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.upgrade-modal-content {
|
|||
|
|
padding: var(--space-xl);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.upgrade-description {
|
|||
|
|
font-size: 1rem;
|
|||
|
|
color: var(--text-muted);
|
|||
|
|
line-height: 1.6;
|
|||
|
|
margin-bottom: var(--space-xl);
|
|||
|
|
text-align: center;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.upgrade-features {
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
gap: var(--space-md);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.feature-item {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: var(--space-md);
|
|||
|
|
padding: var(--space-sm);
|
|||
|
|
background: var(--background-soft);
|
|||
|
|
border-radius: var(--radius-md);
|
|||
|
|
border-left: 3px solid var(--primary-color);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.feature-icon {
|
|||
|
|
width: 18px;
|
|||
|
|
height: 18px;
|
|||
|
|
color: var(--success-color);
|
|||
|
|
flex-shrink: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.feature-item span {
|
|||
|
|
font-weight: 500;
|
|||
|
|
color: var(--text-color);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.upgrade-modal-footer {
|
|||
|
|
padding: var(--space-lg) var(--space-xl);
|
|||
|
|
background: var(--background-soft);
|
|||
|
|
display: flex;
|
|||
|
|
gap: var(--space-md);
|
|||
|
|
justify-content: center;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.upgrade-button {
|
|||
|
|
background: linear-gradient(135deg, var(--accent-solid), var(--accent-solid));
|
|||
|
|
color: var(--on-accent-solid);
|
|||
|
|
border: none;
|
|||
|
|
border-radius: var(--radius-full);
|
|||
|
|
padding: var(--space-md) var(--space-xl);
|
|||
|
|
font-weight: 600;
|
|||
|
|
cursor: pointer;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: var(--space-sm);
|
|||
|
|
transition: all 0.3s ease;
|
|||
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.upgrade-button:hover {
|
|||
|
|
transform: translateY(-2px);
|
|||
|
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
|
|||
|
|
filter: brightness(1.1);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.upgrade-arrow {
|
|||
|
|
width: 16px;
|
|||
|
|
height: 16px;
|
|||
|
|
transition: transform 0.2s ease;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.upgrade-button:hover .upgrade-arrow {
|
|||
|
|
transform: translateX(2px);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.cancel-upgrade-button {
|
|||
|
|
background: transparent;
|
|||
|
|
color: var(--text-muted);
|
|||
|
|
border: 1px solid var(--border-color);
|
|||
|
|
border-radius: var(--radius-full);
|
|||
|
|
padding: var(--space-md) var(--space-lg);
|
|||
|
|
font-weight: 500;
|
|||
|
|
cursor: pointer;
|
|||
|
|
transition: all 0.2s ease;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.cancel-upgrade-button:hover {
|
|||
|
|
background: var(--background-muted);
|
|||
|
|
color: var(--text-color);
|
|||
|
|
border-color: var(--text-muted);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
</style>
|