feat(staffchat): 会话中列(双模式搜索+已删除好友tab+置顶区+相对时间,样式沿用墨玉主题)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
浅念
2026-08-25 17:28:45 +08:00
parent 3f867718ff
commit 0f7dee9693
5 changed files with 443 additions and 40 deletions
+2 -13
View File
@@ -62,7 +62,7 @@ watch(() => [props.ownerId, props.channel] as const, load, { immediate: true })
</script>
<template>
<div class="yk-chat-side">
<div class="yk-chat-side yk-panel-bg yk-panel-sep">
<div class="yk-chat-side__top">
<div class="yk-chat-side__tabs">
<button
@@ -109,18 +109,7 @@ watch(() => [props.ownerId, props.channel] as const, load, { immediate: true })
height: 100%;
width: 280px;
border-right: none;
background:
radial-gradient(circle at 1px 1px, rgba(23,29,43,.03) 1px, transparent 0) 0 0/18px 18px,
linear-gradient(180deg, #fafbfd 0%, #f6f8fa 100%);
position: relative;
}
.yk-chat-side::after {
content: '';
position: absolute;
top: 0; right: 0; bottom: 0;
width: 1px;
background: linear-gradient(180deg, transparent 0%, rgba(13,159,110,.12) 20%, rgba(23,29,43,.06) 100%);
pointer-events: none;
/* 点阵底+右缘玉绿线: 全局 yk-panel-bg / yk-panel-sep(style.css) */
}
/* ── 顶部区(tab + 搜索) ── */
+395 -5
View File
@@ -1,8 +1,14 @@
<!-- 占位组件: Task 10 实现会话列表(好友/群,含搜索+今日条数),签名锁定如下。 -->
<!-- 员工聊天窗口中列会话(对齐云客 wechat-window): 好友/群|聊天记录双模式 +
聊天好友|已删除好友 tab + 置顶区(折叠条) + 今日N红badge + 相对时间。
根元素必须保持单 div——父组件 StaffChat 靠 attrs fallthrough 挂 sc-root__sessions 列宽。 -->
<script setup lang="ts">
import type { Channel, ChatSession } from '@/api/types'
import { computed, ref, watch } from 'vue'
import { ArrowDown } from '@element-plus/icons-vue'
import { getMessages, getSessions } from '@/api'
import type { ChatSession, Channel } from '@/api/types'
import { relativeChatTime } from '@/utils/format'
defineProps<{
const props = defineProps<{
channel: Channel
ownerId: string
/** single=好友会话, group=群会话 */
@@ -10,9 +16,393 @@ defineProps<{
/** 当前选中会话 id(talker_id 或 room_id) */
activeId: string | null
}>()
defineEmits<{ select: [session: ChatSession] }>()
const emit = defineEmits<{ select: [session: ChatSession] }>()
/** 顶部下拉双模式: list=好友/群(会话列表), search=聊天记录(全文搜索聚合)。 */
const mode = ref<'list' | 'search'>('list')
/** 单聊专属 tab: 聊天好友(customer.delete_flag=0) | 已删除好友(=1)。群聊无此行。 */
const friendTab = ref<'live' | 'deleted'>('live')
const keyword = ref('')
const sessions = ref<ChatSession[]>([])
const loading = ref(false)
const errorMsg = ref<string | null>(null)
const pinnedOpen = ref(true)
/** search 模式聚合结果(q 命中的消息按会话去重,取首条作为该会话快照)。 */
const searchResults = ref<ChatSession[]>([])
async function load() {
loading.value = true
errorMsg.value = null
searchResults.value = []
try {
const res = await getSessions({
owner_account_id: props.ownerId,
channel: props.channel,
...(props.kind === 'single' ? { is_del: friendTab.value === 'deleted' ? 1 : 0 } : {}),
})
sessions.value = res.sessions
} catch (e) {
errorMsg.value = (e as Error).message
sessions.value = []
} finally {
loading.value = false
}
}
watch(() => [props.ownerId, props.kind, friendTab.value] as const, load, { immediate: true })
/** 切模式清关键词与旧结果,避免 list 过滤词被误当搜索词。 */
watch(mode, () => {
keyword.value = ''
searchResults.value = []
})
/** search 模式清空关键词 → 回到未搜索态(不残留上次聚合结果)。 */
watch(keyword, (k) => {
if (mode.value === 'search' && !k.trim()) searchResults.value = []
})
/** 置顶区: 仅 单聊+未删好友+list 模式(后端 pinned 目前只有 filehelper)。 */
const pinned = computed(() =>
mode.value === 'list' && props.kind === 'single' && friendTab.value === 'live'
? sessions.value.filter(s => s.pinned)
: [],
)
/** 主列表: list=会话(昵称/微信号前端过滤,置顶剔除), search=聚合结果(不再过滤关键词)。 */
const visible = computed(() => {
let list = mode.value === 'list' ? sessions.value : searchResults.value
list = list.filter(s => (props.kind === 'group') === (s.type === 'group'))
if (mode.value === 'list' && props.kind === 'single' && friendTab.value === 'live') {
list = list.filter(s => !s.pinned)
}
const k = keyword.value.trim()
if (k && mode.value === 'list') {
list = list.filter(s =>
(s.name ?? '').includes(k) || (s.talker_id ?? '').includes(k) || (s.room_id ?? '').includes(k),
)
}
return list
})
/** 空态文案: search 未搜=引导语, 搜了无果=无匹配; list=暂无会话。 */
const emptyText = computed(() => {
if (mode.value === 'search') {
return keyword.value.trim() ? '无匹配聊天记录' : '输入关键词后回车搜索'
}
return '暂无会话'
})
/** 会话显示名:WA 号码去 @s.whatsapp.net 后缀;空→'--'。 */
function displayName(s: ChatSession): string {
const n = s.name ?? s.talker_id ?? s.room_id ?? ''
return (n.includes('@s.whatsapp.net') ? n.replace('@s.whatsapp.net', '') : n) || '--'
}
/** 会话去重/active 键: 好友=talker_id, 群=room_id(与后端 SessionOut 对齐)。 */
function sessionKey(s: ChatSession): string {
return s.talker_id ?? s.room_id ?? ''
}
/** search 模式回车: q 全量搜消息(后端 page_size 上限 100),按会话聚合去重。 */
async function runSearch() {
const k = keyword.value.trim()
if (mode.value !== 'search' || !k) {
searchResults.value = []
return
}
loading.value = true
errorMsg.value = null
try {
const res = await getMessages({
channel: props.channel,
owner_account_id: props.ownerId,
q: k,
page: 1,
page_size: 100,
})
// 已加载的会话列表提供昵称/头像;未命中(search 只回消息)再以 sender_name/键兜底
const known = new Map(sessions.value.map(s => [sessionKey(s), s]))
const seen = new Map<string, ChatSession>()
for (const m of res.items) {
const key = m.room_id ?? m.talker_id ?? ''
if (!key || seen.has(key)) continue
const hit = known.get(key)
seen.set(key, {
// 群消息的 talker_id 是群内发言人,不能当群会话键——群只落 room_id
type: m.room_id ? 'group' : 'friend',
talker_id: m.room_id ? undefined : m.talker_id ?? undefined,
room_id: m.room_id ?? undefined,
name: hit?.name ?? m.sender_name ?? key,
avatar: hit?.avatar ?? undefined,
today_count: 0,
last_chat_time: m.timestamp ?? null,
})
}
searchResults.value = [...seen.values()].sort(
(a, b) => (b.last_chat_time ?? 0) - (a.last_chat_time ?? 0),
)
} catch (e) {
errorMsg.value = (e as Error).message
searchResults.value = []
} finally {
loading.value = false
}
}
</script>
<template>
<div class="yk-empty">会话列表 — Task 10 实现</div>
<div class="sc-sessions yk-panel-bg yk-panel-sep">
<!-- ── 顶部: 模式下拉 + 搜索框 (+ 单聊的好友/已删 tab) ── -->
<div class="sc-sessions__top">
<div class="sc-sessions__row">
<el-select v-model="mode" size="small" class="sc-sessions__mode">
<el-option label="好友/群" value="list" />
<el-option label="聊天记录" value="search" />
</el-select>
<el-input
v-model="keyword"
size="small"
clearable
class="sc-sessions__search"
:placeholder="mode === 'search' ? '请输入关键词搜索' : kind === 'group' ? '群名称,群ID' : '客户昵称,微信号'"
@keyup.enter="mode === 'search' && runSearch()"
/>
</div>
<div v-if="kind === 'single'" class="sc-sessions__tabs">
<button
class="sc-sessions__tab"
:class="{ 'is-active': friendTab === 'live' }"
@click="friendTab = 'live'"
>聊天好友</button>
<button
class="sc-sessions__tab"
:class="{ 'is-active': friendTab === 'deleted' }"
@click="friendTab = 'deleted'"
>已删除好友</button>
</div>
</div>
<el-alert v-if="errorMsg" :title="errorMsg" type="error" :closable="false" />
<div v-loading="loading" class="sc-sessions__list">
<!-- ── 置顶区(折叠条 + 置顶会话卡) ── -->
<div v-if="pinned.length" class="sc-sessions__pinned">
<button class="sc-sessions__fold" @click="pinnedOpen = !pinnedOpen">
<el-icon class="sc-sessions__fold-icon" :class="{ 'is-open': pinnedOpen }">
<ArrowDown />
</el-icon>
折叠置顶聊天
</button>
<template v-if="pinnedOpen">
<div
v-for="s in pinned"
:key="'pinned-' + sessionKey(s)"
class="sc-sessions__item"
:class="{ 'is-active': props.activeId === sessionKey(s) }"
@click="emit('select', s)"
>
<el-avatar :size="36" :src="s.avatar ?? undefined">{{ displayName(s).slice(0, 1) }}</el-avatar>
<div class="sc-sessions__meta">
<div class="sc-sessions__name">
<span>{{ displayName(s) }}</span>
<span v-if="s.today_count > 0" class="sc-sessions__today">今日{{ s.today_count }}</span>
</div>
<div class="sc-sessions__time">{{ relativeChatTime(s.last_chat_time) }}</div>
</div>
</div>
</template>
</div>
<!-- ── 主列表 ── -->
<div v-if="!loading && visible.length === 0" class="yk-empty">{{ emptyText }}</div>
<div
v-for="s in visible"
:key="sessionKey(s)"
class="sc-sessions__item"
:class="{ 'is-active': props.activeId === sessionKey(s) }"
@click="emit('select', s)"
>
<el-avatar :size="36" :src="s.avatar ?? undefined">{{ displayName(s).slice(0, 1) }}</el-avatar>
<div class="sc-sessions__meta">
<div class="sc-sessions__name">
<span>{{ displayName(s) }}</span>
<span v-if="s.today_count > 0" class="sc-sessions__today">今日{{ s.today_count }}</span>
</div>
<div class="sc-sessions__time">{{ relativeChatTime(s.last_chat_time) }}</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* ══ 员工聊天中列(墨玉主题,手法沿用 ChatSidebar) ══
点阵底/右缘玉绿线走全局 yk-panel-bg/yk-panel-sep(style.css);这里只写布局。 */
.sc-sessions {
display: flex;
flex-direction: column;
height: 100%;
min-width: 0;
}
/* ── 顶部区 ── */
.sc-sessions__top {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 12px 10px;
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(8px);
border-bottom: 1px solid rgba(23, 29, 43, 0.05);
}
.sc-sessions__row {
display: flex;
gap: 8px;
}
.sc-sessions__mode {
flex: none;
width: 104px;
}
.sc-sessions__search {
flex: 1;
min-width: 0;
}
/* 聊天好友 | 已删除好友 药丸 tab(单聊专属) */
.sc-sessions__tabs {
display: flex;
gap: 4px;
background: rgba(23, 29, 43, 0.04);
border-radius: 10px;
padding: 3px;
}
.sc-sessions__tab {
flex: 1;
padding: 6px 10px;
font-size: 12.5px;
color: #5a6472;
background: none;
border: none;
border-radius: 8px;
cursor: pointer;
text-align: center;
line-height: 1.3;
transition: all 0.2s cubic-bezier(0.22, 0.8, 0.36, 1);
}
.sc-sessions__tab:hover:not(.is-active) {
color: #2b3240;
background: rgba(255, 255, 255, 0.6);
}
.sc-sessions__tab.is-active {
color: var(--el-color-primary);
background: #fff;
font-weight: 600;
box-shadow: 0 1px 3px rgba(23, 29, 43, 0.08), 0 2px 8px -2px rgba(13, 159, 110, 0.15);
}
/* ── 列表 ── */
.sc-sessions__list {
flex: 1;
overflow-y: auto;
padding: 6px;
}
/* 置顶折叠条 */
.sc-sessions__fold {
display: flex;
align-items: center;
gap: 4px;
width: 100%;
padding: 6px 8px;
margin-bottom: 2px;
font-size: 11.5px;
color: #8a94a4;
background: rgba(23, 29, 43, 0.035);
border: none;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
}
.sc-sessions__fold:hover {
color: #5a6472;
background: rgba(23, 29, 43, 0.06);
}
.sc-sessions__fold-icon {
font-size: 12px;
transition: transform 0.25s cubic-bezier(0.22, 0.8, 0.36, 1);
}
/* 展开时箭头朝下(未转),折叠时转 180° 朝上 */
.sc-sessions__fold-icon.is-open {
transform: rotate(180deg);
}
.sc-sessions__pinned {
margin-bottom: 6px;
}
/* 会话卡(置顶/主列表共用) */
.sc-sessions__item {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 10px;
cursor: pointer;
border-radius: 10px;
margin-bottom: 2px;
border: 1px solid transparent;
transition: background 0.15s ease, box-shadow 0.15s ease;
}
.sc-sessions__item:hover {
background: rgba(255, 255, 255, 0.8);
box-shadow: 0 1px 4px rgba(23, 29, 43, 0.04);
}
.sc-sessions__item.is-active {
background: #fff;
border-color: rgba(13, 159, 110, 0.15);
box-shadow: 0 2px 8px -2px rgba(13, 159, 110, 0.15);
}
.sc-sessions__item.is-active .sc-sessions__name > span:first-child {
color: var(--el-color-primary);
font-weight: 600;
}
.sc-sessions__item .el-avatar {
flex: none;
border-radius: 10px;
border: 1px solid rgba(23, 29, 43, 0.05);
box-shadow: 0 1px 3px rgba(23, 29, 43, 0.06);
}
.sc-sessions__meta {
min-width: 0;
flex: 1;
}
.sc-sessions__name {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 6px;
font-size: 13.5px;
color: #2b3240;
}
.sc-sessions__name > span:first-child {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sc-sessions__today {
font-size: 10.5px;
color: #fff;
background: #f56c6c;
padding: 0 5px;
border-radius: 8px;
flex-shrink: 0;
line-height: 16px;
font-weight: 500;
}
.sc-sessions__time {
font-size: 11px;
color: #8a94a4;
margin-top: 3px;
text-align: right;
}
</style>
@@ -1,16 +1,23 @@
<script setup lang="ts">
import type { Account } from '@/api/types'
const props = defineProps<{ profile: Account | null; ownerIdFallback: string; tab: string }>()
const emit = defineEmits<{ select: [tab: string]; collapse: [] }>()
<!-- TabKey 从 NAVS 派生(单独 script 块——<script setup> 不允许 export);StaffChat.vue 复用做路由 tab 收窄。 -->
<script lang="ts">
const NAVS = [
{ key: 'single', label: '单聊' }, { key: 'group', label: '群聊' }, { key: 'moments', label: '朋友圈' },
{ key: 'contacts', label: '通讯录' }, { key: 'stats', label: '统计分析' },
] as const
/** 员工聊天窗口导航键(与路由 :tab 段同名)。 */
export type TabKey = (typeof NAVS)[number]['key']
</script>
<script setup lang="ts">
import type { Account } from '@/api/types'
const props = defineProps<{ profile: Account | null; ownerIdFallback: string; tab: TabKey }>()
const emit = defineEmits<{ select: [tab: TabKey]; collapse: [] }>()
</script>
<template>
<div class="sc-side">
<div class="sc-side yk-panel-bg yk-panel-sep">
<!-- 员工资料卡: 头像+昵称 / 真名·电话 / 微信号 (对齐云客资料卡四要素) -->
<div class="sc-side__card">
<el-avatar :size="44" :src="props.profile?.avatar ?? undefined">
@@ -30,6 +37,7 @@ const NAVS = [
:key="n.key"
class="sc-side__navbtn"
:class="{ 'is-active': props.tab === n.key }"
:aria-current="props.tab === n.key ? 'page' : undefined"
@click="emit('select', n.key)"
>{{ n.label }}</button>
</nav>
@@ -45,18 +53,7 @@ const NAVS = [
flex-direction: column;
width: 240px;
flex: none;
position: relative;
background:
radial-gradient(circle at 1px 1px, rgba(23,29,43,.03) 1px, transparent 0) 0 0/18px 18px,
linear-gradient(180deg, #fafbfd 0%, #f6f8fa 100%);
}
.sc-side::after {
content: '';
position: absolute;
top: 0; right: 0; bottom: 0;
width: 1px;
background: linear-gradient(180deg, transparent 0%, rgba(13,159,110,.12) 20%, rgba(23,29,43,.06) 100%);
pointer-events: none;
/* 点阵底+右缘玉绿线: 全局 yk-panel-bg / yk-panel-sep(style.css) */
}
/* ── 员工资料卡(毛玻璃名片) ── */
+23
View File
@@ -479,6 +479,29 @@ h2 {
box-shadow: inset 3px 0 0 var(--el-color-primary);
}
/* ══ 墨玉面板皮肤 utility(2026-08-25) ══
ChatSidebar / StaffChatSide / SessionPane 三处共用的点阵纸面底 + 右缘玉绿
渐变分隔线(原为三份 scoped 复制,提取到全局统一维护)。
用法: 根元素挂 `yk-panel-bg yk-panel-sep`,scoped 内只写布局。 */
.yk-panel-bg {
background:
radial-gradient(circle at 1px 1px, rgba(23, 29, 43, 0.03) 1px, transparent 0) 0 0 / 18px 18px,
linear-gradient(180deg, #fafbfd 0%, #f6f8fa 100%);
}
.yk-panel-sep {
position: relative;
}
.yk-panel-sep::after {
content: '';
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 1px;
background: linear-gradient(180deg, transparent 0%, rgba(13, 159, 110, 0.12) 20%, rgba(23, 29, 43, 0.06) 100%);
pointer-events: none;
}
/* ═══════════════════ 详情抽屉美化层(2026-08-22) ═══════════════════
覆盖所有页面的全字段抽屉(客户/员工/群/通话/短信/搜索/聊天/员工详情)
—— 卡片式信息档案风格,玉绿主色系呼应全站主题。
+8 -4
View File
@@ -5,7 +5,7 @@ import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getAccounts } from '@/api'
import type { Account } from '@/api/types'
import StaffChatSide from '@/components/staffchat/StaffChatSide.vue'
import StaffChatSide, { type TabKey } from '@/components/staffchat/StaffChatSide.vue'
import SessionPane from '@/components/staffchat/SessionPane.vue'
import ChatMain from '@/components/staffchat/ChatMain.vue'
import MomentsPane from '@/components/staffchat/MomentsPane.vue'
@@ -15,10 +15,14 @@ import StatsPane from '@/components/staffchat/StatsPane.vue'
const props = defineProps<{ ownerId: string; tab?: string; talkerId?: string }>()
const route = useRoute()
const router = useRouter()
const VALID = ['single', 'group', 'moments', 'contacts', 'stats']
// TabKey 从 StaffChatSide 导出(单一事实源);VALID 仅做运行时兜底
const VALID: readonly TabKey[] = ['single', 'group', 'moments', 'contacts', 'stats']
function isTabKey(v: string | undefined): v is TabKey {
return !!v && (VALID as readonly string[]).includes(v)
}
// 注: 路由 regex 已过滤非法 tab(BOGUS 会进 talkerId 位而非 tab 位,3 段非法 URL 被 catch-all 弹走)
// 这里 computed 兜底 undefined/'' → single
const tab = computed(() => (props.tab && VALID.includes(props.tab) ? props.tab : 'single'))
const tab = computed<TabKey>(() => (isTabKey(props.tab) ? props.tab : 'single'))
const profile = ref<Account | null>(null)
const collapsed = ref(false)
@@ -33,7 +37,7 @@ async function loadProfile() {
}
watch(() => props.ownerId, loadProfile, { immediate: true })
function onSelectTab(t: string) {
function onSelectTab(t: TabKey) {
router.push({ name: 'staff-chat', params: { ownerId: props.ownerId, tab: t } })
}
function onSelectSession(s: { type: 'friend' | 'group'; talker_id?: string | null; room_id?: string | null }) {