feat(staffchat): 会话中列(双模式搜索+已删除好友tab+置顶区+相对时间,样式沿用墨玉主题)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -62,7 +62,7 @@ watch(() => [props.ownerId, props.channel] as const, load, { immediate: true })
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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__top">
|
||||||
<div class="yk-chat-side__tabs">
|
<div class="yk-chat-side__tabs">
|
||||||
<button
|
<button
|
||||||
@@ -109,18 +109,7 @@ watch(() => [props.ownerId, props.channel] as const, load, { immediate: true })
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
width: 280px;
|
width: 280px;
|
||||||
border-right: none;
|
border-right: none;
|
||||||
background:
|
/* 点阵底+右缘玉绿线: 全局 yk-panel-bg / yk-panel-sep(style.css) */
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── 顶部区(tab + 搜索) ── */
|
/* ── 顶部区(tab + 搜索) ── */
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
<!-- 占位组件: Task 10 实现会话列表(好友/群,含搜索+今日条数),签名锁定如下。 -->
|
<!-- 员工聊天窗口中列会话(对齐云客 wechat-window): 好友/群|聊天记录双模式 +
|
||||||
|
聊天好友|已删除好友 tab + 置顶区(折叠条) + 今日N红badge + 相对时间。
|
||||||
|
根元素必须保持单 div——父组件 StaffChat 靠 attrs fallthrough 挂 sc-root__sessions 列宽。 -->
|
||||||
<script setup lang="ts">
|
<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
|
channel: Channel
|
||||||
ownerId: string
|
ownerId: string
|
||||||
/** single=好友会话, group=群会话 */
|
/** single=好友会话, group=群会话 */
|
||||||
@@ -10,9 +16,393 @@ defineProps<{
|
|||||||
/** 当前选中会话 id(talker_id 或 room_id) */
|
/** 当前选中会话 id(talker_id 或 room_id) */
|
||||||
activeId: string | null
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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>
|
</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">
|
<!-- TabKey 从 NAVS 派生(单独 script 块——<script setup> 不允许 export);StaffChat.vue 复用做路由 tab 收窄。 -->
|
||||||
import type { Account } from '@/api/types'
|
<script lang="ts">
|
||||||
|
|
||||||
const props = defineProps<{ profile: Account | null; ownerIdFallback: string; tab: string }>()
|
|
||||||
const emit = defineEmits<{ select: [tab: string]; collapse: [] }>()
|
|
||||||
const NAVS = [
|
const NAVS = [
|
||||||
{ key: 'single', label: '单聊' }, { key: 'group', label: '群聊' }, { key: 'moments', label: '朋友圈' },
|
{ key: 'single', label: '单聊' }, { key: 'group', label: '群聊' }, { key: 'moments', label: '朋友圈' },
|
||||||
{ key: 'contacts', label: '通讯录' }, { key: 'stats', label: '统计分析' },
|
{ key: 'contacts', label: '通讯录' }, { key: 'stats', label: '统计分析' },
|
||||||
] as const
|
] 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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="sc-side">
|
<div class="sc-side yk-panel-bg yk-panel-sep">
|
||||||
<!-- 员工资料卡: 头像+昵称 / 真名·电话 / 微信号 (对齐云客资料卡四要素) -->
|
<!-- 员工资料卡: 头像+昵称 / 真名·电话 / 微信号 (对齐云客资料卡四要素) -->
|
||||||
<div class="sc-side__card">
|
<div class="sc-side__card">
|
||||||
<el-avatar :size="44" :src="props.profile?.avatar ?? undefined">
|
<el-avatar :size="44" :src="props.profile?.avatar ?? undefined">
|
||||||
@@ -30,6 +37,7 @@ const NAVS = [
|
|||||||
:key="n.key"
|
:key="n.key"
|
||||||
class="sc-side__navbtn"
|
class="sc-side__navbtn"
|
||||||
:class="{ 'is-active': props.tab === n.key }"
|
:class="{ 'is-active': props.tab === n.key }"
|
||||||
|
:aria-current="props.tab === n.key ? 'page' : undefined"
|
||||||
@click="emit('select', n.key)"
|
@click="emit('select', n.key)"
|
||||||
>{{ n.label }}</button>
|
>{{ n.label }}</button>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -45,18 +53,7 @@ const NAVS = [
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
width: 240px;
|
width: 240px;
|
||||||
flex: none;
|
flex: none;
|
||||||
position: relative;
|
/* 点阵底+右缘玉绿线: 全局 yk-panel-bg / yk-panel-sep(style.css) */
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── 员工资料卡(毛玻璃名片) ── */
|
/* ── 员工资料卡(毛玻璃名片) ── */
|
||||||
|
|||||||
@@ -479,6 +479,29 @@ h2 {
|
|||||||
box-shadow: inset 3px 0 0 var(--el-color-primary);
|
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) ═══════════════════
|
/* ═══════════════════ 详情抽屉美化层(2026-08-22) ═══════════════════
|
||||||
覆盖所有页面的全字段抽屉(客户/员工/群/通话/短信/搜索/聊天/员工详情)
|
覆盖所有页面的全字段抽屉(客户/员工/群/通话/短信/搜索/聊天/员工详情)
|
||||||
—— 卡片式信息档案风格,玉绿主色系呼应全站主题。
|
—— 卡片式信息档案风格,玉绿主色系呼应全站主题。
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { computed, ref, watch } from 'vue'
|
|||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { getAccounts } from '@/api'
|
import { getAccounts } from '@/api'
|
||||||
import type { Account } from '@/api/types'
|
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 SessionPane from '@/components/staffchat/SessionPane.vue'
|
||||||
import ChatMain from '@/components/staffchat/ChatMain.vue'
|
import ChatMain from '@/components/staffchat/ChatMain.vue'
|
||||||
import MomentsPane from '@/components/staffchat/MomentsPane.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 props = defineProps<{ ownerId: string; tab?: string; talkerId?: string }>()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
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 弹走)
|
// 注: 路由 regex 已过滤非法 tab(BOGUS 会进 talkerId 位而非 tab 位,3 段非法 URL 被 catch-all 弹走)
|
||||||
// 这里 computed 兜底 undefined/'' → single
|
// 这里 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 profile = ref<Account | null>(null)
|
||||||
const collapsed = ref(false)
|
const collapsed = ref(false)
|
||||||
@@ -33,7 +37,7 @@ async function loadProfile() {
|
|||||||
}
|
}
|
||||||
watch(() => props.ownerId, loadProfile, { immediate: true })
|
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 } })
|
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 }) {
|
function onSelectSession(s: { type: 'friend' | 'group'; talker_id?: string | null; room_id?: string | null }) {
|
||||||
|
|||||||
Reference in New Issue
Block a user