feat(staffchat): 页面骨架+左侧资料卡/5导航/折叠(三栏布局对齐云客)
- StaffChat.vue: 路由驱动 tab(single/group/moments/contacts/stats)+折叠态, 三栏 flex(左栏240/中列280/右列flex:1,高calc(100vh-120px));资料卡走 getAccounts(channel=wechat) 按 account_id 匹配,失败退 ownerId 兜底 - StaffChatSide.vue: 员工资料卡(头像/昵称/真名·电话/微信号)+竖排5导航+ 底部收起;墨玉主题沿用 ChatSidebar 手法(点阵底/玉绿渐变分隔线/药丸按钮) - 5 占位组件(SessionPane/ChatMain/MomentsPane/ContactsPane/StatsPane), defineProps/defineEmits 签名按 Task 10-14 锁定 - relativeChatTime 签名可空友好(ms?: number|null,null→''),spec+1例 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,13 @@
|
|||||||
|
<!-- 占位组件: Task 11 实现聊天窗口(消息流+定位 msgid),签名锁定如下。 -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
ownerId: string
|
||||||
|
/** single=单聊(talkerId=客户 wxid), group=群聊(talkerId=room_id) */
|
||||||
|
kind: 'single' | 'group'
|
||||||
|
talkerId: string
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="yk-empty">聊天窗口 — Task 11 实现</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<!-- 占位组件: Task 13 实现通讯录视图,签名锁定如下。 -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{ ownerId: string }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="yk-empty">通讯录 — Task 13 实现</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<!-- 占位组件: Task 12 实现朋友圈视图,签名锁定如下。 -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{ ownerId: string }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="yk-empty">朋友圈 — Task 12 实现</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<!-- 占位组件: Task 10 实现会话列表(好友/群,含搜索+今日条数),签名锁定如下。 -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Channel, ChatSession } from '@/api/types'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
channel: Channel
|
||||||
|
ownerId: string
|
||||||
|
/** single=好友会话, group=群会话 */
|
||||||
|
kind: 'single' | 'group'
|
||||||
|
/** 当前选中会话 id(talker_id 或 room_id) */
|
||||||
|
activeId: string | null
|
||||||
|
}>()
|
||||||
|
defineEmits<{ select: [session: ChatSession] }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="yk-empty">会话列表 — Task 10 实现</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
<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: [] }>()
|
||||||
|
const NAVS = [
|
||||||
|
{ key: 'single', label: '单聊' }, { key: 'group', label: '群聊' }, { key: 'moments', label: '朋友圈' },
|
||||||
|
{ key: 'contacts', label: '通讯录' }, { key: 'stats', label: '统计分析' },
|
||||||
|
] as const
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="sc-side">
|
||||||
|
<!-- 员工资料卡: 头像+昵称 / 真名·电话 / 微信号 (对齐云客资料卡四要素) -->
|
||||||
|
<div class="sc-side__card">
|
||||||
|
<el-avatar :size="44" :src="props.profile?.avatar ?? undefined">
|
||||||
|
{{ (props.profile?.nickname || props.ownerIdFallback).slice(0, 1) }}
|
||||||
|
</el-avatar>
|
||||||
|
<div class="sc-side__who">
|
||||||
|
<div class="sc-side__nick">{{ props.profile?.nickname || props.ownerIdFallback }}</div>
|
||||||
|
<div v-if="props.profile?.user_name || props.profile?.phone" class="sc-side__meta">
|
||||||
|
{{ props.profile?.user_name }} {{ props.profile?.phone }}
|
||||||
|
</div>
|
||||||
|
<div class="sc-side__meta">{{ props.profile?.account_id ?? props.ownerIdFallback }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<nav class="sc-side__nav">
|
||||||
|
<button
|
||||||
|
v-for="n in NAVS"
|
||||||
|
:key="n.key"
|
||||||
|
class="sc-side__navbtn"
|
||||||
|
:class="{ 'is-active': props.tab === n.key }"
|
||||||
|
@click="emit('select', n.key)"
|
||||||
|
>{{ n.label }}</button>
|
||||||
|
</nav>
|
||||||
|
<button class="sc-side__collapse" @click="emit('collapse')">收起侧边栏</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* ══ 员工聊天窗口左栏(墨玉主题,手法沿用 ChatSidebar) ══
|
||||||
|
点阵底 + 玉绿渐变分隔线 + 药丸按钮;card 顶 / nav 中 / collapse 贴底。 */
|
||||||
|
.sc-side {
|
||||||
|
display: flex;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 员工资料卡(毛玻璃名片) ── */
|
||||||
|
.sc-side__card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
background: rgba(255,255,255,.85);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
border: 1px solid rgba(23,29,43,.05);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 1px 3px rgba(23,29,43,.05);
|
||||||
|
}
|
||||||
|
.sc-side__card .el-avatar {
|
||||||
|
flex: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(23,29,43,.05);
|
||||||
|
box-shadow: 0 1px 3px rgba(23,29,43,.06);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.sc-side__who { min-width: 0; }
|
||||||
|
.sc-side__nick {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #2b3240;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.sc-side__meta {
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: #8a94a4;
|
||||||
|
margin-top: 2px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 竖排导航(玉绿茶丸) ── */
|
||||||
|
.sc-side__nav {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 12px 10px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.sc-side__navbtn {
|
||||||
|
padding: 10px 16px;
|
||||||
|
font-size: 13.5px;
|
||||||
|
text-align: left;
|
||||||
|
color: #5a6472;
|
||||||
|
background: rgba(255,255,255,.7);
|
||||||
|
border: 1px solid rgba(23,29,43,.06);
|
||||||
|
border-radius: 999px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s cubic-bezier(.22,.8,.36,1);
|
||||||
|
}
|
||||||
|
.sc-side__navbtn:hover:not(.is-active) {
|
||||||
|
color: #2b3240;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 1px 4px rgba(23,29,43,.06);
|
||||||
|
}
|
||||||
|
.sc-side__navbtn.is-active {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
background: linear-gradient(135deg, #fff 0%, #e8f7ef 100%);
|
||||||
|
border-color: rgba(13,159,110,.25);
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: 0 2px 8px -2px rgba(13,159,110,.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 底部收起 ── */
|
||||||
|
.sc-side__collapse {
|
||||||
|
margin: 10px 12px 12px;
|
||||||
|
padding: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #8a94a4;
|
||||||
|
background: rgba(23,29,43,.04);
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
.sc-side__collapse:hover {
|
||||||
|
color: #5a6472;
|
||||||
|
background: rgba(23,29,43,.07);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<!-- 占位组件: Task 14 实现统计分析视图,签名锁定如下。 -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{ ownerId: string }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="yk-empty">统计分析 — Task 14 实现</div>
|
||||||
|
</template>
|
||||||
@@ -36,4 +36,9 @@ describe('relativeChatTime', () => {
|
|||||||
const lastYear = new Date().getFullYear() - 1
|
const lastYear = new Date().getFullYear() - 1
|
||||||
expect(relativeChatTime(at(lastYear, 5, 10))).toBe(`${lastYear}年6月10日`)
|
expect(relativeChatTime(at(lastYear, 5, 10))).toBe(`${lastYear}年6月10日`)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('null/undefined → 空串(可空友好,免调用方 ?? 0 渲染 1970)', () => {
|
||||||
|
expect(relativeChatTime(null)).toBe('')
|
||||||
|
expect(relativeChatTime(undefined)).toBe('')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -24,8 +24,11 @@ export function formatTimeShort(ts?: number | null): string {
|
|||||||
/**
|
/**
|
||||||
* 云客聊天窗口相对时间(会话列表/消息时间轴用): 今天→HH:mm,
|
* 云客聊天窗口相对时间(会话列表/消息时间轴用): 今天→HH:mm,
|
||||||
* 昨天→`昨天`, 今年→`M月D日`, 往年→`YYYY年M月D日`。
|
* 昨天→`昨天`, 今年→`M月D日`, 往年→`YYYY年M月D日`。
|
||||||
|
* null/undefined → `''`(与 formatTimeShort 一致,调用方可直接传可空字段,
|
||||||
|
* 无需 `?? 0` 兜底——那会把缺时间渲染成 1970年1月1日)。
|
||||||
*/
|
*/
|
||||||
export function relativeChatTime(ms: number): string {
|
export function relativeChatTime(ms?: number | null): string {
|
||||||
|
if (ms == null) return ''
|
||||||
const d = new Date(ms)
|
const d = new Date(ms)
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const sameDay = (a: Date, b: Date) => a.toDateString() === b.toDateString()
|
const sameDay = (a: Date, b: Date) => a.toDateString() === b.toDateString()
|
||||||
|
|||||||
@@ -1,14 +1,136 @@
|
|||||||
<!-- 占位组件: Task 8 只加路由, 五视图实现在后续任务替换本文件。 -->
|
<!-- 员工聊天窗口骨架(对齐云客 wechat-window): 左栏资料卡+导航 / 中列会话 / 右列内容。
|
||||||
|
各 pane 由 Task 10-14 逐个替换占位实现。 -->
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// Route props (/staff-chat/:ownerId/:tab?/:talkerId?) — see router/index.ts.
|
import { computed, ref, watch } from 'vue'
|
||||||
// tab=group 时 talkerId 语义为 roomId; ?msgid= 定位消息(红包转账明细跳转)。
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
const props = defineProps<{
|
import { getAccounts } from '@/api'
|
||||||
ownerId: string
|
import type { Account } from '@/api/types'
|
||||||
tab?: string
|
import StaffChatSide from '@/components/staffchat/StaffChatSide.vue'
|
||||||
talkerId?: string
|
import SessionPane from '@/components/staffchat/SessionPane.vue'
|
||||||
}>()
|
import ChatMain from '@/components/staffchat/ChatMain.vue'
|
||||||
|
import MomentsPane from '@/components/staffchat/MomentsPane.vue'
|
||||||
|
import ContactsPane from '@/components/staffchat/ContactsPane.vue'
|
||||||
|
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']
|
||||||
|
// 注: 路由 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 profile = ref<Account | null>(null)
|
||||||
|
const collapsed = ref(false)
|
||||||
|
async function loadProfile() {
|
||||||
|
try {
|
||||||
|
const res = await getAccounts({ channel: 'wechat', page: 1, page_size: 100 })
|
||||||
|
profile.value = res.items.find(a => a.account_id === props.ownerId) ?? null
|
||||||
|
} catch {
|
||||||
|
// 后端未起/请求失败: 资料卡退化为 ownerId 兜底显示
|
||||||
|
profile.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
watch(() => props.ownerId, loadProfile, { immediate: true })
|
||||||
|
|
||||||
|
function onSelectTab(t: string) {
|
||||||
|
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 }) {
|
||||||
|
router.push({
|
||||||
|
name: 'staff-chat',
|
||||||
|
params: {
|
||||||
|
ownerId: props.ownerId,
|
||||||
|
tab: s.type === 'group' ? 'group' : 'single',
|
||||||
|
talkerId: s.talker_id ?? s.room_id ?? '',
|
||||||
|
},
|
||||||
|
query: route.query.msgid ? { msgid: String(route.query.msgid) } : {},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const chatMode = computed(() => tab.value === 'single' || tab.value === 'group')
|
||||||
|
const paneKind = computed<'single' | 'group'>(() => (tab.value === 'group' ? 'group' : 'single'))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div data-staff-chat :data-owner-id="props.ownerId" />
|
<div class="sc-root">
|
||||||
|
<button v-if="collapsed" class="sc-root__expand" title="展开侧边栏" @click="collapsed = false">
|
||||||
|
展开侧边栏
|
||||||
|
</button>
|
||||||
|
<StaffChatSide
|
||||||
|
v-else
|
||||||
|
:profile="profile"
|
||||||
|
:owner-id-fallback="props.ownerId"
|
||||||
|
:tab="tab"
|
||||||
|
@select="onSelectTab"
|
||||||
|
@collapse="collapsed = true"
|
||||||
|
/>
|
||||||
|
<template v-if="chatMode">
|
||||||
|
<SessionPane
|
||||||
|
class="sc-root__sessions"
|
||||||
|
channel="wechat"
|
||||||
|
:owner-id="props.ownerId"
|
||||||
|
:kind="paneKind"
|
||||||
|
:active-id="props.talkerId || null"
|
||||||
|
@select="onSelectSession"
|
||||||
|
/>
|
||||||
|
<ChatMain
|
||||||
|
class="sc-root__chat"
|
||||||
|
:owner-id="props.ownerId"
|
||||||
|
:kind="paneKind"
|
||||||
|
:talker-id="props.talkerId ?? ''"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<MomentsPane v-else-if="tab === 'moments'" class="sc-root__pane" :owner-id="props.ownerId" />
|
||||||
|
<ContactsPane v-else-if="tab === 'contacts'" class="sc-root__pane" :owner-id="props.ownerId" />
|
||||||
|
<StatsPane v-else-if="tab === 'stats'" class="sc-root__pane" :owner-id="props.ownerId" />
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 三栏 flex: 左栏 240px(StaffChatSide 自带) / 中列 280px / 右列 flex:1。
|
||||||
|
高度预留 app header,对齐 ChatTimeline 的 calc(100vh - 120px)。 */
|
||||||
|
.sc-root {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
height: calc(100vh - 120px);
|
||||||
|
min-height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 折叠态竖条展开按钮 */
|
||||||
|
.sc-root__expand {
|
||||||
|
flex: none;
|
||||||
|
width: 28px;
|
||||||
|
border: 1px solid rgba(23,29,43,.06);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255,255,255,.85);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
color: #5a6472;
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.2em;
|
||||||
|
writing-mode: vertical-lr;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
.sc-root__expand:hover {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
border-color: rgba(13,159,110,.25);
|
||||||
|
box-shadow: 0 2px 8px -2px rgba(13,159,110,.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 中列(会话列表)与右列(内容) — pane 组件自带内部样式,这里只定占位 */
|
||||||
|
.sc-root__sessions {
|
||||||
|
width: 280px;
|
||||||
|
flex: none;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.sc-root__chat {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.sc-root__pane {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user