plus-ui/src/views/customerservice/workbench/components/ConversationList.vue
i548450 5c8b6dc014 feat(customerservice): chat-core + chat-ui + workbench 三栏
- chat-core: 类型定义(Message/Conversation/SsePayload) + SseSubscriber
  订阅共享 sseEventBus 'customerservice' event,按 msgType 分发
- chat-core utils: formatRelativeTime / extractUrlsAsLinks + 19 个 vitest 单测
- chat-ui: MessageStream/MessageBubble/MessageInput/MessageStateBadge/
  DateDivider/ConversationDivider 组件 + useSseConversation/useMessageStream
  composables;CSS variable 暴露主题色
- api/customerservice: conversation/message/agent/mediaUser HTTP 客户端
  按 spec 04-api.md endpoint 形态先行,后端 Task 5-13 完成后无需改前端
- views/customerservice/workbench: 三栏布局 + Pinia store 处理 6 种 SSE
  payload + 自动重连后全量对齐(spec 05-sse.md §6)
- vitest.config.ts: 独立 standalone 配置,跳过 dev server 的 unocss 链
2026-06-09 21:45:34 +08:00

153 lines
3.5 KiB
Vue

<template>
<div class="conv-list">
<el-tabs v-model="active" @tab-change="onTabChange">
<el-tab-pane label="我的对话" name="mine"></el-tab-pane>
<el-tab-pane label="无主队列" name="unassigned"></el-tab-pane>
</el-tabs>
<div class="conv-list-rows">
<div
v-for="row in rows"
:key="row.id"
class="conv-row"
:class="{ active: row.id === openedId, 'has-failure': row.hasSendFailure }"
@click="$emit('open', row.id)"
>
<img v-if="row.mediaUser.avatarUrl" :src="row.mediaUser.avatarUrl" class="conv-row-avatar" alt="" />
<div v-else class="conv-row-avatar conv-row-avatar-fallback">{{ initial(row.mediaUser.nickname) }}</div>
<div class="conv-row-body">
<div class="conv-row-line1">
<span class="conv-row-name">{{ row.mediaUser.nickname || row.mediaUser.externalUserIdInApp }}</span>
<span v-if="row.lastMsgAt" class="conv-row-time">{{ formatRelativeTime(row.lastMsgAt) }}</span>
</div>
<div class="conv-row-line2">
<span class="conv-row-preview">{{ row.lastMsgPreview ?? '' }}</span>
<el-badge v-if="row.unreadCount" :value="row.unreadCount" type="danger" />
</div>
</div>
</div>
<div v-if="rows.length === 0" class="conv-list-empty">暂无对话</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import type { ConversationListItem } from '@/chat-core';
import { formatRelativeTime } from '@/chat-core';
const props = defineProps<{
mine: ConversationListItem[];
unassigned: ConversationListItem[];
openedId?: number | null;
}>();
defineEmits<{
open: [id: number];
'tab-change': [tab: 'mine' | 'unassigned'];
}>();
const active = ref<'mine' | 'unassigned'>('mine');
const rows = computed(() => (active.value === 'mine' ? props.mine : props.unassigned));
function initial(name?: string): string {
if (!name) return '?';
return name.charAt(0).toUpperCase();
}
function onTabChange(name: string | number) {
active.value = name as 'mine' | 'unassigned';
}
</script>
<style scoped lang="scss">
.conv-list {
display: flex;
flex-direction: column;
height: 100%;
border-right: 1px solid var(--chat-border-color);
}
.conv-list-rows {
flex: 1;
overflow-y: auto;
}
.conv-row {
display: flex;
padding: 10px 12px;
cursor: pointer;
border-bottom: 1px solid var(--chat-border-color);
gap: 8px;
&:hover {
background: #f5f7fa;
}
&.active {
background: #ecf5ff;
}
&.has-failure {
border-left: 3px solid var(--chat-status-failed);
}
}
.conv-row-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
flex-shrink: 0;
object-fit: cover;
background: var(--chat-border-color);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-weight: bold;
}
.conv-row-body {
flex: 1;
min-width: 0;
}
.conv-row-line1,
.conv-row-line2 {
display: flex;
justify-content: space-between;
align-items: center;
gap: 6px;
}
.conv-row-name {
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.conv-row-time {
font-size: 12px;
color: var(--chat-divider-color);
flex-shrink: 0;
}
.conv-row-preview {
font-size: 12px;
color: var(--chat-divider-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
.conv-list-empty {
text-align: center;
color: var(--chat-divider-color);
padding: 24px;
font-size: 13px;
}
</style>