feat(chat): 访客与客服聊天界面、历史拉取与断线重连

聊天页支持发送确认、历史补拉和重连;刷新后不重复展示已确认消息。
This commit is contained in:
WangShen007 committed 2026-09-27 21:10:00 +08:00
1 parent ef9fee1585
commit 4ff934ad98
19 files changed
+6252

No files matched your search

@@ -0,0 +1,826 @@
<!--
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { ChatDetail } from '@/types/chat'
import { chatService } from '@/services/chat'
import { userService } from '@/services/user'
import { socketService } from '@/services/socket'
import { toast } from 'vue-sonner'
import LinkedTicketCard from '@/components/tickets/LinkedTicketCard.vue'
import { permissionChecks } from '@/utils/permissions'
import { canRequestRating, endChatMessage as endChatMessageFor } from '@/utils/endChat'
import { getInitials } from '@/utils/text'
import type { Teammate } from '@/services/users'
import { canTakeOverChat, chatAssignee, chatHandler } from '@/utils/chatState'
import { routeChatToHuman } from '@/utils/chatActions'
const canViewTickets = permissionChecks.canViewTickets()
interface Props {
chatInfo: ChatDetail | null
users: Teammate[]
isLoading?: boolean
}
interface Emits {
(e: 'close'): void
(e: 'refresh'): void
(e: 'chatUpdated', chatInfo: ChatDetail): void
(e: 'chatClosed', sessionId: string): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const showEndChatConfirm = ref(false)
const actionLoading = ref(false)
const reassigning = ref(false)
const showReassign = ref(false)
const selectedUserId = ref('')
// Reassigning is a manage-chat action; the parent supplies the teammate list.
const canReassign = permissionChecks.canTakeOverChats()
// Chat action functions
const currentUserId = userService.getUserId()
// Only the web widget can render a rating; on external channels (WhatsApp,
// Messenger, ...) the ask is dropped, so the confirmation must not promise it.
const askRating = computed(() => canRequestRating(props.chatInfo?.channel))
// Shared with the chat pane so both surfaces offer takeover on the same rule.
// This drops main's `status === 'transferred' && user_id !== currentUserId`
// arm deliberately: takeover_session returns False once user_id is set, so
// that arm only ever rendered a button that failed on click.
const canTakeOver = computed(
() => canTakeOverChat(props.chatInfo) && permissionChecks.canTakeOverChats()
)
// Only worth offering while the AI still has it: once a chat is queued or
// claimed there is no AI left to stop.
const canRouteToHuman = computed(
() => chatHandler(props.chatInfo, currentUserId).kind === 'ai'
&& permissionChecks.canTakeOverChats()
)
const canEndChat = computed(() => {
if (!props.chatInfo) return false
return (
props.chatInfo.status !== 'closed' &&
props.chatInfo.user_id === currentUserId
)
})
const handleRouteToHuman = async () => {
if (!props.chatInfo) return
try {
actionLoading.value = true
const updated = await routeChatToHuman(props.chatInfo.session_id)
if (!updated) return
emit('chatUpdated', updated)
emit('refresh')
} finally {
actionLoading.value = false
}
}
const handleTakeover = async () => {
if (!props.chatInfo) return
try {
actionLoading.value = true
await chatService.takeoverChat(props.chatInfo.session_id)
toast.success('已接管会话', {
description: '现在可以在本会话中发送消息',
duration: 4000,
closeButton: true
})
const userName = userService.getUserName()
const userId = userService.getUserId()
// Update local chat info state
const updatedChatInfo = {
...props.chatInfo,
status: 'open' as const,
user_id: userId,
user_name: userName
}
socketService.emit('taken_over', {
session_id: props.chatInfo.session_id,
user_name: userName,
profile_picture: userService.getCurrentUser()?.profile_pic || ''
})
// Emit updated chat info to parent components
emit('chatUpdated', updatedChatInfo)
// Refresh conversations list
emit('refresh')
} catch (err: any) {
console.error('Failed to takeover chat:', err)
toast.error('接管会话失败', {
description: err.response?.data?.detail || '请重试',
duration: 4000,
closeButton: true
})
} finally {
actionLoading.value = false
}
}
const handleEndChatRequest = () => {
showEndChatConfirm.value = true
}
const confirmEndChat = async () => {
if (!props.chatInfo) return
try {
actionLoading.value = true
const timestamp = new Date().toISOString()
const endChatMessage = {
message: endChatMessageFor(props.chatInfo.channel),
message_type: "system",
created_at: timestamp,
session_id: props.chatInfo.session_id,
end_chat: true,
request_rating: askRating.value,
end_chat_reason: "AGENT_REQUEST",
end_chat_description: "Agent manually ended the chat"
}
// Emit message through socket to end chat
socketService.emit('agent_message', {
message: endChatMessage.message,
session_id: props.chatInfo.session_id,
message_type: endChatMessage.message_type,
created_at: timestamp,
end_chat: true,
request_rating: askRating.value,
end_chat_reason: "AGENT_REQUEST",
end_chat_description: "Agent manually ended the chat"
})
toast.success('会话已结束', {
description: askRating.value ? '将邀请客户进行评价' : '会话已关闭',
duration: 4000,
closeButton: true
})
showEndChatConfirm.value = false
// Refresh conversations list
emit('refresh')
// Inform parent to close chat views
emit('chatClosed', props.chatInfo.session_id)
} catch (err: any) {
console.error('Failed to end chat:', err)
toast.error('结束会话失败', {
description: err.response?.data?.detail || '请重试',
duration: 4000,
closeButton: true
})
} finally {
actionLoading.value = false
}
}
const cancelEndChat = () => {
showEndChatConfirm.value = false
}
// Who holds the chat, sharing chatAssignee with the inbox badge and the chat
// pane so all three agree. Only the unassigned wording is panel-specific: it
// names the AI agent rather than saying a flat "AI".
const assignedTo = computed(
() => chatAssignee(props.chatInfo, currentUserId) || props.chatInfo?.agent?.name || 'AI 客服'
)
// Helper function to format date
const formatDate = (dateString: string): string => {
const date = new Date(dateString)
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString()
}
// Backend status enum stays English in the API; only the label is localised.
const statusLabels: Record<string, string> = {
open: '进行中',
closed: '已结束',
transferred: '已转交',
pending: '等待中'
}
const statusLabel = (status: string) => statusLabels[status] || status
// Integrator-supplied fields (e.g. student_name, center_name) passed via
// POST /generate-token's `custom_data`. Shown as a label/value list below the
// customer's name and email; snake_case keys are prettified for display.
const metaDataEntries = computed(() => {
const metaData = props.chatInfo?.customer?.meta_data
if (!metaData || typeof metaData !== 'object') return []
return Object.entries(metaData)
.filter(([, value]) => value !== null && value !== undefined && value !== '')
.map(([key, value]) => ({
key,
label: key.replace(/[_-]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
value: typeof value === 'object' ? JSON.stringify(value) : String(value)
}))
})
// Initials for the mobile hero avatar
const customerInitials = computed(() =>
getInitials(props.chatInfo?.customer?.full_name || props.chatInfo?.customer?.email)
)
// The teammate list arrives as a prop (ConversationsView loads it once from
// /users/teammates). This component used to fetch /users itself into a local
// ref that shadowed the prop — which needed manage_users, so for an agent the
// dropdown was always empty even though the API would have allowed the
// reassign.
const openReassign = () => {
showReassign.value = true
}
const cancelReassign = () => {
showReassign.value = false
selectedUserId.value = ''
}
const confirmReassign = async () => {
if (!props.chatInfo || !selectedUserId.value) return
try {
reassigning.value = true
const updated = await chatService.reassignChat(props.chatInfo.session_id, selectedUserId.value)
toast.success('会话已重新分配')
emit('chatUpdated', updated)
emit('refresh')
showReassign.value = false
} catch (err: any) {
console.error('Failed to reassign chat:', err)
toast.error('重新分配失败', { description: err.response?.data?.detail || '请重试' })
} finally {
reassigning.value = false
}
}
</script>
<template>
<div v-if="chatInfo" class="chat-info-sidebar">
<div class="chat-info-header">
<h3>会话信息</h3>
<button @click="emit('close')" class="close-btn" aria-label="关闭会话信息">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="chat-info-content">
<!-- Mobile-only hero per the app design; hidden on desktop -->
<div class="customer-hero">
<div class="customer-avatar">{{ customerInitials }}</div>
<div class="customer-hero-name">{{ chatInfo.customer.full_name || chatInfo.customer.email || '客户' }}</div>
<div v-if="chatInfo.customer.email" class="customer-hero-email">{{ chatInfo.customer.email }}</div>
</div>
<div class="info-section">
<h4>客户</h4>
<div class="info-item">
<span class="label">姓名:</span>
<span class="value">{{ chatInfo.customer.full_name || '暂无' }}</span>
</div>
<div class="info-item">
<span class="label">邮箱:</span>
<span class="value">{{ chatInfo.customer.email || '暂无' }}</span>
</div>
<div v-for="entry in metaDataEntries" :key="entry.key" class="info-item">
<span class="label">{{ entry.label }}:</span>
<span class="value">{{ entry.value }}</span>
</div>
</div>
<div v-if="canViewTickets && chatInfo.session_id" class="info-section">
<LinkedTicketCard :session-id="chatInfo.session_id" />
</div>
<div class="info-section">
<h4>分配</h4>
<div class="info-item">
<span class="label">分配给:</span>
<span class="value">{{ assignedTo }}</span>
</div>
</div>
<div class="info-section">
<h4>时间线</h4>
<div class="info-item">
<span class="label">创建时间:</span>
<span class="value">{{ formatDate(chatInfo.created_at) }}</span>
</div>
<div class="info-item">
<span class="label">更新时间:</span>
<span class="value">{{ formatDate(chatInfo.updated_at) }}</span>
</div>
</div>
<div class="info-section">
<h4>状态</h4>
<div class="info-item">
<span class="label">当前状态:</span>
<span class="value status-badge" :class="chatInfo.status">
{{ statusLabel(chatInfo.status) }}
</span>
</div>
<div class="info-item">
<span class="label">消息数:</span>
<span class="value">{{ chatInfo.messages.length }}</span>
</div>
</div>
<!-- Chat Actions Section -->
<div class="info-section">
<h4>操作</h4>
<div class="chat-actions">
<button
v-if="canTakeOver"
class="action-btn takeover-btn"
:disabled="actionLoading"
@click="handleTakeover"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
</svg>
{{ actionLoading ? '接管中…' : '接管会话' }}
</button>
<!-- Stops the AI without making the chat yours. -->
<button
v-if="canRouteToHuman"
class="action-btn"
:disabled="actionLoading"
@click="handleRouteToHuman"
>
<font-awesome-icon icon="fa-solid fa-user-group" />
转给团队
</button>
<button
v-if="canEndChat"
class="action-btn end-chat-btn"
:disabled="actionLoading"
@click="handleEndChatRequest"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16,17 21,12 16,7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
结束会话
</button>
<button
v-if="canReassign && chatInfo.status === 'open' && chatInfo.user_id && users.length"
class="action-btn"
:disabled="reassigning"
@click="openReassign"
>
重新分配
</button>
</div>
</div>
</div>
<!-- End Chat Confirmation Modal -->
<div v-if="showEndChatConfirm" class="end-chat-modal">
<div class="end-chat-modal-content">
<h3>结束会话</h3>
<p v-if="askRating">确定要结束此会话并邀请客户评价吗?</p>
<p v-else>确定要结束此会话吗?</p>
<div class="end-chat-modal-actions">
<button class="cancel-btn" @click="cancelEndChat">取消</button>
<button class="confirm-btn" @click="confirmEndChat" :disabled="actionLoading">
{{ actionLoading ? '结束中…' : (askRating ? '结束会话并请求评价' : '结束会话') }}
</button>
</div>
</div>
</div>
<!-- Reassign Modal -->
<div v-if="showReassign" class="end-chat-modal">
<div class="end-chat-modal-content">
<h3>重新分配会话</h3>
<p>选择要分配此会话的用户。</p>
<div style="margin-bottom: 12px;">
<select v-model="selectedUserId" class="filter-input" style="width: 100%;">
<option value="" disabled>选择用户</option>
<option v-for="u in users" :key="u.id" :value="u.id">{{ u.full_name }} ({{ u.email }})</option>
</select>
</div>
<div class="end-chat-modal-actions">
<button class="cancel-btn" @click="cancelReassign">取消</button>
<button class="confirm-btn" :disabled="!selectedUserId || reassigning" @click="confirmReassign">
{{ reassigning ? '分配中…' : '确认分配' }}
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.chat-info-sidebar {
background: var(--bg2);
border-left: 1px solid var(--o08);
overflow-y: auto;
animation: slideInRight 0.3s ease-out;
}
@keyframes slideInRight {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
.chat-info-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-md) var(--space-lg);
border-bottom: 1px solid var(--o07);
background: var(--bg2);
}
.chat-info-header h3 {
margin: 0;
font-family: var(--font-display);
font-size: 16px;
font-weight: 600;
color: var(--text);
}
.close-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: 1px solid var(--o10);
border-radius: 8px;
background: var(--o05);
color: var(--muted);
cursor: pointer;
transition: all var(--transition-fast);
}
.close-btn:hover {
background: var(--o10);
color: var(--text);
}
.chat-info-content {
padding: var(--space-lg);
}
/* Mobile-only customer hero (hidden on desktop) */
.customer-hero {
display: none;
flex-direction: column;
align-items: center;
text-align: center;
padding: var(--space-sm) 0 var(--space-lg);
border-bottom: 1px solid var(--o07);
margin-bottom: var(--space-lg);
}
.customer-avatar {
width: 84px;
height: 84px;
border-radius: 50%;
background: var(--grad-purple-teal);
color: var(--on-light);
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-display);
font-size: 30px;
font-weight: var(--font-weight-bold);
margin-bottom: 14px;
}
.customer-hero-name {
font-family: var(--font-display);
font-size: 22px;
font-weight: var(--font-weight-bold);
letter-spacing: var(--tracking-display);
color: var(--text);
}
.customer-hero-email {
font-size: 14px;
color: var(--muted);
margin-top: 4px;
}
.info-section {
margin-bottom: var(--space-lg);
}
.info-section:last-child {
margin-bottom: 0;
}
.info-section h4 {
margin: 0 0 var(--space-md) 0;
font-family: var(--font-mono);
font-size: 10.5px;
font-weight: 600;
color: var(--faint);
text-transform: uppercase;
letter-spacing: 0.08em;
border-bottom: 1px solid var(--o07);
padding-bottom: var(--space-xs);
}
.info-item {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: var(--space-sm);
gap: var(--space-sm);
}
.info-item:last-child {
margin-bottom: 0;
}
.info-item .label {
font-size: 12px;
font-weight: 500;
color: var(--muted);
min-width: 80px;
flex-shrink: 0;
}
.info-item .value {
font-size: 13px;
color: var(--text);
text-align: right;
word-break: break-word;
flex: 1;
}
.status-badge {
padding: 4px 8px;
border-radius: var(--radius-full);
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
}
/* Higher specificity than ".info-item .value" so the pill hugs the status text
instead of inheriting .value's flex:1 and stretching its background across
the rest of the row. */
.info-item .value.status-badge {
flex: none;
}
.status-badge.open {
background: rgba(201,242,78,.12);
color: var(--accent-ink);
}
.status-badge.closed {
background: var(--o06);
color: var(--muted);
}
.status-badge.transferred {
background: var(--warning-bg);
color: var(--warning-color);
}
/* Chat Actions */
.chat-actions {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.action-btn {
display: flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-sm) var(--space-md);
border: 1px solid var(--o10);
border-radius: 10px;
background: var(--o05);
color: var(--text3);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all var(--transition-fast);
text-align: left;
width: 100%;
}
.action-btn:hover:not(:disabled) {
background: var(--o10);
border-color: var(--o14);
color: var(--text);
}
.action-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.takeover-btn {
border-color: rgba(201,242,78,.3);
color: var(--accent-ink);
}
.takeover-btn:hover:not(:disabled) {
background: rgba(201,242,78,.1);
}
.end-chat-btn {
border-color: rgba(255,138,115,.3);
color: var(--c-coral);
}
.end-chat-btn:hover:not(:disabled) {
background: rgba(255,138,115,.1);
}
/* End Chat Modal */
.end-chat-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
animation: fadeIn 0.2s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.end-chat-modal-content {
background: var(--surface);
border: 1px solid var(--o10);
border-radius: 20px;
padding: var(--space-xl);
max-width: 400px;
width: 90%;
box-shadow: var(--shadow-lg);
animation: slideInUp 0.3s ease-out;
}
@keyframes slideInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.end-chat-modal-content h3 {
margin: 0 0 var(--space-md) 0;
font-family: var(--font-display);
font-size: 18px;
font-weight: 600;
color: var(--text);
}
.end-chat-modal-content p {
margin: 0 0 var(--space-lg) 0;
color: var(--muted);
line-height: 1.5;
}
.end-chat-modal-actions {
display: flex;
gap: var(--space-sm);
justify-content: flex-end;
}
.cancel-btn, .confirm-btn {
padding: var(--space-sm) var(--space-lg);
border-radius: var(--radius-sm);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all var(--transition-fast);
border: 1px solid transparent;
min-width: 80px;
}
.cancel-btn {
background: var(--o06);
color: var(--text3);
border-color: var(--o10);
}
.cancel-btn:hover {
background: var(--o10);
color: var(--text);
}
/* Mobile: full-screen "Details" page (class applied by ConversationsView) */
@media (max-width: 768px) {
.chat-info-sidebar.mobile-fullscreen {
position: fixed;
inset: 0;
z-index: var(--z-fullscreen-page);
height: 100vh;
height: 100dvh;
border-left: none;
background: var(--bg);
display: flex;
flex-direction: column;
}
.chat-info-sidebar.mobile-fullscreen .chat-info-header {
padding-top: calc(var(--space-md) + var(--safe-top));
}
.chat-info-sidebar.mobile-fullscreen .chat-info-content {
flex: 1;
overflow-y: auto;
padding-bottom: calc(var(--space-lg) + var(--safe-bottom));
}
.chat-info-sidebar.mobile-fullscreen .customer-hero {
display: flex;
}
.chat-info-sidebar.mobile-fullscreen .close-btn {
width: 44px;
height: 44px;
}
.chat-info-sidebar.mobile-fullscreen .action-btn {
min-height: 48px;
font-size: 15px;
}
}
.confirm-btn {
background: var(--error-color);
color: white;
border-color: transparent;
}
.confirm-btn:hover:not(:disabled) {
filter: brightness(1.1);
transform: translateY(-1px);
}
.confirm-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
</style>
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,466 @@
<!--
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import type { Teammate } from '@/services/users'
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
interface FilterValues {
customerEmailFilter: string
agentFilter: string
userFilter: string
dateFromFilter: string
dateToFilter: string
}
interface Props {
showFilters: boolean
filterValues: FilterValues
users: Teammate[]
agents: Array<{id: string, name: string, display_name: string | null}>
loadingUsers?: boolean
loadingAgents?: boolean
}
interface Emits {
(e: 'toggle'): void
(e: 'apply', filters: FilterValues): void
(e: 'clear'): void
(e: 'update:filterValues', filters: FilterValues): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
// Local filter state
const localFilters = ref<FilterValues>({ ...props.filterValues })
// Update local filters when props change
const updateLocalFilters = () => {
localFilters.value = { ...props.filterValues }
}
// Watch for prop changes
onMounted(() => {
updateLocalFilters()
})
const hasActiveFilters = computed(() => {
return localFilters.value.customerEmailFilter.trim() ||
localFilters.value.agentFilter.trim() ||
localFilters.value.userFilter.trim() ||
localFilters.value.dateFromFilter ||
localFilters.value.dateToFilter
})
const activeFilterCount = computed(() => {
return Object.values({
customerEmailFilter: localFilters.value.customerEmailFilter.trim(),
agentFilter: localFilters.value.agentFilter.trim(),
userFilter: localFilters.value.userFilter.trim(),
dateFromFilter: localFilters.value.dateFromFilter,
dateToFilter: localFilters.value.dateToFilter
}).filter(Boolean).length
})
const applyFilters = () => {
emit('update:filterValues', { ...localFilters.value })
emit('apply', { ...localFilters.value })
}
const clearFilters = () => {
localFilters.value = {
customerEmailFilter: '',
agentFilter: '',
userFilter: '',
dateFromFilter: '',
dateToFilter: ''
}
emit('update:filterValues', { ...localFilters.value })
emit('clear')
}
// Click outside handler
const handleClickOutside = (event: Event) => {
const target = event.target as HTMLElement
const filtersContainer = document.querySelector('.filters-container')
const filterToggleBtn = document.querySelector('.filter-toggle-btn')
if (props.showFilters &&
filtersContainer &&
!filtersContainer.contains(target) &&
filterToggleBtn &&
!filterToggleBtn.contains(target)) {
emit('toggle')
}
}
onMounted(() => {
document.addEventListener('click', handleClickOutside)
})
onBeforeUnmount(() => {
document.removeEventListener('click', handleClickOutside)
})
</script>
<template>
<div class="filter-header-section">
<!-- Filter Toggle Button -->
<button
@click="emit('toggle')"
class="filter-toggle-btn"
:class="{ active: showFilters, 'has-filters': hasActiveFilters }"
aria-label="筛选"
>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/>
</svg>
<span v-if="hasActiveFilters" class="filter-count">{{ activeFilterCount }}</span>
</button>
<!-- Advanced Filters Dropdown -->
<div v-if="showFilters" class="filters-container">
<div class="filters-dropdown">
<div class="filters-header">
<h3>筛选会话</h3>
<button @click="emit('toggle')" class="close-btn" aria-label="关闭筛选">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="filters-grid">
<div class="filter-group">
<label for="customer-email">客户邮箱</label>
<input
id="customer-email"
v-model="localFilters.customerEmailFilter"
type="email"
placeholder="按客户邮箱筛选…"
class="filter-input"
/>
</div>
<div class="filter-group">
<label for="agent-filter">客服</label>
<select
id="agent-filter"
v-model="localFilters.agentFilter"
class="filter-input filter-select"
:disabled="loadingAgents"
>
<option value="">全部客服</option>
<option v-for="agent in agents" :key="agent.id" :value="agent.id">
{{ agent.display_name || agent.name }}
</option>
</select>
</div>
<div class="filter-group">
<label for="user-filter">用户</label>
<select
id="user-filter"
v-model="localFilters.userFilter"
class="filter-input filter-select"
:disabled="loadingUsers"
>
<option value="">全部用户</option>
<option v-for="user in users" :key="user.id" :value="user.id">
{{ user.full_name }} ({{ user.email }})
</option>
</select>
</div>
<div class="filter-group">
<label for="date-from">开始日期</label>
<input
id="date-from"
v-model="localFilters.dateFromFilter"
type="date"
class="filter-input"
/>
</div>
<div class="filter-group">
<label for="date-to">结束日期</label>
<input
id="date-to"
v-model="localFilters.dateToFilter"
type="date"
class="filter-input"
/>
</div>
</div>
<div class="filter-actions">
<button @click="applyFilters" class="apply-btn">应用筛选</button>
<button @click="clearFilters" class="clear-btn">清除全部</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.filter-header-section {
position: relative;
}
.filter-toggle-btn {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
background: var(--background-color);
color: var(--text-muted);
cursor: pointer;
transition: all var(--transition-fast);
}
.filter-toggle-btn:hover {
background: var(--background-soft);
color: var(--text-primary);
border-color: var(--primary-color-soft);
}
.filter-toggle-btn.active {
background: var(--accent-solid);
color: var(--on-accent-solid);
border-color: var(--primary-color);
}
.filter-toggle-btn.has-filters {
background: var(--primary-color-soft);
color: var(--primary-color);
border-color: var(--primary-color);
}
.filter-count {
position: absolute;
top: -6px;
right: -6px;
background: var(--error-color);
color: white;
border-radius: 50%;
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 600;
}
.filters-container {
position: absolute;
top: 100%;
right: 0;
z-index: 1000;
background: var(--background-soft);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
animation: slideDown 0.2s ease-out;
min-width: 600px;
max-width: 90vw;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.filters-dropdown {
padding: var(--space-lg);
}
.filters-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-md);
}
.filters-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.close-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-muted);
cursor: pointer;
transition: all var(--transition-fast);
}
.close-btn:hover {
background: var(--background-color);
color: var(--text-primary);
}
.filters-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: var(--space-md);
margin-bottom: var(--space-lg);
}
.filter-group {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.filter-group label {
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.filter-input {
padding: 10px 12px;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
background: var(--background-color);
color: var(--text-primary);
font-size: 14px;
transition: all var(--transition-fast);
}
.filter-select {
appearance: none;
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
background-position: right 8px center;
background-repeat: no-repeat;
background-size: 16px;
padding-right: 40px;
cursor: pointer;
}
.filter-input:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 2px var(--primary-color-soft);
}
.filter-input::placeholder {
color: var(--text-muted);
}
.filter-actions {
display: flex;
gap: var(--space-sm);
justify-content: flex-end;
padding-top: var(--space-md);
border-top: 1px solid var(--border-color);
}
.apply-btn, .clear-btn {
padding: 10px 20px;
border-radius: var(--radius-sm);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all var(--transition-fast);
border: 1px solid transparent;
min-width: 100px;
}
.apply-btn {
background: var(--accent-solid);
color: var(--on-accent-solid);
}
.apply-btn:hover {
background: var(--primary-color-hover);
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.clear-btn {
background: var(--background-color);
color: var(--text-muted);
border-color: var(--border-color);
}
.clear-btn:hover {
background: var(--background-soft);
color: var(--text-primary);
border-color: var(--text-muted);
}
/* Responsive design */
@media (max-width: 768px) {
.filters-container {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
min-width: unset;
max-width: unset;
border-radius: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
}
.filters-dropdown {
background: var(--background-color);
border-radius: var(--radius-lg);
max-width: 90vw;
max-height: 90vh;
overflow-y: auto;
}
.filters-grid {
grid-template-columns: 1fr;
}
.filter-actions {
justify-content: stretch;
}
.apply-btn, .clear-btn {
flex: 1;
}
}
</style>
@@ -0,0 +1,815 @@
<!--
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import type { Conversation, ChatDetail } from '@/types/chat'
import ConversationChat from '@/components/conversations/ConversationChat.vue'
import ChannelBadge from '@/components/common/ChannelBadge.vue'
import HandlerBadge from '@/components/conversations/HandlerBadge.vue'
import { useConversationsList } from '@/composables/useConversationsList'
import { useBreakpoint } from '@/composables/useBreakpoint'
import { chatHandler } from '@/utils/chatState'
import { userService } from '@/services/user'
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
const props = defineProps<{
conversations: Conversation[]
loading: boolean
error: string
statusFilter: 'open' | 'closed'
hasMore: boolean
loadingMore: boolean
showChatInfo?: boolean
initialSessionId?: string | null
mobilePane?: 'list' | 'chat'
}>()
const emit = defineEmits<{
(e: 'refresh'): void
(e: 'chatUpdated', data: ChatDetail): void
(e: 'chatSelected', data: ChatDetail): void
(e: 'clearUnread', sessionId: string): void
(e: 'updateFilter', status: 'open' | 'closed'): void
(e: 'loadMore'): void
(e: 'select', sessionId: string): void
(e: 'back'): void
(e: 'info'): void
}>()
const { isMobile } = useBreakpoint()
const currentUserId = userService.getUserId()
// The preview used to be prefixed with the AI agent's name unconditionally,
// which read as "the bot said this" even on a chat a colleague had taken over.
// Anonymous visitors have neither a name nor a real email (the backend hides the
// placeholder), so the row would otherwise show nothing at all.
const customerName = (conv: Conversation) =>
conv.customer?.full_name || conv.customer?.email || '访客'
// Display labels for the backend status enum, which stays in English in the API.
const statusLabels: Record<string, string> = {
open: '进行中',
closed: '已结束',
transferred: '已转交',
pending: '等待中'
}
const statusLabel = (status: string) => statusLabels[status] || status
const previewPrefix = (conv: Conversation) => {
const handler = chatHandler(conv, currentUserId)
return handler.kind === 'human' ? handler.label : conv.agent.name
}
const {
selectedChat,
selectedId,
chatLoading,
formattedConversations,
loadChatDetail,
unreadMessages,
clearUnread
} = useConversationsList(props, emit)
// Enhanced loadChatDetail to also emit chat-selected event
const loadChatDetailWithSelection = async (sessionId: string) => {
await loadChatDetail(sessionId)
// Emit the chat-selected event with the loaded chat detail
if (selectedChat.value) {
emit('chatSelected', selectedChat.value)
}
}
// On mobile a tap routes through the URL (?session=) so back navigation works;
// the parent pushes the query, which flows back in via initialSessionId.
const handleConversationClick = (sessionId: string) => {
if (isMobile.value) {
emit('select', sessionId)
} else {
loadChatDetailWithSelection(sessionId)
}
}
// Function to update the selected chat from parent
const updateSelectedChat = (updatedChat: ChatDetail) => {
console.log('updateSelectedChat', updatedChat)
if (selectedChat.value && selectedChat.value.session_id === updatedChat.session_id) {
selectedChat.value = updatedChat
}
}
// Function to clear the selected chat from parent
const clearSelectedChat = () => {
selectedChat.value = null
selectedId.value = null
}
// Expose the function to parent component
defineExpose({
updateSelectedChat,
clearSelectedChat
})
// Intersection observer for infinite scrolling
const listContainer = ref<HTMLElement | null>(null)
const loadMoreTrigger = ref<HTMLElement | null>(null)
let observer: IntersectionObserver | null = null
// Scroll to top functionality
const showScrollToTop = ref(false)
const scrollThreshold = 300 // Show button after scrolling down 300px
// Handle scroll event to show/hide scroll-to-top button
const handleScroll = () => {
if (listContainer.value) {
showScrollToTop.value = listContainer.value.scrollTop > scrollThreshold
}
}
// Scroll to top function
const scrollToTop = () => {
if (listContainer.value) {
listContainer.value.scrollTo({
top: 0,
behavior: 'smooth'
})
}
}
// Flag to track filter changes
const filterChanged = ref(false)
// Watch for filter changes to set the flag
watch(() => props.statusFilter, () => {
filterChanged.value = true
})
// Watch for loading state changes to select first conversation after filter change
watch(() => props.loading, (isLoading, prevLoading) => {
// Only proceed if loading has just completed (was true, now false)
if (prevLoading === true && isLoading === false && filterChanged.value) {
// Reset the flag
filterChanged.value = false
// When loading completes after filter change, select the first conversation if
// available (desktop only — on mobile the list pane stays put until a tap)
if (props.conversations.length > 0 && !isMobile.value) {
loadChatDetailWithSelection(props.conversations[0].session_id)
} else {
// If no conversations available, clear the selected chat
selectedChat.value = null
selectedId.value = null
}
}
})
// Deep-link: open a specific session directly (from analytics "Sessions Needing
// Attention", mobile ?session= navigation, or a push notification). Fetches the
// chat detail by id, so it works even if the session isn't in the currently
// loaded/filtered list. Reacts to every change so in-app deep-links keep working.
watch(
() => props.initialSessionId,
(sessionId) => {
if (sessionId && sessionId !== selectedId.value) {
loadChatDetailWithSelection(sessionId)
}
},
{ immediate: true }
)
// Setup intersection observer for infinite scrolling
onMounted(() => {
setupIntersectionObserver()
// Add scroll event listener
if (listContainer.value) {
listContainer.value.addEventListener('scroll', handleScroll)
}
})
// Watch for changes in the loadMoreTrigger ref
watch(loadMoreTrigger, (newValue) => {
if (newValue && observer) {
observer.observe(newValue)
}
})
// Watch for changes in the listContainer ref to add scroll listener
watch(listContainer, (newValue) => {
if (newValue) {
newValue.addEventListener('scroll', handleScroll)
}
})
// Setup intersection observer
const setupIntersectionObserver = () => {
if (window.IntersectionObserver) {
// Disconnect previous observer if it exists
if (observer) {
observer.disconnect()
}
observer = new IntersectionObserver((entries) => {
const entry = entries[0]
if (entry.isIntersecting && props.hasMore && !props.loadingMore) {
emit('loadMore')
}
}, { threshold: 0.5 })
if (loadMoreTrigger.value) {
observer.observe(loadMoreTrigger.value)
}
}
}
// Cleanup intersection observer and event listeners
onBeforeUnmount(() => {
if (observer) {
observer.disconnect()
observer = null
}
// Remove scroll event listener
if (listContainer.value) {
listContainer.value.removeEventListener('scroll', handleScroll)
}
})
</script>
<template>
<div class="conversations-container"
:class="{ 'with-chat-info': showChatInfo, 'mobile-show-chat': mobilePane === 'chat' }">
<!-- Sidebar with conversation list -->
<div class="conversations-sidebar">
<!-- Filter controls -->
<div class="filter-controls">
<button
class="filter-btn"
:class="{ active: statusFilter === 'open' }"
@click="emit('updateFilter', 'open')"
>
进行中
</button>
<button
class="filter-btn"
:class="{ active: statusFilter === 'closed' }"
@click="emit('updateFilter', 'closed')"
>
已结束
</button>
</div>
<div v-if="loading && !loadingMore" class="loading-state">
加载会话中…
</div>
<div v-else-if="error" class="error-state">
{{ error }}
<button @click="emit('refresh')" class="refresh-button">重试</button>
</div>
<div v-else-if="conversations.length === 0" class="empty-state">
暂无会话
</div>
<div v-else class="conversations-list" ref="listContainer" tabindex="0" aria-label="会话列表">
<div
v-for="conv in formattedConversations"
:key="conv.session_id"
class="conversation-item"
:class="{ active: selectedId === conv.session_id }"
role="button"
tabindex="0"
:aria-label="`与 ${customerName(conv)} 的会话`"
:aria-current="selectedId === conv.session_id ? 'true' : undefined"
@click="handleConversationClick(conv.session_id)"
@keydown.enter.prevent="handleConversationClick(conv.session_id)"
@keydown.space.prevent="handleConversationClick(conv.session_id)"
>
<div class="conversation-item-header">
<h3>{{ customerName(conv) }}</h3>
<ChannelBadge :channel="conv.channel" />
<span class="timestamp">{{ conv.timeAgo }}</span>
</div>
<div class="conversation-preview">
<span class="agent-name">{{ previewPrefix(conv) }}:</span>
<!-- Product message preview -->
<template v-if="conv.message_type === 'product' && conv.shopify_output?.products?.length">
<p class="last-message product-preview">
<span class="product-icon">🛍️</span>
已分享 {{ conv.shopify_output.products.length }} 件商品
</p>
</template>
<!-- Regular message preview -->
<template v-else>
<p class="last-message">{{ conv.last_message }}</p>
</template>
<div v-if="unreadMessages[conv.session_id]" class="unread-bubble">
{{ unreadMessages[conv.session_id] }}
</div>
</div>
<div class="message-count">{{ conv.message_count }} 条消息</div>
<div class="conversation-footer">
<div class="conversation-status" :class="conv.status">
{{ statusLabel(conv.status) }}
</div>
<HandlerBadge :chat="conv" :current-user-id="currentUserId" />
</div>
</div>
<!-- Loading more indicator -->
<div
v-if="hasMore || loadingMore"
class="load-more-trigger"
ref="loadMoreTrigger"
>
<div v-if="loadingMore" class="loading-more">
<div class="loading-spinner"></div>
<span>加载更多会话中…</span>
</div>
<div v-else-if="hasMore" class="load-more-container">
<span class="load-more-hint">向下滚动加载更多</span>
<button
class="load-more-button"
@click="emit('loadMore')"
:disabled="loadingMore"
>
加载更多
</button>
</div>
</div>
</div>
<!-- Scroll to top button -->
<button
v-if="showScrollToTop"
class="scroll-to-top-btn"
@click="scrollToTop"
aria-label="回到顶部"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 15l-6-6-6 6"/>
</svg>
</button>
</div>
<!-- Chat view -->
<div class="chat-view">
<div v-if="chatLoading" class="loading-state">
加载会话中…
</div>
<ConversationChat
v-else-if="selectedChat"
:chat="selectedChat"
@refresh="() => {
// force: this chat is already open, and loadChatDetail skips a session
// it thinks it has — without it this refetch was a silent no-op.
selectedChat && loadChatDetail(selectedChat.session_id, true);
emit('refresh');
}"
@chatUpdated="(updated) => {
// Forward as well as store locally. Taking a chat over from the chat
// pane changes user_id, and the info panel decides whether to offer
// 'End Chat' from its own chatInfo prop — which only the parent can
// update. Without this the button stayed hidden until a refresh.
selectedChat = updated;
emit('chatUpdated', updated);
}"
@clearUnread="clearUnread"
@back="emit('back')"
@info="emit('info')"
/>
</div>
</div>
</template>
<style scoped>
.conversations-container {
display: grid;
grid-template-columns: 320px 1fr;
/* Same as the parent grid: without a bounded row the sidebar's height: 100%
resolves against content, so .conversations-list can never scroll. */
grid-template-rows: minmax(0, 1fr);
gap: 0;
height: 100%;
width: 100%;
background: var(--bg);
color: var(--text);
position: relative;
}
.conversations-container.with-chat-info {
grid-template-columns: 320px 1fr;
}
.conversations-sidebar {
border-right: 1px solid var(--o08);
background: var(--bg2);
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
min-width: 0;
position: relative;
}
.filter-controls {
display: flex;
padding: 10px 12px;
border-bottom: 1px solid var(--o07);
background: var(--bg2);
gap: 4px;
}
.filter-btn {
flex: 1;
padding: 7px 12px;
background: transparent;
border: none;
color: var(--muted);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all var(--transition-fast);
border-radius: 8px;
}
.filter-btn:hover {
background: var(--o05);
color: var(--text);
}
.filter-btn.active {
background: var(--o10);
color: var(--text);
font-weight: 600;
}
.conversations-list {
overflow-y: auto;
flex: 1;
min-height: 0;
padding: 0;
margin: 0;
scrollbar-width: thin; /* For Firefox */
scrollbar-color: var(--border-color) transparent; /* For Firefox */
}
/* Webkit scrollbar styling */
.conversations-list::-webkit-scrollbar {
width: 6px;
}
.conversations-list::-webkit-scrollbar-track {
background: transparent;
}
.conversations-list::-webkit-scrollbar-thumb {
background-color: var(--border-color);
border-radius: 3px;
}
/* Status and handler sit on one line and wrap together on a narrow row. */
.conversation-footer {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
margin-top: 4px;
min-width: 0;
}
.conversation-status {
font-size: 10px;
font-family: var(--font-mono);
letter-spacing: 0.04em;
padding: 2px 7px;
border-radius: var(--radius-full);
text-transform: uppercase;
display: inline-block;
font-weight: 600;
}
.conversation-status.open {
background: rgba(201,242,78,.12);
color: var(--accent-ink);
}
.conversation-status.closed {
background: var(--o06);
color: var(--muted);
}
.conversation-status.transferred {
background-color: var(--warning-color-soft);
color: var(--warning-color);
}
.chat-view {
height: 100%;
width: 100%;
overflow: hidden;
position: relative;
min-width: 0;
display: flex;
flex-direction: column;
}
.sidebar-header {
padding: 12px 16px;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
}
.sidebar-header h2 {
font-size: 14px;
font-weight: 500;
}
.conversation-item {
padding: 12px 16px;
border-bottom: 1px solid var(--o06);
cursor: pointer;
transition: all var(--transition-fast);
min-height: 72px;
}
.conversation-item:hover {
background: var(--o05);
}
.conversation-item.active {
background: var(--accent-bg-06);
border-left: 3px solid var(--accent-ink);
}
.conversation-item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-xs);
}
.conversation-item-header h3 {
font-size: 13px;
font-weight: 600;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 170px;
}
.timestamp {
font-size: 11px;
color: var(--muted);
}
.conversation-preview {
display: flex;
gap: 4px;
margin-bottom: 4px;
font-size: 12px;
align-items: center;
}
.agent-name {
font-weight: 500;
color: var(--muted);
}
.last-message {
font-size: 12px;
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 140px;
}
.message-count {
font-size: 11px;
color: var(--faint);
}
.unread-bubble {
background: var(--accent-solid);
color: var(--on-accent-solid);
border-radius: 50%;
min-width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 700;
margin-left: auto;
}
.no-chat-selected {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--muted);
opacity: 0.7;
}
.loading-state,
.error-state,
.empty-state {
padding: 16px;
text-align: center;
color: var(--muted);
font-size: 12px;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.refresh-button {
padding: 4px 8px;
background: var(--o06);
border: 1px solid var(--o10);
border-radius: var(--radius-sm);
color: var(--text3);
cursor: pointer;
font-size: 12px;
}
.load-more-trigger {
height: 40px;
display: flex;
align-items: center;
justify-content: center;
padding: 12px 0;
margin: 8px 0;
}
.loading-more {
font-size: 12px;
color: var(--text-muted);
text-align: center;
display: flex;
align-items: center;
gap: 8px;
}
.loading-spinner {
width: 16px;
height: 16px;
border: 2px solid var(--primary-color);
border-radius: 50%;
border-top-color: transparent;
animation: spin 1s linear infinite;
}
.load-more-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.load-more-hint {
font-size: 12px;
color: var(--text-muted);
opacity: 0.7;
}
.load-more-button {
padding: 6px 12px;
background-color: var(--background-soft);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-size: 12px;
cursor: pointer;
transition: all var(--transition-fast);
}
.load-more-button:hover {
background-color: var(--accent-solid);
color: var(--on-accent-solid);
border-color: var(--primary-color);
}
.load-more-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.scroll-to-top-btn {
position: absolute;
bottom: 16px;
right: 16px;
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--accent-solid);
color: var(--on-accent-solid);
border: none;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
transition: all var(--transition-fast);
z-index: 10;
}
.scroll-to-top-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.scroll-to-top-btn:active {
transform: translateY(0);
}
.product-preview {
display: flex;
align-items: center;
gap: 4px;
color: var(--primary-color);
font-weight: 500;
}
.product-icon {
font-size: 14px;
line-height: 1;
}
.last-message.product-preview {
color: var(--primary-color);
max-width: 160px;
}
/* Mobile: single stacked pane — the list by default, the chat when a session
is selected (?session= in the URL, via the mobilePane prop) */
@media (max-width: 768px) {
.conversations-container,
.conversations-container.with-chat-info {
grid-template-columns: 1fr;
}
.conversations-container .chat-view {
display: none;
}
.conversations-container.mobile-show-chat .conversations-sidebar {
display: none;
}
.conversations-container.mobile-show-chat .chat-view {
display: flex;
}
.conversations-sidebar {
border-right: none;
}
.conversation-item {
padding: 14px 16px;
}
.conversation-item.active {
background: transparent;
border-left: none;
}
.conversation-item-header h3 {
max-width: none;
font-size: 15px;
}
.last-message {
max-width: none;
flex: 1;
}
}
</style>
@@ -0,0 +1,82 @@
<!--
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { computed } from 'vue'
import { chatHandler, type ChatHandlerKind, type HandlerCandidate } from '@/utils/chatState'
const props = defineProps<{
chat?: HandlerCandidate | null
currentUserId?: string | null
}>()
const HANDLER_ICONS: Record<ChatHandlerKind, string> = {
ai: 'fa-solid fa-robot',
waiting: 'fa-solid fa-user-clock',
human: 'fa-solid fa-user',
closed: 'fa-solid fa-lock',
}
const handler = computed(() => chatHandler(props.chat, props.currentUserId))
// A closed chat has nobody handling it, and the status pill beside this badge
// already says so — saying it twice is noise.
const visible = computed(() => handler.value.kind !== 'closed')
</script>
<template>
<span v-if="visible" class="handler-badge" :class="`handler-${handler.kind}`">
<font-awesome-icon :icon="HANDLER_ICONS[handler.kind]" />
<span class="handler-label">{{ handler.label }}</span>
</span>
</template>
<style scoped>
.handler-badge {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 100%;
padding: 2px 7px;
border-radius: var(--radius-full);
font-size: 10px;
font-weight: 600;
line-height: 1.6;
letter-spacing: 0.02em;
white-space: nowrap;
}
/* The name can be long; the badge shrinks rather than pushing the row wider. */
.handler-label {
overflow: hidden;
text-overflow: ellipsis;
}
.handler-ai {
background: var(--accent-bg-12);
color: var(--accent-ink);
}
.handler-waiting {
background: var(--warning-color-soft);
color: var(--warning-color);
}
.handler-human {
background: var(--success-color-soft);
color: var(--success-color);
}
</style>
@@ -0,0 +1,41 @@
<!--
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
// A small icon + label above a message saying where it came from (a tapped
// button, an external app).
defineProps<{
icon: string
label: string
}>()
</script>
<template>
<span class="message-tag">
<font-awesome-icon :icon="icon" /> {{ label }}
</span>
</template>
<style scoped>
.message-tag {
display: inline-flex;
align-items: center;
gap: 4px;
margin-bottom: 4px;
font-size: var(--text-xs);
color: var(--muted);
}
</style>
@@ -0,0 +1,351 @@
<!--
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
/**
* Start a WhatsApp conversation with a phone number.
*
* One shared modal behind both entry points — the Conversations "New
* conversation" button and the People drawer's "Message on WhatsApp" — so
* template choice and variable filling can never diverge between them.
*/
import { ref, computed, watch, onBeforeUnmount } from 'vue'
import { toast } from 'vue-sonner'
import channelsService, { type ChannelAccount } from '@/services/channels'
import { peopleService } from '@/services/people'
import type { PersonListItem } from '@/types/people'
import { DEFAULT_LANGUAGE } from '@/utils/whatsappLanguages'
import BaseModal from '@/components/common/BaseModal.vue'
import WhatsAppTemplateSelect, {
type TemplateSelection,
} from '@/components/conversations/WhatsAppTemplateSelect.vue'
const props = defineProps<{
accounts: ChannelAccount[]
/** Pre-selected person (the People-drawer entry point). */
person?: { id: string; name?: string | null; phone?: string | null } | null
}>()
const emit = defineEmits<{
(e: 'close'): void
(e: 'started', sessionId: string): void
}>()
// Outbound = business-initiated: Meta only reliably delivers Utility and
// Authentication templates to someone who hasn't messaged us; Marketing also
// carries per-user caps. The server enforces the same rule.
const OUTBOUND_CATEGORIES = ['UTILITY', 'AUTHENTICATION']
const accountId = ref(props.accounts[0]?.id ?? '')
const to = ref(props.person?.phone ?? '')
const name = ref('')
const selection = ref<TemplateSelection | null>(null)
const sending = ref(false)
/** The person this number belongs to, when known — prevents a duplicate row. */
const pickedPerson = ref<{ id: string; label: string } | null>(
props.person ? { id: props.person.id, label: props.person.name || props.person.phone || '' } : null,
)
// The phone the pick was based on. Editing away from it unlinks — but a
// person picked WITHOUT a stored phone stays linked to whatever is typed:
// that's the drawer's "message this person at a number you enter" case.
let pickedPhone: string | null = props.person?.phone ?? null
/**
* Autocomplete against People. Degrades silently to a plain phone input where
* People isn't available (non-Pro plan or missing permission): the feature is
* a convenience, not a requirement, and a 403 must not break outbound.
*/
const suggestions = ref<PersonListItem[]>([])
const peopleAvailable = ref(true)
let searchTimer: ReturnType<typeof setTimeout> | undefined
/** Guards against out-of-order responses — see searchPeople. */
let searchToken = 0
const searchPeople = async () => {
const term = to.value.trim()
if (!peopleAvailable.value || pickedPerson.value || term.length < 3) {
suggestions.value = []
return
}
// The debounce limits how OFTEN we ask, not what order the answers arrive in:
// pause mid-number and two requests are in flight. A broader prefix scans more
// rows, so it tends to resolve LAST and overwrite the narrower result — the
// agent then picks from suggestions for a number they have already moved past.
const token = ++searchToken
try {
const result = await peopleService.listPeople({ search: term, page_size: 5 })
if (token !== searchToken) return
suggestions.value = result.items.filter((p) => p.phone || p.name)
} catch (error: any) {
if (token !== searchToken) return
// Only a 403 means People genuinely isn't available to this user (non-Pro
// plan or missing permission) and is worth latching off. Latching on ANY
// error let a single network blip kill autocomplete for the rest of the
// modal's life — and a silently dead autocomplete invites exactly the
// duplicate person that picking someone exists to prevent.
if (error?.response?.status === 403) peopleAvailable.value = false
suggestions.value = []
}
}
watch(to, () => {
// Typing away from the picked person's own number un-picks them.
if (pickedPerson.value && pickedPhone && to.value !== pickedPhone) {
pickedPerson.value = null
pickedPhone = null
}
clearTimeout(searchTimer)
searchTimer = setTimeout(searchPeople, 300)
})
const pick = (person: PersonListItem) => {
// Falls back to the phone like the props path does: searchPeople admits
// anyone with a phone OR a name, so a phone-only person reaches here and
// would otherwise render "Sending to" followed by nothing.
pickedPerson.value = {
id: person.id,
label: person.name || person.email || person.phone || '该联系人',
}
pickedPhone = person.phone ?? null
if (person.phone) to.value = person.phone
suggestions.value = []
}
const unpick = () => {
pickedPerson.value = null
pickedPhone = null
}
// Client-side mirror of the server's normalize_phone: '+' then 8-15 digits,
// decoration forgiven. The server remains the authority.
const phoneLooksValid = computed(() =>
/^\+[1-9]\d{7,14}$/.test(to.value.replace(/[\s\-().]/g, '')),
)
const canSend = computed(
() => phoneLooksValid.value && !!selection.value?.complete && !sending.value,
)
const send = async () => {
if (!canSend.value || !selection.value) return
const { template, components } = selection.value
try {
sending.value = true
const result = await channelsService.startWhatsAppConversation(accountId.value, {
to: to.value,
template_name: template.name,
language: template.language || DEFAULT_LANGUAGE,
components,
customer_id: pickedPerson.value?.id,
customer_name: name.value.trim() || undefined,
})
toast.success('消息已发送', {
description: '会话已进入收件箱,对方回复后将由 AI 客服应答。',
})
emit('started', result.session_id)
} catch (error: any) {
toast.error('无法发起会话', {
description: error?.response?.data?.detail || '请重试',
closeButton: true,
})
} finally {
sending.value = false
}
}
onBeforeUnmount(() => clearTimeout(searchTimer))
</script>
<template>
<BaseModal title="新建 WhatsApp 会话" width="560px" @close="emit('close')">
<!-- Meta's opt-in policy is the operator's obligation; state it, don't police it -->
<p class="nwc-intro">
只能联系已同意在 WhatsApp 接收消息的人,否则消息被举报后 Meta 会封禁企业账号。
</p>
<label v-if="accounts.length > 1" class="nwc-field">
<span class="nwc-label">发送号码</span>
<select v-model="accountId" class="nwc-input">
<option v-for="account in accounts" :key="account.id" :value="account.id">
{{ account.display_name || account.external_account_id }}
</option>
</select>
</label>
<div class="nwc-field">
<span class="nwc-label">接收方</span>
<input
v-model="to"
class="nwc-input"
placeholder="+91 12345 67890"
autocomplete="off"
:aria-label="peopleAvailable ? '电话号码或搜索联系人' : '电话号码'"
/>
<span v-if="pickedPerson" class="nwc-picked">
发送至 <strong>{{ pickedPerson.label }}</strong>
<button type="button" class="nwc-unpick" aria-label="不是此人" @click="unpick">×</button>
</span>
<span v-else-if="to && !phoneLooksValid" class="nwc-hint">
请使用带国家代码的国际格式,例如 +91…
</span>
<!-- A plain list of buttons, deliberately NOT role="listbox". The
children are real buttons: Tab reaches each one and Enter picks it.
Claiming listbox told screen readers to expect role="option"
children and announced "listbox, 0 options" over working controls —
worse than the honest markup, and the full combobox pattern
(aria-expanded/controls/activedescendant plus arrow-key handling)
is more than a field that degrades to a plain phone input needs. -->
<ul v-if="suggestions.length" class="nwc-suggestions" aria-label="匹配的联系人">
<li v-for="person in suggestions" :key="person.id">
<button type="button" class="nwc-suggestion" @click="pick(person)">
<span class="nwc-suggestion-name">{{ person.name || person.email }}</span>
<span v-if="person.phone" class="nwc-suggestion-phone">{{ person.phone }}</span>
</button>
</li>
</ul>
</div>
<label v-if="!pickedPerson" class="nwc-field">
<span class="nwc-label">姓名(选填)</span>
<input v-model="name" class="nwc-input" placeholder="例如:张伟" autocomplete="off" />
<span class="nwc-hint">仅当该号码尚未存在于联系人中时使用。</span>
</label>
<div class="nwc-field">
<span class="nwc-label">模板</span>
<!-- Key: switching the sending number reloads that account's templates -->
<WhatsAppTemplateSelect
:key="accountId"
v-model:selection="selection"
:account-id="accountId"
:categories="OUTBOUND_CATEGORIES"
empty-hint="主动发起的会话需要已审核的工具类(Utility)或身份验证类(Authentication)模板。可在「设置 → 集成」的 WhatsApp 卡片中创建。"
/>
</div>
<template #actions>
<button class="modal-btn" @click="emit('close')">取消</button>
<button
class="modal-btn modal-btn-primary"
:disabled="!canSend"
:aria-busy="sending"
@click="send"
>
<font-awesome-icon v-if="sending" icon="fa-solid fa-spinner" spin />
{{ sending ? '发送中…' : '发送并开启会话' }}
</button>
</template>
</BaseModal>
</template>
<style scoped>
.nwc-intro {
margin: 0 0 16px;
color: var(--muted);
font-size: 13px;
line-height: 1.6;
}
.nwc-field {
display: block;
margin-bottom: 14px;
position: relative;
}
.nwc-label {
display: block;
font-size: 13px;
font-weight: 600;
margin-bottom: 6px;
}
.nwc-input {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--border-color);
border-radius: var(--radius-btn, 8px);
background: var(--background-soft);
color: inherit;
font-size: 14px;
font-family: inherit;
}
.nwc-hint,
.nwc-picked {
display: block;
font-size: 12px;
color: var(--muted);
margin-top: 4px;
}
.nwc-suggestions {
list-style: none;
margin: 4px 0 0;
padding: 4px;
border: 1px solid var(--border-color);
border-radius: var(--radius-btn, 8px);
background: var(--background-color);
position: absolute;
left: 0;
right: 0;
z-index: 10;
box-shadow: var(--shadow-md);
}
.nwc-suggestion {
width: 100%;
display: flex;
justify-content: space-between;
gap: 10px;
padding: 8px 10px;
border: none;
background: none;
color: inherit;
font-size: 13px;
text-align: left;
cursor: pointer;
border-radius: 6px;
}
.nwc-suggestion:hover {
background: var(--o08);
}
.nwc-unpick {
border: none;
background: none;
color: var(--muted);
cursor: pointer;
font-size: 13px;
padding: 0 2px;
}
.nwc-suggestion-phone {
color: var(--muted);
font-variant-numeric: tabular-nums;
}
</style>
@@ -0,0 +1,96 @@
<!--
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { ref, computed } from 'vue'
import { toast } from 'vue-sonner'
import channelsService, { type WhatsAppTemplate } from '@/services/channels'
import { DEFAULT_LANGUAGE } from '@/utils/whatsappLanguages'
import BaseModal from '@/components/common/BaseModal.vue'
import WhatsAppTemplateSelect, {
type TemplateSelection,
} from '@/components/conversations/WhatsAppTemplateSelect.vue'
const props = defineProps<{
accountId: string
sessionId: string
}>()
const emit = defineEmits<{
(e: 'close'): void
(e: 'sent', template: WhatsAppTemplate): void
}>()
const selection = ref<TemplateSelection | null>(null)
const sending = ref(false)
const canSend = computed(() => !!selection.value?.complete && !sending.value)
const send = async () => {
if (!selection.value || !canSend.value) return
const { template, components } = selection.value
try {
sending.value = true
await channelsService.sendWhatsAppTemplate(props.accountId, {
session_id: props.sessionId,
template_name: template.name,
language: template.language || DEFAULT_LANGUAGE,
components,
})
toast.success('模板已发送', { description: '客户可在接下来 24 小时内回复。' })
emit('sent', template)
} catch (error: any) {
toast.error('模板发送失败', {
description: error?.response?.data?.detail || '请重试',
closeButton: true,
})
} finally {
sending.value = false
}
}
</script>
<template>
<BaseModal title="发送模板" @close="emit('close')">
<p class="tpl-intro">
该会话已超出 WhatsApp 的 24 小时窗口,发送已审核的模板可重新开启会话。
</p>
<WhatsAppTemplateSelect v-model:selection="selection" :account-id="accountId" />
<template #actions>
<button class="modal-btn" @click="emit('close')">取消</button>
<button
class="modal-btn modal-btn-primary"
:disabled="!canSend"
:aria-busy="sending"
@click="send"
>
<font-awesome-icon v-if="sending" icon="fa-solid fa-spinner" spin />
{{ sending ? '发送中…' : '发送模板' }}
</button>
</template>
</BaseModal>
</template>
<style scoped>
.tpl-intro {
margin: 0 0 16px;
color: var(--muted);
font-size: 14px;
line-height: 1.6;
}
</style>
@@ -0,0 +1,343 @@
<!--
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
/**
* Template choice + variable filling, shared by the session-mode picker
* (reopen an expired window) and the phone-mode New-conversation modal.
*
* One component on purpose: Chatwoot and Intercom each bolted template
* variables onto one send path and not the other, and both are still paying
* for the split. The parent owns the send; this owns what to send.
*/
import { ref, computed, watch, onMounted } from 'vue'
import channelsService, {
type TemplateComponent,
type WhatsAppTemplate,
} from '@/services/channels'
import {
isSendable,
isAuthentication,
templatePreviewText,
templateVariables,
previewTemplate,
isTemplateComplete,
buildTemplateComponents,
isSameTemplate,
templateKey,
} from '@/utils/whatsappTemplates'
import { languageLabel } from '@/utils/whatsappLanguages'
export interface TemplateSelection {
template: WhatsAppTemplate
components?: TemplateComponent[]
complete: boolean
}
const props = defineProps<{
accountId: string
/** Restrict to these categories (e.g. outbound = UTILITY/AUTHENTICATION). */
categories?: string[]
emptyHint?: string
}>()
/** The parent reads this to send: null until a template is chosen. */
const selection = defineModel<TemplateSelection | null>('selection', { default: null })
const templates = ref<WhatsAppTemplate[]>([])
const loading = ref(true)
/** Set when the list itself could not load — distinct from "no templates yet". */
const loadError = ref('')
const selected = ref<WhatsAppTemplate | null>(null)
const values = ref<Record<number, string>>({})
const sendable = computed(() =>
templates.value.filter(
(template) =>
isSendable(template) &&
(!props.categories ||
props.categories.includes(String(template.category || '').toUpperCase())),
),
)
const variables = computed(() => (selected.value ? templateVariables(selected.value) : []))
const preview = computed(() =>
selected.value ? previewTemplate(selected.value, values.value) : '',
)
// An authentication template's single value is the passcode, not an authored
// {{n}} — naming it "Variable 1" would tell the agent nothing.
const isAuthSelected = computed(() => !!selected.value && isAuthentication(selected.value))
const fieldLabel = (index: number) =>
isAuthSelected.value ? '验证码' : `变量 ${index}`
const select = (template: WhatsAppTemplate) => {
// Re-clicking the current choice must not wipe what has been typed into it.
// Compared by name AND language: a same-named sibling is a DIFFERENT
// template, and returning early there silently kept the language already
// chosen — sending English to someone the agent picked Spanish for.
if (isSameTemplate(selected.value, template)) return
selected.value = template
values.value = {}
}
watch([selected, values], () => {
selection.value = selected.value
? {
template: selected.value,
components: buildTemplateComponents(selected.value, values.value),
complete: isTemplateComplete(selected.value, values.value),
}
: null
}, { deep: true })
onMounted(async () => {
try {
templates.value = await channelsService.listWhatsAppTemplates(props.accountId)
// Skip straight past a choice of one — the agent still confirms by sending.
if (sendable.value.length === 1) select(sendable.value[0])
} catch (error: any) {
loadError.value = error?.response?.data?.detail || '模板加载失败'
} finally {
loading.value = false
}
})
</script>
<template>
<div v-if="loading" class="tps-skeleton" aria-live="polite" aria-busy="true">
<div v-for="n in 3" :key="n" class="tps-skeleton-row"></div>
<span class="tps-visually-hidden">加载模板中</span>
</div>
<div v-else-if="loadError" class="tps-empty">
<font-awesome-icon icon="fa-solid fa-circle-exclamation" />
<p>{{ loadError }}</p>
</div>
<div v-else-if="sendable.length === 0" class="tps-empty">
<p>暂无已审核的模板。</p>
<p class="tps-empty-hint">
{{ emptyHint || '可在「设置 → 集成」中的 WhatsApp 卡片创建。每个模板都需经 Meta 审核后方可发送。' }}
</p>
</div>
<div v-else class="tps-body">
<ul class="tps-list" aria-label="已审核的模板">
<!-- Keyed and compared on name+language: one name can have a row per
language, and keying on the name alone gave Vue duplicate keys and
lit every sibling up as selected at once. -->
<li v-for="template in sendable" :key="templateKey(template)">
<button
type="button"
class="tps-option"
:class="{ selected: isSameTemplate(selected, template) }"
:aria-pressed="isSameTemplate(selected, template)"
@click="select(template)"
>
<span class="tps-option-head">
<span class="tps-name">{{ template.name }}</span>
<span v-if="template.language" class="tps-lang">
{{ languageLabel(template.language) }}
</span>
</span>
<span class="tps-preview">{{ templatePreviewText(template) }}</span>
</button>
</li>
</ul>
<div v-if="selected && variables.length" class="tps-fields">
<label v-for="index in variables" :key="index" class="tps-field">
<span class="tps-field-label">{{ fieldLabel(index) }}</span>
<input
v-model="values[index]"
type="text"
class="tps-input"
:placeholder="isAuthSelected ? '要发送的验证码' : `{{${index}}} 的值`"
/>
</label>
</div>
<div v-if="selected" class="tps-result">
<span class="tps-field-label">预览</span>
<p class="tps-result-text">{{ preview }}</p>
</div>
</div>
</template>
<style scoped>
.tps-body {
overflow-y: auto;
flex: 1;
}
.tps-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.tps-option {
width: 100%;
text-align: left;
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px;
border: 1px solid var(--border-color);
border-radius: var(--radius-btn, 8px);
background: var(--background-soft);
color: inherit;
cursor: pointer;
}
.tps-option:hover {
border-color: var(--accent-solid);
}
.tps-option.selected {
border-color: var(--accent-solid);
background: color-mix(in srgb, var(--accent-solid) 10%, var(--background-soft));
}
.tps-option:focus-visible {
outline: 2px solid var(--accent-solid);
outline-offset: 2px;
}
.tps-option-head {
display: flex;
align-items: center;
gap: 8px;
}
.tps-name {
font-weight: 600;
font-size: 14px;
}
.tps-lang {
font-size: 11px;
padding: 2px 6px;
border-radius: 999px;
background: var(--background-color);
border: 1px solid var(--border-color);
color: var(--muted);
}
.tps-preview {
font-size: 13px;
color: var(--muted);
line-height: 1.5;
}
.tps-fields {
margin-top: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.tps-field {
display: block;
}
.tps-field-label {
display: block;
font-size: 13px;
font-weight: 600;
margin-bottom: 6px;
}
.tps-input {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--border-color);
border-radius: var(--radius-btn, 8px);
background: var(--background-soft);
color: inherit;
font-size: 14px;
}
.tps-result {
margin-top: 16px;
}
.tps-result-text {
margin: 0;
padding: 12px;
border-radius: var(--radius-btn, 8px);
background: var(--background-soft);
border: 1px solid var(--border-color);
font-size: 14px;
line-height: 1.5;
white-space: pre-wrap;
}
.tps-skeleton {
display: flex;
flex-direction: column;
gap: 8px;
}
/* Like the real row, the border is what makes it legible — --background-soft
alone is invisible against the modal in dark theme. */
.tps-skeleton-row {
height: 56px;
border-radius: var(--radius-btn, 8px);
border: 1px solid var(--border-color);
background: var(--o08);
animation: tps-pulse 1.4s ease-in-out infinite;
}
@keyframes tps-pulse {
50% {
opacity: 0.6;
}
}
@media (prefers-reduced-motion: reduce) {
.tps-skeleton-row {
animation: none;
}
}
.tps-empty {
text-align: center;
padding: 24px 8px;
color: var(--muted);
font-size: 14px;
}
.tps-empty p {
margin: 0 0 6px;
}
.tps-empty-hint {
font-size: 13px;
line-height: 1.6;
}
.tps-visually-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
</style>